« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.json5
-rw-r--r--.gitignore9
-rw-r--r--.taprc6
-rw-r--r--LICENSE.txt2
-rw-r--r--README.md40
-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.json8903
-rw-r--r--package.json59
-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.js (renamed from src/data/things/cacheable-object.js)87
-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/serialize.js4
-rw-r--r--src/data/thing.js77
-rw-r--r--src/data/things/album.js521
-rw-r--r--src/data/things/art-tag.js99
-rw-r--r--src/data/things/artist.js443
-rw-r--r--src/data/things/flash.js370
-rw-r--r--src/data/things/group.js170
-rw-r--r--src/data/things/homepage-layout.js201
-rw-r--r--src/data/things/index.js35
-rw-r--r--src/data/things/language.js520
-rw-r--r--src/data/things/news-entry.js56
-rw-r--r--src/data/things/static-page.js72
-rw-r--r--src/data/things/thing.js385
-rw-r--r--src/data/things/track.js814
-rw-r--r--src/data/things/validators.js367
-rw-r--r--src/data/things/wiki-info.js96
-rw-r--r--src/data/validators.js997
-rw-r--r--src/data/yaml.js1735
-rw-r--r--src/file-size-preloader.js8
-rw-r--r--src/find.js253
-rw-r--r--src/gen-thumbs.js1355
-rw-r--r--src/listing-spec.js1251
-rw-r--r--src/misc-templates.js816
-rw-r--r--src/page/album-commentary.js142
-rw-r--r--src/page/album.js653
-rw-r--r--src/page/artist-alias.js27
-rw-r--r--src/page/artist.js755
-rw-r--r--src/page/flash-act.js23
-rw-r--r--src/page/flash.js348
-rw-r--r--src/page/group.js327
-rw-r--r--src/page/homepage.js197
-rw-r--r--src/page/index.js43
-rw-r--r--src/page/listing.js252
-rw-r--r--src/page/news.js152
-rw-r--r--src/page/static.js35
-rw-r--r--src/page/tag.js99
-rw-r--r--src/page/track.js452
-rw-r--r--src/repl.js61
-rw-r--r--src/static/client.js532
-rw-r--r--src/static/client3.js3483
-rw-r--r--src/static/icons.svg50
-rw-r--r--src/static/lazy-loading.js2
-rw-r--r--src/static/site2.css1232
-rw-r--r--src/static/site6.css2401
-rw-r--r--src/static/warning.svg93
-rw-r--r--src/strings-default.json388
-rw-r--r--src/strings-default.yaml1850
-rwxr-xr-xsrc/upd8.js3823
-rw-r--r--src/url-spec.js33
-rw-r--r--src/util/aggregate.js647
-rw-r--r--src/util/cli.js161
-rw-r--r--src/util/colors.js6
-rw-r--r--src/util/external-links.js998
-rw-r--r--src/util/find.js162
-rw-r--r--src/util/html.js1859
-rw-r--r--src/util/io.js21
-rw-r--r--src/util/link.js188
-rw-r--r--src/util/magic-constants.js10
-rw-r--r--src/util/node-utils.js57
-rw-r--r--src/util/replacer.js549
-rw-r--r--src/util/serialize.js8
-rw-r--r--src/util/sort.js405
-rw-r--r--src/util/sugar.js857
-rw-r--r--src/util/urls.js135
-rw-r--r--src/util/wiki-data.js556
-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
420 files changed, 60866 insertions, 15541 deletions
diff --git a/.eslintrc.json b/.eslintrc.json
index f104d38..5568290 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -10,9 +10,12 @@
   },
   "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 447e891..a9dda76 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,3 @@
-node_modules
+/node_modules
+/.tap
 .DS_Store
-
-# FOR NOW: We are using site2.css instead of site.css,
-# thanks to breaking changes - we can't have the release
-# site reuse the same CSS as the staging/preview one
-# anymore!
-src/static/site.css
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 ee787c0..06480de 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagini
 
 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 16.x LTS should also work
+- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; 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:
@@ -19,7 +19,7 @@ $ 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://nebula.ed1.club/git/hsmusic-media media
+$ git clone https://github.com/hsmusic/hsmusic-media media
 Cloning into 'media'...
 ```
 
@@ -106,9 +106,9 @@ HSMusic works using a number of repositories in tandem:
   - 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`][ed1-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.
-- *Language repo:* The language repository, holding up-to-date strings and other localization info for HSMusic. NB: This repo isn't currently online as its structure and tooling haven't been polished or properly put together yet, but it's not required for building the site.
-  - Strings and language info are stored in top-level JSON files within this repository. They're based off the `src/strings-default.json` file within the code repo (and don't need to provide translations for all strings to be used for site building).
+- [`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.
 
@@ -139,21 +139,22 @@ The source code for HSMusic is divided across a number of source files, loosely
 
 - `src/`
   - `data/`
-    - `cacheable-object.js`: Backbone of how data objects (colloquially "things") store, share, and compute their properties
-    - `things.js`: Descriptors for all "thing" classes used across the wiki: albums, tracks, artists, groups, etc
-    - `validators.js`: Convenient error-throwing utilities which help ensure properties set on things follow the right format
-    - `yaml.js`: Mappings from YAML documents (the format used in `hsmusic-data`) to things (actual data objects), and a full set of utilities used to actually load that data from scratch
-  - `page/`
-    - All page templates (HTML content and layout metadata) are kept in source files under this directory
-  - `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)
+    - `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
-  - `repl.js`: Standalone utility for loading all wiki data and providing a convenient REPL to run filters and transformations on data objects right from the Node.js command line
   - `listing-spec.js`: Descriptors for computations and HTML templates used for the Listings part of the site
-  - `misc-templates.js`: General collection of HTML patterns used across page generation
   - `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
@@ -166,16 +167,17 @@ hsmusic is a relatively generic music wiki software, so you're more than encoura
 
 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, we encourage you to [drop the main dev 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!
+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!
 
-  [ed1-media]: https://nebula.ed1.club/git/hsmusic-media/
   [discord]: https://hsmusic.wiki/discord/
   [fandom]: https://homestuck-and-mspa-music.fandom.com/wiki/Homestuck_and_MSPA_Music_Wiki
   [feedback]: https://hsmusic.wiki/feedback/
   [github]: https://github.com/hsmusic/hsmusic-wiki
   [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 e12b9c7..ad7c5ab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,29 +9,109 @@
             "version": "0.1.0",
             "license": "GPL-3.0",
             "dependencies": {
+                "@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": {
-                "eslint": "^8.18.0",
-                "tape": "^5.4.1"
+                "chokidar": "^3.5.3",
+                "tap": "^18.4.0",
+                "tcompare": "^6.0.0"
+            },
+            "engines": {
+                "node": ">= 20.9.0"
             }
         },
-        "node_modules/@eslint/eslintrc": {
-            "version": "1.3.0",
-            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.0.tgz",
-            "integrity": "sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==",
+        "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": {
+                "ansi-styles": "^6.2.1",
+                "is-fullwidth-code-point": "^4.0.0"
+            },
+            "engines": {
+                "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/chalk/ansi-styles?sponsor=1"
+            }
+        },
+        "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": ">=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.3.2",
-                "globals": "^13.15.0",
+                "espree": "^9.5.1",
+                "globals": "^13.19.0",
                 "ignore": "^5.2.0",
                 "import-fresh": "^3.2.1",
                 "js-yaml": "^4.1.0",
@@ -40,33 +120,1258 @@
             },
             "engines": {
                 "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
+            }
+        },
+        "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/@humanwhocodes/config-array": {
-            "version": "0.9.5",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
-            "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==",
-            "dev": true,
+            "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.4"
+                "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==",
+            "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": {
+                "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/@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": {
+                "ansi-regex": "^6.0.1"
+            },
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+            }
+        },
+        "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/acorn": {
-            "version": "8.7.1",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
-            "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==",
+        "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/@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"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "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": {
+                "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": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@npmcli/fs": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz",
+            "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==",
+            "dev": true,
+            "dependencies": {
+                "semver": "^7.3.5"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "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": {
+                "@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": {
+                "node-which": "bin/which.js"
+            },
+            "engines": {
+                "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/isaacs"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "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": {
+                "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/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/@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,
+            "dependencies": {
+                "minipass": "^7.0.3"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "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/@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": {
+                "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/@tapjs/fixture/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/@tapjs/fixture/node_modules/glob": {
+            "version": "10.3.10",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^2.3.5",
+                "minimatch": "^9.0.1",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                "path-scurry": "^1.10.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/@tapjs/fixture/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "dev": true,
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/@tapjs/fixture/node_modules/rimraf": {
+            "version": "5.0.5",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+            "dev": true,
+            "dependencies": {
+                "glob": "^10.3.7"
+            },
+            "bin": {
+                "rimraf": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=14"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "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": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "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": {
+                "@tapjs/after": "1.1.17",
+                "@tapjs/stack": "1.2.7",
+                "resolve-import": "^1.4.5",
+                "walk-up-path": "^3.0.1"
+            },
+            "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/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": {
+                "@tapjs/error-serdes": "1.2.1",
+                "@tapjs/stack": "1.2.7",
+                "tap-parser": "15.3.1"
+            },
+            "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/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": {
+                "pirates": "^4.0.5",
+                "process-on-spawn": "^1.0.0",
+                "signal-exit": "^4.0.2",
+                "uuid": "^8.3.2"
+            },
+            "engines": {
+                "node": ">=16.17"
+            }
+        },
+        "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/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/@tapjs/run/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/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/chalk/chalk?sponsor=1"
+            }
+        },
+        "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": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "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": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/@tapjs/run/node_modules/rimraf": {
+            "version": "5.0.5",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+            "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/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": {
+                "isexe": "^3.1.1"
+            },
+            "bin": {
+                "node-which": "bin/which.js"
+            },
+            "engines": {
+                "node": "^16.13.0 || >=18.0.0"
+            }
+        },
+        "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/@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": {
+                "@tufjs/canonical-json": "2.0.0",
+                "minimatch": "^9.0.3"
+            },
+            "engines": {
+                "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/@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": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "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": {
+                "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"
             },
@@ -78,16 +1383,57 @@
             "version": "5.3.2",
             "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
             "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
-            "dev": true,
             "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==",
-            "dev": true,
             "dependencies": {
                 "fast-deep-equal": "^3.1.1",
                 "fast-json-stable-stringify": "^2.0.0",
@@ -99,11 +1445,37 @@
                 "url": "https://github.com/sponsors/epoberezkin"
             }
         },
+        "node_modules/ansi-escapes": {
+            "version": "6.2.0",
+            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz",
+            "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==",
+            "dev": true,
+            "dependencies": {
+                "type-fest": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=14.16"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/ansi-escapes/node_modules/type-fest": {
+            "version": "3.13.1",
+            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
+            "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
+            "dev": true,
+            "engines": {
+                "node": ">=14.16"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
         "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==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             }
@@ -112,7 +1484,6 @@
             "version": "4.3.0",
             "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
             "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-            "dev": true,
             "dependencies": {
                 "color-convert": "^2.0.1"
             },
@@ -123,75 +1494,213 @@
                 "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": {
+                "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/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/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,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3",
-                "es-abstract": "^1.19.0",
-                "is-string": "^1.0.7"
-            },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=16"
             }
         },
-        "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/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": ">= 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/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
+            "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==",
-            "dev": true,
             "dependencies": {
                 "balanced-match": "^1.0.0",
                 "concat-map": "0.0.1"
             }
         },
-        "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/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": {
+                "fill-range": "^7.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/builtins": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz",
+            "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==",
+            "dev": true,
+            "dependencies": {
+                "semver": "^7.0.0"
+            }
+        },
+        "node_modules/c8": {
+            "version": "8.0.1",
+            "resolved": "https://registry.npmjs.org/c8/-/c8-8.0.1.tgz",
+            "integrity": "sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==",
             "dev": true,
             "dependencies": {
-                "function-bind": "^1.1.1",
-                "get-intrinsic": "^1.0.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"
+            },
+            "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/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/isaacs"
             }
         },
         "node_modules/callsites": {
             "version": "3.1.0",
             "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
             "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
-            "dev": true,
             "engines": {
                 "node": ">=6"
             }
@@ -200,7 +1709,6 @@
             "version": "4.1.2",
             "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
             "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-            "dev": true,
             "dependencies": {
                 "ansi-styles": "^4.1.0",
                 "supports-color": "^7.1.0"
@@ -212,16 +1720,230 @@
                 "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": ">= 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/sindresorhus"
+            }
+        },
+        "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": {
+                "restore-cursor": "^4.0.0"
+            },
+            "engines": {
+                "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+            },
+            "funding": {
+                "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==",
-            "dev": true,
             "dependencies": {
                 "color-name": "~1.1.4"
             },
@@ -232,8 +1954,7 @@
         "node_modules/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==",
-            "dev": true
+            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
         },
         "node_modules/command-exists": {
             "version": "1.2.9",
@@ -243,14 +1964,27 @@
         "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=",
+            "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==",
-            "dev": true,
             "dependencies": {
                 "path-key": "^3.1.0",
                 "shebang-command": "^2.0.0",
@@ -264,7 +1998,6 @@
             "version": "4.3.4",
             "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
             "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
-            "dev": true,
             "dependencies": {
                 "ms": "2.1.2"
             },
@@ -277,61 +2010,24 @@
                 }
             }
         },
-        "node_modules/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==",
-            "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"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
         "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==",
-            "dev": true
+            "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
         },
-        "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/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,
-            "dependencies": {
-                "object-keys": "^1.0.12"
-            },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=0.3.1"
             }
         },
-        "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/doctrine": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
             "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
-            "dev": true,
             "dependencies": {
                 "esutils": "^2.0.2"
             },
@@ -339,93 +2035,56 @@
                 "node": ">=6.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/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": {
-                "minimatch": "^3.0.4"
-            },
-            "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"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "iconv-lite": "^0.6.2"
             }
         },
-        "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/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,
-            "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"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">=6"
             }
         },
-        "node_modules/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==",
+        "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,
-            "dependencies": {
-                "is-callable": "^1.1.4",
-                "is-date-object": "^1.0.1",
-                "is-symbol": "^1.0.2"
-            },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "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==",
-            "dev": true,
             "engines": {
                 "node": ">=10"
             },
@@ -434,13 +2093,17 @@
             }
         },
         "node_modules/eslint": {
-            "version": "8.18.0",
-            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.18.0.tgz",
-            "integrity": "sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA==",
-            "dev": true,
+            "version": "8.37.0",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.37.0.tgz",
+            "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==",
             "dependencies": {
-                "@eslint/eslintrc": "^1.3.0",
-                "@humanwhocodes/config-array": "^0.9.2",
+                "@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",
@@ -448,20 +2111,22 @@
                 "doctrine": "^3.0.0",
                 "escape-string-regexp": "^4.0.0",
                 "eslint-scope": "^7.1.1",
-                "eslint-utils": "^3.0.0",
-                "eslint-visitor-keys": "^3.3.0",
-                "espree": "^9.3.2",
-                "esquery": "^1.4.0",
+                "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",
-                "functional-red-black-tree": "^1.0.1",
-                "glob-parent": "^6.0.1",
-                "globals": "^13.15.0",
+                "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",
@@ -469,11 +2134,9 @@
                 "minimatch": "^3.1.2",
                 "natural-compare": "^1.4.0",
                 "optionator": "^0.9.1",
-                "regexpp": "^3.2.0",
                 "strip-ansi": "^6.0.1",
                 "strip-json-comments": "^3.1.0",
-                "text-table": "^0.2.0",
-                "v8-compile-cache": "^2.0.3"
+                "text-table": "^0.2.0"
             },
             "bin": {
                 "eslint": "bin/eslint.js"
@@ -489,7 +2152,6 @@
             "version": "7.1.1",
             "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
             "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
-            "dev": true,
             "dependencies": {
                 "esrecurse": "^4.3.0",
                 "estraverse": "^5.2.0"
@@ -498,61 +2160,37 @@
                 "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
             }
         },
-        "node_modules/eslint-utils": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
-            "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
-            "dev": true,
-            "dependencies": {
-                "eslint-visitor-keys": "^2.0.0"
-            },
-            "engines": {
-                "node": "^10.0.0 || ^12.0.0 || >= 14.0.0"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/mysticatea"
-            },
-            "peerDependencies": {
-                "eslint": ">=5"
-            }
-        },
-        "node_modules/eslint-utils/node_modules/eslint-visitor-keys": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
-            "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
-            "dev": true,
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/eslint-visitor-keys": {
-            "version": "3.3.0",
-            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
-            "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
-            "dev": true,
+            "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.3.2",
-            "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.2.tgz",
-            "integrity": "sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==",
-            "dev": true,
+            "version": "9.5.1",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
+            "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==",
             "dependencies": {
-                "acorn": "^8.7.1",
+                "acorn": "^8.8.0",
                 "acorn-jsx": "^5.3.2",
-                "eslint-visitor-keys": "^3.3.0"
+                "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.4.0",
-            "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
-            "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
-            "dev": true,
+            "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"
             },
@@ -564,7 +2202,6 @@
             "version": "4.3.0",
             "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
             "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
-            "dev": true,
             "dependencies": {
                 "estraverse": "^5.2.0"
             },
@@ -576,7 +2213,6 @@
             "version": "5.3.0",
             "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
             "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
-            "dev": true,
             "engines": {
                 "node": ">=4.0"
             }
@@ -585,34 +2221,52 @@
             "version": "2.0.3",
             "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
             "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
-            "dev": true,
             "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==",
-            "dev": true
+            "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==",
-            "dev": true
+            "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==",
-            "dev": true
+            "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==",
-            "dev": true,
             "dependencies": {
                 "flat-cache": "^3.0.4"
             },
@@ -620,11 +2274,37 @@
                 "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/sindresorhus"
+            }
+        },
         "node_modules/flat-cache": {
             "version": "3.0.4",
             "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
             "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
-            "dev": true,
             "dependencies": {
                 "flatted": "^3.1.0",
                 "rimraf": "^3.0.2"
@@ -636,91 +2316,108 @@
         "node_modules/flatted": {
             "version": "3.2.5",
             "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
-            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
-            "dev": true
+            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
         },
-        "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/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": {
-                "is-callable": "^1.1.3"
+                "cross-spawn": "^7.0.0",
+                "signal-exit": "^4.0.1"
+            },
+            "engines": {
+                "node": ">=14"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "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/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=",
-            "dev": true
+            "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.1",
-            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
-            "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
-            "dev": true
-        },
-        "node_modules/functional-red-black-tree": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
-            "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
-            "dev": true
-        },
-        "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==",
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+            "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
             "dev": true,
-            "dependencies": {
-                "function-bind": "^1.1.1",
-                "has": "^1.0.3",
-                "has-symbols": "^1.0.1"
-            },
             "funding": {
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "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==",
-            "dev": true,
-            "engines": {
-                "node": ">=8.0.0"
-            }
+        "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-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/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,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
-            },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": "6.* || 8.* || >= 10.*"
             }
         },
         "node_modules/glob": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
-            "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
-            "dev": true,
+            "version": "7.2.3",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+            "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
             "dependencies": {
                 "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"
             },
@@ -735,7 +2432,6 @@
             "version": "6.0.2",
             "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
             "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
-            "dev": true,
             "dependencies": {
                 "is-glob": "^4.0.3"
             },
@@ -744,10 +2440,9 @@
             }
         },
         "node_modules/globals": {
-            "version": "13.15.0",
-            "resolved": "https://registry.npmjs.org/globals/-/globals-13.15.0.tgz",
-            "integrity": "sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==",
-            "dev": true,
+            "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"
             },
@@ -758,98 +2453,170 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "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/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/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": {
-                "function-bind": "^1.1.1"
+                "function-bind": "^1.1.2"
             },
             "engines": {
-                "node": ">= 0.4.0"
+                "node": ">= 0.4"
             }
         },
-        "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/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,
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "dependencies": {
+                "lru-cache": "^10.0.1"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
             }
         },
-        "node_modules/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==",
+        "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": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
+                "agent-base": "^7.1.0",
+                "debug": "^4.3.4"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">= 14"
             }
         },
-        "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==",
+        "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"
+            },
             "engines": {
-                "node": ">=8"
+                "node": ">= 14"
             }
         },
-        "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/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.4"
+                "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"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
             }
         },
-        "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/ignore-walk/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/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": {
-                "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/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==",
+            "dependencies": {
+                "queue": "6.0.2"
+            },
             "bin": {
-                "he": "bin/he"
-            }
-        },
-        "node_modules/ignore": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
-            "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
-            "dev": true,
+                "image-size": "bin/image-size.js"
+            },
             "engines": {
-                "node": ">= 4"
+                "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==",
-            "dev": true,
             "dependencies": {
                 "parent-module": "^1.0.0",
                 "resolve-from": "^4.0.0"
@@ -865,16 +2632,26 @@
             "version": "0.1.4",
             "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
             "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
-            "dev": true,
             "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=",
-            "dev": true,
             "dependencies": {
                 "once": "^1.3.0",
                 "wrappy": "1"
@@ -883,101 +2660,133 @@
         "node_modules/inherits": {
             "version": "2.0.4",
             "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-            "dev": true
+            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
         },
-        "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/ink": {
+            "version": "4.4.1",
+            "resolved": "https://registry.npmjs.org/ink/-/ink-4.4.1.tgz",
+            "integrity": "sha512-rXckvqPBB0Krifk5rn/5LvQGmyXwCUpBfmTwbkQNBY9JY8RSl3b8OftBNEYxg4+SWUhEKcPifgope28uL9inlA==",
             "dev": true,
             "dependencies": {
-                "get-intrinsic": "^1.1.0",
-                "has": "^1.0.3",
-                "side-channel": "^1.0.4"
+                "@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": ">= 0.4"
+                "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/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/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,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
-            },
             "engines": {
-                "node": ">= 0.4"
+                "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/is-bigint": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
-            "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+        "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,
-            "dependencies": {
-                "has-bigints": "^1.0.1"
+            "engines": {
+                "node": ">=10"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "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/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": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "tshy": "^1.7.0"
             }
         },
-        "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/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,
-            "engines": {
-                "node": ">= 0.4"
+            "dependencies": {
+                "binary-extensions": "^2.0.0"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">=8"
             }
         },
-        "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/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": {
-                "has": "^1.0.3"
+                "ci-info": "^3.2.0"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "bin": {
+                "is-ci": "bin.js"
             }
         },
-        "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/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": {
-                "has-tostringtag": "^1.0.0"
-            },
-            "engines": {
-                "node": ">= 0.4"
+                "hasown": "^2.0.0"
             },
             "funding": {
                 "url": "https://github.com/sponsors/ljharb"
@@ -987,16 +2796,26 @@
             "version": "2.1.1",
             "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
             "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
-            "dev": true,
             "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==",
-            "dev": true,
             "dependencies": {
                 "is-extglob": "^2.1.1"
             },
@@ -1004,169 +2823,128 @@
                 "node": ">=0.10.0"
             }
         },
-        "node_modules/is-map": {
+        "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-map/-/is-map-2.0.2.tgz",
-            "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==",
+            "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz",
+            "integrity": "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==",
             "dev": true,
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "dependencies": {
+                "tslib": "^2.0.3"
             }
         },
-        "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/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.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=0.12.0"
             }
         },
-        "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==",
-            "dev": true,
-            "dependencies": {
-                "has-tostringtag": "^1.0.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": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=8"
             }
         },
-        "node_modules/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==",
+        "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,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
-            },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=0.10.0"
             }
         },
-        "node_modules/is-set": {
+        "node_modules/is-upper-case": {
             "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz",
-            "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==",
+            "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz",
+            "integrity": "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==",
             "dev": true,
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "dependencies": {
+                "tslib": "^2.0.3"
             }
         },
-        "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/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,
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">=8"
             }
         },
-        "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/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": {
-                "has-tostringtag": "^1.0.0"
+                "istanbul-lib-coverage": "^3.0.0",
+                "make-dir": "^4.0.0",
+                "supports-color": "^7.1.0"
             },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=10"
             }
         },
-        "node_modules/is-symbol": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
-            "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+        "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": {
-                "has-symbols": "^1.0.2"
+                "html-escaper": "^2.0.0",
+                "istanbul-lib-report": "^3.0.0"
             },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=8"
             }
         },
-        "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/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": {
-                "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"
+                "@isaacs/cliui": "^8.0.2"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=14"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-weakmap": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz",
-            "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==",
-            "dev": true,
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/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==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.2"
+                "url": "https://github.com/sponsors/isaacs"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "optionalDependencies": {
+                "@pkgjs/parseargs": "^0.11.0"
             }
         },
-        "node_modules/is-weakset": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz",
-            "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
-            },
+        "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": {
-                "url": "https://github.com/sponsors/ljharb"
+                "type": "opencollective",
+                "url": "https://opencollective.com/js-sdsl"
             }
         },
-        "node_modules/isarray": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
-            "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
-            "dev": true
-        },
-        "node_modules/isexe": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+        "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": {
@@ -1180,23 +2958,43 @@
                 "js-yaml": "bin/js-yaml.js"
             }
         },
+        "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==",
-            "dev": true
+            "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==",
-            "dev": true
+            "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==",
-            "dev": true,
             "dependencies": {
                 "prelude-ls": "^1.2.1",
                 "type-check": "~0.4.0"
@@ -1205,17 +3003,119 @@
                 "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==",
+            "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==",
-            "dev": true,
             "dependencies": {
                 "brace-expansion": "^1.1.7"
             },
@@ -1223,90 +3123,459 @@
                 "node": "*"
             }
         },
-        "node_modules/minimist": {
-            "version": "1.2.6",
-            "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
-            "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
-            "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/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/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==",
-            "dev": true
+            "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==",
-            "dev": true
+            "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
         },
-        "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/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,
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "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==",
-            "dev": true,
             "dependencies": {
                 "deep-is": "^0.1.3",
                 "fast-levenshtein": "^2.0.6",
@@ -1319,11 +3588,85 @@
                 "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==",
-            "dev": true,
             "dependencies": {
                 "callsites": "^3.0.0"
             },
@@ -1331,11 +3674,27 @@
                 "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=",
-            "dev": true,
             "engines": {
                 "node": ">=0.10.0"
             }
@@ -1344,99 +3703,452 @@
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
             "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             }
         },
-        "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==",
-            "dev": true
+        "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==",
-            "dev": true,
             "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==",
-            "dev": true,
             "engines": {
                 "node": ">=6"
             }
         },
-        "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/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": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "loose-envify": "^1.1.0"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=0.10.0"
+            }
+        },
+        "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"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "peerDependencies": {
+                "react": "^18.2.0"
             }
         },
-        "node_modules/regexpp": {
-            "version": "3.2.0",
-            "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
-            "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+        "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/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": {
+                "loose-envify": "^1.1.0",
+                "scheduler": "^0.23.0"
+            },
             "engines": {
-                "node": ">=8"
+                "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/mysticatea"
+                "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/readdirp": {
+            "version": "3.6.0",
+            "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+            "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+            "dev": true,
+            "dependencies": {
+                "picomatch": "^2.2.1"
+            },
+            "engines": {
+                "node": ">=8.10.0"
+            }
+        },
+        "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==",
-            "dev": true,
             "engines": {
                 "node": ">=4"
             }
         },
-        "node_modules/resumer": {
-            "version": "0.0.0",
-            "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz",
-            "integrity": "sha1-8ej0YeQGS6Oegq883CqMiT0HZ1k=",
+        "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": {
+                "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/isaacs"
+            }
+        },
+        "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": {
+                "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/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": {
-                "through": "~2.3.4"
+                "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/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/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==",
-            "dev": true,
             "dependencies": {
                 "glob": "^7.1.3"
             },
@@ -1447,11 +4159,75 @@
                 "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": {
+                "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==",
-            "dev": true,
             "dependencies": {
                 "shebang-regex": "^3.0.0"
             },
@@ -1463,72 +4239,300 @@
             "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/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/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/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/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": {
-                "call-bind": "^1.0.0",
-                "get-intrinsic": "^1.0.2",
-                "object-inspect": "^1.9.0"
+                "strip-ansi": "^7.1.0"
+            },
+            "engines": {
+                "node": ">=16"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "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/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": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3",
-                "es-abstract": "^1.19.1"
+                "ansi-regex": "^6.0.1"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/chalk/strip-ansi?sponsor=1"
             }
         },
-        "node_modules/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==",
+        "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": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "eastasianwidth": "^0.2.0",
+                "emoji-regex": "^9.2.2",
+                "strip-ansi": "^7.0.1"
+            },
+            "engines": {
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/string.prototype.trimstart": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
-            "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
+        "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": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "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/sponsors/ljharb"
+                "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"
@@ -1541,7 +4545,6 @@
             "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==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             },
@@ -1549,11 +4552,15 @@
                 "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==",
-            "dev": true,
             "dependencies": {
                 "has-flag": "^4.0.0"
             },
@@ -1561,55 +4568,393 @@
                 "node": ">=8"
             }
         },
-        "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/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": {
-                "tape": "bin/tape"
+                "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/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": {
+                "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==",
-            "dev": true
+            "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="
         },
-        "node_modules/through": {
-            "version": "2.3.8",
-            "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
-            "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
-            "dev": true
+        "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/chalk/chalk?sponsor=1"
+            }
+        },
+        "node_modules/tshy/node_modules/glob": {
+            "version": "10.3.10",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^2.3.5",
+                "minimatch": "^9.0.1",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                "path-scurry": "^1.10.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/tshy/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "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==",
-            "dev": true,
             "dependencies": {
                 "prelude-ls": "^1.2.1"
             },
@@ -1621,7 +4966,6 @@
             "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==",
-            "dev": true,
             "engines": {
                 "node": ">=10"
             },
@@ -1629,41 +4973,129 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "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/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": {
-                "function-bind": "^1.1.1",
-                "has-bigints": "^1.0.1",
-                "has-symbols": "^1.0.2",
-                "which-boxed-primitive": "^1.0.2"
+                "unique-slug": "^4.0.0"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "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/uri-js": {
             "version": "4.4.1",
             "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
             "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
-            "dev": true,
             "dependencies": {
                 "punycode": "^2.1.0"
             }
         },
-        "node_modules/v8-compile-cache": {
-            "version": "2.3.0",
-            "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
-            "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+        "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/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==",
-            "dev": true,
             "dependencies": {
                 "isexe": "^2.0.0"
             },
@@ -1674,84 +5106,331 @@
                 "node": ">= 8"
             }
         },
-        "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/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": {
-                "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"
+                "string-width": "^5.0.1"
+            },
+            "engines": {
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "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/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-map": "^2.0.1",
-                "is-set": "^2.0.1",
-                "is-weakmap": "^2.0.1",
-                "is-weakset": "^2.0.1"
+                "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-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": {
+            "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": {
-                "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"
+                "ansi-styles": "^4.0.0",
+                "string-width": "^4.1.0",
+                "strip-ansi": "^6.0.0"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=10"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
             }
         },
-        "node_modules/word-wrap": {
-            "version": "1.2.3",
-            "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
-            "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+        "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": ">=0.10.0"
+                "node": ">=8"
+            }
+        },
+        "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": {
+                "emoji-regex": "^8.0.0",
+                "is-fullwidth-code-point": "^3.0.0",
+                "strip-ansi": "^6.0.1"
+            },
+            "engines": {
+                "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/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": {
-        "@eslint/eslintrc": {
-            "version": "1.3.0",
-            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.0.tgz",
-            "integrity": "sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==",
+        "@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.3.2",
-                "globals": "^13.15.0",
+                "espree": "^9.5.1",
+                "globals": "^13.19.0",
                 "ignore": "^5.2.0",
                 "import-fresh": "^3.2.1",
                 "js-yaml": "^4.1.0",
@@ -1759,41 +5438,907 @@
                 "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.9.5",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
-            "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==",
-            "dev": true,
+            "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.4"
+                "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==",
+            "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
         },
-        "acorn": {
-            "version": "8.7.1",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
-            "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==",
+        "@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==",
-            "dev": true,
             "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==",
-            "dev": true,
             "requires": {
                 "fast-deep-equal": "^3.1.1",
                 "fast-json-stable-stringify": "^2.0.0",
@@ -1801,96 +6346,365 @@
                 "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==",
-            "dev": true
+            "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==",
-            "dev": true,
             "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": {
+                "fill-range": "^7.0.1"
+            }
+        },
+        "builtins": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz",
+            "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==",
             "dev": true,
             "requires": {
-                "function-bind": "^1.1.1",
-                "get-intrinsic": "^1.0.2"
+                "semver": "^7.0.0"
+            }
+        },
+        "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": {
+                "@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
+                }
+            }
+        },
+        "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": {
+                "@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"
+                    }
+                }
             }
         },
         "callsites": {
             "version": "3.1.0",
             "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
-            "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
-            "dev": true
+            "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==",
-            "dev": true,
             "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
+        },
+        "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": {
+                "restore-cursor": "^4.0.0"
+            }
+        },
+        "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": {
+                "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"
+                    }
+                }
+            }
+        },
+        "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": {
+                "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"
+                    }
+                }
+            }
+        },
+        "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"
+            }
+        },
         "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==",
-            "dev": true,
             "requires": {
                 "color-name": "~1.1.4"
             }
@@ -1898,8 +6712,7 @@
         "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==",
-            "dev": true
+            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
         },
         "command-exists": {
             "version": "1.2.9",
@@ -1909,14 +6722,24 @@
         "concat-map": {
             "version": "0.0.1",
             "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
-            "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+            "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==",
-            "dev": true,
             "requires": {
                 "path-key": "^3.1.0",
                 "shebang-command": "^2.0.0",
@@ -1927,142 +6750,86 @@
             "version": "4.3.4",
             "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
             "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
-            "dev": true,
             "requires": {
                 "ms": "2.1.2"
             }
         },
-        "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==",
-            "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"
-            }
-        },
         "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==",
-            "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==",
-            "dev": true,
-            "requires": {
-                "object-keys": "^1.0.12"
-            }
+            "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
         },
-        "defined": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
-            "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
+        "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==",
-            "dev": true,
             "requires": {
                 "esutils": "^2.0.2"
             }
         },
-        "dotignore": {
-            "version": "0.1.2",
-            "resolved": "https://registry.npmjs.org/dotignore/-/dotignore-0.1.2.tgz",
-            "integrity": "sha512-UGGGWfSauusaVJC+8fgV+NVvBXkCTmVv7sk6nojDZZvuOUNGUy0Zk4UpHQD6EDjS0jpBwcACvH4eofvyzBcRDw==",
-            "dev": true,
-            "requires": {
-                "minimatch": "^3.0.4"
-            }
+        "eastasianwidth": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+            "dev": true
         },
-        "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==",
-            "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"
-            }
+        "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
         },
-        "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==",
+        "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.4",
-                "is-date-object": "^1.0.1",
-                "is-symbol": "^1.0.2"
+                "iconv-lite": "^0.6.2"
             }
         },
+        "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
+        },
+        "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
+        },
+        "escalade": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+            "dev": true
+        },
         "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==",
-            "dev": true
+            "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
         },
         "eslint": {
-            "version": "8.18.0",
-            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.18.0.tgz",
-            "integrity": "sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA==",
-            "dev": true,
+            "version": "8.37.0",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.37.0.tgz",
+            "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==",
             "requires": {
-                "@eslint/eslintrc": "^1.3.0",
-                "@humanwhocodes/config-array": "^0.9.2",
+                "@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",
@@ -2070,20 +6837,22 @@
                 "doctrine": "^3.0.0",
                 "escape-string-regexp": "^4.0.0",
                 "eslint-scope": "^7.1.1",
-                "eslint-utils": "^3.0.0",
-                "eslint-visitor-keys": "^3.3.0",
-                "espree": "^9.3.2",
-                "esquery": "^1.4.0",
+                "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",
-                "functional-red-black-tree": "^1.0.1",
-                "glob-parent": "^6.0.1",
-                "globals": "^13.15.0",
+                "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",
@@ -2091,62 +6860,39 @@
                 "minimatch": "^3.1.2",
                 "natural-compare": "^1.4.0",
                 "optionator": "^0.9.1",
-                "regexpp": "^3.2.0",
                 "strip-ansi": "^6.0.1",
                 "strip-json-comments": "^3.1.0",
-                "text-table": "^0.2.0",
-                "v8-compile-cache": "^2.0.3"
+                "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==",
-            "dev": true,
             "requires": {
                 "esrecurse": "^4.3.0",
                 "estraverse": "^5.2.0"
             }
         },
-        "eslint-utils": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
-            "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
-            "dev": true,
-            "requires": {
-                "eslint-visitor-keys": "^2.0.0"
-            },
-            "dependencies": {
-                "eslint-visitor-keys": {
-                    "version": "2.1.0",
-                    "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
-                    "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
-                    "dev": true
-                }
-            }
-        },
         "eslint-visitor-keys": {
-            "version": "3.3.0",
-            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
-            "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
-            "dev": true
+            "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.3.2",
-            "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.2.tgz",
-            "integrity": "sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==",
-            "dev": true,
+            "version": "9.5.1",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
+            "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==",
             "requires": {
-                "acorn": "^8.7.1",
+                "acorn": "^8.8.0",
                 "acorn-jsx": "^5.3.2",
-                "eslint-visitor-keys": "^3.3.0"
+                "eslint-visitor-keys": "^3.4.0"
             }
         },
         "esquery": {
-            "version": "1.4.0",
-            "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
-            "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
-            "dev": true,
+            "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"
             }
@@ -2155,7 +6901,6 @@
             "version": "4.3.0",
             "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
             "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
-            "dev": true,
             "requires": {
                 "estraverse": "^5.2.0"
             }
@@ -2163,47 +6908,78 @@
         "estraverse": {
             "version": "5.3.0",
             "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
-            "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
-            "dev": true
+            "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==",
+            "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==",
-            "dev": true
+            "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==",
-            "dev": true
+            "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==",
-            "dev": true
+            "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==",
-            "dev": true,
             "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": {
+                "to-regex-range": "^5.0.1"
+            }
+        },
+        "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==",
-            "dev": true,
             "requires": {
                 "flatted": "^3.1.0",
                 "rimraf": "^3.0.2"
@@ -2212,79 +6988,72 @@
         "flatted": {
             "version": "3.2.5",
             "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
-            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
-            "dev": true
+            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
         },
-        "for-each": {
-            "version": "0.3.3",
-            "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
-            "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
+        "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": {
-                "is-callable": "^1.1.3"
+                "cross-spawn": "^7.0.0",
+                "signal-exit": "^4.0.1"
             }
         },
-        "foreach": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz",
-            "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=",
+        "fromentries": {
+            "version": "1.3.2",
+            "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz",
+            "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==",
             "dev": true
         },
+        "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": {
+                "minipass": "^7.0.3"
+            }
+        },
         "fs.realpath": {
             "version": "1.0.0",
             "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
-            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
-            "dev": true
+            "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.1",
-            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
-            "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+            "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
             "dev": true
         },
-        "functional-red-black-tree": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
-            "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
+        "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-intrinsic": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
-            "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
-            "dev": true,
-            "requires": {
-                "function-bind": "^1.1.1",
-                "has": "^1.0.3",
-                "has-symbols": "^1.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==",
+        "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
         },
-        "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==",
-            "dev": true,
-            "requires": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
-            }
-        },
         "glob": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
-            "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
-            "dev": true,
+            "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"
             }
@@ -2293,82 +7062,145 @@
             "version": "6.0.2",
             "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
             "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
-            "dev": true,
             "requires": {
                 "is-glob": "^4.0.3"
             }
         },
         "globals": {
-            "version": "13.15.0",
-            "resolved": "https://registry.npmjs.org/globals/-/globals-13.15.0.tgz",
-            "integrity": "sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==",
-            "dev": true,
+            "version": "13.20.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
+            "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
             "requires": {
                 "type-fest": "^0.20.2"
             }
         },
-        "has": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
-            "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+        "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
+        },
+        "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": {
-                "function-bind": "^1.1.1"
+                "function-bind": "^1.1.2"
             }
         },
-        "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
+        "he": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+            "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
         },
-        "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==",
+        "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": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
+                "lru-cache": "^10.0.1"
             }
         },
-        "has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+        "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
         },
-        "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==",
+        "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
         },
-        "has-tostringtag": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
-            "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+        "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": {
-                "has-symbols": "^1.0.2"
+                "agent-base": "^7.1.0",
+                "debug": "^4.3.4"
             }
         },
-        "he": {
-            "version": "1.2.0",
-            "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
-            "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
+        "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.0",
-            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
-            "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
-            "dev": true
+            "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==",
-            "dev": true,
             "requires": {
                 "parent-module": "^1.0.0",
                 "resolve-from": "^4.0.0"
@@ -2377,14 +7209,18 @@
         "imurmurhash": {
             "version": "0.1.4",
             "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
-            "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+            "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"
@@ -2393,197 +7229,214 @@
         "inherits": {
             "version": "2.0.4",
             "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-            "dev": true
+            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
         },
-        "internal-slot": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
-            "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+        "ink": {
+            "version": "4.4.1",
+            "resolved": "https://registry.npmjs.org/ink/-/ink-4.4.1.tgz",
+            "integrity": "sha512-rXckvqPBB0Krifk5rn/5LvQGmyXwCUpBfmTwbkQNBY9JY8RSl3b8OftBNEYxg4+SWUhEKcPifgope28uL9inlA==",
             "dev": true,
             "requires": {
-                "get-intrinsic": "^1.1.0",
-                "has": "^1.0.3",
-                "side-channel": "^1.0.4"
+                "@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
+                }
             }
         },
-        "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==",
-            "dev": true,
-            "requires": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
-            }
+        "ip": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
+            "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
+            "dev": true
         },
-        "is-bigint": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
-            "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+        "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": {
-                "has-bigints": "^1.0.1"
+                "tshy": "^1.7.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-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-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==",
-            "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==",
+        "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": "^1.0.3"
+                "ci-info": "^3.2.0"
             }
         },
-        "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-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": {
-                "has-tostringtag": "^1.0.0"
+                "hasown": "^2.0.0"
             }
         },
         "is-extglob": {
             "version": "2.1.1",
             "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
-            "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+            "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-glob": {
             "version": "4.0.3",
             "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
             "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
-            "dev": true,
             "requires": {
                 "is-extglob": "^2.1.1"
             }
         },
-        "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-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-negative-zero": {
+        "is-lower-case": {
             "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
-            "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
-            "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==",
-            "dev": true,
-            "requires": {
-                "has-tostringtag": "^1.0.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==",
+            "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz",
+            "integrity": "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
+                "tslib": "^2.0.3"
             }
         },
-        "is-set": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz",
-            "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==",
+        "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-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==",
+        "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-string": {
-            "version": "1.0.7",
-            "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
-            "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+        "is-upper-case": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz",
+            "integrity": "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==",
             "dev": true,
             "requires": {
-                "has-tostringtag": "^1.0.0"
+                "tslib": "^2.0.3"
             }
         },
-        "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,
-            "requires": {
-                "has-symbols": "^1.0.2"
-            }
+        "isexe": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
         },
-        "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==",
+        "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
+        },
+        "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": {
-                "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"
+                "istanbul-lib-coverage": "^3.0.0",
+                "make-dir": "^4.0.0",
+                "supports-color": "^7.1.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==",
-            "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==",
+        "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"
+                "html-escaper": "^2.0.0",
+                "istanbul-lib-report": "^3.0.0"
             }
         },
-        "is-weakset": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz",
-            "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==",
+        "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": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
+                "@isaacs/cliui": "^8.0.2",
+                "@pkgjs/parseargs": "^0.11.0"
             }
         },
-        "isarray": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
-            "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
-            "dev": true
+        "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=="
         },
-        "isexe": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+        "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
         },
         "js-yaml": {
@@ -2594,109 +7447,472 @@
                 "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==",
-            "dev": true
+            "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
         },
         "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==",
+            "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
         },
         "levn": {
             "version": "0.4.1",
             "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
             "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
-            "dev": true,
             "requires": {
                 "prelude-ls": "^1.2.1",
                 "type-check": "~0.4.0"
             }
         },
+        "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": {
+                "p-locate": "^5.0.0"
+            }
+        },
+        "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==",
+            "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": {
+                "js-tokens": "^3.0.0 || ^4.0.0"
+            }
+        },
+        "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
+        },
+        "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": {
+                "semver": "^7.5.3"
+            }
+        },
+        "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": {
+                "@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"
+            }
+        },
+        "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
         },
         "minimatch": {
             "version": "3.1.2",
             "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
             "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
-            "dev": true,
             "requires": {
                 "brace-expansion": "^1.1.7"
             }
         },
-        "minimist": {
-            "version": "1.2.6",
-            "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
-            "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
+        "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/minipass-fetch/-/minipass-fetch-3.0.4.tgz",
+            "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==",
+            "dev": true,
+            "requires": {
+                "encoding": "^0.1.13",
+                "minipass": "^7.0.3",
+                "minipass-sized": "^1.0.3",
+                "minizlib": "^2.1.2"
+            }
+        },
+        "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
         },
         "ms": {
             "version": "2.1.2",
             "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-            "dev": true
+            "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==",
+            "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-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-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": {
+                "hosted-git-info": "^7.0.0",
+                "is-core-module": "^2.8.1",
+                "semver": "^7.3.5",
+                "validate-npm-package-license": "^3.0.4"
+            }
+        },
+        "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-is": {
-            "version": "1.1.5",
-            "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
-            "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==",
+        "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.2",
-                "define-properties": "^1.1.3"
+                "semver": "^7.1.1"
             }
         },
-        "object-keys": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
-            "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+        "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
         },
-        "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-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": {
-                "call-bind": "^1.0.0",
-                "define-properties": "^1.1.3",
-                "has-symbols": "^1.0.1",
-                "object-keys": "^1.1.1"
+                "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==",
-            "dev": true,
             "requires": {
                 "deep-is": "^0.1.3",
                 "fast-levenshtein": "^2.0.6",
@@ -2706,100 +7922,443 @@
                 "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==",
-            "dev": true,
             "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=",
-            "dev": true
+            "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==",
+            "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
         },
-        "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==",
+        "polite-json": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/polite-json/-/polite-json-4.0.1.tgz",
+            "integrity": "sha512-8LI5ZeCPBEb4uBbcYKNVwk4jgqNx1yHReWoW4H4uUihWlSqZsUDfSITrRhjliuPgxsNPFhNSudGO2Zu4cbWinQ==",
             "dev": true
         },
         "prelude-ls": {
             "version": "1.2.1",
             "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
-            "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+            "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
+        },
+        "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": {
+                "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
+                }
+            }
+        },
+        "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": {
+                "fromentries": "^1.2.0"
+            }
+        },
+        "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": {
+                "err-code": "^2.0.2",
+                "retry": "^0.12.0"
+            }
+        },
         "punycode": {
             "version": "2.1.1",
             "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
-            "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
-            "dev": true
+            "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"
+            }
         },
-        "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==",
+        "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.2",
-                "define-properties": "^1.1.3"
+                "loose-envify": "^1.1.0"
             }
         },
-        "regexpp": {
-            "version": "3.2.0",
-            "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
-            "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+        "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": {
+                "loose-envify": "^1.1.0",
+                "scheduler": "^0.23.0"
+            }
+        },
+        "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
         },
-        "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==",
+        "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": {
-                "is-core-module": "^2.2.0",
-                "path-parse": "^1.0.6"
+                "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==",
-            "dev": true
+            "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
         },
-        "resumer": {
-            "version": "0.0.0",
-            "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz",
-            "integrity": "sha1-8ej0YeQGS6Oegq883CqMiT0HZ1k=",
+        "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": {
-                "through": "~2.3.4"
+                "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
+                }
             }
         },
+        "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/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==",
-            "dev": true,
             "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==",
-            "dev": true,
             "requires": {
                 "shebang-regex": "^3.0.0"
             }
@@ -2807,55 +8366,220 @@
         "shebang-regex": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
-            "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+            "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
         },
-        "side-channel": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
-            "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+        "sigstore": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.1.0.tgz",
+            "integrity": "sha512-kPIj+ZLkyI3QaM0qX8V/nSsweYND3W448pwkDgS6CQ74MfhEkIR8ToK5Iyx46KJYRjseVcD3Rp9zAmUAj6ZjPw==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.0",
-                "get-intrinsic": "^1.0.2",
-                "object-inspect": "^1.9.0"
+                "@sigstore/bundle": "^2.1.0",
+                "@sigstore/protobuf-specs": "^0.2.1",
+                "@sigstore/sign": "^2.1.0",
+                "@sigstore/tuf": "^2.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==",
+        "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": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3",
-                "es-abstract": "^1.19.1"
+                "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
+                }
             }
         },
-        "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==",
+        "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": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "ip": "^2.0.0",
+                "smart-buffer": "^4.2.0"
             }
         },
-        "string.prototype.trimstart": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
-            "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
+        "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": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "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"
@@ -2864,64 +8588,296 @@
         "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==",
-            "dev": true
+            "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==",
-            "dev": true,
             "requires": {
                 "has-flag": "^4.0.0"
             }
         },
-        "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"
+        "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,
+            "requires": {
+                "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"
+                    }
+                }
+            }
+        },
+        "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": {
+                "@istanbuljs/schema": "^0.1.2",
+                "glob": "^7.1.4",
+                "minimatch": "^3.0.4"
             }
         },
         "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==",
-            "dev": true
+            "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": {
+                "is-number": "^7.0.0"
+            }
         },
-        "through": {
-            "version": "2.3.8",
-            "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
-            "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
+        "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==",
-            "dev": true,
             "requires": {
                 "prelude-ls": "^1.2.1"
             }
@@ -2929,94 +8885,291 @@
         "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==",
+            "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
         },
-        "unbox-primitive": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
-            "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+        "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": {
-                "function-bind": "^1.1.1",
-                "has-bigints": "^1.0.1",
-                "has-symbols": "^1.0.2",
-                "which-boxed-primitive": "^1.0.2"
+                "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==",
-            "dev": true,
             "requires": {
                 "punycode": "^2.1.0"
             }
         },
-        "v8-compile-cache": {
-            "version": "2.3.0",
-            "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
-            "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+        "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==",
-            "dev": true,
             "requires": {
                 "isexe": "^2.0.0"
             }
         },
-        "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==",
+        "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": {
-                "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"
+                "string-width": "^5.0.1"
             }
         },
-        "which-collection": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz",
-            "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==",
+        "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": {
-                "is-map": "^2.0.1",
-                "is-set": "^2.0.1",
-                "is-weakmap": "^2.0.1",
-                "is-weakset": "^2.0.1"
+                "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"
+                    }
+                }
             }
         },
-        "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==",
+        "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": {
-                "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"
+                "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"
+                    }
+                }
             }
         },
-        "word-wrap": {
-            "version": "1.2.3",
-            "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
-            "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
-            "dev": true
-        },
         "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 052aae2..fc755f4 100644
--- a/package.json
+++ b/package.json
@@ -8,18 +8,69 @@
         "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": {
+        "@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": {
-        "eslint": "^8.18.0",
-        "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/things/cacheable-object.js b/src/data/cacheable-object.js
index 6a210cc..1e7c7aa 100644
--- a/src/data/things/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -74,30 +74,26 @@
 //      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});
 }
 
 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.
-    */
+  // 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();
@@ -143,7 +139,7 @@ export default class CacheableObject {
 
       const definition = {
         configurable: false,
-        enumerable: true,
+        enumerable: flags.expose,
       };
 
       if (flags.update) {
@@ -183,13 +179,9 @@ export default class CacheableObject {
           } 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
-          ].join(' ');
-          throw error;
+        } catch (caughtError) {
+          throw new CacheableObjectPropertyValueError(
+            property, oldValue, newValue, {cause: caughtError});
         }
       }
 
@@ -250,20 +242,27 @@ export default class CacheableObject {
 
     let getAllDependencies;
 
-    const dependencyKeys = expose.dependencies;
-    if (dependencyKeys?.length > 0) {
-      const reflectionEntry = [this.constructor.instance, this];
-      const dependencyGetters = dependencyKeys
-        .map(key => () => [key, this.#propertyUpdateValues[key]]);
+    if (expose.dependencies?.length > 0) {
+      const dependencyKeys = expose.dependencies.slice();
+      const shouldReflect = dependencyKeys.includes('this');
+
+      getAllDependencies = () => {
+        const dependencies = Object.create(null);
+
+        for (const key of dependencyKeys) {
+          dependencies[key] = this.#propertyUpdateValues[key];
+        }
+
+        if (shouldReflect) {
+          dependencies.this = this;
+        }
 
-      getAllDependencies = () =>
-        Object.fromEntries(dependencyGetters
-          .map(f => f())
-          .concat([reflectionEntry]));
+        return dependencies;
+      };
     } else {
-      const allDependencies = {[this.constructor.instance]: this};
-      Object.freeze(allDependencies);
-      getAllDependencies = () => allDependencies;
+      const dependencies = Object.create(null);
+      Object.freeze(dependencies);
+      getAllDependencies = () => dependencies;
     }
 
     if (flags.update) {
@@ -347,4 +346,24 @@ export default class CacheableObject {
       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/serialize.js b/src/data/serialize.js
index 52aacb0..8cac330 100644
--- a/src/data/serialize.js
+++ b/src/data/serialize.js
@@ -19,6 +19,10 @@ export function toContribRefs(contribs) {
   return contribs?.map(({who, what}) => ({who: toRef(who), what}));
 }
 
+export function toCommentaryRefs(entries) {
+  return entries?.map(({artist, ...props}) => ({artist: toRef(artist), ...props}));
+}
+
 // Interface
 
 export const serializeDescriptors = Symbol();
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/album.js b/src/data/things/album.js
index 4890aaa..40cd463 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -1,134 +1,177 @@
-import Thing from './thing.js';
-
-import find from '../../util/find.js';
+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,
-    TrackGroup,
-
-    validators: {
-      isDate,
-      isDimensions,
-      validateArrayItems,
-      validateInstanceOf,
-    },
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({
     // 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'),
+    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',
+      }),
+    ],
 
-    bannerStyle: Thing.common.simpleString(),
-    bannerFileExtension: Thing.common.fileExtension('jpg'),
-    bannerDimensions: {
-      flags: {update: true, expose: true},
-      update: {validate: isDimensions},
-    },
+    // Update only
 
-    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),
+    artistData: wikiData({
+      class: input.value(Artist),
+    }),
 
-    commentary: Thing.common.commentary(),
-    additionalFiles: Thing.common.additionalFiles(),
+    artTagData: wikiData({
+      class: input.value(ArtTag),
+    }),
 
-    // Update only
+    groupData: wikiData({
+      class: input.value(Group),
+    }),
 
-    artistData: Thing.common.wikiData(Artist),
-    artTagData: Thing.common.wikiData(ArtTag),
-    groupData: Thing.common.wikiData(Group),
-    trackData: Thing.common.wikiData(Track),
+    // 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
 
-    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)
-            : [],
-      },
-    },
+    commentatorArtists: commentatorArtists(),
 
-    groups: Thing.common.dynamicThingsFromReferenceList(
-      'groupsByRef',
-      'groupData',
-      find.group
-    ),
-
-    artTags: Thing.common.dynamicThingsFromReferenceList(
-      'artTagsByRef',
-      'artTagData',
-      find.artTag
-    ),
+    hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}),
+    hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
+    hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
+
+    tracks: [
+      withTracks(),
+      exposeDependency({dependency: '#tracks'}),
+    ],
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -159,10 +202,10 @@ export class Album extends Thing {
     bannerDimensions: S.id,
 
     hasTrackArt: S.id,
-    isMajorRelease: S.id,
     isListedOnHomepage: S.id,
 
-    commentary: S.id,
+    commentary: S.toCommentaryRefs,
+
     additionalFiles: S.id,
 
     tracks: S.toRefs,
@@ -170,74 +213,222 @@ export class Album extends Thing {
     artTags: S.toRefs,
     commentatorArtists: S.toRefs,
   });
-}
-
-export class TrackGroup extends Thing {
-  static [Thing.getPropertyDescriptors] = ({
-    isColor,
-    Track,
 
-    validators: {
-      validateInstanceOf,
+  static [Thing.findSpecs] = {
+    album: {
+      referenceTypes: ['album', 'album-commentary', 'album-gallery'],
+      bindTo: 'albumData',
     },
-  }) => ({
-    // Update & expose
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Album': {property: 'name'},
+      'Directory': {property: 'directory'},
 
-    name: Thing.common.name('Unnamed Track Group'),
+      'Bandcamp Album ID': {
+        property: 'bandcampAlbumIdentifier',
+        transform: String,
+      },
+
+      'Bandcamp Artwork ID': {
+        property: 'bandcampArtworkIdentifier',
+        transform: String,
+      },
 
-    color: {
-      flags: {update: true, expose: true},
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
 
-      update: {validate: isColor},
+      'Color': {property: 'color'},
+      'URLs': {property: 'urls'},
 
-      expose: {
-        dependencies: ['album'],
+      'Has Track Numbers': {property: 'hasTrackNumbers'},
+      'Listed on Homepage': {property: 'isListedOnHomepage'},
+      'Listed in Galleries': {property: 'isListedInGalleries'},
 
-        transform(color, {album}) {
-          return color ?? album?.color ?? null;
-        },
+      'Cover Art Date': {
+        property: 'coverArtDate',
+        transform: parseDate,
       },
-    },
 
-    dateOriginallyReleased: Thing.common.simpleDate(),
+      'Default Track Cover Art Date': {
+        property: 'trackArtDate',
+        transform: parseDate,
+      },
 
-    tracksByRef: Thing.common.referenceList(Track),
+      'Date Added': {
+        property: 'dateAddedToWiki',
+        transform: parseDate,
+      },
 
-    isDefaultTrackGroup: Thing.common.flag(false),
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
 
-    // Update only
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
 
-    album: {
-      flags: {update: true},
-      update: {validate: validateInstanceOf(Album)},
-    },
+      'Wallpaper Artists': {
+        property: 'wallpaperArtistContribs',
+        transform: parseContributors,
+      },
 
-    trackData: Thing.common.wikiData(Track),
+      'Wallpaper Style': {property: 'wallpaperStyle'},
+      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
 
-    // Expose only
+      'Banner Artists': {
+        property: 'bannerArtistContribs',
+        transform: parseContributors,
+      },
 
-    tracks: {
-      flags: {expose: true},
+      'Banner Style': {property: 'bannerStyle'},
+      'Banner File Extension': {property: 'bannerFileExtension'},
 
-      expose: {
-        dependencies: ['tracksByRef', 'trackData'],
-        compute: ({tracksByRef, trackData}) =>
-          tracksByRef && trackData
-            ? tracksByRef.map((ref) => find.track(ref, trackData)).filter(Boolean)
-            : [],
+      'Banner Dimensions': {
+        property: 'bannerDimensions',
+        transform: parseDimensions,
       },
-    },
 
-    startIndex: {
-      flags: {expose: true},
+      'Commentary': {property: 'commentary'},
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'Franchises': {ignore: true},
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
 
-      expose: {
-        dependencies: ['album'],
-        compute: ({album, [TrackGroup.instance]: trackGroup}) =>
-          album.trackGroups
-            .slice(0, album.trackGroups.indexOf(trackGroup))
-            .reduce((acc, tg) => acc + tg.tracks.length, 0),
+      '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
index 0f888a2..3149b31 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,42 +1,107 @@
-import Thing from './thing.js';
+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 {
-  sortAlbumsTracksChronologically,
-} from '../../util/wiki-data.js';
+  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,
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Art Tag'),
-    directory: Thing.common.directory(),
-    color: Thing.common.color(),
-    isContentWarning: Thing.common.flag(false),
+    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: Thing.common.wikiData(Album),
-    trackData: Thing.common.wikiData(Track),
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
 
     // Expose only
 
-    // Previously known as: (tag).things
     taggedInThings: {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['albumData', 'trackData'],
-        compute: ({albumData, trackData, [ArtTag.instance]: artTag}) =>
+        dependencies: ['this', 'albumData', 'trackData'],
+        compute: ({this: artTag, albumData, trackData}) =>
           sortAlbumsTracksChronologically(
             [...albumData, ...trackData]
               .filter(({artTags}) => artTags.includes(artTag)),
-            {getDate: o => o.coverArtDate}),
+            {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
index 303f33f..841d652 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -1,121 +1,230 @@
-import Thing from './thing.js';
-
-import find from '../../util/find.js';
+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,
-
-    validators: {
-      isName,
-      validateArrayItems,
-    },
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({Album, Flash, Track}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Artist'),
-    directory: Thing.common.directory(),
-    urls: Thing.common.urls(),
-    contextNotes: Thing.common.simpleString(),
+    name: name('Unnamed Artist'),
+    directory: directory(),
+    urls: urls(),
+
+    contextNotes: contentString(),
 
-    hasAvatar: Thing.common.flag(false),
-    avatarFileExtension: Thing.common.fileExtension('jpg'),
+    hasAvatar: flag(false),
+    avatarFileExtension: fileExtension('jpg'),
 
     aliasNames: {
       flags: {update: true, expose: true},
-      update: {
-        validate: validateArrayItems(isName),
-      },
+      update: {validate: validateArrayItems(isName)},
+      expose: {transform: (names) => names ?? []},
     },
 
-    isAlias: Thing.common.flag(),
-    aliasedArtistRef: Thing.common.singleReference(Artist),
+    isAlias: flag(),
 
-    // Update only
-
-    albumData: Thing.common.wikiData(Album),
-    artistData: Thing.common.wikiData(Artist),
-    flashData: Thing.common.wikiData(Flash),
-    trackData: Thing.common.wikiData(Track),
+    aliasedArtist: singleReference({
+      class: input.value(Artist),
+      find: input.value(find.artist),
+      data: 'artistData',
+    }),
 
-    // Expose only
+    // Update only
 
-    aliasedArtist: {
-      flags: {expose: true},
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
 
-      expose: {
-        dependencies: ['artistData', 'aliasedArtistRef'],
-        compute: ({artistData, aliasedArtistRef}) =>
-          aliasedArtistRef && artistData
-            ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'})
-            : null,
-      },
-    },
+    artistData: wikiData({
+      class: input.value(Artist),
+    }),
 
-    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)) ?? [],
-      },
-    },
+    flashData: wikiData({
+      class: input.value(Flash),
+    }),
 
-    tracksAsCommentator: {
-      flags: {expose: true},
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
 
-      expose: {
-        dependencies: ['trackData'],
+    // Expose only
 
-        compute: ({trackData, [Artist.instance]: artist}) =>
-          trackData?.filter(({commentatorArtists}) =>
-            commentatorArtists.includes(artist)) ?? [],
+    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,
+          ]),
       },
-    },
-
-    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)) ?? [],
+    ],
+
+    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,
+          ]),
       },
-    },
-
-    flashesAsContributor: Artist.filterByContrib(
-      'flashData',
-      'contributorContribs'
-    ),
+    ],
+
+    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] = ({
@@ -145,19 +254,139 @@ export class Artist extends Thing {
     flashesAsContributor: S.toRefs,
   });
 
-  static filterByContrib = (thingDataProperty, contribsProperty) => ({
-    flags: {expose: true},
+  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,
 
-    expose: {
-      dependencies: [thingDataProperty],
+    save(results) {
+      const artists = results;
 
-      compute: ({
-        [thingDataProperty]: thingData,
-        [Artist.instance]: artist
-      }) =>
-        thingData?.filter(thing =>
-          thing[contribsProperty]
-            .some(contrib => contrib.who === artist)) ?? [],
+      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
index 1383fa8..ceed79f 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -1,25 +1,48 @@
-import Thing from './thing.js';
-
-import find from '../../util/find.js';
+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,
-
-    validators: {
-      isDirectory,
-      isNumber,
-      isString,
-      oneOf,
-    },
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({Artist, Track, FlashAct}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Flash'),
+    name: name('Unnamed Flash'),
 
     directory: {
       flags: {update: true, expose: true},
@@ -40,60 +63,77 @@ export class Flash extends Thing {
 
     page: {
       flags: {update: true, expose: true},
-      update: {validate: oneOf(isString, isNumber)},
+      update: {validate: anyOf(isString, isNumber)},
 
       expose: {
         transform: (value) => (value === null ? null : value.toString()),
       },
     },
 
-    date: Thing.common.simpleDate(),
+    color: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isColor),
+      }),
 
-    coverArtFileExtension: Thing.common.fileExtension('jpg'),
+      withFlashAct(),
 
-    contributorContribsByRef: Thing.common.contribsByRef(),
+      withPropertyFromObject({
+        object: '#flashAct',
+        property: input.value('color'),
+      }),
 
-    featuredTracksByRef: Thing.common.referenceList(Track),
+      exposeDependency({dependency: '#flashAct.color'}),
+    ],
 
-    urls: Thing.common.urls(),
+    date: simpleDate(),
 
-    // Update only
+    coverArtFileExtension: fileExtension('jpg'),
 
-    artistData: Thing.common.wikiData(Artist),
-    trackData: Thing.common.wikiData(Track),
-    flashActData: Thing.common.wikiData(FlashAct),
+    contributorContribs: contributionList(),
 
-    // Expose only
+    featuredTracks: referenceList({
+      class: input.value(Track),
+      find: input.value(find.track),
+      data: 'trackData',
+    }),
 
-    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
+    urls: urls(),
 
-    featuredTracks: Thing.common.dynamicThingsFromReferenceList(
-      'featuredTracksByRef',
-      'trackData',
-      find.track
-    ),
+    commentary: commentary(),
 
-    act: {
-      flags: {expose: true},
+    // Update only
 
-      expose: {
-        dependencies: ['flashActData'],
+    artistData: wikiData({
+      class: input.value(Artist),
+    }),
 
-        compute: ({flashActData, [Flash.instance]: flash}) =>
-          flashActData.find((act) => act.flashes.includes(flash)) ?? null,
-      },
-    },
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
 
-    color: {
-      flags: {expose: true},
+    flashActData: wikiData({
+      class: input.value(FlashAct),
+    }),
 
-      expose: {
-        dependencies: ['flashActData'],
+    // Expose only
 
-        compute: ({flashActData, [Flash.instance]: flash}) =>
-          flashActData.find((act) => act.flashes.includes(flash))?.color ?? null,
-      },
-    },
+    commentatorArtists: commentatorArtists(),
+
+    act: [
+      withFlashAct(),
+      exposeDependency({dependency: '#flashAct'}),
+    ],
+
+    side: [
+      withFlashAct(),
+
+      withPropertyFromObject({
+        object: '#flashAct',
+        property: input.value('side'),
+      }),
+
+      exposeDependency({dependency: '#flashAct.side'}),
+    ],
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -108,43 +148,223 @@ export class Flash extends Thing {
     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.getPropertyDescriptors] = ({
-    validators: {
-      isColor,
-    },
-  }) => ({
+  static [Thing.referenceType] = 'flash-act';
+  static [Thing.friendlyName] = `Flash Act`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Flash Act'),
-    color: Thing.common.color(),
-    anchor: Thing.common.simpleString(),
-    jump: Thing.common.simpleString(),
+    name: name('Unnamed Flash Act'),
+    directory: directory(),
+    color: color(),
 
-    jumpColor: {
-      flags: {update: true, expose: true},
-      update: {validate: isColor},
-      expose: {
-        dependencies: ['color'],
-        transform: (jumpColor, {color}) =>
-          jumpColor ?? color,
-      }
-    },
+    listTerminology: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isContentString),
+      }),
+
+      withFlashSide(),
 
-    flashesByRef: Thing.common.referenceList(Flash),
+      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: Thing.common.wikiData(Flash),
+    flashData: wikiData({
+      class: input.value(Flash),
+    }),
+
+    flashSideData: wikiData({
+      class: input.value(FlashSide),
+    }),
 
     // Expose only
 
-    flashes: Thing.common.dynamicThingsFromReferenceList(
-      'flashesByRef',
-      'flashData',
-      find.flash
-    ),
-  })
+    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
index 26fe9a5..0dbbbb7 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -1,26 +1,47 @@
-import Thing from './thing.js';
-
-import find from '../../util/find.js';
+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,
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({Album}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Group'),
-    directory: Thing.common.directory(),
+    name: name('Unnamed Group'),
+    directory: directory(),
+
+    description: contentString(),
 
-    description: Thing.common.simpleString(),
+    urls: urls(),
 
-    urls: Thing.common.urls(),
+    featuredAlbums: referenceList({
+      class: input.value(Album),
+      find: input.value(find.album),
+      data: 'albumData',
+    }),
 
     // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    groupCategoryData: Thing.common.wikiData(GroupCategory),
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    groupCategoryData: wikiData({
+      class: input.value(GroupCategory),
+    }),
 
     // Expose only
 
@@ -29,7 +50,10 @@ export class Group extends Thing {
 
       expose: {
         dependencies: ['description'],
-        compute: ({description}) => description.split('<hr class="split">')[0],
+        compute: ({description}) =>
+          (description
+            ? description.split('<hr class="split">')[0]
+            : null),
       },
     },
 
@@ -37,8 +61,8 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['albumData'],
-        compute: ({albumData, [Group.instance]: group}) =>
+        dependencies: ['this', 'albumData'],
+        compute: ({this: group, albumData}) =>
           albumData?.filter((album) => album.groups.includes(group)) ?? [],
       },
     },
@@ -47,9 +71,8 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['groupCategoryData'],
-
-        compute: ({groupCategoryData, [Group.instance]: group}) =>
+        dependencies: ['this', 'groupCategoryData'],
+        compute: ({this: group, groupCategoryData}) =>
           groupCategoryData.find((category) => category.groups.includes(group))
             ?.color,
       },
@@ -59,36 +82,113 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['groupCategoryData'],
-        compute: ({groupCategoryData, [Group.instance]: group}) =>
+        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.getPropertyDescriptors] = ({
-    Group,
-  }) => ({
-    // Update & expose
+  static [Thing.referenceType] = 'group-category';
+  static [Thing.friendlyName] = `Group Category`;
 
-    name: Thing.common.name('Unnamed Group Category'),
-    color: Thing.common.color(),
+  static [Thing.getPropertyDescriptors] = ({Group}) => ({
+    // Update & expose
 
-    groupsByRef: Thing.common.referenceList(Group),
+    name: name('Unnamed Group Category'),
+    directory: directory(),
 
-    // Update only
+    color: color(),
 
-    groupData: Thing.common.wikiData(Group),
+    groups: referenceList({
+      class: input.value(Group),
+      find: input.value(find.group),
+      data: 'groupData',
+    }),
 
-    // Expose only
+    // Update only
 
-    groups: Thing.common.dynamicThingsFromReferenceList(
-      'groupsByRef',
-      'groupData',
-      find.group
-    ),
+    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
index 5948ff4..00d6aef 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -1,19 +1,37 @@
-import Thing from './thing.js';
-
-import find from '../../util/find.js';
+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.getPropertyDescriptors] = ({
-    HomepageLayoutRow,
+  static [Thing.friendlyName] = `Homepage Layout`;
 
-    validators: {
-      validateArrayItems,
-      validateInstanceOf,
-    },
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({
     // Update & expose
 
-    sidebarContent: Thing.common.simpleString(),
+    sidebarContent: contentString(),
+
+    navbarLinks: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isStringNonEmpty)},
+    },
 
     rows: {
       flags: {update: true, expose: true},
@@ -22,17 +40,25 @@ export class HomepageLayout extends Thing {
         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.getPropertyDescriptors] = ({
-    Album,
-    Group,
-  }) => ({
+  static [Thing.friendlyName] = `Homepage Row`;
+
+  static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Homepage Row'),
+    name: name('Unnamed Homepage Row'),
 
     type: {
       flags: {update: true, expose: true},
@@ -44,29 +70,36 @@ export class HomepageLayoutRow extends Thing {
       },
     },
 
-    color: Thing.common.color(),
+    color: 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),
+    // 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.getPropertyDescriptors] = (opts, {
-    Album,
-    Group,
-
-    validators: {
-      isCountingNumber,
-      isString,
-      validateArrayItems,
-    },
-  } = opts) => ({
+  static [Thing.friendlyName] = `Homepage Albums Row`;
+
+  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
     ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
 
     // Update & expose
@@ -84,8 +117,52 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
       },
     },
 
-    sourceGroupByRef: Thing.common.singleReference(Group),
-    sourceAlbumsByRef: Thing.common.referenceList(Album),
+    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},
@@ -96,19 +173,51 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
       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'},
+    },
+  });
 
-    // Expose only
+  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']}!`);
+      }
+    },
 
-    sourceGroup: Thing.common.dynamicThingFromSingleReference(
-      'sourceGroupByRef',
-      'groupData',
-      find.group
-    ),
+    save(results) {
+      if (!results[0]) {
+        return;
+      }
 
-    sourceAlbums: Thing.common.dynamicThingsFromReferenceList(
-      'sourceAlbumsByRef',
-      'albumData',
-      find.album
-    ),
+      const {header: homepageLayout, entries: rows} = results[0];
+      Object.assign(homepageLayout, {rows});
+      return {homepageLayout};
+    },
   });
 }
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 11b6b1a..3bf8409 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -1,12 +1,12 @@
-import {logError} from '../../util/cli.js';
-import {openAggregate, showAggregate} from '../../util/sugar.js';
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
 
-import * as path from 'path';
-import {fileURLToPath} from 'url';
+import {openAggregate, showAggregate} from '#aggregate';
+import {logError} from '#cli';
+import {compositeFrom} from '#composite';
+import * as serialize from '#serialize';
 
-import Thing from './thing.js';
-import * as validators from './validators.js';
-import * as serialize from '../serialize.js';
+import Thing from '#thing';
 
 import * as albumClasses from './album.js';
 import * as artTagClasses from './art-tag.js';
@@ -79,6 +79,8 @@ function errorDuplicateClassNames() {
 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;
     }
   }
@@ -116,7 +118,7 @@ function descriptorAggregateHelper({
 }
 
 function evaluatePropertyDescriptors() {
-  const opts = {...allClasses, validators};
+  const opts = {...allClasses};
 
   return descriptorAggregateHelper({
     message: `Errors evaluating Thing class property descriptors`,
@@ -126,8 +128,21 @@ function evaluatePropertyDescriptors() {
         throw new Error(`Missing [Thing.getPropertyDescriptors] function`);
       }
 
-      constructor.propertyDescriptors =
-        constructor[Thing.getPropertyDescriptors](opts);
+      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) {
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 3086ad2..dbe1ff3 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,11 +1,27 @@
-import Thing from './thing.js';
+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] = ({
-    validators: {
-      isLanguageCode,
-    },
-  }) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     // General language code. This is used to identify the language distinctly
@@ -18,7 +34,7 @@ export class Language extends Thing {
 
     // Human-readable name. This should be the language's own native name, not
     // localized to any other language.
-    name: Thing.common.simpleString(),
+    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
@@ -40,21 +56,53 @@ export class Language extends Thing {
     // 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),
+    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'],
-        transform(strings, {inheritedStrings}) {
-          if (strings || inheritedStrings) {
-            return {...(inheritedStrings ?? {}), ...(strings ?? {})};
-          } else {
-            return null;
+        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;
         },
       },
     },
@@ -66,9 +114,16 @@ export class Language extends Thing {
       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: Thing.common.externalFunction(),
+    escapeHTML: externalFunction(),
 
     // Expose only
 
@@ -95,13 +150,14 @@ export class Language extends Thing {
       },
     },
 
+    // 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 ?? {})};
+          const allStrings = {...inheritedStrings, ...strings};
           return Object.fromEntries(
             Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)])
           );
@@ -124,8 +180,8 @@ export class Language extends Thing {
     };
   }
 
-  $(key, args = {}) {
-    return this.formatString(key, args);
+  $(...args) {
+    return this.formatString(...args);
   }
 
   assertIntlAvailable(property) {
@@ -139,20 +195,22 @@ export class Language extends Thing {
     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`);
-    }
+  formatString(...args) {
+    const hasOptions =
+      typeof args.at(-1) === 'object' &&
+      args.at(-1) !== null;
 
-    return this.formatStringHelper(this.strings_htmlEscaped, key, args);
-  }
+    const key =
+      (hasOptions ? args.slice(0, -1) : args)
+        .filter(Boolean)
+        .join('.');
 
-  formatStringNoHTMLEscape(key, args = {}) {
-    return this.formatStringHelper(this.strings, key, args);
-  }
+    const options =
+      (hasOptions
+        ? args.at(-1)
+        : {});
 
-  formatStringHelper(strings, key, args = {}) {
-    if (!strings) {
+    if (!this.strings) {
       throw new Error(`Strings unavailable`);
     }
 
@@ -160,32 +218,203 @@ export class Language extends Thing {
       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
-    );
+    // 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);
+          }
 
-    // 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}`);
-    }
+          // 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);
@@ -196,6 +425,108 @@ export class Language extends Thing {
     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');
@@ -226,6 +557,37 @@ export class Language extends Thing {
       : 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});
@@ -249,22 +611,64 @@ export class Language extends Thing {
     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.intl_listConjunction.format(array);
+    return this.#formatListHelper(
+      array,
+      array => this.intl_listConjunction.format(array));
   }
 
   // Disjunction lists: A, B, or C
   formatDisjunctionList(array) {
     this.assertIntlAvailable('intl_listDisjunction');
-    return this.intl_listDisjunction.format(array);
+    return this.#formatListHelper(
+      array,
+      array => this.intl_listDisjunction.format(array));
   }
 
   // Unit lists: A, B, C
   formatUnitList(array) {
     this.assertIntlAvailable('intl_listUnit');
-    return this.intl_listUnit.format(array);
+    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
@@ -298,23 +702,29 @@ export class Language extends Thing {
   }
 }
 
-const countHelper = (stringKey, argName = stringKey) =>
+const countHelper = (stringKey, optionName = stringKey) =>
   function(value, {unit = false} = {}) {
     return this.formatString(
       unit
         ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
         : `count.${stringKey}`,
-      {[argName]: this.formatNumber(value)});
+      {[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
index 4391141..43d1638 100644
--- a/src/data/things/news-entry.js
+++ b/src/data/things/news-entry.js
@@ -1,16 +1,24 @@
-import Thing from './thing.js';
+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: Thing.common.name('Unnamed News Entry'),
-    directory: Thing.common.directory(),
-    date: Thing.common.simpleDate(),
+    name: name('Unnamed News Entry'),
+    directory: directory(),
+    date: simpleDate(),
 
-    content: Thing.common.simpleString(),
+    content: contentString(),
 
     // Expose only
 
@@ -24,4 +32,42 @@ export class NewsEntry extends Thing {
       },
     },
   });
+
+  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
index 226e0b6..0327497 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -1,16 +1,23 @@
-import Thing from './thing.js';
+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] = ({
-    validators: {
-      isName,
-    },
-  }) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Static Page'),
+    name: name('Unnamed Static Page'),
 
     nameShort: {
       flags: {update: true, expose: true},
@@ -22,9 +29,52 @@ export class StaticPage extends Thing {
       },
     },
 
-    directory: Thing.common.directory(),
-    content: Thing.common.simpleString(),
-    stylesheet: Thing.common.simpleString(),
-    showInNavigationBar: Thing.common.flag(true),
+    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/thing.js b/src/data/things/thing.js
deleted file mode 100644
index b9fa60c..0000000
--- a/src/data/things/thing.js
+++ /dev/null
@@ -1,385 +0,0 @@
-// Thing: base class for wiki data types, providing wiki-specific utility
-// functions on top of essential CacheableObject behavior.
-
-import CacheableObject from './cacheable-object.js';
-
-import {
-  isAdditionalFileList,
-  isBoolean,
-  isCommentary,
-  isColor,
-  isContributionList,
-  isDate,
-  isDirectory,
-  isFileExtension,
-  isName,
-  isString,
-  isURL,
-  validateArrayItems,
-  validateInstanceOf,
-  validateReference,
-  validateReferenceList,
-} from './validators.js';
-
-import {inspect} from 'util';
-import {color} from '../../util/cli.js';
-import {getKebabCase} from '../../util/wiki-data.js';
-
-import find from '../../util/find.js';
-
-export default class Thing extends CacheableObject {
-  static referenceType = Symbol('Thing.referenceType');
-
-  static getPropertyDescriptors = Symbol('Thing.getPropertyDescriptors');
-  static getSerializeDescriptors = Symbol('Thing.getSerializeDescriptors');
-
-  // 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!
-  static 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: (thingDataProperty, referencerRefListProperty) => ({
-      flags: {expose: true},
-
-      expose: {
-        dependencies: [thingDataProperty],
-
-        compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) =>
-          thingData?.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: (thingDataProperty, referencerRefListProperty) => ({
-      flags: {expose: true},
-
-      expose: {
-        dependencies: [thingDataProperty],
-
-        compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) =>
-          thingData?.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'})
-                  )
-                )
-              )
-            : [],
-      },
-    }),
-  };
-
-  // 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} ${color.green(`"${this.name}"`)}` : `${cname}`) +
-      (this.directory ? ` (${color.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}`;
-  }
-}
diff --git a/src/data/things/track.js b/src/data/things/track.js
index d2930ff..cc49fc2 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -1,332 +1,566 @@
-import Thing from './thing.js';
-
-import {inspect} from 'util';
-import {color} from '../../util/cli.js';
-
-import find from '../../util/find.js';
+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,
-
-    validators: {
-      isBoolean,
-      isDate,
-      isDuration,
-      isFileExtension,
-    },
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Track'),
-    directory: Thing.common.directory(),
+    name: name('Unnamed Track'),
+    directory: directory(),
 
-    duration: {
-      flags: {update: true, expose: true},
-      update: {validate: isDuration},
-    },
+    additionalNames: additionalNameList(),
+    sharedAdditionalNames: sharedAdditionalNameList(),
+    inferredAdditionalNames: inferredAdditionalNameList(),
 
-    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),
-    sampledTracksByRef: 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
-          ),
+    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,
       },
-    },
 
-    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',
+      'Bandcamp Track ID': {
+        property: 'bandcampTrackIdentifier',
+        transform: String,
       },
-    },
 
-    // Previously known as: (track).aka
-    originalReleaseTrackByRef: Thing.common.singleReference(Track),
+      'Bandcamp Artwork ID': {
+        property: 'bandcampArtworkIdentifier',
+        transform: String,
+      },
 
-    dataSourceAlbumByRef: Thing.common.singleReference(Album),
+      'Duration': {
+        property: 'duration',
+        transform: parseDuration,
+      },
 
-    commentary: Thing.common.commentary(),
-    lyrics: Thing.common.simpleString(),
-    additionalFiles: Thing.common.additionalFiles(),
+      'Color': {property: 'color'},
+      'URLs': {property: 'urls'},
 
-    // Update only
+      'Date First Released': {
+        property: 'dateFirstReleased',
+        transform: parseDate,
+      },
 
-    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),
+      'Cover Art Date': {
+        property: 'coverArtDate',
+        transform: parseDate,
+      },
 
-    // Expose only
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
-    commentatorArtists: Thing.common.commentatorArtists(),
+      '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'},
 
-    album: {
-      flags: {expose: true},
+      'Lyrics': {property: 'lyrics'},
+      'Commentary': {property: 'commentary'},
 
-      expose: {
-        dependencies: ['albumData'],
-        compute: ({[Track.instance]: track, albumData}) =>
-          albumData?.find((album) => album.tracks.includes(track)) ?? null,
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
       },
-    },
 
-    // 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,
+      'Sheet Music Files': {
+        property: 'sheetMusicFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'MIDI Project Files': {
+        property: 'midiProjectFiles',
+        transform: parseAdditionalFiles,
       },
-    },
 
-    color: {
-      flags: {expose: true},
+      'Originally Released As': {property: 'originalReleaseTrack'},
+      'Referenced Tracks': {property: 'referencedTracks'},
+      'Sampled Tracks': {property: 'sampledTracks'},
 
-      expose: {
-        dependencies: ['albumData'],
+      'Franchises': {ignore: true},
+      'Inherit Franchises': {ignore: true},
 
-        compute: ({albumData, [Track.instance]: track}) =>
-          Track.findAlbum(track, albumData)?.trackGroups.find((tg) =>
-            tg.tracks.includes(track)
-          )?.color ?? null,
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
       },
-    },
 
-    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,
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
       },
-    },
 
-    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);
-        },
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
       },
+
+      'Art Tags': {property: 'artTags'},
+
+      'Review Points': {ignore: true},
     },
 
-    // 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
-    ),
-
-    sampledTracks: Thing.common.dynamicThingsFromReferenceList(
-      'sampledTracksByRef',
-      '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))
-            : [],
+    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]),
     },
 
-    // For the same reasoning, exclude re-releases from sampled tracks too.
-    sampledByTracks: {
-      flags: {expose: true},
+    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]),
+    },
+  };
 
-      expose: {
-        dependencies: ['trackData'],
+  // Track YAML loading is handled in album.js.
+  static [Thing.getYamlLoadingSpec] = null;
 
-        compute: ({trackData, [Track.instance]: track}) =>
-          trackData
-            ? trackData
-                .filter((t) => !t.originalReleaseTrack)
-                .filter((t) => t.sampledTracks?.includes(track))
-            : [],
-      },
-    },
+  [inspect.custom](depth) {
+    const parts = [];
 
-    // Previously known as: (track).flashes
-    featuredInFlashes: Thing.common.reverseReferenceList(
-      'flashData',
-      'featuredTracks'
-    ),
-
-    artTags: Thing.common.dynamicThingsFromReferenceList(
-      'artTagsByRef',
-      'artTagData',
-      find.artTag
-    ),
-  });
+    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)})`);
+    }
 
-  // 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
-  static findAlbum = (track, albumData) =>
-    albumData?.find((album) => album.tracks.includes(track));
-
-  // Another reused utility function. This one's logic is a bit more complicated.
-  static hasCoverArt = (
-    track,
-    albumData,
-    coverArtistContribsByRef,
-    hasCoverArt
-  ) => (
-    hasCoverArt ??
-    (coverArtistContribsByRef?.length > 0 || null) ??
-    Track.findAlbum(track, albumData)?.hasTrackArt ??
-    true
-  );
-
-  [inspect.custom]() {
-    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;
+    return parts.join('');
   }
 }
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
deleted file mode 100644
index cc603d4..0000000
--- a/src/data/things/validators.js
+++ /dev/null
@@ -1,367 +0,0 @@
-import {withAggregate} from '../../util/sugar.js';
-
-import {color, ENABLE_COLOR} from '../../util/cli.js';
-
-import {inspect as nodeInspect} from 'util';
-
-function inspect(value) {
-  return nodeInspect(value, {colors: ENABLE_COLOR});
-}
-
-// Basic types (primitives)
-
-function a(noun) {
-  return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`;
-}
-
-function isType(value, type) {
-  if (typeof value !== type)
-    throw new TypeError(`Expected ${a(type)}, got ${typeof value}`);
-
-  return true;
-}
-
-export function isBoolean(value) {
-  return isType(value, 'boolean');
-}
-
-export function isNumber(value) {
-  return isType(value, 'number');
-}
-
-export function isPositive(number) {
-  isNumber(number);
-
-  if (number <= 0) throw new TypeError(`Expected positive number`);
-
-  return true;
-}
-
-export function isNegative(number) {
-  isNumber(number);
-
-  if (number >= 0) throw new TypeError(`Expected negative number`);
-
-  return true;
-}
-
-export function isPositiveOrZero(number) {
-  isNumber(number);
-
-  if (number < 0) throw new TypeError(`Expected positive number or zero`);
-
-  return true;
-}
-
-export function isNegativeOrZero(number) {
-  isNumber(number);
-
-  if (number > 0) throw new TypeError(`Expected negative number or zero`);
-
-  return true;
-}
-
-export function isInteger(number) {
-  isNumber(number);
-
-  if (number % 1 !== 0) throw new TypeError(`Expected integer`);
-
-  return true;
-}
-
-export function isCountingNumber(number) {
-  isInteger(number);
-  isPositive(number);
-
-  return true;
-}
-
-export function isWholeNumber(number) {
-  isInteger(number);
-  isPositiveOrZero(number);
-
-  return true;
-}
-
-export function isString(value) {
-  return isType(value, 'string');
-}
-
-export function isStringNonEmpty(value) {
-  isString(value);
-
-  if (value.trim().length === 0)
-    throw new TypeError(`Expected non-empty string`);
-
-  return true;
-}
-
-// Complex types (non-primitives)
-
-export function isInstance(value, constructor) {
-  isObject(value);
-
-  if (!(value instanceof constructor))
-    throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`);
-
-  return true;
-}
-
-export function isDate(value) {
-  return isInstance(value, Date);
-}
-
-export function isObject(value) {
-  isType(value, 'object');
-
-  // Note: Please remember that null is always a valid value for properties
-  // held by a CacheableObject. This assertion is exclusively for use in other
-  // contexts.
-  if (value === null) throw new TypeError(`Expected an object, got null`);
-
-  return true;
-}
-
-export function isArray(value) {
-  if (typeof value !== 'object' || value === null || !Array.isArray(value))
-    throw new TypeError(`Expected an array, got ${value}`);
-
-  return true;
-}
-
-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;
-    }
-  };
-}
-
-export function validateArrayItems(itemValidator) {
-  const fn = validateArrayItemsHelper(itemValidator);
-
-  return (array) => {
-    isArray(array);
-
-    withAggregate({message: 'Errors validating array items'}, ({wrap}) => {
-      array.forEach(wrap(fn));
-    });
-
-    return true;
-  };
-}
-
-export function validateInstanceOf(constructor) {
-  return (object) => isInstance(object, constructor);
-}
-
-// Wiki data (primitives & non-primitives)
-
-export function isColor(color) {
-  isStringNonEmpty(color);
-
-  if (color.startsWith('#')) {
-    if (![4, 5, 7, 9].includes(color.length))
-      throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`);
-
-    if (/[^0-9a-fA-F]/.test(color.slice(1)))
-      throw new TypeError(`Expected hexadecimal digits`);
-
-    return true;
-  }
-
-  throw new TypeError(`Unknown color format`);
-}
-
-export function isCommentary(commentary) {
-  return isString(commentary);
-}
-
-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(', ')}]`);
-        });
-      }
-    });
-
-    return true;
-  };
-}
-
-export const isContribution = validateProperties({
-  who: isArtistRef,
-  what: (value) =>
-    value === undefined ||
-    value === null ||
-    isStringNonEmpty(value),
-});
-
-export const isContributionList = validateArrayItems(isContribution);
-
-export const isAdditionalFile = validateProperties({
-  title: isString,
-  description: (value) =>
-    value === undefined ||
-    value === null ||
-    isString(value),
-  files: validateArrayItems(isString),
-});
-
-export const isAdditionalFileList = validateArrayItems(isAdditionalFile);
-
-export function isDimensions(dimensions) {
-  isArray(dimensions);
-
-  if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`);
-
-  isPositive(dimensions[0]);
-  isInteger(dimensions[0]);
-  isPositive(dimensions[1]);
-  isInteger(dimensions[1]);
-
-  return true;
-}
-
-export function isDirectory(directory) {
-  isStringNonEmpty(directory);
-
-  if (directory.match(/[^a-zA-Z0-9_-]/))
-    throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`);
-
-  return true;
-}
-
-export function isDuration(duration) {
-  isNumber(duration);
-  isPositiveOrZero(duration);
-
-  return true;
-}
-
-export function isFileExtension(string) {
-  isStringNonEmpty(string);
-
-  if (string[0] === '.')
-    throw new TypeError(`Expected no dot (.) at the start of file extension`);
-
-  if (string.match(/[^a-zA-Z0-9_]/))
-    throw new TypeError(`Expected only alphanumeric and underscore`);
-
-  return true;
-}
-
-export function isLanguageCode(string) {
-  // TODO: This is a stub function because really we don't need a detailed
-  // is-language-code parser right now.
-
-  isString(string);
-
-  return true;
-}
-
-export function isName(name) {
-  return isString(name);
-}
-
-export function isURL(string) {
-  isStringNonEmpty(string);
-
-  new URL(string);
-
-  return true;
-}
-
-export function validateReference(type = 'track') {
-  return (ref) => {
-    isStringNonEmpty(ref);
-
-    const match = ref
-      .trim()
-      .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
-
-    if (!match) throw new TypeError(`Malformed reference`);
-
-    const {groups: {typePart, directoryPart}} = match;
-
-    if (typePart) {
-      if (typePart !== type)
-        throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`);
-
-      isDirectory(directoryPart);
-    }
-
-    isName(ref);
-
-    return true;
-  };
-}
-
-export function validateReferenceList(type = '') {
-  return validateArrayItems(validateReference(type));
-}
-
-// Compositional utilities
-
-export function oneOf(...checks) {
-  return (value) => {
-    const errorMeta = [];
-
-    for (let i = 0, check; (check = checks[i]); i++) {
-      try {
-        const result = check(value);
-
-        if (result !== true) {
-          throw new Error(`Check returned false`);
-        }
-
-        return true;
-      } catch (error) {
-        errorMeta.push([check, i, error]);
-      }
-    }
-
-    // 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);
-    }
-    throw new AggregateError(errors, `Expected one of ${checks.length} possible checks, but none were true`);
-  };
-}
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index adf085e..316bd3b 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -1,20 +1,20 @@
-import Thing from './thing.js';
+export const WIKI_INFO_FILE = 'wiki-info.yaml';
 
-import find from '../../util/find.js';
+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.getPropertyDescriptors] = ({
-    Group,
+  static [Thing.friendlyName] = `Wiki Info`;
 
-    validators: {
-      isLanguageCode,
-      isName,
-      isURL,
-    },
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({Group}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Wiki'),
+    name: name('Unnamed Wiki'),
 
     // Displayed in nav bar.
     nameShort: {
@@ -27,12 +27,19 @@ export class WikiInfo extends Thing {
       },
     },
 
-    color: Thing.common.color(),
+    color: {
+      flags: {update: true, expose: true},
+      update: {validate: isColor},
+
+      expose: {
+        transform: color => color ?? '#0088ff',
+      },
+    },
 
     // One-line description used for <meta rel="description"> tag.
-    description: Thing.common.simpleString(),
+    description: contentString(),
 
-    footerContent: Thing.common.simpleString(),
+    footerContent: contentString(),
 
     defaultLanguage: {
       flags: {update: true, expose: true},
@@ -44,25 +51,60 @@ export class WikiInfo extends Thing {
       update: {validate: isURL},
     },
 
-    divideTrackListsByGroupsByRef: Thing.common.referenceList(Group),
+    divideTrackListsByGroups: referenceList({
+      class: input.value(Group),
+      find: input.value(find.group),
+      data: 'groupData',
+    }),
 
     // 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),
+    enableFlashesAndGames: flag(false),
+    enableListings: flag(false),
+    enableNews: flag(false),
+    enableArtTagUI: flag(false),
+    enableGroupUI: flag(false),
 
     // Update only
 
-    groupData: Thing.common.wikiData(Group),
+    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'},
+    },
+  };
 
-    // Expose only
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {oneDocumentTotal},
+    thingConstructors: {WikiInfo},
+  }) => ({
+    title: `Process wiki info file`,
+    file: WIKI_INFO_FILE,
 
-    divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList(
-      'divideTrackListsByGroupsByRef',
-      'groupData',
-      find.group
-    ),
+    documentMode: oneDocumentTotal,
+    documentThing: WikiInfo,
+
+    save(wikiInfo) {
+      if (!wikiInfo) {
+        return;
+      }
+
+      return {wikiInfo};
+    },
   });
 }
diff --git a/src/data/validators.js b/src/data/validators.js
new file mode 100644
index 0000000..987f806
--- /dev/null
+++ b/src/data/validators.js
@@ -0,0 +1,997 @@
+import {inspect as nodeInspect} from 'node:util';
+
+import {openAggregate, withAggregate} from '#aggregate';
+import {colors, ENABLE_COLOR} from '#cli';
+import {cut, empty, matchMultiline, typeAppearance} from '#sugar';
+import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot}
+  from '#wiki-data';
+
+function inspect(value) {
+  return nodeInspect(value, {colors: ENABLE_COLOR});
+}
+
+export function getValidatorCreator(validator) {
+  return validator[Symbol.for(`hsmusic.validator.creator`)] ?? null;
+}
+
+export function getValidatorCreatorMeta(validator) {
+  return validator[Symbol.for(`hsmusic.validator.creatorMeta`)] ?? null;
+}
+
+export function setValidatorCreatorMeta(validator, creator, meta) {
+  validator[Symbol.for(`hsmusic.validator.creator`)] = creator;
+  validator[Symbol.for(`hsmusic.validator.creatorMeta`)] = meta;
+  return validator;
+}
+
+// Basic types (primitives)
+
+export function a(noun) {
+  return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`;
+}
+
+export function validateType(type) {
+  const fn = value => {
+    if (typeof value !== type)
+      throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`);
+
+    return true;
+  };
+
+  setValidatorCreatorMeta(fn, validateType, {type});
+
+  return fn;
+}
+
+export const isBoolean =
+  validateType('boolean');
+
+export const isFunction =
+  validateType('function');
+
+export const isNumber =
+  validateType('number');
+
+export const isString =
+  validateType('string');
+
+export const isSymbol =
+  validateType('symbol');
+
+// Use isObject instead, which disallows null.
+export const isTypeofObject =
+  validateType('object');
+
+export function isPositive(number) {
+  isNumber(number);
+
+  if (number <= 0) throw new TypeError(`Expected positive number`);
+
+  return true;
+}
+
+export function isNegative(number) {
+  isNumber(number);
+
+  if (number >= 0) throw new TypeError(`Expected negative number`);
+
+  return true;
+}
+
+export function isPositiveOrZero(number) {
+  isNumber(number);
+
+  if (number < 0) throw new TypeError(`Expected positive number or zero`);
+
+  return true;
+}
+
+export function isNegativeOrZero(number) {
+  isNumber(number);
+
+  if (number > 0) throw new TypeError(`Expected negative number or zero`);
+
+  return true;
+}
+
+export function isInteger(number) {
+  isNumber(number);
+
+  if (number % 1 !== 0) throw new TypeError(`Expected integer`);
+
+  return true;
+}
+
+export function isCountingNumber(number) {
+  isInteger(number);
+  isPositive(number);
+
+  return true;
+}
+
+export function isWholeNumber(number) {
+  isInteger(number);
+  isPositiveOrZero(number);
+
+  return true;
+}
+
+export function isStringNonEmpty(value) {
+  isString(value);
+
+  if (value.trim().length === 0)
+    throw new TypeError(`Expected non-empty string`);
+
+  return true;
+}
+
+export function optional(validator) {
+  return value =>
+    value === null ||
+    value === undefined ||
+    validator(value);
+}
+
+// Complex types (non-primitives)
+
+export function isInstance(value, constructor) {
+  isObject(value);
+
+  if (!(value instanceof constructor))
+    throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`);
+
+  return true;
+}
+
+export function isDate(value) {
+  isInstance(value, Date);
+
+  if (isNaN(value))
+    throw new TypeError(`Expected valid date`);
+
+  return true;
+}
+
+export function isObject(value) {
+  isTypeofObject(value);
+
+  // Note: Please remember that null is always a valid value for properties
+  // held by a CacheableObject. This assertion is exclusively for use in other
+  // contexts.
+  if (value === null)
+    throw new TypeError(`Expected an object, got null`);
+
+  return true;
+}
+
+export function isArray(value) {
+  if (typeof value !== 'object' || value === null || !Array.isArray(value))
+    throw new TypeError(`Expected an array, got ${typeAppearance(value)}`);
+
+  return true;
+}
+
+// This one's shaped a bit different from other "is" functions.
+// More like validate functions, it returns a function.
+export function is(...values) {
+  if (Array.isArray(values)) {
+    values = new Set(values);
+  }
+
+  if (values.size === 1) {
+    const expected = Array.from(values)[0];
+
+    return (value) => {
+      if (value !== expected) {
+        throw new TypeError(`Expected ${expected}, got ${value}`);
+      }
+
+      return true;
+    };
+  }
+
+  const fn = (value) => {
+    if (!values.has(value)) {
+      throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`);
+    }
+
+    return true;
+  };
+
+  setValidatorCreatorMeta(fn, is, {values});
+
+  return fn;
+}
+
+function validateArrayItemsHelper(itemValidator) {
+  return (item, index, array) => {
+    try {
+      const value = itemValidator(item, index, array);
+
+      if (value !== true) {
+        throw new Error(`Expected validator to return true`);
+      }
+    } catch (caughtError) {
+      const indexPart = colors.yellow(`zero-index ${index}`)
+      const itemPart = inspect(item);
+      const message = `Error at ${indexPart}: ${itemPart}`;
+      const error = new Error(message, {cause: caughtError});
+      error[Symbol.for('hsmusic.annotateError.indexInSourceArray')] = index;
+      throw error;
+    }
+  };
+}
+
+export function validateArrayItems(itemValidator) {
+  const helper = validateArrayItemsHelper(itemValidator);
+
+  return (array) => {
+    isArray(array);
+
+    withAggregate({message: 'Errors validating array items'}, ({call}) => {
+      for (let index = 0; index < array.length; index++) {
+        call(helper, array[index], index, array);
+      }
+    });
+
+    return true;
+  };
+}
+
+export function strictArrayOf(itemValidator) {
+  return validateArrayItems(itemValidator);
+}
+
+export function sparseArrayOf(itemValidator) {
+  return validateArrayItems((item, index, array) => {
+    if (item === false || item === null) {
+      return true;
+    }
+
+    return itemValidator(item, index, array);
+  });
+}
+
+export function looseArrayOf(itemValidator) {
+  return validateArrayItems((item, index, array) => {
+    if (item === false || item === null || item === undefined) {
+      return true;
+    }
+
+    return itemValidator(item, index, array);
+  });
+}
+
+export function validateInstanceOf(constructor) {
+  const fn = (object) => isInstance(object, constructor);
+
+  setValidatorCreatorMeta(fn, validateInstanceOf, {constructor});
+
+  return fn;
+}
+
+// Wiki data (primitives & non-primitives)
+
+export function isColor(color) {
+  isStringNonEmpty(color);
+
+  if (color.startsWith('#')) {
+    if (![4, 5, 7, 9].includes(color.length))
+      throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`);
+
+    if (/[^0-9a-fA-F]/.test(color.slice(1)))
+      throw new TypeError(`Expected hexadecimal digits`);
+
+    return true;
+  }
+
+  throw new TypeError(`Unknown color format`);
+}
+
+export function 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 {
+    [validateProperties.validateOtherKeys]: validateOtherKeys = null,
+    [validateProperties.allowOtherKeys]: allowOtherKeys = false,
+  } = spec;
+
+  const specEntries = Object.entries(spec);
+  const specKeys = Object.keys(spec);
+
+  return (object) => {
+    isObject(object);
+
+    if (Array.isArray(object))
+      throw new TypeError(`Expected an object, got array`);
+
+    withAggregate({message: `Errors validating object properties`}, ({push}) => {
+      const testEntries = specEntries.slice();
+
+      const unknownKeys = Object.keys(object).filter((key) => !specKeys.includes(key));
+      if (validateOtherKeys) {
+        for (const key of unknownKeys) {
+          testEntries.push([key, validateOtherKeys]);
+        }
+      }
+
+      for (const [specKey, specValidator] of testEntries) {
+        const value = object[specKey];
+        try {
+          specValidator(value);
+        } catch (caughtError) {
+          const keyPart = colors.green(specKey);
+          const valuePart = inspect(value);
+          const message = `Error for key ${keyPart}: ${valuePart}`;
+          push(new Error(message, {cause: caughtError}));
+        }
+      }
+
+      if (!validateOtherKeys && !allowOtherKeys && !empty(unknownKeys)) {
+        push(new Error(
+          `Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`));
+      }
+    });
+
+    return true;
+  };
+}
+
+validateProperties.validateOtherKeys = Symbol();
+validateProperties.allowOtherKeys = Symbol();
+
+export const validateAllPropertyValues = (validator) =>
+  validateProperties({
+    [validateProperties.validateOtherKeys]: validator,
+  });
+
+const illeaglInvisibleSpace = {
+  action: 'delete',
+};
+
+const illegalVisibleSpace = {
+  action: 'replace',
+  with: ' ',
+  withAnnotation: `normal space`,
+};
+
+const illegalContentSpec = [
+  {illegal: '\u200b', annotation: `zero-width space`, ...illeaglInvisibleSpace},
+  {illegal: '\u2005', annotation: `four-per-em space`, ...illegalVisibleSpace},
+  {illegal: '\u205f', annotation: `medium mathematical space`, ...illegalVisibleSpace},
+  {illegal: '\xa0', annotation: `non-breaking space`, ...illegalVisibleSpace},
+
+  {
+    action: 'replace',
+    illegal: '<a href',
+    annotation: `HTML-style link`,
+    with: '[...](...)',
+    withAnnotation: `markdown`,
+  },
+];
+
+for (const entry of illegalContentSpec) {
+  entry.test = string =>
+    string.startsWith(entry.illegal);
+
+  if (entry.action === 'replace') {
+    entry.enact = string =>
+      string.replaceAll(entry.illegal, entry.with);
+  }
+}
+
+const 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: optional(isStringNonEmpty),
+});
+
+export const isContributionList = validateArrayItems(isContribution);
+
+export const isAdditionalFile = validateProperties({
+  title: isName,
+  description: optional(isContentString),
+  files: optional(validateArrayItems(isString)),
+});
+
+export const isAdditionalFileList = validateArrayItems(isAdditionalFile);
+
+export const isTrackSection = validateProperties({
+  name: optional(isName),
+  color: optional(isColor),
+  dateOriginallyReleased: optional(isDate),
+  isDefaultTrackSection: optional(isBoolean),
+  tracks: optional(validateReferenceList('track')),
+});
+
+export const isTrackSectionList = validateArrayItems(isTrackSection);
+
+export function isDimensions(dimensions) {
+  isArray(dimensions);
+
+  if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`);
+
+  if (dimensions[0] !== null) {
+    isPositive(dimensions[0]);
+    isInteger(dimensions[0]);
+  }
+
+  if (dimensions[1] !== null) {
+    isPositive(dimensions[1]);
+    isInteger(dimensions[1]);
+  }
+
+  return true;
+}
+
+export function isDirectory(directory) {
+  isStringNonEmpty(directory);
+
+  if (directory.match(/[^a-zA-Z0-9_-]/))
+    throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`);
+
+  return true;
+}
+
+export function isDuration(duration) {
+  isNumber(duration);
+  isPositiveOrZero(duration);
+
+  return true;
+}
+
+export function isFileExtension(string) {
+  isStringNonEmpty(string);
+
+  if (string[0] === '.')
+    throw new TypeError(`Expected no dot (.) at the start of file extension`);
+
+  if (string.match(/[^a-zA-Z0-9_]/))
+    throw new TypeError(`Expected only alphanumeric and underscore`);
+
+  return true;
+}
+
+export function isLanguageCode(string) {
+  // TODO: This is a stub function because really we don't need a detailed
+  // is-language-code parser right now.
+
+  isString(string);
+
+  return true;
+}
+
+export function isName(name) {
+  return isContentString(name);
+}
+
+export function isURL(string) {
+  isStringNonEmpty(string);
+
+  new URL(string);
+
+  return true;
+}
+
+export function validateReference(type = 'track') {
+  return (ref) => {
+    isStringNonEmpty(ref);
+
+    const match = ref
+      .trim()
+      .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
+
+    if (!match) throw new TypeError(`Malformed reference`);
+
+    const {groups: {typePart, directoryPart}} = match;
+
+    if (typePart) {
+      if (typePart !== type)
+        throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`);
+
+      isDirectory(directoryPart);
+    }
+
+    isName(ref);
+
+    return true;
+  };
+}
+
+export function validateReferenceList(type = '') {
+  return validateArrayItems(validateReference(type));
+}
+
+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 anyOf(...validators) {
+  const validConstants = new Set();
+  const validConstructors = new Set();
+  const validTypes = new Set();
+
+  const constantValidators = [];
+  const constructorValidators = [];
+  const typeValidators = [];
+
+  const leftoverValidators = [];
+
+  for (const validator of validators) {
+    const creator = getValidatorCreator(validator);
+    const creatorMeta = getValidatorCreatorMeta(validator);
+
+    switch (creator) {
+      case is:
+        for (const value of creatorMeta.values) {
+          validConstants.add(value);
+        }
+
+        constantValidators.push(validator);
+        break;
+
+      case validateInstanceOf:
+        validConstructors.add(creatorMeta.constructor);
+        constructorValidators.push(validator);
+        break;
+
+      case validateType:
+        validTypes.add(creatorMeta.type);
+        typeValidators.push(validator);
+        break;
+
+      default:
+        leftoverValidators.push(validator);
+        break;
+    }
+  }
+
+  return (value) => {
+    const errorInfo = [];
+
+    if (validConstants.has(value)) {
+      return true;
+    }
+
+    if (!empty(validTypes)) {
+      if (validTypes.has(typeof value)) {
+        return true;
+      }
+    }
+
+    for (const constructor of validConstructors) {
+      if (value instanceof constructor) {
+        return true;
+      }
+    }
+
+    for (const [i, validator] of leftoverValidators.entries()) {
+      try {
+        const result = validator(value);
+
+        if (result !== true) {
+          throw new Error(`Check returned false`);
+        }
+
+        return true;
+      } catch (error) {
+        errorInfo.push([validator, i, error]);
+      }
+    }
+
+    // Don't process error messages until every validator has failed.
+
+    const errors = [];
+    const prefaceErrorInfo = [];
+
+    let offset = 0;
+
+    if (!empty(validConstants)) {
+      const constants =
+        Array.from(validConstants);
+
+      const gotPart = `, got ${value}`;
+
+      prefaceErrorInfo.push([
+        constantValidators,
+        offset++,
+        new TypeError(
+          `Expected any of ${constants.join(' ')}` + gotPart),
+      ]);
+    }
+
+    if (!empty(validTypes)) {
+      const types =
+        Array.from(validTypes);
+
+      const gotType = typeAppearance(value);
+      const gotPart = `, got ${gotType}`;
+
+      prefaceErrorInfo.push([
+        typeValidators,
+        offset++,
+        new TypeError(
+          `Expected any of ${types.join(', ')}` + gotPart),
+      ]);
+    }
+
+    if (!empty(validConstructors)) {
+      const names =
+        Array.from(validConstructors)
+          .map(constructor => constructor.name);
+
+      const gotName = value?.constructor?.name;
+      const gotPart = (gotName ? `, got ${gotName}` : ``);
+
+      prefaceErrorInfo.push([
+        constructorValidators,
+        offset++,
+        new TypeError(
+          `Expected any of ${names.join(', ')}` + gotPart),
+      ]);
+    }
+
+    for (const info of errorInfo) {
+      info[1] += offset;
+    }
+
+    for (const [validator, i, error] of prefaceErrorInfo.concat(errorInfo)) {
+      error.message =
+        (validator?.name
+          ? `${i + 1}. "${validator.name}": ${error.message}`
+          : `${i + 1}. ${error.message}`);
+
+      error.check =
+        (Array.isArray(validator) && validator.length === 1
+          ? validator[0]
+          : validator);
+
+      errors.push(error);
+    }
+
+    const total = offset + leftoverValidators.length;
+    throw new AggregateError(errors,
+      `Expected any of ${total} possible checks, ` +
+      `but none were true`);
+  };
+}
diff --git a/src/data/yaml.js b/src/data/yaml.js
index ab97ab7..86f3014 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -1,108 +1,109 @@
 // 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 T from './things/index.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';
+import {
+  filterReferenceErrors,
+  reportContentTextErrors,
+  reportDuplicateDirectories,
+} from '#data-checks';
 
 import {
+  annotateErrorWithFile,
   decorateErrorWithIndex,
-  empty,
-  mapAggregate,
+  decorateErrorWithAnnotation,
   openAggregate,
   showAggregate,
   withAggregate,
-} from '../util/sugar.js';
-
-import {
-  sortAlbumsTracksChronologically,
-  sortAlphabetically,
-  sortChronologically,
-} from '../util/wiki-data.js';
+} from '#aggregate';
 
-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});
+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 DATA_ALBUM_DIRECTORY = 'album';
-export const DATA_STATIC_PAGE_DIRECTORY = 'static-page';
-
-// --> 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 = [],
-  }
-) {
-  if (!thingClass) {
+//
+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 (!thingConstructor) {
     throw new Error(`Missing Thing class`);
   }
 
-  if (!propertyFieldMapping) {
-    throw new Error(`Expected propertyFieldMapping to be provided`);
+  if (!fieldSpecs) {
+    throw new Error(`Expected fields to be provided`);
   }
 
-  const knownFields = Object.values(propertyFieldMapping);
+  const knownFields = Object.keys(fieldSpecs);
 
-  // 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 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 = propertyFieldMapping['name'];
+    const nameField = propertyToField.name;
     if (!nameField) return fn;
 
     return (document) => {
@@ -112,345 +113,245 @@ function makeProcessDocument(
         const name = document[nameField];
         error.message = name
           ? `(name: ${inspect(name)}) ${error.message}`
-          : `(${color.dim(`no name found`)}) ${error.message}`;
+          : `(${colors.dim(`no name found`)}) ${error.message}`;
         throw error;
       }
     };
   };
 
   return decorateErrorWithName((document) => {
+    const nameField = propertyToField.name;
+    const namePart =
+      (nameField
+        ? (document[nameField]
+          ? ` named ${colors.green(`"${document[nameField]}"`)}`
+          : ` (name field, "${nameField}", not specified)`)
+        : ``);
+
+    const constructorPart =
+      (thingConstructor[Thing.friendlyName]
+        ? thingConstructor[Thing.friendlyName]
+     : thingConstructor.name
+        ? thingConstructor.name
+        : `document`);
+
+    const aggregate = openAggregate({
+      message: `Errors processing ${constructorPart}` + namePart,
+    });
+
     const documentEntries = Object.entries(document)
       .filter(([field]) => !ignoredFields.includes(field));
 
+    const skippedFields = new Set();
+
     const unknownFields = documentEntries
       .map(([field]) => field)
       .filter((field) => !knownFields.includes(field));
 
     if (!empty(unknownFields)) {
-      throw new makeProcessDocument.UnknownFieldsError(unknownFields);
+      aggregate.push(new UnknownFieldsError(unknownFields));
+
+      for (const field of unknownFields) {
+        skippedFields.add(field);
+      }
+    }
+
+    const presentFields = Object.keys(document);
+
+    const fieldCombinationErrors = [];
+
+    for (const {message, fields} of invalidFieldCombinations) {
+      const fieldsPresent =
+        presentFields.filter(field => fields.includes(field));
+
+      if (fieldsPresent.length >= 2) {
+        const filteredDocument =
+          filterProperties(
+            document,
+            fieldsPresent,
+            {preserveOriginalOrder: true});
+
+        fieldCombinationErrors.push(
+          new FieldCombinationError(filteredDocument, message));
+
+        for (const field of Object.keys(filteredDocument)) {
+          skippedFields.add(field);
+        }
+      }
+    }
+
+    if (!empty(fieldCombinationErrors)) {
+      aggregate.push(new FieldCombinationAggregateError(fieldCombinationErrors));
     }
 
     const fieldValues = {};
 
-    for (const [field, value] of documentEntries) {
-      if (Object.hasOwn(fieldTransformations, field)) {
-        fieldValues[field] = fieldTransformations[field](value);
-      } else {
-        fieldValues[field] = value;
+    for (const [field, documentValue] of documentEntries) {
+      if (skippedFields.has(field)) continue;
+
+      // This variable would like to certify itself as "not into capitalism".
+      let propertyValue =
+        (fieldSpecs[field].transform
+          ? fieldSpecs[field].transform(documentValue)
+          : documentValue);
+
+      // Completely blank items in a YAML list are read as null.
+      // They're handy to have around when filling out a document and shouldn't
+      // be considered an error (or data at all).
+      if (Array.isArray(propertyValue)) {
+        const wasEmpty = empty(propertyValue);
+
+        propertyValue =
+          propertyValue.filter(item => item !== null);
+
+        const isEmpty = empty(propertyValue);
+
+        // Don't set arrays which are empty as a result of the above filter.
+        // Arrays which were originally empty, i.e. `Field: []`, are still
+        // valid data, but if it's just an array not containing any filled out
+        // items, it should be treated as a placeholder and skipped over.
+        if (isEmpty && !wasEmpty) {
+          propertyValue = null;
+        }
       }
+
+      fieldValues[field] = propertyValue;
     }
 
-    const sourceProperties = {};
+    const thing = Reflect.construct(thingConstructor, []);
+
+    const fieldValueErrors = [];
 
     for (const [field, value] of Object.entries(fieldValues)) {
-      const property = fieldPropertyMapping[field];
-      sourceProperties[property] = value;
+      const {property} = fieldSpecs[field];
+
+      try {
+        thing[property] = value;
+      } catch (caughtError) {
+        skippedFields.add(field);
+        fieldValueErrors.push(new FieldValueError(
+          field, value, {cause: caughtError}));
+      }
     }
 
-    const thing = Reflect.construct(thingClass, []);
+    if (!empty(fieldValueErrors)) {
+      aggregate.push(new FieldValueAggregateError(
+        fieldValueErrors, thingConstructor));
+    }
 
-    withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({call}) => {
-      for (const [property, value] of Object.entries(sourceProperties)) {
-        call(() => (thing[property] = value));
-      }
-    });
+    if (skippedFields.size >= 1) {
+      aggregate.push(
+        new SkippedFieldsSummaryError(
+          filterProperties(
+            document,
+            Array.from(skippedFields),
+            {preserveOriginalOrder: true})));
+    }
 
-    return thing;
+    return {thing, aggregate};
   });
 }
 
-makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error {
+export class UnknownFieldsError extends Error {
   constructor(fields) {
-    super(`Unknown fields present: ${fields.join(', ')}`);
+    super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`);
     this.fields = fields;
   }
-};
+}
 
-export const processAlbumDocument = makeProcessDocument(T.Album, {
-  fieldTransformations: {
-    'Artists': parseContributors,
-    'Cover Artists': parseContributors,
-    'Default Track Cover Artists': parseContributors,
-    'Wallpaper Artists': parseContributors,
-    'Banner Artists': parseContributors,
-
-    '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),
-
-    'Banner Dimensions': parseDimensions,
-
-    'Additional Files': parseAdditionalFiles,
-  },
-
-  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',
-
-    wallpaperArtistContribsByRef: 'Wallpaper Artists',
-    wallpaperStyle: 'Wallpaper Style',
-    wallpaperFileExtension: 'Wallpaper File Extension',
-
-    bannerArtistContribsByRef: 'Banner Artists',
-    bannerStyle: 'Banner Style',
-    bannerFileExtension: 'Banner File Extension',
-    bannerDimensions: 'Banner Dimensions',
+export class FieldCombinationAggregateError extends AggregateError {
+  constructor(errors) {
+    super(errors, `Invalid field combinations - all involved fields ignored`);
+  }
+}
 
-    date: 'Date',
-    trackArtDate: 'Default Track Cover Art Date',
-    coverArtDate: 'Cover Art Date',
-    dateAddedToWiki: 'Date Added',
+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),
+    });
 
-    hasCoverArt: 'Has Cover Art',
-    hasTrackArt: 'Has Track Art',
-    hasTrackNumbers: 'Has Track Numbers',
-    isMajorRelease: 'Major Release',
-    isListedOnHomepage: 'Listed on Homepage',
-
-    groupsByRef: 'Groups',
-    artTagsByRef: 'Art Tags',
-    commentary: 'Commentary',
-
-    additionalFiles: 'Additional Files',
-  },
-});
-
-export const processTrackGroupDocument = makeProcessDocument(T.TrackGroup, {
-  fieldTransformations: {
-    'Date Originally Released': (value) => new Date(value),
-  },
-
-  propertyFieldMapping: {
-    name: 'Group',
-    color: 'Color',
-    dateOriginallyReleased: 'Date Originally Released',
-  },
-});
-
-export const processTrackDocument = makeProcessDocument(T.Track, {
-  fieldTransformations: {
-    'Duration': getDurationInSeconds,
-
-    'Date First Released': (value) => new Date(value),
-    'Cover Art Date': (value) => new Date(value),
-
-    'Artists': parseContributors,
-    'Contributors': parseContributors,
-    'Cover Artists': parseContributors,
-
-    'Additional Files': parseAdditionalFiles,
-  },
-
-  propertyFieldMapping: {
-    name: 'Track',
-
-    directory: 'Directory',
-    duration: 'Duration',
-    urls: 'URLs',
-
-    coverArtDate: 'Cover Art Date',
-    coverArtFileExtension: 'Cover Art File Extension',
-    dateFirstReleased: 'Date First Released',
-    hasCoverArt: 'Has Cover Art',
-    hasURLs: 'Has URLs',
-
-    referencedTracksByRef: 'Referenced Tracks',
-    sampledTracksByRef: 'Sampled Tracks',
-    artistContribsByRef: 'Artists',
-    contributorContribsByRef: 'Contributors',
-    coverArtistContribsByRef: 'Cover Artists',
-    artTagsByRef: 'Art Tags',
-    originalReleaseTrackByRef: 'Originally Released As',
-
-    commentary: 'Commentary',
-    lyrics: 'Lyrics',
-
-    additionalFiles: 'Additional Files',
-  },
-});
-
-export const processArtistDocument = makeProcessDocument(T.Artist, {
-  propertyFieldMapping: {
-    name: 'Artist',
-
-    directory: 'Directory',
-    urls: 'URLs',
-    hasAvatar: 'Has Avatar',
-    avatarFileExtension: 'Avatar File Extension',
-
-    aliasNames: 'Aliases',
-
-    contextNotes: 'Context Notes',
-  },
-
-  ignoredFields: ['Dead URLs'],
-});
-
-export const processFlashDocument = makeProcessDocument(T.Flash, {
-  fieldTransformations: {
-    'Date': (value) => new Date(value),
-
-    'Contributors': parseContributors,
-  },
-
-  propertyFieldMapping: {
-    name: 'Flash',
-
-    directory: 'Directory',
-    page: 'Page',
-    date: 'Date',
-    coverArtFileExtension: 'Cover Art File Extension',
-
-    featuredTracksByRef: 'Featured Tracks',
-    contributorContribsByRef: 'Contributors',
-    urls: 'URLs',
-  },
-});
-
-export const processFlashActDocument = makeProcessDocument(T.FlashAct, {
-  propertyFieldMapping: {
-    name: 'Act',
-    color: 'Color',
-    anchor: 'Anchor',
-    jump: 'Jump',
-    jumpColor: 'Jump Color',
-  },
-});
-
-export const processNewsEntryDocument = makeProcessDocument(T.NewsEntry, {
-  fieldTransformations: {
-    'Date': (value) => new Date(value),
-  },
-
-  propertyFieldMapping: {
-    name: 'Name',
-    directory: 'Directory',
-    date: 'Date',
-    content: 'Content',
-  },
-});
-
-export const processArtTagDocument = makeProcessDocument(T.ArtTag, {
-  propertyFieldMapping: {
-    name: 'Tag',
-    directory: 'Directory',
-    color: 'Color',
-    isContentWarning: 'Is CW',
-  },
-});
-
-export const processGroupDocument = makeProcessDocument(T.Group, {
-  propertyFieldMapping: {
-    name: 'Group',
-    directory: 'Directory',
-    description: 'Description',
-    urls: 'URLs',
-  },
-});
-
-export const processGroupCategoryDocument = makeProcessDocument(T.GroupCategory, {
-  propertyFieldMapping: {
-    name: 'Category',
-    color: 'Color',
-  },
-});
-
-export const processStaticPageDocument = makeProcessDocument(T.StaticPage, {
-  propertyFieldMapping: {
-    name: 'Name',
-    nameShort: 'Short Name',
-    directory: 'Directory',
-
-    content: 'Content',
-    stylesheet: 'Style',
-
-    showInNavigationBar: 'Show in Navigation Bar',
-  },
-});
-
-export const processWikiInfoDocument = makeProcessDocument(T.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 const processHomepageLayoutDocument = makeProcessDocument(T.HomepageLayout, {
-  propertyFieldMapping: {
-    sidebarContent: 'Sidebar Content',
-  },
-
-  ignoredFields: ['Homepage'],
-});
-
-export function makeProcessHomepageLayoutRowDocument(rowClass, spec) {
-  return makeProcessDocument(rowClass, {
-    ...spec,
-
-    propertyFieldMapping: {
-      name: 'Row',
-      color: 'Color',
-      type: 'Type',
-      ...spec.propertyFieldMapping,
-    },
-  });
+    this.fields = fields;
+  }
 }
 
-export const homepageLayoutRowTypeProcessMapping = {
-  albums: makeProcessHomepageLayoutRowDocument(T.HomepageLayoutAlbumsRow, {
-    propertyFieldMapping: {
-      sourceGroupByRef: 'Group',
-      countAlbumsFromGroup: 'Count',
-      sourceAlbumsByRef: 'Albums',
-      actionLinks: 'Actions',
-    },
-  }),
-};
-
-export function processHomepageLayoutRowDocument(document) {
-  const type = document['Type'];
+export class FieldValueAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
 
-  const match = Object.entries(homepageLayoutRowTypeProcessMapping)
-    .find(([key]) => key === type);
+  constructor(errors, thingConstructor) {
+    const constructorText =
+      colors.green(thingConstructor.name);
 
-  if (!match) {
-    throw new TypeError(`No processDocument function for row type ${type}!`);
+    super(
+      errors,
+      `Errors processing field values for ${constructorText}`);
   }
-
-  return match[1](document);
 }
 
-// --> Utilities shared across document parsing functions
+export class FieldValueError extends Error {
+  constructor(field, value, options) {
+    const fieldText =
+      colors.green(`"${field}"`);
 
-export function getDurationInSeconds(string) {
-  if (typeof string === 'number') {
-    return string;
+    const valueText =
+      inspect(value, {maxStringLength: 40});
+
+    super(
+      `Failed to set ${fieldText} field to ${valueText}`,
+      options);
   }
+}
 
+export class SkippedFieldsSummaryError extends Error {
+  constructor(filteredDocument) {
+    const entries = Object.entries(filteredDocument);
+
+    const lines =
+      entries.map(([field, value]) =>
+        ` - ${field}: ` +
+        inspect(value, {maxStringLength: 70})
+          .split('\n')
+          .map((line, index) => index === 0 ? line : `   ${line}`)
+          .join('\n'));
+
+    const numFieldsText =
+      (entries.length === 1
+        ? `1 field`
+        : `${entries.length} fields`);
+
+    super(
+      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) +
+      lines.join('\n') + '\n' +
+      colors.bright(colors.yellow(`See above errors for details.`)));
+  }
+}
+
+export function parseDate(date) {
+  return new Date(date);
+}
+
+export function parseDuration(string) {
   if (typeof string !== 'string') {
-    throw new TypeError(`Expected a string or number, got ${string}`);
+    return string;
   }
 
   const parts = string.split(':').map((n) => parseInt(n));
@@ -464,7 +365,6 @@ export function getDurationInSeconds(string) {
 }
 
 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;
@@ -477,56 +377,63 @@ export function parseAdditionalFiles(array) {
   }));
 }
 
-export function parseCommentary(text) {
-  if (text) {
-    const lines = String(text.trim()).split('\n');
-    if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) {
-      throw new Error(`Missing commentary citation: "${lines[0].slice(0, 40)}..."`);
-    }
-    return text;
-  } else {
-    return null;
-  }
-}
+export const extractAccentRegex =
+  /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
 
-export function parseContributors(contributors) {
-  if (!contributors) {
-    return null;
-  }
+export const extractPrefixAccentRegex =
+  /^(?:\((?<accent>.*)\) )?(?<main>.*?)$/;
 
-  if (contributors.length === 1 && contributors[0].startsWith('<i>')) {
-    const arr = [];
-    arr.textContent = contributors[0];
-    return arr;
+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;
   }
 
-  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};
+  return contributionStrings.map(item => {
+    if (typeof item === 'object' && item['Who'])
+      return {who: item['Who'], what: item['What'] ?? null};
+
+    if (typeof item !== 'string') return item;
+
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      who: match.groups.main,
+      what: match.groups.accent ?? null,
+    };
   });
+}
 
-  const badContributor = contributors.find((val) => typeof val === 'string');
-  if (badContributor) {
-    throw new Error(`Incorrectly formatted contribution: "${badContributor}".`);
+export function parseAdditionalNames(additionalNameStrings) {
+  if (!Array.isArray(additionalNameStrings)) {
+    return additionalNameStrings;
   }
 
-  if (contributors.length === 1 && contributors[0].who === 'none') {
-    return null;
-  }
+  return additionalNameStrings.map(item => {
+    if (typeof item === 'object' && item['Name'])
+      return {name: item['Name'], annotation: item['Annotation'] ?? null};
+
+    if (typeof item !== 'string') return item;
 
-  return contributors;
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      name: match.groups.main,
+      annotation: match.groups.accent ?? null,
+    };
+  });
 }
 
-function parseDimensions(string) {
-  if (!string) {
-    return null;
+export function parseDimensions(string) {
+  // It's technically possible to pass an array like [30, 40] through here.
+  // That's not really an issue because if it isn't of the appropriate shape,
+  // the Thing object's validators will handle the error.
+  if (typeof string !== 'string') {
+    return string;
   }
 
   const parts = string.split(/[x,* ]+/g);
@@ -544,8 +451,6 @@ function parseDimensions(string) {
   return nums;
 }
 
-// --> Data repository loading functions and descriptors
-
 // documentModes: Symbols indicating sets of behavior for loading and processing
 // data files.
 export const documentModes = {
@@ -618,313 +523,152 @@ 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});
 
-  {
-    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 = T.Thing.getReference(album);
-
-        const closeCurrentTrackGroup = () => {
-          if (currentTracksByRef) {
-            let trackGroup;
-
-            if (currentTrackGroup) {
-              trackGroup = currentTrackGroup;
-            } else {
-              trackGroup = new T.TrackGroup();
-              trackGroup.name = `Default Track Group`;
-              trackGroup.isDefaultTrackGroup = true;
-            }
+  return steps;
+};
 
-            trackGroup.album = album;
-            trackGroup.tracksByRef = currentTracksByRef;
-            trackGroups.push(trackGroup);
-          }
-        };
+export async function loadAndProcessDataDocuments({dataPath}) {
+  const processDataAggregate = openAggregate({
+    message: `Errors processing data files`,
+  });
+  const wikiDataResult = {};
 
-        for (const entry of entries) {
-          if (entry instanceof T.TrackGroup) {
-            closeCurrentTrackGroup();
-            currentTracksByRef = [];
-            currentTrackGroup = entry;
-            continue;
-          }
+  function decorateErrorWithFile(fn) {
+    return decorateErrorWithAnnotation(fn,
+      (caughtError, firstArg) =>
+        annotateErrorWithFile(
+          caughtError,
+          path.relative(
+            dataPath,
+            (typeof firstArg === 'object'
+              ? firstArg.file
+              : firstArg))));
+  }
 
-          trackData.push(entry);
+  function asyncDecorateErrorWithFile(fn) {
+    return decorateErrorWithFile(fn).async;
+  }
 
-          entry.dataSourceAlbumByRef = albumRef;
+  for (const dataStep of getDataSteps()) {
+    await processDataAggregate.nestAsync(
+      {
+        message: `Errors during data step: ${colors.bright(dataStep.title)}`,
+        translucent: true,
+      },
+      async ({call, callAsync, map, mapAsync, push}) => {
+        const {documentMode} = dataStep;
 
-          const trackRef = T.Thing.getReference(entry);
-          if (currentTracksByRef) {
-            currentTracksByRef.push(trackRef);
-          } else {
-            currentTracksByRef = [trackRef];
-          }
+        if (!Object.values(documentModes).includes(documentMode)) {
+          throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
         }
 
-        closeCurrentTrackGroup();
-
-        album.trackGroups = trackGroups;
-        albumData.push(album);
-      }
+        // 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(`---`)}'`,
+          });
 
-      return {albumData, trackData};
-    },
-  },
+          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)}), `);
+              }
 
-  {
-    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 = T.Thing.getReference(artist);
-        return artist.aliasNames?.map((name) => {
-          const alias = new T.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 T.FlashAct)) {
-        throw new Error(`Expected an act at top of flash data file`);
-      }
+              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)}"`);
+              }
 
-      for (const thing of results) {
-        if (thing instanceof T.FlashAct) {
-          if (flashAct) {
-            Object.assign(flashAct, {flashesByRef});
+              aggregate.push(new Error(parts.join('')));
+            }
           }
 
-          flashAct = thing;
-          flashesByRef = [];
-        } else {
-          flashesByRef.push(T.Thing.getReference(thing));
-        }
-      }
-
-      if (flashAct) {
-        Object.assign(flashAct, {flashesByRef});
-      }
-
-      const flashData = results.filter((x) => x instanceof T.Flash);
-      const flashActData = results.filter((x) => x instanceof T.FlashAct);
-
-      return {flashData, flashActData};
-    },
-  },
+          return {documents: filteredDocuments, aggregate};
+        };
 
-  {
-    title: `Process groups file`,
-    file: GROUP_DATA_FILE,
-
-    documentMode: documentModes.allInOne,
-    processDocument(document) {
-      return 'Category' in document
-        ? processGroupCategoryDocument(document)
-        : processGroupDocument(document);
-    },
-
-    save(results) {
-      let groupCategory;
-      let groupsByRef = [];
-
-      if (results[0] && !(results[0] instanceof T.GroupCategory)) {
-        throw new Error(`Expected a category at top of group data file`);
-      }
+        const processDocument = (document, thingClassOrFn) => {
+          const thingClass =
+            (thingClassOrFn.prototype instanceof Thing
+              ? thingClassOrFn
+              : thingClassOrFn(document));
 
-      for (const thing of results) {
-        if (thing instanceof T.GroupCategory) {
-          if (groupCategory) {
-            Object.assign(groupCategory, {groupsByRef});
+          if (typeof thingClass !== 'function') {
+            throw new Error(`Expected a thing class, got ${typeAppearance(thingClass)}`);
           }
 
-          groupCategory = thing;
-          groupsByRef = [];
-        } else {
-          groupsByRef.push(T.Thing.getReference(thing));
-        }
-      }
-
-      if (groupCategory) {
-        Object.assign(groupCategory, {groupsByRef});
-      }
-
-      const groupData = results.filter((x) => x instanceof T.Group);
-      const groupCategoryData = results.filter((x) => x instanceof T.GroupCategory);
-
-      return {groupData, groupCategoryData};
-    },
-  },
-
-  {
-    title: `Process homepage layout file`,
-    files: [HOMEPAGE_LAYOUT_DATA_FILE],
-
-    documentMode: documentModes.headerAndEntries,
-    processHeaderDocument: processHomepageLayoutDocument,
-    processEntryDocument: processHomepageLayoutRowDocument,
-
-    save(results) {
-      if (!results[0]) {
-        return;
-      }
-
-      const {header: homepageLayout, entries: rows} = results[0];
-      Object.assign(homepageLayout, {rows});
-      return {homepageLayout};
-    },
-  },
-
-  // TODO: WD.wikiInfo.enableNews &&
-  {
-    title: `Process news data file`,
-    file: NEWS_DATA_FILE,
-
-    documentMode: documentModes.allInOne,
-    processDocument: processNewsEntryDocument,
-
-    save(newsData) {
-      sortChronologically(newsData);
-      newsData.reverse();
-
-      return {newsData};
-    },
-  },
-
-  {
-    title: `Process art tags file`,
-    file: ART_TAG_DATA_FILE,
-
-    documentMode: documentModes.allInOne,
-    processDocument: processArtTagDocument,
-
-    save(artTagData) {
-      sortAlphabetically(artTagData);
-
-      return {artTagData};
-    },
-  },
-
-  {
-    title: `Process static page files`,
-    files: async (dataPath) =>
-      (
-        await findFiles(path.join(dataPath, DATA_STATIC_PAGE_DIRECTORY), {
-          filter: f => path.extname(f) === '.yaml',
-          joinParentDirectory: false,
-        })
-      ).map(file => path.join(DATA_STATIC_PAGE_DIRECTORY, file)),
-
-    documentMode: documentModes.onePerFile,
-    processDocument: processStaticPageDocument,
-
-    save(staticPageData) {
-      return {staticPageData};
-    },
-  },
-];
-
-export async function loadAndProcessDataDocuments({dataPath}) {
-  const processDataAggregate = openAggregate({
-    message: `Errors processing data files`,
-  });
-  const wikiDataResult = {};
+          if (!(thingClass.prototype instanceof Thing)) {
+            throw new Error(`Expected a thing class, got ${thingClass.name}`);
+          }
 
-  function decorateErrorWithFile(fn) {
-    return (x, index, array) => {
-      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;
-      }
-    };
-  }
+          const spec = thingClass[Thing.yamlDocumentSpec];
 
-  for (const dataStep of dataSteps) {
-    await processDataAggregate.nestAsync(
-      {message: `Errors during data step: ${dataStep.title}`},
-      async ({call, callAsync, map, mapAsync, nest}) => {
-        const {documentMode} = dataStep;
+          if (!spec) {
+            throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
+          }
 
-        if (!Object.values(documentModes).includes(documentMode)) {
-          throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
-        }
+          // TODO: Making a function to only call it just like that is
+          // obviously pretty jank! It should be created once per data step.
+          const fn = makeProcessDocument(thingClass, spec);
+          return fn(document);
+        };
 
         if (
           documentMode === documentModes.allInOne ||
@@ -940,34 +684,82 @@ export async function loadAndProcessDataDocuments({dataPath}) {
               ? await callAsync(dataStep.file, dataPath)
               : dataStep.file);
 
-          const readResult = await callAsync(readFile, file, 'utf-8');
+          const statResult = await callAsync(() =>
+            stat(file).then(
+              () => true,
+              error => {
+                if (error.code === 'ENOENT') {
+                  return false;
+                } else {
+                  throw error;
+                }
+              }));
+
+          if (statResult === false) {
+            const saveResult = call(dataStep.save, {
+              [documentModes.allInOne]: [],
+              [documentModes.oneDocumentTotal]: {},
+            }[documentMode]);
+
+            if (!saveResult) return;
+
+            Object.assign(wikiDataResult, saveResult);
 
-          if (!readResult) {
             return;
           }
 
-          const yamlResult =
-            documentMode === documentModes.oneDocumentTotal
-              ? call(yaml.load, readResult)
-              : call(yaml.loadAll, readResult);
+          const readResult = await callAsync(readFile, file, 'utf-8');
 
-          if (!yamlResult) {
+          if (!readResult) {
             return;
           }
 
           let processResults;
 
-          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);
+          switch (documentMode) {
+            case documentModes.oneDocumentTotal: {
+              const yamlResult = call(yaml.load, readResult);
+
+              if (!yamlResult) {
+                processResults = null;
+                break;
+              }
+
+              const {thing, aggregate} =
+                processDocument(yamlResult, dataStep.documentThing);
+
+              processResults = thing;
+
+              call(() => aggregate.close());
+
+              break;
+            }
+
+            case documentModes.allInOne: {
+              const yamlResults = call(yaml.loadAll, readResult);
+
+              if (!yamlResults) {
+                processResults = [];
+                return;
+              }
+
+              const {documents, aggregate: filterAggregate} =
+                filterBlankDocuments(yamlResults);
+
+              call(filterAggregate.close);
+
+              processResults = [];
+
+              map(documents, decorateErrorWithIndex(document => {
+                const {thing, aggregate} =
+                  processDocument(document, dataStep.documentThing);
+
+                processResults.push(thing);
+                aggregate.close();
+              }), {message: `Errors processing documents`});
+
+              break;
+            }
           }
 
           if (!processResults) return;
@@ -985,106 +777,123 @@ export async function loadAndProcessDataDocuments({dataPath}) {
           throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
         }
 
-        let files = (
-          typeof dataStep.files === 'function'
-            ? await callAsync(dataStep.files, dataPath)
-            : dataStep.files
-        )
+        const filesFromDataStep =
+          (typeof dataStep.files === 'function'
+            ? await callAsync(() =>
+                dataStep.files(dataPath).then(
+                  files => files,
+                  error => {
+                    if (error.code === 'ENOENT') {
+                      return [];
+                    } else {
+                      throw error;
+                    }
+                  }))
+            : dataStep.files);
+
+        const filesUnderDataPath =
+          filesFromDataStep
+            .map(file => path.join(dataPath, file));
+
+        const yamlResults = [];
+
+        await mapAsync(filesUnderDataPath, {message: `Errors loading data files`},
+          asyncDecorateErrorWithFile(async file => {
+            let contents;
+            try {
+              contents = await readFile(file, 'utf-8');
+            } catch (caughtError) {
+              throw new Error(`Failed to read data file`, {cause: caughtError});
+            }
 
-        if (!files) {
-          return;
-        }
+            let documents;
+            try {
+              documents = yaml.loadAll(contents);
+            } catch (caughtError) {
+              throw new Error(`Failed to parse valid YAML`, {cause: caughtError});
+            }
 
-        files = 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 {documents: filteredDocuments, aggregate: filterAggregate} =
+              filterBlankDocuments(documents);
+
+            try {
+              filterAggregate.close();
+            } catch (caughtError) {
+              // Blank documents aren't a critical error, they're just something
+              // that should be noted - the (filtered) documents still get pushed.
+              const pathToFile = path.relative(dataPath, file);
+              annotateErrorWithFile(caughtError, pathToFile);
+              push(caughtError);
+            }
 
-              const 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});
-            });
-          });
-        }
+            yamlResults.push({file, documents: filteredDocuments});
+          }));
 
-        if (documentMode === documentModes.onePerFile) {
-          nest({message: `Errors processing data files as valid documents`}, ({call}) => {
-            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 processResults = [];
 
-              const result = call(
-                decorateErrorWithFile(({document}) =>
-                  dataStep.processDocument(document)
-                ),
-                {file, document: documents[0]}
-              );
+        switch (documentMode) {
+          case documentModes.headerAndEntries:
+            map(yamlResults, {message: `Errors processing documents in data files`, translucent: true},
+              decorateErrorWithFile(({documents}) => {
+                const headerDocument = documents[0];
+                const entryDocuments = documents.slice(1).filter(Boolean);
 
-              if (!result) {
-                return;
-              }
+                if (!headerDocument)
+                  throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
 
-              processResults.push(result);
-            });
-          });
+                withAggregate({message: `Errors processing documents`}, ({push}) => {
+                  const {thing: headerObject, aggregate: headerAggregate} =
+                    processDocument(headerDocument, dataStep.headerDocumentThing);
+
+                  try {
+                    headerAggregate.close();
+                  } catch (caughtError) {
+                    caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
+                    push(caughtError);
+                  }
+
+                  const entryObjects = [];
+
+                  for (let index = 0; index < entryDocuments.length; index++) {
+                    const entryDocument = entryDocuments[index];
+
+                    const {thing: entryObject, aggregate: entryAggregate} =
+                      processDocument(entryDocument, dataStep.entryDocumentThing);
+
+                    entryObjects.push(entryObject);
+
+                    try {
+                      entryAggregate.close();
+                    } catch (caughtError) {
+                      caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
+                      push(caughtError);
+                    }
+                  }
+
+                  processResults.push({
+                    header: headerObject,
+                    entries: entryObjects,
+                  });
+                });
+              }));
+            break;
+
+          case documentModes.onePerFile:
+            map(yamlResults, {message: `Errors processing data files as valid documents`},
+              decorateErrorWithFile(({documents}) => {
+                if (documents.length > 1)
+                  throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
+
+                if (empty(documents) || !documents[0])
+                  throw new Error(`Expected a document, this file is empty`);
+
+                const {thing, aggregate} =
+                  processDocument(documents[0], dataStep.documentThing);
+
+                processResults.push(thing);
+                aggregate.close();
+              }));
+            break;
         }
 
         const saveResult = call(dataStep.save, processResults);
@@ -1104,40 +913,93 @@ export async function loadAndProcessDataDocuments({dataPath}) {
 
 // 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++) {
-      const thing = things[i];
-      for (let j = 0; j < keys.length; j++) {
-        const key = keys[j];
+  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: sortChronologically(wikiData.albumData.slice()),
-    trackData: sortAlbumsTracksChronologically(wikiData.trackData.slice()),
-  });
+  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
@@ -1147,196 +1009,6 @@ export function sortWikiDataArrays(wikiData) {
   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 (empty(duplicateDirectories)) 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',
-      sampledTracksByRef: '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;
-}
-
 // Utility function for loading all wiki data from the provided YAML data
 // directory (e.g. the root of the hsmusic-data repository). This doesn't
 // provide much in the way of customization; it's meant to be used more as
@@ -1344,8 +1016,11 @@ 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, {
+  bindFind,
+  getAllFindSpecs,
+
   showAggregate: customShowAggregate = showAggregate,
-} = {}) {
+}) {
   const showAggregate = customShowAggregate;
 
   let wikiData;
@@ -1367,7 +1042,7 @@ export async function quickLoadAllFromYAML(dataPath, {
   linkWikiDataArrays(wikiData);
 
   try {
-    filterDuplicateDirectories(wikiData).close();
+    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
     logInfo`No duplicate directories found. (complete data)`;
   } catch (error) {
     showAggregate(error);
@@ -1375,11 +1050,19 @@ export async function quickLoadAllFromYAML(dataPath, {
   }
 
   try {
-    filterReferenceErrors(wikiData).close();
+    filterReferenceErrors(wikiData, {bindFind}).close();
     logInfo`No reference errors found. (complete data)`;
   } catch (error) {
     showAggregate(error);
-    logWarn`Duplicate directories found. (partial data)`;
+    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);
diff --git a/src/file-size-preloader.js b/src/file-size-preloader.js
index ca1452d..4eadde7 100644
--- a/src/file-size-preloader.js
+++ b/src/file-size-preloader.js
@@ -17,8 +17,9 @@
 // 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';
+
+import {logWarn} from '#cli';
 
 export default class FileSizePreloader {
   #paths = [];
@@ -28,6 +29,8 @@ export default class FileSizePreloader {
   #loadingPromise = null;
   #resolveLoadingPromise = null;
 
+  hadErrored = false;
+
   loadPaths(...paths) {
     this.#paths.push(...paths.filter((p) => !this.#paths.includes(p)));
     return this.#startLoadingPaths();
@@ -66,6 +69,7 @@ export default class FileSizePreloader {
       // 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();
     }
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 dc1f6fb..8a58269 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -74,47 +74,364 @@
 
 '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 {
+  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 {
+  colors,
+  fileIssue,
   logError,
   logInfo,
   logWarn,
+  logicalPathTo,
   parseOptions,
   progressPromiseAll,
-} from './util/cli.js';
-
-import {commandExists, isMain, promisifyProcess} from './util/node-utils.js';
-
-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))
-              : [],
-          () => (filterFile(name) ? [path.join(subDirPath, name)] : [])
-        )
-      )
-    ).then((pathArrays) => pathArrays.flatMap((x) => x));
-
-  return readdir(startDirPath).then((names) => recursive(names, ''));
+} from '#cli';
+
+import {
+  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) {
@@ -127,15 +444,26 @@ function readFileMD5(filePath) {
   });
 }
 
-async function getImageMagickVersion(spawnConvert) {
-  const proc = spawnConvert(['--version'], false);
+async function identifyImageDimensions(filePath) {
+  // See: https://github.com/image-size/image-size/issues/96
+  const buffer = await readFile(filePath);
+  const dimensions = dimensionsOf(buffer);
+  return [dimensions.width, dimensions.height];
+}
+
+async function getImageMagickVersion(binary) {
+  const proc = spawn(binary, ['--version']);
 
   let allData = '';
   proc.stdout.on('data', (data) => {
     allData += data.toString();
   });
 
-  await promisifyProcess(proc, false);
+  try {
+    await promisifyProcess(proc, false);
+  } catch (error) {
+    return null;
+  }
 
   if (!allData.match(/ImageMagick/i)) {
     return null;
@@ -149,102 +477,432 @@ async function getImageMagickVersion(spawnConvert) {
   return match[1];
 }
 
-async function getSpawnConvert() {
-  let fn, description, version;
-  if (await commandExists('convert')) {
-    fn = (args) => spawn('convert', args);
-    description = 'convert';
-  } else if (await commandExists('magick')) {
-    fn = (args, prefix = true) =>
-      spawn('magick', prefix ? ['convert', ...args] : args);
-    description = 'magick convert';
-  } else {
-    return [`no convert or magick binary`, null];
+async function getSpawnMagick(tool) {
+  if (tool !== 'identify' && tool !== 'convert') {
+    throw new Error(`Expected identify or convert`);
   }
 
-  version = await getImageMagickVersion(fn);
+  let fn = null;
+  let description = null;
+  let version = null;
 
-  if (version === null) {
-    return [`binary --version output didn't indicate it's ImageMagick`];
+  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];
 }
 
-function generateImageThumbnails(filePath, {spawnConvert}) {
-  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}) =>
-    spawnConvert([
-      filePath,
-      '-strip',
-      '-resize',
-      `${size}x${size}>`,
-      '-interlace',
-      'Plane',
-      '-quality',
-      `${quality}%`,
-      output(name),
-    ]);
-
-  return Promise.all([
-    promisifyProcess(convert('.medium', {size: 400, quality: 95}), false),
-    promisifyProcess(convert('.small', {size: 250, quality: 85}), false),
+// 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 dirnameInCache =
+    path.join(mediaCachePath, path.dirname(imagePath));
+
+  const filename =
+    path.basename(imagePath, path.extname(imagePath)) +
+    `.${thumbtack}.jpg`;
+
+  const filePathInCache =
+    path.join(dirnameInCache, filename);
+
+  await mkdir(dirnameInCache, {recursive: true});
+
+  const specEntry = thumbnailSpec[thumbtack];
+  const {size, quality} = specEntry;
+
+  const convertProcess = spawnConvert([
+    filePathInMedia,
+    '-strip',
+    '-resize',
+    `${size}x${size}>`,
+    '-interlace',
+    'Plane',
+    '-quality',
+    `${quality}%`,
+    filePathInCache,
   ]);
+
+  await promisifyProcess(convertProcess, false);
+}
+
+export async function determineMediaCachePath({
+  mediaPath,
+  providedMediaCachePath,
+  disallowDoubling = false,
+}) {
+  if (!mediaPath) {
+    return {
+      annotation: 'media path not provided',
+      mediaCachePath: null,
+    };
+  }
+
+  if (providedMediaCachePath) {
+    return {
+      annotation: 'custom path provided',
+      mediaCachePath: providedMediaCachePath,
+    };
+  }
+
+  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 default async function genThumbs(mediaPath, {
+export async function migrateThumbsIntoDedicatedCacheDirectory({
+  mediaPath,
+  mediaCachePath,
+
   queueSize = 0,
-  quiet = false,
-} = {}) {
+}) {
   if (!mediaPath) {
-    throw new Error('Expected mediaPath to be passed');
+    throw new Error('Expected mediaPath');
   }
 
-  const quietInfo = quiet ? () => null : logInfo;
+  if (!mediaCachePath) {
+    throw new Error(`Expected mediaCachePath`);
+  }
 
-  const filterFile = (name) => {
-    // TODO: Why is this not working????????
-    // thumbnail-cache.json is 8eing passed through, for some reason.
+  logInfo`Migrating thumbnail files into dedicated directory.`;
+  logInfo`Moving thumbs from: ${mediaPath}`;
+  logInfo`Moving thumbs into: ${mediaCachePath}`;
 
-    const ext = path.extname(name);
-    if (ext !== '.jpg' && ext !== '.png') return false;
+  const thumbFiles = await traverse(mediaPath, {
+    pathStyle: 'device',
+    filterFile: file => isThumb(file),
+    filterDir: name => name !== '.git',
+  });
 
-    const rest = path.basename(name, ext);
-    if (rest.endsWith('.medium') || rest.endsWith('.small')) return false;
+  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;
+    });
 
-    return true;
-  };
+    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};
+    }
 
-  const filterDir = (name) => {
-    if (name === '.git') return false;
-    return true;
-  };
+    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 rename(
+        path.join(mediaPath, CACHE_FILE),
+        path.join(mediaCachePath, CACHE_FILE));
+      logInfo`Moved thumbnail cache file.`;
+    } catch (error) {
+      logWarn`Failed to move cache file. (${CACHE_FILE})`;
+      logWarn`Check its permissions, or try copying/pasting.`;
+    }
+  }
+
+  return {success: true};
+}
+
+// Fill in missing details (usually on older entries) that don't actually
+// require generating any thumbnails to determine. This makes sure all
+// entries are using the latest specbust, too.
+export async function refreshThumbnailCache(cache, {mediaPath, queueSize}) {
+  if (Object.keys(cache).length === 0) {
+    return;
+  }
+
+  await progressPromiseAll(`Refreshing existing entries on thumbnail cache`,
+    queue(
+      Object.entries(cache)
+        .map(([imagePath, cacheEntry]) => async () => {
+          const details = thumbnailCacheEntryToDetails(cacheEntry);
+
+          // Couldn't parse this entry, it won't be used later on.
+          // But leave it around just in case some other version of hsmusic
+          // can get use out of it.
+          if (!details) {
+            return;
+          }
+
+          const {tackbust, width, height} = details;
+
+          let {md5, mtime} = details;
+          let updatedAnything = false;
+
+          try {
+            const filePathInMedia = path.join(mediaPath, imagePath);
+
+            if (md5 === null) {
+              md5 = await readFileMD5(filePathInMedia);
+              updatedAnything = true;
+            }
+
+            if (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');
 
-  const [convertInfo, spawnConvert] = (await getSpawnConvert()) ?? [];
   if (!spawnConvert) {
     logError`${`It looks like you don't have ImageMagick installed.`}`;
     logError`ImageMagick is required to generate thumbnails for display on the wiki.`;
-    logError`(Error message: ${convertInfo})`;
+    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 false;
+    return {success: false};
   } else {
-    logInfo`Found ImageMagick binary: ${convertInfo}`;
+    logInfo`Found ImageMagick binary:  ${convertInfo}`;
   }
 
-  let cache,
-    firstRun = false;
+  quietInfo`Running up to ${magickThreads + ' magick threads'} simultaneously.`;
+
+  let cache = {};
+  let firstRun = false;
+
   try {
-    cache = JSON.parse(await readFile(path.join(mediaPath, CACHE_FILE)));
+    cache = JSON.parse(await readFile(path.join(mediaCachePath, CACHE_FILE)));
     quietInfo`Cache file successfully read.`;
   } catch (error) {
-    cache = {};
     if (error.code === 'ENOENT') {
       firstRun = true;
     } else {
@@ -255,8 +913,21 @@ export default async function genThumbs(mediaPath, {
     }
   }
 
+  await refreshThumbnailCache(cache, {mediaPath, queueSize});
+
   try {
-    await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache));
+    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};
+  }
+
+  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}`;
@@ -264,6 +935,7 @@ export default async function genThumbs(mediaPath, {
       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.`;
     }
@@ -271,95 +943,213 @@ export default async function genThumbs(mediaPath, {
     await delay(WARNING_DELAY_TIME);
   }
 
-  const imagePaths = await traverse(mediaPath, {filterFile, filterDir});
+  if (firstRun) {
+    cache = {};
+  }
+
+  const imagePaths = await traverseSourceImagePaths(mediaPath, {target: 'generate'});
 
-  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
-    )
-  );
+  const imageThumbtacksNeeded =
+    await progressPromiseAll(`Determining thumbtacks needed`,
+      queue(
+        imagePaths.map(imagePath => () =>
+          determineThumbtacksNeededForFile({
+            filePath: path.join(mediaPath, imagePath),
+            mediaPath: imagePath,
+            cache,
+          }).catch(error => ({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 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 (error) {
-      logError`Failed to read at least one image file!`;
-      logError`This implies a thumbnail probably won't be generatable.`;
+
+    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 false;
+      return {success: false};
     } else {
       quietInfo`All image files successfully read.`;
     }
   }
 
-  // Technically we could pro8a8ly mut8te the cache varia8le in-place?
-  // 8ut that seems kinda iffy.
-  const updatedCache = Object.assign({}, cache);
-
-  const entriesToGenerate = imageToMD5Entries.filter(
-    ([filePath, md5]) => md5 !== cache[filePath]
-  );
+  // 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 (entriesToGenerate.length === 0) {
+  if (empty(imagePaths)) {
     logInfo`All image thumbnails are already up-to-date - nice!`;
-    return true;
+    return {success: true, cache};
   }
 
-  const failed = [];
-  const succeeded = [];
+  const imageDimensions =
+    await progressPromiseAll(`Identifying dimensions of image files`,
+      queue(
+        imagePaths.map(imagePath => () =>
+          identifyImageDimensions(path.join(mediaPath, imagePath))
+            .catch(error => ({error}))),
+        queueSize));
+
+  {
+    const erroredPaths = imagePaths.slice();
+    const errors = imageDimensions.map(result => result.error);
+    filterMultipleArrays(erroredPaths, errors, (_imagePath, error) => error);
+
+    for (const {imagePath, error} of stitchArrays({imagePath: erroredPaths, error: errors})) {
+      logError`Failed to identify dimensions for ${imagePath}: ${error}`;
+    }
+
+    if (!empty(errors)) {
+      logError`Failed to identify dimensions for at least one image file!`;
+      logError`This indicates a thumbnail probably wouldn't be generated.`;
+      logError`So, exiting early.`;
+      return {success: false};
+    } else {
+      quietInfo`All image files successfully had dimensions identified.`;
+    }
+  }
+
+  let numFailed = 0;
   const writeMessageFn = () =>
-    `Writing image thumbnails. [failed: ${failed.length}]`;
+    `Writing image thumbnails. [failed: ${numFailed}]`;
 
-  // 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), {spawnConvert}).then(
-              () => {
-                updatedCache[filePath] = md5;
-                succeeded.push(filePath);
-              },
-              (error) => {
-                failed.push([filePath, error]);
-              }
-            )
-      )
-    )
-  );
-
-  if (failed.length > 0) {
-    for (const [path, error] of failed) {
-      logError`Thumbnails failed to generate for ${path} - ${error}`;
+  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!`;
     }
-    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!`;
-  } else {
-    logInfo`Generated all (updated) thumbnails successfully!`;
+  }
+
+  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(mediaPath, CACHE_FILE),
-      JSON.stringify(updatedCache)
-    );
+      path.join(mediaCachePath, CACHE_FILE),
+      stringifyCache(cache));
     quietInfo`Updated cache file successfully written!`;
   } catch (error) {
     logWarn`Failed to write updated cache file: ${error}`;
@@ -367,7 +1157,240 @@ export default async function genThumbs(mediaPath, {
     logWarn`Sorry about that!`;
   }
 
-  return true;
+  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)) {
@@ -376,6 +1399,7 @@ if (isMain(import.meta.url)) {
       'media-path': {
         type: 'value',
       },
+
       'queue-size': {
         type: 'value',
         validate(size) {
@@ -384,6 +1408,7 @@ if (isMain(import.meta.url)) {
           return true;
         },
       },
+
       queue: {alias: 'queue-size'},
     });
 
diff --git a/src/listing-spec.js b/src/listing-spec.js
index e01912c..73fbee6 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -1,995 +1,312 @@
-import {
-  empty,
-  accumulateSum,
-} from './util/sugar.js';
-
-import {
-  chunkByProperties,
-  getArtistNumContributions,
-  getTotalDuration,
-  sortAlphabetically,
-  sortByDate,
-  sortChronologically,
-} from './util/wiki-data.js';
-
-const listingSpec = [
-  {
-    directory: 'albums/by-name',
-    stringsKey: 'listAlbums.byName',
-
-    data: ({wikiData: {albumData}}) =>
-      sortAlphabetically(albumData.slice()),
-
-    row: (album, {language, link}) =>
-      language.$('listingPage.listAlbums.byName.item', {
-        album: link.album(album),
-        tracks: language.countTracks(album.tracks.length, {unit: true}),
-      }),
-  },
-
-  {
-    directory: 'albums/by-tracks',
-    stringsKey: 'listAlbums.byTracks',
-
-    data: ({wikiData: {albumData}}) =>
-      albumData.slice()
-        .sort((a, b) => b.tracks.length - a.tracks.length),
-
-    row: (album, {language, link}) =>
-      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: {albumData}}) =>
-      albumData
-        .map(album => ({
-          album,
-          duration: getTotalDuration(album.tracks),
-        }))
-        .filter(album => album.duration)
-        .sort((a, b) => b.duration - a.duration),
-
-    row: ({album, duration}, {language, link}) =>
-      language.$('listingPage.listAlbums.byDuration.item', {
-        album: link.album(album),
-        duration: language.formatDuration(duration),
-      }),
-  },
-
-  {
-    directory: 'albums/by-date',
-    stringsKey: 'listAlbums.byDate',
-
-    data: ({wikiData: {albumData}}) =>
-      sortChronologically(
-        albumData
-          .filter(album => album.date)),
-
-    row: (album, {language, link}) =>
-      language.$('listingPage.listAlbums.byDate.item', {
-        album: link.album(album),
-        date: language.formatDate(album.date),
-      }),
-  },
-
-  {
-    directory: 'albums/by-date-added',
-    stringsKey: 'listAlbums.byDateAdded',
-
-    data: ({wikiData: {albumData}}) =>
-      chunkByProperties(
-        albumData
-          .filter(a => a.dateAddedToWiki)
-          .sort((a, b) => {
-            if (a.dateAddedToWiki < b.dateAddedToWiki) return -1;
-            if (a.dateAddedToWiki > b.dateAddedToWiki) return 1;
-          }),
-        ['dateAddedToWiki']),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({dateAddedToWiki, chunk: albums}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listAlbums.byDateAdded.date', {
-              date: language.formatDate(dateAddedToWiki),
-            })),
-
-          html.tag('dd',
-            html.tag('ul',
-              albums.map((album) =>
-                html.tag('li',
-                  language.$('listingPage.listAlbums.byDateAdded.album', {
-                    album: link.album(album),
-                  }))))),
-        ])),
-  },
-
-  {
-    directory: 'artists/by-name',
-    stringsKey: 'listArtists.byName',
-
-    data: ({wikiData: {artistData}}) =>
-      sortAlphabetically(artistData.slice())
-        .map(artist => ({
-          artist,
-          contributions: getArtistNumContributions(artist),
-        })),
-
-    row: ({artist, contributions}, {language, link}) =>
-      language.$('listingPage.listArtists.byName.item', {
-        artist: link.artist(artist),
-        contributions: language.countContributions(contributions, {
-          unit: true,
-        }),
-      }),
-  },
-
-  {
-    directory: 'artists/by-contribs',
-    stringsKey: 'listArtists.byContribs',
-
-    data: ({wikiData: {artistData, wikiInfo}}) => ({
-      toTracks: artistData
-        .map(artist => ({
-          artist,
-          contributions:
-            artist.tracksAsContributor.length +
-            artist.tracksAsArtist.length,
-        }))
-        .sort((a, b) => b.contributions - a.contributions)
-        .filter(({contributions}) => contributions),
-
-      toArtAndFlashes: artistData
-        .map(artist => ({
-          artist,
-          contributions:
-            artist.tracksAsCoverArtist.length +
-            artist.albumsAsCoverArtist.length +
-            artist.albumsAsWallpaperArtist.length +
-            artist.albumsAsBannerArtist.length +
-            (wikiInfo.enableFlashesAndGames
-              ? artist.flashesAsContributor.length
-              : 0),
-        }))
-        .sort((a, b) => b.contributions - a.contributions)
-        .filter(({contributions}) => contributions),
-
-      // This is a kinda naughty hack, 8ut like, it's the only place
-      // we'd 8e passing wikiData to html() otherwise, so like....
-      // (Ok we do do this again once later.)
-      showAsFlashes: wikiInfo.enableFlashesAndGames,
-    }),
-
-    html: (
-      {toTracks, toArtAndFlashes, showAsFlashes},
-      {html, language, link}
-    ) =>
-      html.tag('div', {class: 'content-columns'}, [
-        html.tag('div', {class: 'column'}, [
-          html.tag('h2',
-            language.$('listingPage.misc.trackContributors')),
-
-          html.tag('ul',
-            toTracks.map(({artist, contributions}) =>
-              html.tag('li',
-                language.$('listingPage.listArtists.byContribs.item', {
-                  artist: link.artist(artist),
-                  contributions: language.countContributions(contributions, {
-                    unit: true,
-                  }),
-                })))),
-        ]),
-
-        html.tag('div', {class: 'column'}, [
-          html.tag('h2',
-            language.$(
-              'listingPage.misc' +
-                (showAsFlashes
-                  ? '.artAndFlashContributors'
-                  : '.artContributors'))),
-
-          html.tag('ul',
-            toArtAndFlashes.map(({artist, contributions}) =>
-              html.tag('li',
-                language.$('listingPage.listArtists.byContribs.item', {
-                  artist: link.artist(artist),
-                  contributions:
-                    language.countContributions(contributions, {unit: true}),
-                })))),
-        ]),
-    ]),
-  },
-
-  {
-    directory: 'artists/by-commentary',
-    stringsKey: 'listArtists.byCommentary',
-
-    data: ({wikiData: {artistData}}) =>
-      artistData
-        .map(artist => ({
-          artist,
-          entries:
-            artist.tracksAsCommentator.length +
-            artist.albumsAsCommentator.length,
-        }))
-        .filter(({entries}) => entries)
-        .sort((a, b) => b.entries - a.entries),
-
-    row: ({artist, entries}, {language, link}) =>
-      language.$('listingPage.listArtists.byCommentary.item', {
-        artist: link.artist(artist),
-        entries: language.countCommentaryEntries(entries, {unit: true}),
-      }),
-  },
-
-  {
-    directory: 'artists/by-duration',
-    stringsKey: 'listArtists.byDuration',
-
-    data: ({wikiData: {artistData}}) =>
-      artistData
-        .map((artist) => ({
-          artist,
-          duration: getTotalDuration([
-            ...(artist.tracksAsArtist ?? []),
-            ...(artist.tracksAsContributor ?? []),
-          ], {originalReleasesOnly: true}),
-        }))
-        .filter(({duration}) => duration > 0)
-        .sort((a, b) => b.duration - a.duration),
-
-    row: ({artist, duration}, {language, link}) =>
-      language.$('listingPage.listArtists.byDuration.item', {
-        artist: link.artist(artist),
-        duration: language.formatDuration(duration),
-      }),
-  },
-
-  {
-    directory: 'artists/by-latest',
-    stringsKey: 'listArtists.byLatest',
-
-    data({wikiData: {
-      albumData,
-      flashData,
-      trackData,
-      wikiInfo,
-    }}) {
-      const processContribs = values => {
-        const filteredValues = values
-          .filter(value => value.date && !empty(value.contribs));
-
-        const datedArtistLists = sortByDate(filteredValues)
-          .map(({
-            contribs,
-            date,
-          }) => ({
-            artists: contribs.map(({who}) => who),
-            date,
-          }));
-
-        const remainingArtists = new Set(datedArtistLists.flatMap(({artists}) => artists));
-        const artistEntries = [];
-
-        for (let i = datedArtistLists.length - 1; i >= 0; i--) {
-          const {artists, date} = datedArtistLists[i];
-          for (const artist of artists) {
-            if (!remainingArtists.has(artist))
-              continue;
-
-            remainingArtists.delete(artist);
-            artistEntries.push({
-              artist,
-              date,
-
-              // For sortChronologically!
-              directory: artist.directory,
-              name: artist.name,
-            });
-          }
-
-          // Early exit: If we've gotten every artist, there's no need to keep
-          // going.
-          if (remainingArtists.size === 0)
-            break;
+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}`))
         }
+      }
 
-        return sortChronologically(artistEntries, {latestFirst: true});
-      };
-
-      // Tracks are super easy to sort because they only have one pertinent
-      // date: the date the track was released on.
-
-      const toTracks = processContribs(
-        trackData.map(({
-          artistContribs,
-          date,
-        }) => ({
-          contribs: artistContribs,
-          date,
-        })));
-
-      // Artworks are a bit more involved because there are multiple dates
-      // involved - cover artists correspond to one date, wallpaper artists to
-      // another, etc.
-
-      const toArtAndFlashes = processContribs([
-        ...trackData.map(({
-          coverArtistContribs,
-          coverArtDate,
-        }) => ({
-          contribs: coverArtistContribs,
-          date: coverArtDate,
-        })),
-
-        ...flashData
-          ? flashData.map(({
-              contributorContribs,
-              date,
-            }) => ({
-              contribs: contributorContribs,
-              date,
-            }))
-          : [],
-
-        ...albumData.flatMap(({
-          bannerArtistContribs,
-          coverArtistContribs,
-          coverArtDate,
-          date,
-          wallpaperArtistContribs,
-        }) => [
-          {
-            contribs: coverArtistContribs,
-            date: coverArtDate,
-          },
-          {
-            contribs: bannerArtistContribs,
-            date, // TODO: bannerArtDate (see issue #90)
-          },
-          {
-            contribs: wallpaperArtistContribs,
-            date, // TODO: wallpaperArtDate (see issue #90)
-          },
-        ]),
-      ]);
-
-      return {
-        toArtAndFlashes,
-        toTracks,
-
-        // (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: wikiInfo.enableFlashesAndGames,
-      };
-    },
-
-    html: (
-      {toTracks, toArtAndFlashes, showAsFlashes},
-      {html, language, link}
-    ) =>
-      html.tag('div', {class: 'content-columns'}, [
-        html.tag('div', {class: 'column'}, [
-          html.tag('h2',
-            language.$('listingPage.misc.trackContributors')),
-
-          html.tag('ul',
-            toTracks.map(({artist, date}) =>
-              html.tag('li',
-                language.$('listingPage.listArtists.byLatest.item', {
-                  artist: link.artist(artist),
-                  date: language.formatDate(date),
-                })))),
-        ]),
-
-        html.tag('div', {class: 'column'}, [
-          html.tag('h2',
-            language.$(
-              'listingPage.misc' +
-                (showAsFlashes
-                  ? '.artAndFlashContributors'
-                  : '.artContributors'))),
-
-          html.tag('ul',
-            toArtAndFlashes.map(({artist, date}) =>
-              html.tag('li',
-                language.$('listingPage.listArtists.byLatest.item', {
-                  artist: link.artist(artist),
-                  date: language.formatDate(date),
-                })))),
-        ]),
-      ]),
-  },
-
-  {
-    directory: 'groups/by-name',
-    stringsKey: 'listGroups.byName',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
-
-    data: ({wikiData: {groupData}}) =>
-      sortAlphabetically(groupData.slice()),
-
-    row: (group, {language, link}) =>
-      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: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
-
-    data: ({wikiData: {groupCategoryData}}) =>
-      groupCategoryData
-        .map(category => ({
-          category,
-          groups: category.groups,
-        })),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({category, groups}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listGroups.byCategory.category', {
-              category: empty(groups)
-                ? category.name
-                : link.groupInfo(groups[0], {
-                    text: category.name,
-                  }),
-            })),
-
-          html.tag('dd',
-            empty(groups)
-              ? null // todo: #85
-              : html.tag('ul',
-                  category.groups.map(group =>
-                    html.tag('li',
-                      language.$('listingPage.listGroups.byCategory.group', {
-                        group: link.groupInfo(group),
-                        gallery: link.groupGallery(group, {
-                          text: language.$('listingPage.listGroups.byCategory.group.gallery'),
-                        }),
-                      }))))),
-        ])),
-  },
+      listing.seeAlso = listing.seeAlso.filter(Boolean);
 
-  {
-    directory: 'groups/by-albums',
-    stringsKey: 'listGroups.byAlbums',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
-
-    data: ({wikiData: {groupData}}) =>
-      groupData
-        .map(group => ({
-          group,
-          albums: group.albums.length
-        }))
-        .sort((a, b) => b.albums - a.albums),
-
-    row: ({group, albums}, {language, link}) =>
-      language.$('listingPage.listGroups.byAlbums.item', {
-        group: link.groupInfo(group),
-        albums: language.countAlbums(albums, {unit: true}),
-      }),
-  },
+      if (!empty(suberrors)) {
+        errors.push(new AggregateError(suberrors, `Errors matching "see also" listings for ${listing.directory}`));
+      }
+    } else {
+      listing.seeAlso = null;
+    }
+  }
 
-  {
-    directory: 'groups/by-tracks',
-    stringsKey: 'listGroups.byTracks',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
-
-    data: ({wikiData: {groupData}}) =>
-      groupData
-        .map((group) => ({
-          group,
-          tracks: accumulateSum(
-            group.albums,
-            ({tracks}) => tracks.length),
-        }))
-        .sort((a, b) => b.tracks - a.tracks),
-
-    row: ({group, tracks}, {language, link}) =>
-      language.$('listingPage.listGroups.byTracks.item', {
-        group: link.groupInfo(group),
-        tracks: language.countTracks(tracks, {unit: true}),
-      }),
-  },
-
-  {
-    directory: 'groups/by-duration',
-    stringsKey: 'listGroups.byDuration',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
-
-    data: ({wikiData: {groupData}}) =>
-      groupData
-        .map(group => ({
-          group,
-          duration: getTotalDuration(
-            group.albums.flatMap(album => album.tracks),
-            {originalReleasesOnly: true}),
-        }))
-        .sort((a, b) => b.duration - a.duration),
-
-    row: ({group, duration}, {language, link}) =>
-      language.$('listingPage.listGroups.byDuration.item', {
-        group: link.groupInfo(group),
-        duration: language.formatDuration(duration),
-      }),
-  },
-
-  {
-    directory: 'groups/by-latest-album',
-    stringsKey: 'listGroups.byLatest',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
-
-    data: ({wikiData: {groupData}}) =>
-      sortChronologically(
-        groupData
-          .map(group => {
-            const albums = group.albums.filter(a => a.date);
-            return !empty(albums) && {
-              group,
-              directory: group.directory,
-              name: group.name,
-              date: albums[albums.length - 1].date,
-            };
-          })
-          .filter(Boolean),
-        {latestFirst: true}),
-
-    row: ({group, date}, {language, link}) =>
-      language.$('listingPage.listGroups.byLatest.item', {
-        group: link.groupInfo(group),
-        date: language.formatDate(date),
-      }),
-  },
-
-  {
-    directory: 'tracks/by-name',
-    stringsKey: 'listTracks.byName',
-
-    data: ({wikiData: {trackData}}) =>
-      sortAlphabetically(trackData.slice()),
-
-    row: (track, {language, link}) =>
-      language.$('listingPage.listTracks.byName.item', {
-        track: link.track(track),
-      }),
-  },
-
-  {
-    directory: 'tracks/by-album',
-    stringsKey: 'listTracks.byAlbum',
-
-    data: ({wikiData: {albumData}}) =>
-      albumData.map(album => ({
-        album,
-        tracks: album.tracks,
-      })),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, tracks}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listTracks.byAlbum.album', {
-              album: link.album(album),
-            })),
-
-          html.tag('dd',
-            html.tag('ol',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.byAlbum.track', {
-                    track: link.track(track),
-                  }))))),
-        ])),
-  },
-
-  {
-    directory: 'tracks/by-date',
-    stringsKey: 'listTracks.byDate',
-
-    data: ({wikiData: {trackData}}) =>
-      chunkByProperties(
-        sortChronologically(trackData.filter(t => t.date)),
-        ['album', 'date']),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, date, chunk: tracks}) => [
-          html.tag('dt',
-            language.$('listingPage.listTracks.byDate.album', {
-              album: link.album(album),
-              date: language.formatDate(date),
-            })),
-
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                track.aka
-                  ? html.tag('li',
-                      {class: 'rerelease'},
-                      language.$('listingPage.listTracks.byDate.track.rerelease', {
-                        track: link.track(track),
-                      }))
-                  : html.tag('li',
-                      language.$('listingPage.listTracks.byDate.track', {
-                        track: link.track(track),
-                      }))))),
-        ])),
-  },
-
-  {
-    directory: 'tracks/by-duration',
-    stringsKey: 'listTracks.byDuration',
-
-    data: ({wikiData: {trackData}}) =>
-      trackData
-        .map(track => ({
-          track,
-          duration: track.duration
-        }))
-        .filter(({duration}) => duration > 0)
-        .sort((a, b) => b.duration - a.duration),
-
-    row: ({track, duration}, {language, link}) =>
-      language.$('listingPage.listTracks.byDuration.item', {
-        track: link.track(track),
-        duration: language.formatDuration(duration),
-      }),
-  },
-
-  {
-    directory: 'tracks/by-duration-in-album',
-    stringsKey: 'listTracks.byDurationInAlbum',
-
-    data: ({wikiData: {albumData}}) =>
-      albumData.map(album => ({
-        album,
-        tracks: album.tracks
-          .slice()
-          .sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0)),
-      })),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, tracks}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listTracks.byDurationInAlbum.album', {
-              album: link.album(album),
-            })),
-
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.byDurationInAlbum.track', {
-                    track: link.track(track),
-                    duration: language.formatDuration(track.duration ?? 0),
-                  }))))),
-        ])),
-  },
-
-  {
-    directory: 'tracks/by-times-referenced',
-    stringsKey: 'listTracks.byTimesReferenced',
-
-    data: ({wikiData: {trackData}}) =>
-      trackData
-        .map(track => ({
-          track,
-          timesReferenced: track.referencedByTracks.length,
-        }))
-        .filter(({timesReferenced}) => timesReferenced)
-        .sort((a, b) => b.timesReferenced - a.timesReferenced),
-
-    row: ({track, timesReferenced}, {language, link}) =>
-      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: {wikiInfo}}) =>
-      wikiInfo.enableFlashesAndGames,
-
-    data: ({wikiData: {trackData}}) =>
-      chunkByProperties(
-        trackData.filter(t => !empty(t.featuredInFlashes)),
-        ['album']),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, chunk: tracks}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listTracks.inFlashes.byAlbum.album', {
-              album: link.album(album),
-              date: language.formatDate(album.date),
-            })),
-
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.inFlashes.byAlbum.track', {
-                    track: link.track(track),
-                    flashes: language.formatConjunctionList(
-                      track.featuredInFlashes.map(link.flash)),
-                  }))))),
-        ])),
-  },
-
-  {
-    directory: 'tracks/in-flashes/by-flash',
-    stringsKey: 'listTracks.inFlashes.byFlash',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableFlashesAndGames,
-
-    data: ({wikiData: {flashData}}) =>
-      sortChronologically(flashData.slice())
-        .map(flash => ({
-          flash,
-          tracks: flash.featuredTracks,
-        })),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({flash, tracks}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listTracks.inFlashes.byFlash.flash', {
-              flash: link.flash(flash),
-              date: language.formatDate(flash.date),
-            })),
-
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.inFlashes.byFlash.track', {
-                    track: link.track(track),
-                    album: link.album(track.album),
-                  }))))),
-        ])),
-  },
-
-  {
-    directory: 'tracks/with-lyrics',
-    stringsKey: 'listTracks.withLyrics',
-
-    data: ({wikiData: {albumData}}) =>
-      albumData
-        .map(album => ({
-          album,
-          tracks: album.tracks.filter(t => t.lyrics),
-        }))
-        .filter(({tracks}) => !empty(tracks)),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, tracks}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listTracks.withLyrics.album', {
-              album: link.album(album),
-              date: language.formatDate(album.date),
-            })),
-
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.withLyrics.track', {
-                    track: link.track(track),
-                  }))))),
-        ])),
-  },
-
-  {
-    directory: 'tags/by-name',
-    stringsKey: 'listTags.byName',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableArtTagUI,
-
-    data: ({wikiData: {artTagData}}) =>
-      sortAlphabetically(
-        artTagData
-          .filter(tag => !tag.isContentWarning)
-          .map(tag => ({
-            tag,
-            timesUsed: tag.taggedInThings.length,
-
-            // For sortAlphabetically!
-            directory: tag.directory,
-            name: tag.name,
-          }))),
-
-    row: ({tag, timesUsed}, {language, link}) =>
-      language.$('listingPage.listTags.byName.item', {
-        tag: link.tag(tag),
-        timesUsed: language.countTimesUsed(timesUsed, {unit: true}),
-      }),
-  },
-
-  {
-    directory: 'tags/by-uses',
-    stringsKey: 'listTags.byUses',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableArtTagUI,
-
-    data: ({wikiData: {artTagData}}) =>
-      artTagData
-        .filter(tag => !tag.isContentWarning)
-        .map(tag => ({
-          tag,
-          timesUsed: tag.taggedInThings.length
-        }))
-        .sort((a, b) => b.timesUsed - a.timesUsed),
-
-    row: ({tag, timesUsed}, {language, link}) =>
-      language.$('listingPage.listTags.byUses.item', {
-        tag: link.tag(tag),
-        timesUsed: language.countTimesUsed(timesUsed, {unit: true}),
-      }),
-  },
-
-  {
-    // Holy beans the spaghetti LOL
-
-    directory: 'random',
-    stringsKey: 'other.randomPages',
-
-    data: ({wikiData: {fandomAlbumData, officialAlbumData}}) => [
-      {
-        albums: officialAlbumData,
-        name: 'Official',
-        randomCode: 'official',
-      },
-      {
-        albums: fandomAlbumData,
-        name: 'Fandom',
-        randomCode: 'fandom',
-      },
-    ],
-
-    html: (data, {getLinkThemeString, html}) =>
-      html.fragment([
-        html.tag('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.`),
-
-        html.tag('p',
-          {class: 'js-hide-once-data'},
-          `(Data files are downloading in the background! Please wait for data to load.)`),
-
-        html.tag('p',
-          {class: 'js-show-once-data'},
-          `(Data files have finished being downloaded. The links should work!)`),
-
-        html.tag('dl', [
-          html.tag('dt',
-            `Miscellaneous:`),
-
-          html.tag('dd',
-            html.tag('ul', [
-              html.tag('li', [
-                html.tag('a',
-                  {href: '#', 'data-random': 'artist'},
-                  `Random Artist`),
-                '(' +
-                  html.tag('a',
-                    {href: '#', 'data-random': 'artist-more-than-one-contrib'},
-                    `&gt;1 contribution`) +
-                  ')',
-              ]),
-
-              html.tag('li',
-                html.tag('a',
-                  {href: '#', 'data-random': 'album'},
-                  `Random Album (whole site)`)),
-
-              html.tag('li',
-                html.tag('a',
-                  {href: '#', 'data-random': 'track'},
-                  `Random Track (whole site)`)),
-            ])),
-
-          ...data.flatMap(({albums, name, randomCode}) => [
-            html.tag('dt', [
-              name + ':',
-              '(' +
-                html.tag('a',
-                  {href: '#', 'data-random': 'album-in-' + randomCode},
-                  `Random Album`) +
-                ', ' +
-                html.tag('a',
-                  {href: '#', 'data-random': 'track-in' + randomCode},
-                  'Random Track') +
-                ')',
-            ]),
-
-            html.tag('dd',
-              html.tag('ul',
-                albums.map(album =>
-                  html.tag('li',
-                    html.tag('a',
-                      {
-                        href: '#',
-                        'data-random': 'track-in-album',
-                        style: getLinkThemeString(album.color) +
-                          `; --album-directory: ${album.directory}`,
-                      },
-                      album.name))))),
-          ]),
-        ]),
-      ]),
-  },
-];
+  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));
+  listingSpec.filter(l => l.directory.startsWith(directoryPrefix));
 
 const listingTargetSpec = [
   {
-    title: ({language}) => language.$('listingPage.target.album'),
+    stringsKey: 'album',
     listings: filterListings('album'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.artist'),
+    stringsKey: 'artist',
     listings: filterListings('artist'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.group'),
+    stringsKey: 'group',
     listings: filterListings('group'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.track'),
+    stringsKey: 'track',
     listings: filterListings('track'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.tag'),
+    stringsKey: 'tag',
     listings: filterListings('tag'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.other'),
-    listings: [listingSpec.find((l) => l.directory === 'random')],
+    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 755ad64..0000000
--- a/src/misc-templates.js
+++ /dev/null
@@ -1,816 +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 T from './data/things/index.js';
-
-import {
-  empty,
-  unique,
-} from './util/sugar.js';
-
-import {
-  getTotalDuration,
-  sortAlbumsTracksChronologically,
-  sortChronologically,
-} from './util/wiki-data.js';
-
-const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
-
-const MASTODON_DOMAINS = ['types.pl'];
-
-// "Additional Files" listing
-
-function unbound_generateAdditionalFilesShortcut(additionalFiles, {
-  html,
-  language,
-}) {
-  if (empty(additionalFiles)) return '';
-
-  return language.$('releaseInfo.additionalFiles.shortcut', {
-    anchorLink:
-      html.tag('a',
-        {href: '#additional-files'},
-        language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
-    titles: language.formatUnitList(
-      additionalFiles.map(g => g.title)),
-  });
-}
-
-function unbound_generateAdditionalFilesList(additionalFiles, {
-  html,
-  language,
-
-  getFileSize,
-  linkFile,
-}) {
-  if (empty(additionalFiles)) return [];
-
-  const fileCount = additionalFiles.flatMap((g) => g.files).length;
-
-  return html.fragment([
-    html.tag('p',
-      {
-        id: 'additional-files',
-        class: ['content-heading'],
-      },
-      language.$('releaseInfo.additionalFiles.heading', {
-        additionalFiles: language.countAdditionalFiles(fileCount, {
-          unit: true,
-        }),
-      })),
-
-    html.tag('dl',
-      additionalFiles.flatMap(({title, description, files}) => [
-        html.tag('dt',
-          (description
-            ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
-                title,
-                description,
-              })
-            : language.$('releaseInfo.additionalFiles.entry', {title}))),
-
-        html.tag('dd',
-          html.tag('ul',
-            files.map((file) => {
-              const size = getFileSize(file);
-              return html.tag('li',
-                (size
-                  ? language.$('releaseInfo.additionalFiles.file.withSize', {
-                      file: linkFile(file),
-                      size: language.formatFileSize(size),
-                    })
-                  : language.$('releaseInfo.additionalFiles.file', {
-                      file: linkFile(file),
-                    })));
-            }))),
-      ])),
-  ]);
-}
-
-// Artist strings
-
-function unbound_getArtistString(artists, {
-  html,
-  language,
-  link,
-
-  iconifyURL,
-
-  showIcons = false,
-  showContrib = false,
-}) {
-  return language.formatConjunctionList(
-    artists.map(({who, what}) => {
-      const {urls} = who;
-
-      const hasContribPart = !!(showContrib && what);
-      const hasExternalPart = !!(showIcons && !empty(urls));
-
-      const artistLink = link.artist(who);
-
-      const externalLinks = hasExternalPart &&
-        html.tag('span',
-          {
-            [html.noEdgeWhitespace]: true,
-            class: 'icons'
-          },
-          language.formatUnitList(
-            urls.map(url => iconifyURL(url, {language}))));
-
-      return (
-        (hasContribPart
-          ? (hasExternalPart
-              ? language.$('misc.artistLink.withContribution.withExternalLinks', {
-                  artist: artistLink,
-                  contrib: what,
-                  links: externalLinks,
-                })
-              : language.$('misc.artistLink.withContribution', {
-                  artist: artistLink,
-                  contrib: what,
-                }))
-          : (hasExternalPart
-              ? language.$('misc.artistLink.withExternalLinks', {
-                  artist: artistLink,
-                  links: externalLinks,
-                })
-              : language.$('misc.artistLink', {
-                  artist: artistLink,
-                })))
-      );
-    }));
-}
-
-// Chronology links
-
-function unbound_generateChronologyLinks(currentThing, {
-  html,
-  language,
-  link,
-
-  generateNavigationLinks,
-
-  dateKey = 'date',
-  contribKey,
-  getThings,
-  headingString,
-}) {
-  const contributions = currentThing[contribKey];
-
-  if (empty(contributions)) {
-    return [];
-  }
-
-  if (contributions.length > 8) {
-    return html.tag('div', {class: 'chronology'},
-      language.$('misc.chronology.seeArtistPages'));
-  }
-
-  return contributions
-    .map(({who: artist}) => {
-      const thingsUnsorted = unique(getThings(artist))
-        .filter((t) => t[dateKey]);
-
-      // Kinda a hack, but we automatically detect which is (probably) the
-      // right function to use here.
-      const args = [thingsUnsorted, {getDate: (t) => t[dateKey]}];
-      const things = (
-        thingsUnsorted.every(t => t instanceof T.Album || t instanceof T.Track)
-          ? sortAlbumsTracksChronologically(...args)
-          : sortChronologically(...args));
-
-      if (things.length === 0) return '';
-
-      const index = things.indexOf(currentThing);
-
-      if (index === -1) return '';
-
-      const heading = (
-        html.tag('span', {class: 'heading'},
-          language.$(headingString, {
-            index: language.formatIndex(index + 1, {language}),
-            artist: link.artist(artist),
-          })));
-
-      const navigation = things.length > 1 &&
-        html.tag('span',
-          {
-            [html.onlyIfContent]: true,
-            class: 'buttons',
-          },
-          generateNavigationLinks(currentThing, {
-            data: things,
-            isMain: false,
-          }));
-
-      return (
-        html.tag('div', {class: 'chronology'},
-          (navigation
-            ? language.$('misc.chronology.withNavigation', {
-                heading,
-                navigation,
-              })
-            : heading)));
-    });
-}
-
-// Content warning tags
-
-function unbound_getRevealStringFromWarnings(warnings, {
-  html,
-  language,
-}) {
-  return (
-    language.$('misc.contentWarnings', {warnings}) +
-    html.tag('br') +
-    html.tag('span', {class: 'reveal-interaction'},
-      language.$('misc.contentWarnings.reveal'))
-  );
-}
-
-function unbound_getRevealStringFromTags(tags, {
-  language,
-
-  getRevealStringFromWarnings,
-}) {
-  return (
-    tags?.some(tag => tag.isContentWarning) &&
-      getRevealStringFromWarnings(
-        language.formatUnitList(
-          tags
-            .filter(tag => tag.isContentWarning)
-            .map(tag => tag.name)))
-  );
-}
-
-// Cover art links
-
-function unbound_generateCoverLink({
-  html,
-  img,
-  language,
-  link,
-
-  getRevealStringFromTags,
-
-  alt,
-  path,
-  src,
-  tags = [],
-  to,
-  wikiData,
-}) {
-  const {wikiInfo} = wikiData;
-
-  if (!src && path) {
-    src = to(...path);
-  }
-
-  if (!src) {
-    throw new Error(`Expected src or path`);
-  }
-
-  const linkedTags = tags.filter(tag => !tag.isContentWarning);
-
-  return html.tag('div', {id: 'cover-art-container'}, [
-    img({
-      src,
-      alt,
-      thumb: 'medium',
-      id: 'cover-art',
-      link: true,
-      square: true,
-      reveal: getRevealStringFromTags(tags, {language}),
-    }),
-
-    wikiInfo.enableArtTagUI &&
-    linkedTags.length &&
-      html.tag('p', {class: 'tags'},
-        language.$('releaseInfo.artTags.inline', {
-          tags: language.formatUnitList(
-            linkedTags.map(tag => link.tag(tag))),
-        })),
-  ]);
-}
-
-// CSS & color shenanigans
-
-function unbound_getThemeString(color, {
-  getColors,
-
-  additionalVariables = [],
-} = {}) {
-  if (!color) return '';
-
-  const {
-    primary,
-    dark,
-    dim,
-    bg,
-    bgBlack,
-    shadow,
-  } = getColors(color);
-
-  const variables = [
-    `--primary-color: ${primary}`,
-    `--dark-color: ${dark}`,
-    `--dim-color: ${dim}`,
-    `--bg-color: ${bg}`,
-    `--bg-black-color: ${bgBlack}`,
-    `--shadow-color: ${shadow}`,
-    ...additionalVariables,
-  ].filter(Boolean);
-
-  if (!variables.length) return '';
-
-  return [
-    `:root {`,
-    ...variables.map((line) => `    ${line};`),
-    `}`
-  ].join('\n');
-}
-
-function unbound_getAlbumStylesheet(album, {
-  to,
-}) {
-  const hasWallpaper = album.wallpaperArtistContribs.length >= 1;
-  const hasWallpaperStyle = !!album.wallpaperStyle;
-  const hasBannerStyle = !!album.bannerStyle;
-
-  const wallpaperSource =
-    (hasWallpaper &&
-      to(
-        'media.albumWallpaper',
-        album.directory,
-        album.wallpaperFileExtension));
-
-  const wallpaperPart =
-    (hasWallpaper
-      ? [
-          `body::before {`,
-          `    background-image: url("${wallpaperSource}");`,
-          ...(hasWallpaperStyle
-            ? album.wallpaperStyle
-                .split('\n')
-                .map(line => `    ${line}`)
-            : []),
-          `}`,
-        ]
-      : []);
-
-  const bannerPart =
-    (hasBannerStyle
-      ? [
-          `#banner img {`,
-          ...album.bannerStyle
-            .split('\n')
-            .map(line => `    ${line}`),
-          `}`,
-        ]
-      : []);
-
-  return [
-    ...wallpaperPart,
-    ...bannerPart,
-  ]
-    .filter(Boolean)
-    .join('\n');
-}
-
-// Divided track lists
-
-function unbound_generateTrackListDividedByGroups(tracks, {
-  html,
-  language,
-
-  getTrackItem,
-  wikiData,
-}) {
-  const {divideTrackListsByGroups: groups} = wikiData.wikiInfo;
-
-  if (empty(groups)) {
-    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 dt = name =>
-    html.tag('dt',
-      language.$('trackList.group', {
-        group: name,
-      }));
-
-  const ddul = tracks =>
-    html.tag('dd',
-      html.tag('ul',
-        tracks.map(t => getTrackItem(t))));
-
-  return html.tag('dl', [
-    ...Object.values(lists)
-      .filter(({tracks}) => tracks.length)
-      .flatMap(({group, tracks}) => [
-        dt(group.name),
-        ddul(tracks),
-      ]),
-
-    ...html.fragment(
-      other.length && [
-        dt(language.$('trackList.group.other')),
-        ddul(other),
-      ]),
-  ]);
-}
-
-// Fancy lookin' links
-
-function unbound_fancifyURL(url, {
-  html,
-  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 html.tag('a',
-    {
-      href: url,
-      class: 'nowrap',
-    },
-
-    // truly unhinged indentation here
-    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);
-}
-
-function unbound_fancifyFlashURL(url, flash, {
-  html,
-  language,
-
-  fancifyURL,
-}) {
-  const link = fancifyURL(url);
-  return html.tag('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);
-}
-
-function unbound_iconifyURL(url, {
-  html,
-  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 html.tag('a',
-    {
-      href: url,
-      class: 'icon',
-    },
-    html.tag('svg', [
-      html.tag('title', msg),
-      html.tag('use', {
-        href: to('shared.staticFile', `icons.svg#icon-${id}`),
-      }),
-    ]));
-}
-
-// Grids
-
-function unbound_getGridHTML({
-  img,
-  html,
-  language,
-
-  getRevealStringFromTags,
-
-  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: html.fragment([
-          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),
-          }),
-          html.tag('span', item.name),
-          detailsFn &&
-            html.tag('span', detailsFn(item)),
-        ]),
-      }))
-    .join('\n');
-}
-
-function unbound_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,
-  });
-}
-
-function unbound_getFlashGridHTML({
-  link,
-
-  getFlashCover,
-  getGridHTML,
-  ...props
-}) {
-  return getGridHTML({
-    srcFn: getFlashCover,
-    linkFn: link.flash,
-    ...props,
-  });
-}
-
-// Nav-bar links
-
-function unbound_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(', ');
-}
-
-// Generate "previous" and "next" links relative to a given current thing and a
-// data set (array of things) which includes it, optionally including additional
-// provided links like "random". This is for use in navigation bars and other
-// inline areas.
-//
-// By default, generated links include ID attributes which enable client-side
-// keyboard shortcuts. Provide isMain: false to disable this (if the generated
-// links aren't the for the page's primary navigation).
-function unbound_generateNavigationLinks(current, {
-  language,
-  link,
-
-  additionalLinks = [],
-  data,
-  isMain = true,
-  linkKey = 'anything',
-}) {
-  let previousLink, nextLink;
-
-  if (current) {
-    const linkFn = link[linkKey].bind(link);
-
-    const index = data.indexOf(current);
-    const previousThing = data[index - 1];
-    const nextThing = data[index + 1];
-
-    previousLink = previousThing &&
-      linkFn(previousThing, {
-        attributes: {
-          id: isMain && 'previous-button',
-          title: previousThing.name,
-        },
-        text: language.$('misc.nav.previous'),
-        color: false,
-      });
-
-    nextLink = nextThing &&
-      linkFn(nextThing, {
-        attributes: {
-          id: isMain && 'next-button',
-          title: nextThing.name,
-        },
-        text: language.$('misc.nav.next'),
-        color: false,
-      });
-  }
-
-  const links = [
-    previousLink,
-    nextLink,
-    ...additionalLinks,
-  ].filter(Boolean);
-
-  if (!links.length) {
-    return '';
-  }
-
-  return language.formatUnitList(links);
-}
-
-// Sticky heading, ooooo
-
-function unbound_generateStickyHeadingContainer(headingContent, {
-  html,
-}) {
-  return html.tag('div',
-    {class: 'content-sticky-heading-container'},
-    [
-      html.tag('h1', headingContent),
-      html.tag('h2', {class: 'content-sticky-subheading'}),
-    ]);
-}
-
-// Footer stuff
-
-function unbound_getFooterLocalizationLinks(pathname, {
-  html,
-  language,
-  to,
-  paths,
-
-  defaultLanguage,
-  languages,
-}) {
-  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')})
-  );
-}
-
-// Exports
-
-export {
-  unbound_generateAdditionalFilesList as generateAdditionalFilesList,
-  unbound_generateAdditionalFilesShortcut as generateAdditionalFilesShortcut,
-
-  unbound_getArtistString as getArtistString,
-
-  unbound_generateChronologyLinks as generateChronologyLinks,
-
-  unbound_getRevealStringFromWarnings as getRevealStringFromWarnings,
-  unbound_getRevealStringFromTags as getRevealStringFromTags,
-
-  unbound_generateCoverLink as generateCoverLink,
-
-  unbound_getThemeString as getThemeString,
-  unbound_getAlbumStylesheet as getAlbumStylesheet,
-
-  unbound_generateTrackListDividedByGroups as generateTrackListDividedByGroups,
-
-  unbound_fancifyURL as fancifyURL,
-  unbound_fancifyFlashURL as fancifyFlashURL,
-  unbound_iconifyURL as iconifyURL,
-
-  unbound_getGridHTML as getGridHTML,
-  unbound_getAlbumGridHTML as getAlbumGridHTML,
-  unbound_getFlashGridHTML as getFlashGridHTML,
-
-  unbound_generateInfoGalleryLinks as generateInfoGalleryLinks,
-  unbound_generateNavigationLinks as generateNavigationLinks,
-
-  unbound_generateStickyHeadingContainer as generateStickyHeadingContainer,
-
-  unbound_getFooterLocalizationLinks as getFooterLocalizationLinks,
-}
diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js
deleted file mode 100644
index 9004002..0000000
--- a/src/page/album-commentary.js
+++ /dev/null
@@ -1,142 +0,0 @@
-// Album commentary page and index specifications.
-
-import {accumulateSum} from '../util/sugar.js';
-import {filterAlbumsByCommentary} from '../util/wiki-data.js';
-
-export function condition({wikiData}) {
-  return filterAlbumsByCommentary(wikiData.albumData).length;
-}
-
-export function targets({wikiData}) {
-  return filterAlbumsByCommentary(wikiData.albumData);
-}
-
-export function write(album) {
-  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: ({
-      generateStickyHeadingContainer,
-      getAlbumStylesheet,
-      getLinkThemeString,
-      getThemeString,
-      html,
-      language,
-      link,
-      transformMultiline,
-    }) => ({
-      title: language.$('albumCommentaryPage.title', {album: album.name}),
-      stylesheet: getAlbumStylesheet(album),
-      theme: getThemeString(album.color),
-
-      main: {
-        content: html.tag('div', {class: 'long-content'}, [
-          generateStickyHeadingContainer(
-            language.$('albumCommentaryPage.title', {
-              album: link.album(album),
-            })),
-
-          html.tag('p',
-            language.$('albumCommentaryPage.infoLine', {
-              words: html.tag('b', language.formatWordCount(words, {unit: true})),
-              entries: html.tag('b', language.countCommentaryEntries(entries.length, {unit: true})),
-            })),
-
-          ...html.fragment(album.commentary && [
-            html.tag('h3',
-              {class: ['content-heading']},
-              language.$('albumCommentaryPage.entry.title.albumCommentary')),
-            html.tag('blockquote',
-              transformMultiline(album.commentary)),
-          ]),
-
-          ...album.tracks.filter(t => t.commentary).flatMap(track => [
-            html.tag('h3',
-              {id: 'track.directory', class: ['content-heading']},
-              language.$('albumCommentaryPage.entry.title.trackCommentary', {
-                track: link.track(track),
-              })),
-
-            html.tag('blockquote',
-              {style: getLinkThemeString(track.color)},
-              transformMultiline(track.commentary)),
-          ])
-        ]),
-      },
-
-      nav: {
-        linkContainerClasses: ['nav-links-hierarchy'],
-        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 = accumulateSum(data, ({entries}) => entries.length);
-  const totalWords = accumulateSum(data, ({words}) => words);
-
-  const page = {
-    type: 'page',
-    path: ['commentaryIndex'],
-    page: ({
-      html,
-      language,
-      link,
-    }) => ({
-      title: language.$('commentaryIndex.title'),
-
-      main: {
-        content: html.tag('div', {class: 'long-content'}, [
-          html.tag('h1', language.$('commentaryIndex.title')),
-          html.tag('p', language.$('commentaryIndex.infoLine', {
-            words: html.tag('b', language.formatWordCount(totalWords, {unit: true})),
-            entries: html.tag('b', language.countCommentaryEntries(totalEntries, {unit: true})),
-          })),
-          html.tag('p', language.$('commentaryIndex.albumList.title')),
-          html.tag('ul', data.map(({album, entries, words}) =>
-            html.tag('li', language.$('commentaryIndex.albumList.item', {
-              album: link.albumCommentary(album),
-              words: language.formatWordCount(words, {unit: true}),
-              entries: language.countCommentaryEntries(entries.length, {unit: true}),
-            }))))
-        ]),
-      },
-
-      nav: {simple: true},
-    }),
-  };
-
-  return [page];
-}
diff --git a/src/page/album.js b/src/page/album.js
index 1a90a79..c7327cc 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -1,64 +1,77 @@
-// Album page specification.
-
-import {
-  bindOpts,
-  compareArrays,
-  empty,
-} from '../util/sugar.js';
-
-import {
-  getAlbumCover,
-  getAlbumListTag,
-  getTotalDuration,
-} from '../util/wiki-data.js';
+export const description = `per-album info, artwork gallery & commentary pages`;
 
 export function targets({wikiData}) {
   return wikiData.albumData;
 }
 
-export function write(album, {wikiData}) {
-  const unbound_trackToListItem = (track, {
-    getArtistString,
-    getLinkThemeString,
-    html,
-    language,
-    link,
-  }) => {
-    const itemOpts = {
-      duration: language.formatDuration(track.duration ?? 0),
-      track: link.track(track),
-    };
+export function pathsForTarget(album) {
+  const hasCommentaryPage = !!album.commentary || album.tracks.some(t => t.commentary);
 
-    return html.tag('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: html.tag('span',
-              {class: 'by'},
-              language.$('trackList.item.withArtists.by', {
-                artists: getArtistString(track.artistContribs),
-              })),
-          }));
-  };
+  return [
+    {
+      type: 'page',
+      path: ['album', album.directory],
 
-  const hasCommentaryEntries =
-    !empty([album, ...album.tracks].filter((x) => x.commentary));
-  const hasAdditionalFiles = !empty(album.additionalFiles);
-  const albumDuration = getTotalDuration(album.tracks);
+      contentFunction: {
+        name: 'generateAlbumInfoPage',
+        args: [album],
+      },
+    },
 
-  const displayTrackGroups =
-    album.trackGroups &&
-      (album.trackGroups.length > 1 ||
-        !album.trackGroups[0].isDefaultTrackGroup);
+    {
+      type: 'page',
+      path: ['albumGallery', album.directory],
+
+      contentFunction: {
+        name: 'generateAlbumGalleryPage',
+        args: [album],
+      },
+    },
 
-  const listTag = getAlbumListTag(album);
+    hasCommentaryPage && {
+      type: 'page',
+      path: ['albumCommentary', album.directory],
 
+      contentFunction: {
+        name: 'generateAlbumCommentaryPage',
+        args: [album],
+      },
+    },
+
+    /*
+    {
+      type: 'data',
+      path: ['album', album.directory],
+
+      contentFunction: {
+        name: 'generateAlbumDataFile',
+        args: [album],
+      },
+    },
+    */
+  ];
+}
+
+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 write(album, {wikiData}) {
   const data = {
     type: 'data',
     path: ['album', album.directory],
@@ -84,10 +97,10 @@ export function write(album, {wikiData}) {
       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),
+      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),
@@ -95,533 +108,5 @@ export function write(album, {wikiData}) {
       })),
     }),
   };
-
-  const page = {
-    type: 'page',
-    path: ['album', album.directory],
-    page: ({
-      fancifyURL,
-      generateAdditionalFilesShortcut,
-      generateAdditionalFilesList,
-      generateChronologyLinks,
-      generateCoverLink,
-      generateNavigationLinks,
-      generateStickyHeadingContainer,
-      getAlbumCover,
-      getAlbumStylesheet,
-      getArtistString,
-      getLinkThemeString,
-      getSizeOfAdditionalFile,
-      getThemeString,
-      html,
-      link,
-      language,
-      transformMultiline,
-      urls,
-    }) => {
-      const trackToListItem = bindOpts(unbound_trackToListItem, {
-        getArtistString,
-        getLinkThemeString,
-        html,
-        language,
-        link,
-      });
-
-      const cover = getAlbumCover(album);
-
-      return {
-        title: language.$('albumPage.title', {album: album.name}),
-        stylesheet: getAlbumStylesheet(album),
-
-        themeColor: album.color,
-        theme:
-          getThemeString(album.color, {
-            additionalVariables: [
-              `--album-directory: ${album.directory}`,
-            ],
-          }),
-
-        banner: !empty(album.bannerArtistContribs) && {
-          dimensions: album.bannerDimensions,
-          path: [
-            'media.albumBanner',
-            album.directory,
-            album.bannerFileExtension,
-          ],
-          alt: language.$('misc.alt.albumBanner'),
-          position: 'top',
-        },
-
-        main: {
-          content: [
-            cover && generateCoverLink({
-              src: cover,
-              alt: language.$('misc.alt.albumCover'),
-              tags: album.artTags,
-            }),
-
-            generateStickyHeadingContainer(
-              language.$('albumPage.title', {album: album.name})),
-
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                !empty(album.artistContribs) &&
-                  language.$('releaseInfo.by', {
-                    artists: getArtistString(album.artistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(album.coverArtistContribs) &&
-                  language.$('releaseInfo.coverArtBy', {
-                    artists: getArtistString(album.coverArtistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(album.wallpaperArtistContribs) &&
-                  language.$('releaseInfo.wallpaperArtBy', {
-                    artists: getArtistString(album.wallpaperArtistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(album.bannerArtistContribs) &&
-                  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),
-                  }),
-
-                album.duration > 0 &&
-                  language.$('releaseInfo.duration', {
-                    duration: language.formatDuration(albumDuration, {
-                      approximate: album.tracks.length > 1,
-                    }),
-                  }),
-              ]),
-
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                hasAdditionalFiles &&
-                  generateAdditionalFilesShortcut(album.additionalFiles),
-
-                hasCommentaryEntries &&
-                  language.$('releaseInfo.viewCommentary', {
-                    link: link.albumCommentary(album, {
-                      text: language.$('releaseInfo.viewCommentary.link'),
-                    }),
-                  }),
-              ]),
-
-            !empty(album.urls) &&
-              html.tag('p',
-                language.$('releaseInfo.listenOn', {
-                  links: language.formatDisjunctionList(
-                    album.urls.map(url => fancifyURL(url, {album: true}))
-                  ),
-                })),
-
-            displayTrackGroups &&
-              html.tag('dl',
-                {class: 'album-group-list'},
-                album.trackGroups.flatMap(({
-                  name,
-                  startIndex,
-                  tracks,
-                }) => [
-                  html.tag('dt',
-                    {class: ['content-heading']},
-                    language.$('trackList.section.withDuration', {
-                      duration: language.formatDuration(getTotalDuration(tracks), {
-                        approximate: tracks.length > 1,
-                      }),
-                      section: name,
-                    })),
-                  html.tag('dd',
-                    html.tag(listTag,
-                      listTag === 'ol' ? {start: startIndex + 1} : {},
-                      tracks.map(trackToListItem))),
-                ])),
-
-            !displayTrackGroups &&
-              html.tag(listTag,
-                album.tracks.map(trackToListItem)),
-
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                album.dateAddedToWiki &&
-                  language.$('releaseInfo.addedToWiki', {
-                    date: language.formatDate(
-                      album.dateAddedToWiki
-                    ),
-                  })
-              ]),
-
-            ...html.fragment(
-              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}),
-                })),
-
-            ...html.fragment(
-              album.commentary && [
-                html.tag('p',
-                  {class: ['content-heading']},
-                  language.$('releaseInfo.artistCommentary')),
-                html.tag('blockquote', transformMultiline(album.commentary)),
-              ]),
-          ],
-        },
-
-        sidebarLeft: generateAlbumSidebar(album, null, {
-          fancifyURL,
-          getLinkThemeString,
-          html,
-          link,
-          language,
-          transformMultiline,
-          wikiData,
-        }),
-
-        nav: {
-          linkContainerClasses: ['nav-links-hierarchy'],
-          links: [
-            {toHome: true},
-            {
-              html: language.$('albumPage.nav.album', {
-                album: link.album(album, {class: 'current'}),
-              }),
-            },
-          ],
-          bottomRowContent: generateAlbumNavLinks(album, null, {
-            generateNavigationLinks,
-            html,
-            language,
-          }),
-          content: generateAlbumChronologyLinks(album, null, {
-            generateChronologyLinks,
-            html,
-          }),
-        },
-
-        secondaryNav: generateAlbumSecondaryNav(album, null, {
-          getLinkThemeString,
-          html,
-          language,
-          link,
-        }),
-      };
-    },
-  };
-
-  return [page, data];
-}
-
-// Utility functions
-
-export function generateAlbumSidebar(album, currentTrack, {
-  fancifyURL,
-  getLinkThemeString,
-  html,
-  language,
-  link,
-  transformMultiline,
-}) {
-  const isAlbumPage = !currentTrack;
-  const isTrackPage = !!currentTrack;
-
-  const listTag = getAlbumListTag(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 = [
-    html.tag('h1', link.album(album)),
-    ...trackGroups.map(({name, color, startIndex, tracks, isDefaultTrackGroup}) => {
-      const groupName =
-        html.tag('span',
-          {class: 'group-name'},
-          nameOrDefault(
-            isDefaultTrackGroup,
-            name
-          ));
-      return 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: isTrackPage && tracks.includes(currentTrack),
-          class: tracks.includes(currentTrack) && 'current',
-        },
-        [
-          html.tag(
-            'summary',
-            {style: getLinkThemeString(color)},
-            [
-              listTag === 'ol' &&
-                language.$('albumSidebar.trackList.group.withRange', {
-                  group: groupName,
-                  range: `${startIndex + 1}&ndash;${
-                    startIndex + tracks.length
-                  }`,
-                }),
-              listTag === 'ul' &&
-                language.$('albumSidebar.trackList.group', {
-                  group: groupName,
-                }),
-            ]),
-          html.tag(listTag,
-            listTag === 'ol' ? {start: startIndex + 1} : {},
-            tracks.map(trackToListItem)),
-        ]);
-    }),
-  ];
-
-  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};
-    })
-    // This is a map and not a flatMap because the distinction between which
-    // group sets of elements belong to matters. That means this variable is an
-    // array of arrays, and we'll need to treat it as such later!
-    .map(({group, next, previous}) => [
-      html.tag('h1', language.$('albumSidebar.groupBox.title', {
-        group: link.groupInfo(group),
-      })),
-
-      isAlbumPage &&
-        transformMultiline(group.descriptionShort),
-
-      !empty(group.urls) &&
-        html.tag('p', language.$('releaseInfo.visitOn', {
-          links: language.formatDisjunctionList(
-            group.urls.map((url) => fancifyURL(url))
-          ),
-        })),
-
-      ...html.fragment(
-        isAlbumPage && [
-          next &&
-            html.tag('p',
-              {class: 'group-chronology-link'},
-              language.$('albumSidebar.groupBox.next', {
-                album: link.album(next),
-              })),
-
-          previous &&
-            html.tag('p',
-              {class: 'group-chronology-link'},
-              language.$('albumSidebar.groupBox.previous', {
-                album: link.album(previous),
-              })),
-        ]),
-    ]);
-
-  if (empty(groupParts)) {
-    return {
-      stickyMode: 'column',
-      content: trackListPart,
-    };
-  } else if (isTrackPage) {
-    const combinedGroupPart = {
-      classes: ['no-sticky-header'],
-      content: groupParts
-        .map(groupPart => groupPart.filter(Boolean).join('\n'))
-        .join('\n<hr>\n'),
-    };
-    return {
-      stickyMode: 'column',
-      multiple: [trackListPart, combinedGroupPart],
-    };
-  } else {
-    return {
-      stickyMode: 'last',
-      multiple: [...groupParts, trackListPart],
-    };
-  }
-}
-
-export function generateAlbumSecondaryNav(album, currentTrack, {
-  getLinkThemeString,
-  html,
-  language,
-  link,
-}) {
-  const isAlbumPage = !currentTrack;
-
-  const {groups} = album;
-
-  if (empty(groups)) {
-    return null;
-  }
-
-  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}) => {
-      const previousLink =
-        isAlbumPage &&
-        previous &&
-          link.album(previous, {
-            color: false,
-            text: language.$('misc.nav.previous'),
-          });
-      const nextLink =
-        isAlbumPage &&
-        next &&
-          link.album(next, {
-            color: false,
-            text: language.$('misc.nav.next'),
-          });
-      const links = [previousLink, nextLink].filter(Boolean);
-      return html.tag('span',
-        {style: getLinkThemeString(group.color)},
-        [
-          language.$('albumSidebar.groupBox.title', {
-            group: link.groupInfo(group),
-          }),
-          !empty(links) && `(${language.formatUnitList(links)})`,
-        ]);
-    });
-
-  return {
-    classes: ['nav-links-groups'],
-    content: groupParts,
-  };
-}
-
-export function generateAlbumNavLinks(album, currentTrack, {
-  generateNavigationLinks,
-  html,
-  language,
-}) {
-  const isTrackPage = !!currentTrack;
-
-  if (album.tracks.length <= 1) {
-    return '';
-  }
-
-  const randomLink = html.tag('a',
-    {
-      href: '#',
-      'data-random': 'track-in-album',
-      id: 'random-button'
-    },
-    (isTrackPage
-      ? language.$('trackPage.nav.random')
-      : language.$('albumPage.nav.randomTrack')));
-
-  const navigationLinks =
-    generateNavigationLinks(currentTrack, {
-      additionalLinks: [randomLink],
-      data: album.tracks,
-      linkKey: 'track',
-    });
-
-  return `(${navigationLinks})`;
-}
-
-export function generateAlbumChronologyLinks(album, currentTrack, {
-  generateChronologyLinks,
-  html,
-}) {
-  return html.tag(
-    'div',
-    {
-      [html.onlyIfContent]: true,
-      class: 'nav-chronology-links',
-    },
-    [
-      ...html.fragment(
-        currentTrack && [
-          ...html.fragment(
-            generateChronologyLinks(currentTrack, {
-              contribKey: 'artistContribs',
-              getThings: (artist) => [
-                ...artist.tracksAsArtist,
-                ...artist.tracksAsContributor,
-              ],
-              headingString: 'misc.chronology.heading.track',
-            })),
-
-          ...html.fragment(
-            generateChronologyLinks(currentTrack, {
-              contribKey: 'contributorContribs',
-              getThings: (artist) => [
-                ...artist.tracksAsArtist,
-                ...artist.tracksAsContributor,
-              ],
-              headingString: 'misc.chronology.heading.track',
-            })),
-        ]),
-
-      ...html.fragment(
-        generateChronologyLinks(currentTrack || album, {
-          contribKey: 'coverArtistContribs',
-          dateKey: 'coverArtDate',
-          getThings: (artist) => [
-            ...artist.albumsAsCoverArtist,
-            ...artist.tracksAsCoverArtist,
-          ],
-          headingString: 'misc.chronology.heading.coverArt',
-        })),
-    ]);
 }
+*/
diff --git a/src/page/artist-alias.js b/src/page/artist-alias.js
index e2b1604..c117779 100644
--- a/src/page/artist-alias.js
+++ b/src/page/artist-alias.js
@@ -1,19 +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) {
+export function pathsForTarget(aliasArtist) {
   const {aliasedArtist} = aliasArtist;
 
-  const redirect = {
-    type: 'redirect',
-    fromPath: ['artist', aliasArtist.directory],
-    toPath: ['artist', aliasedArtist.directory],
-    title: () => aliasedArtist.name,
-  };
+  // Don't generate a redirect page if this aliased name resolves to the same
+  // directory as the original artist! See issue #280.
+  if (aliasArtist.directory === aliasedArtist.directory) {
+    return [];
+  }
 
-  return [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 b62b32b..f80bd90 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -1,690 +1,103 @@
-// Artist page specification.
-//
-// NB: See artist-alias.js for artist alias redirect pages.
-
-import {
-  bindOpts,
-  empty,
-  unique,
-} from '../util/sugar.js';
+import {empty} from '#sugar';
 
-import {
-  chunkByProperties,
-  getTotalDuration,
-  sortAlbumsTracksChronologically,
-  sortChronologically,
-} from '../util/wiki-data.js';
+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 = sortAlbumsTracksChronologically(
-    unique([
-      ...(artist.albumsAsCoverArtist ?? []),
-      ...(artist.albumsAsWallpaperArtist ?? []),
-      ...(artist.albumsAsBannerArtist ?? []),
-      ...(artist.tracksAsCoverArtist ?? []),
-    ]),
-    {getDate: (o) => o.coverArtDate});
-
-  const artThingsGallery = sortAlbumsTracksChronologically(
-    [
-      ...(artist.albumsAsCoverArtist ?? []),
-      ...(artist.tracksAsCoverArtist ?? []),
-    ],
-    {getDate: (o) => o.coverArtDate});
-
-  const commentaryThings = sortAlbumsTracksChronologically([
-    ...(artist.albumsAsCommentator ?? []),
-    ...(artist.tracksAsCommentator ?? []),
-  ]);
-
-  const hasGallery = !empty(artThingsGallery);
-
-  const getArtistsAndContrib = (thing, key) => ({
-    artists: thing[key]?.filter(({who}) => who !== artist),
-    contrib: thing[key]?.find(({who}) => who === artist),
-    thing,
-    key,
-  });
-
-  const artListChunks = chunkByProperties(
-    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.date,
-          ...props,
-        }))),
-    ['date', 'album']);
-
-  const commentaryListChunks = chunkByProperties(
-    commentaryThings.map((thing) => ({
-      album: thing.album || thing,
-      track: thing.album ? thing : null,
-    })),
-    ['album']);
-
-  const allTracks = sortAlbumsTracksChronologically(
-    unique([
-      ...(artist.tracksAsArtist ?? []),
-      ...(artist.tracksAsContributor ?? []),
-    ]));
-
-  const chunkTracks = (tracks) =>
-    chunkByProperties(
-      tracks.map((track) => ({
-        track,
-        date: +track.date,
-        album: track.album,
-        duration: track.duration,
-        originalReleaseTrack: track.originalReleaseTrack,
-        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, {originalReleasesOnly: true}),
-    }));
-
-  const trackListChunks = chunkTracks(allTracks);
-  const totalDuration = getTotalDuration(allTracks.filter(t => !t.originalReleaseTrack));
-
-  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 = sortChronologically(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,
-    original,
-    entry,
-    artists,
-    contrib,
-  }) =>
-    original
-      ? language.$('artistPage.creditList.entry.rerelease', {entry})
-      : !empty(artists)
-      ? 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,
-    html,
-    language,
-    link,
-  }) =>
-    html.tag('dl',
-      chunks.flatMap(({date, album, chunk, duration}) => [
-        html.tag('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),
-          })),
-
-        html.tag('dd',
-          html.tag('ul',
-            chunk
-              .map(({track, ...props}) => ({
-                original: track.originalReleaseTrack,
-                entry: language.$('artistPage.creditList.entry.track.withDuration', {
-                  track: link.track(track),
-                  duration: language.formatDuration(track.duration ?? 0),
-                }),
-                ...props,
-              }))
-              .map(({original, ...opts}) =>
-                html.tag('li',
-                  {class: original && 'rerelease'},
-                  generateEntryAccents({
-                    getArtistString,
-                    language,
-                    original,
-                    ...opts,
-                  })
-                )
-              ))),
-      ]));
-
-  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 jumpTo = {
-    tracks: !empty(allTracks),
-    art: !empty(artThingsAll),
-    flashes: wikiInfo.enableFlashesAndGames && !empty(flashes),
-    commentary: !empty(commentaryThings),
-  };
-
-  const showJumpTo = Object.values(jumpTo).includes(true);
-
-  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.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),
-        },
-      };
-    },
-  };
-
-  const infoPage = {
-    type: 'page',
-    path: ['artist', artist.directory],
-    page: ({
-      fancifyURL,
-      generateCoverLink,
-      generateInfoGalleryLinks,
-      generateStickyHeadingContainer,
-      getArtistAvatar,
-      getArtistString,
-      html,
-      link,
-      language,
-      transformMultiline,
-    }) => {
-      const generateTrackList = bindOpts(unbound_generateTrackList, {
-        getArtistString,
-        html,
-        language,
-        link,
-      });
-
-      return {
-        title: language.$('artistPage.title', {artist: name}),
-
-        main: {
-          content: [
-            artist.hasAvatar &&
-              generateCoverLink({
-                src: getArtistAvatar(artist),
-                alt: language.$('misc.alt.artistAvatar'),
-              }),
-
-            generateStickyHeadingContainer(
-              language.$('artistPage.title', {
-                artist: name,
-              })),
-
-            ...html.fragment(
-              contextNotes && [
-                html.tag('p',
-                  language.$('releaseInfo.note')),
-
-                html.tag('blockquote',
-                  transformMultiline(contextNotes)),
-
-                html.tag('hr'),
-              ]),
-
-            !empty(urls) &&
-              html.tag('p',
-                language.$('releaseInfo.visitOn', {
-                  links: language.formatDisjunctionList(
-                    urls.map((url) => fancifyURL(url, {language}))
-                  ),
-                })),
-
-            hasGallery &&
-              html.tag('p',
-                language.$('artistPage.viewArtGallery', {
-                  link: link.artistGallery(artist, {
-                    text: language.$('artistPage.viewArtGallery.link'),
-                  }),
-                })),
-
-            showJumpTo &&
-              html.tag('p',
-                language.$('misc.jumpTo.withLinks', {
-                  links: language.formatUnitList(
-                    [
-                      jumpTo.tracks &&
-                        html.tag('a',
-                          {href: '#tracks'},
-                          language.$('artistPage.trackList.title')),
-
-                      jumpTo.art &&
-                        html.tag('a',
-                          {href: '#art'},
-                          language.$('artistPage.artList.title')),
-
-                      jumpTo.flashes &&
-                        html.tag('a',
-                          {href: '#flashes'},
-                          language.$('artistPage.flashList.title')),
-
-                      jumpTo.commentary &&
-                        html.tag('a',
-                          {href: '#commentary'},
-                          language.$('artistPage.commentaryList.title')),
-                    ].filter(Boolean)),
-                })),
-
-            ...html.fragment(
-              !empty(allTracks) && [
-                html.tag('h2',
-                  {id: 'tracks', class: ['content-heading']},
-                  language.$('artistPage.trackList.title')),
-
-                totalDuration > 0 &&
-                  html.tag('p',
-                    language.$('artistPage.contributedDurationLine', {
-                      artist: artist.name,
-                      duration: language.formatDuration(
-                        totalDuration,
-                        {
-                          approximate: true,
-                          unit: true,
-                        }
-                      ),
-                    })),
-
-                !empty(musicGroups) &&
-                  html.tag('p',
-                    language.$('artistPage.musicGroupsLine', {
-                      groups: language.formatUnitList(
-                        musicGroups.map(({group, contributions}) =>
-                          language.$('artistPage.groupsLine.item', {
-                            group: link.groupInfo(group),
-                            contributions:
-                              language.countContributions(
-                                contributions
-                              ),
-                          })
-                        )
-                      ),
-                    })),
-
-                generateTrackList(trackListChunks),
-              ]),
-
-            ...html.fragment(
-              !empty(artThingsAll) && [
-                html.tag('h2',
-                  {id: 'art', class: ['content-heading']},
-                  language.$('artistPage.artList.title')),
-
-                hasGallery &&
-                  html.tag('p',
-                    language.$('artistPage.viewArtGallery.orBrowseList', {
-                      link: link.artistGallery(artist, {
-                        text: language.$('artistPage.viewArtGallery.link'),
-                      })
-                    })),
-
-                !empty(artGroups) &&
-                  html.tag('p',
-                    language.$('artistPage.artGroupsLine', {
-                    groups: language.formatUnitList(
-                      artGroups.map(({group, contributions}) =>
-                        language.$('artistPage.groupsLine.item', {
-                          group: link.groupInfo(group),
-                          contributions:
-                            language.countContributions(
-                              contributions
-                            ),
-                        })
-                      )
-                    ),
-                  })),
-
-                html.tag('dl',
-                  artListChunks.flatMap(({date, album, chunk}) => [
-                    html.tag('dt', language.$('artistPage.creditList.album.withDate', {
-                      album: link.album(album),
-                      date: language.formatDate(date),
-                    })),
-
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({track, key, ...props}) => ({
-                            ...props,
-                            entry:
-                              track
-                                ? language.$('artistPage.creditList.entry.track', {
-                                    track: link.track(track),
-                                  })
-                                : html.tag('i',
-                                    language.$('artistPage.creditList.entry.album.' + {
-                                      wallpaperArtistContribs:
-                                        'wallpaperArt',
-                                      bannerArtistContribs:
-                                        'bannerArt',
-                                      coverArtistContribs:
-                                        'coverArt',
-                                    }[key])),
-                          }))
-                          .map((opts) => generateEntryAccents({
-                            getArtistString,
-                            language,
-                            ...opts,
-                          }))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-
-            ...html.fragment(
-              wikiInfo.enableFlashesAndGames &&
-              !empty(flashes) && [
-                html.tag('h2',
-                  {id: 'flashes', class: ['content-heading']},
-                  language.$('artistPage.flashList.title')),
+export function pathsForTarget(artist) {
+  const hasGalleryPage =
+    !empty(artist.tracksAsCoverArtist) ||
+    !empty(artist.albumsAsCoverArtist);
 
-                html.tag('dl',
-                  flashListChunks.flatMap(({
-                    act,
-                    chunk,
-                    dateFirst,
-                    dateLast,
-                  }) => [
-                    html.tag('dt',
-                      language.$('artistPage.creditList.flashAct.withDateRange', {
-                        act: link.flash(chunk[0].flash, {
-                          text: act.name,
-                        }),
-                        dateRange: language.formatDateRange(
-                          dateFirst,
-                          dateLast
-                        ),
-                      })),
+  return [
+    {
+      type: 'page',
+      path: ['artist', artist.directory],
 
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({flash, ...props}) => ({
-                            ...props,
-                            entry: language.$('artistPage.creditList.entry.flash', {
-                              flash: link.flash(flash),
-                            }),
-                          }))
-                          .map(opts => generateEntryAccents({
-                            getArtistString,
-                            language,
-                            ...opts,
-                          }))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-
-            ...html.fragment(
-              !empty(commentaryThings) && [
-                html.tag('h2',
-                  {id: 'commentary', class: ['content-heading']},
-                  language.$('artistPage.commentaryList.title')),
-
-                html.tag('dl',
-                  commentaryListChunks.flatMap(({album, chunk}) => [
-                    html.tag('dt',
-                      language.$('artistPage.creditList.album', {
-                        album: link.album(album),
-                      })),
-
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({track}) => track
-                            ? language.$('artistPage.creditList.entry.track', {
-                                track: link.track(track),
-                              })
-                            : html.tag('i',
-                                language.$('artistPage.creditList.entry.album.commentary')))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-          ],
-        },
-
-        nav: generateNavForArtist(artist, false, hasGallery, {
-          generateInfoGalleryLinks,
-          link,
-          language,
-          wikiData,
-        }),
-      };
+      contentFunction: {
+        name: 'generateArtistInfoPage',
+        args: [artist],
+      },
     },
-  };
-
-  const galleryPage = hasGallery && {
-    type: 'page',
-    path: ['artistGallery', artist.directory],
-    page: ({
-      generateInfoGalleryLinks,
-      getAlbumCover,
-      getGridHTML,
-      getTrackCover,
-      html,
-      link,
-      language,
-    }) => ({
-      title: language.$('artistGalleryPage.title', {artist: name}),
-
-      main: {
-        classes: ['top-index'],
-        content: [
-          html.tag('h1',
-            language.$('artistGalleryPage.title', {
-              artist: name,
-            })),
 
-          html.tag('p',
-            {class: 'quick-info'},
-            language.$('artistGalleryPage.infoLine', {
-              coverArts: language.countCoverArts(artThingsGallery.length, {
-                unit: true,
-              }),
-            })),
+    hasGalleryPage && {
+      type: 'page',
+      path: ['artistGallery', artist.directory],
 
-          html.tag('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),
-            })),
-        ],
+      contentFunction: {
+        name: 'generateArtistGalleryPage',
+        args: [artist],
       },
-
-      nav: generateNavForArtist(artist, true, hasGallery, {
-        generateInfoGalleryLinks,
-        link,
-        language,
-        wikiData,
-      }),
-    }),
-  };
-
-  return [data, infoPage, galleryPage].filter(Boolean);
+    },
+  ];
 }
 
-// Utility functions
+/*
+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;
+  };
 
-function generateNavForArtist(artist, isGallery, hasGallery, {
-  generateInfoGalleryLinks,
-  language,
-  link,
-  wikiData,
-}) {
-  const {wikiInfo} = wikiData;
+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 {
-    linkContainerClasses: ['nav-links-hierarchy'],
-    links: [
-      {toHome: true},
-      wikiInfo.enableListings && {
-        path: ['localized.listingIndex'],
-        title: language.$('listingIndex.title'),
+    return {
+      albums: {
+        asCoverArtist: artist.albumsAsCoverArtist
+          .map(serializeArtistsAndContrib('coverArtistContribs')),
+        asWallpaperArtist: artist.albumsAsWallpaperArtist
+          .map(serializeArtistsAndContrib('wallpaperArtistContribs')),
+        asBannerArtist: artist.albumsAsBannerArtis
+          .map(serializeArtistsAndContrib('bannerArtistContribs')),
       },
-      {
-        html: language.$('artistPage.nav.artist', {
-          artist: link.artist(artist, {class: 'current'}),
-        }),
-      },
-      hasGallery && {
-        divider: false,
-        html: `(${infoGalleryLinks})`,
+      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 d968d00..7df7415 100644
--- a/src/page/flash.js
+++ b/src/page/flash.js
@@ -1,7 +1,4 @@
-// Flash page and index specifications.
-
-import {empty} from '../util/sugar.js';
-import {getFlashLink} from '../util/wiki-data.js';
+export const description = `flash & game pages`;
 
 export function condition({wikiData}) {
   return wikiData.wikiInfo.enableFlashesAndGames;
@@ -11,333 +8,26 @@ export function targets({wikiData}) {
   return wikiData.flashData;
 }
 
-export function write(flash, {wikiData}) {
-  const page = {
-    type: 'page',
-    path: ['flash', flash.directory],
-    page: ({
-      fancifyFlashURL,
-      generateChronologyLinks,
-      generateCoverLink,
-      generateNavigationLinks,
-      generateStickyHeadingContainer,
-      getArtistString,
-      getFlashCover,
-      getThemeString,
-      html,
-      link,
-      language,
-    }) => ({
-      title: language.$('flashPage.title', {flash: flash.name}),
-
-      themeColor: flash.color,
-      theme:
-        getThemeString(flash.color, {
-          additionalVariables: [
-            `--flash-directory: ${flash.directory}`,
-          ],
-        }),
-
-      main: {
-        content: [
-          generateCoverLink({
-            src: getFlashCover(flash),
-            alt: language.$('misc.alt.flashArt'),
-          }),
-
-          generateStickyHeadingContainer(
-            language.$('flashPage.title', {
-              flash: flash.name,
-            })),
-
-          html.tag('p',
-            language.$('releaseInfo.released', {
-              date: language.formatDate(flash.date),
-            })),
+export function pathsForTarget(flash) {
+  return [
+    {
+      type: 'page',
+      path: ['flash', flash.directory],
 
-          (flash.page || !empty(flash.urls)) &&
-            html.tag('p',
-              language.$('releaseInfo.playOn', {
-                links: language.formatDisjunctionList(
-                  [
-                    flash.page && getFlashLink(flash),
-                    ...(flash.urls ?? []),
-                  ].map((url) => fancifyFlashURL(url, flash))
-                ),
-              })),
-
-          ...html.fragment(
-            !empty(flash.featuredTracks) && [
-              html.tag('p',
-                {class: ['content-heading']},
-                language.$('releaseInfo.tracksFeatured', {
-                  flash: html.tag('i', flash.name),
-                })),
-
-              html.tag('ul',
-                flash.featuredTracks.map(track =>
-                  html.tag('li',
-                    language.$('trackList.item.withArtists', {
-                      track: link.track(track),
-                      by: html.tag('span', {class: 'by'},
-                        language.$('trackList.item.withArtists.by', {
-                          artists: getArtistString(track.artistContribs),
-                        })),
-                    })))),
-            ]),
-
-          ...html.fragment(
-            !empty(flash.contributorContribs) && [
-              html.tag('p',
-                {class: ['content-heading']},
-                language.$('releaseInfo.contributors')),
-
-              html.tag('ul',
-                flash.contributorContribs.map(contrib =>
-                  html.tag('li',
-                    getArtistString([contrib], {
-                      showContrib: true,
-                      showIcons: true,
-                    })))),
-            ]),
-        ],
+      contentFunction: {
+        name: 'generateFlashInfoPage',
+        args: [flash],
       },
-
-      sidebarLeft: generateSidebarForFlash(flash, {
-        html,
-        language,
-        link,
-        wikiData,
-      }),
-
-      nav: generateNavForFlash(flash, {
-        generateChronologyLinks,
-        generateNavigationLinks,
-        html,
-        link,
-        language,
-        wikiData,
-      }),
-    }),
-  };
-
-  return [page];
+    },
+  ];
 }
 
-export function writeTargetless({
-  wikiData,
-}) {
-  const {flashActData} = wikiData;
-
-  const page = {
-    type: 'page',
-    path: ['flashIndex'],
-    page: ({
-      getFlashGridHTML,
-      getLinkThemeString,
-      html,
-      language,
-      link,
-    }) => ({
-      title: language.$('flashIndex.title'),
-
-      main: {
-        classes: ['flash-index'],
-        content: [
-          html.tag('h1',
-            language.$('flashIndex.title')),
-
-          html.tag('div',
-            {class: 'long-content'},
-            [
-              html.tag('p',
-                {class: 'quick-info'},
-                language.$('misc.jumpTo')),
-
-              html.tag('ul',
-                {class: 'quick-info'},
-                flashActData
-                  .filter(act => act.jump)
-                  .map(({anchor, jump, jumpColor}) =>
-                    html.tag('li',
-                      html.tag('a',
-                        {
-                          href: '#' + anchor,
-                          style: getLinkThemeString(jumpColor),
-                        },
-                        jump)))),
-            ]),
-
-          ...flashActData.flatMap((act, i) => [
-            html.tag('h2',
-              {
-                id: '#' + act.anchor,
-                style: getLinkThemeString(act.color),
-              },
-              link.flash(act.flashes[0], {
-                text: act.name,
-              })),
-
-            html.tag('div',
-              {class: 'grid-listing'},
-              getFlashGridHTML({
-                entries: act.flashes.map((flash) => ({
-                  item: flash,
-                })),
-                lazy: i === 0 ? 4 : true,
-              })),
-          ]),
-        ],
-      },
-
-      nav: {simple: true},
-    }),
-  };
-
-  return [page];
-}
-
-// Utility functions
-
-function generateNavForFlash(flash, {
-  generateChronologyLinks,
-  generateNavigationLinks,
-  html,
-  language,
-  link,
-  wikiData
-}) {
-  const {flashData} = wikiData;
-
-  const previousNextLinks = generateNavigationLinks(flash, {
-    data: flashData,
-    linkKey: 'flash',
-  });
-
-  return {
-    linkContainerClasses: ['nav-links-hierarchy'],
-    links: [
-      {toHome: true},
-      {
-        path: ['localized.flashIndex'],
-        title: language.$('flashIndex.title'),
-      },
-      {
-        html: language.$('flashPage.nav.flash', {
-          flash: link.flash(flash, {class: 'current'}),
-        }),
-      },
-    ],
-
-    bottomRowContent: previousNextLinks && `(${previousNextLinks})`,
-
-    content: html.tag('div',
-      {
-        [html.onlyIfContent]: true,
-        class: 'nav-chronology-links',
-      },
-      generateChronologyLinks(flash, {
-        headingString: 'misc.chronology.heading.flash',
-        contribKey: 'contributorContribs',
-        getThings: (artist) => artist.flashesAsContributor,
-      })),
-  };
-}
-
-function generateSidebarForFlash(flash, {
-  html,
-  language,
-  link,
-  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: [
-      html.tag('h1',
-        link.flashIndex('', {
-          text: language.$('flashIndex.title'),
-        })),
-
-      html.tag('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 &&
-              html.tag('dd',
-                html.tag('ul',
-                  act.flashes
-                    .map(f =>
-                      html.tag('li',
-                        {class: f === flash && 'current'},
-                        link.flash(f))))),
-          ])),
-    ],
-  };
+export function pathsTargetless() {
+  return [
+    {
+      type: 'page',
+      path: ['flashIndex'],
+      contentFunction: {name: 'generateFlashIndexPage'},
+    },
+  ];
 }
diff --git a/src/page/group.js b/src/page/group.js
index c4c376b..b0ed5ba 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -1,312 +1,53 @@
-// Group page specifications.
+import {empty} from '#sugar';
 
-import {
-  empty,
-} from '../util/sugar.js';
-
-import {
-  getTotalDuration,
-  sortChronologically,
-} from '../util/wiki-data.js';
+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, {originalReleasesOnly: true});
-
-  const albumLines = group.albums.map((album) => ({
-    album,
-    otherGroup: album.groups.find((g) => g !== group),
-  }));
-
-  const infoPage = {
-    type: 'page',
-    path: ['groupInfo', group.directory],
-    page: ({
-      fancifyURL,
-      generateInfoGalleryLinks,
-      generateNavigationLinks,
-      generateStickyHeadingContainer,
-      getLinkThemeString,
-      getThemeString,
-      html,
-      language,
-      link,
-      transformMultiline,
-    }) => ({
-      title: language.$('groupInfoPage.title', {group: group.name}),
-
-      themeColor: group.color,
-      theme: getThemeString(group.color),
-
-      main: {
-        content: [
-          generateStickyHeadingContainer(
-            language.$('groupInfoPage.title', {
-              group: group.name
-            })),
-
-          !empty(group.urls) &&
-            html.tag('p',
-              language.$('releaseInfo.visitOn', {
-                links: language.formatDisjunctionList(
-                  group.urls.map(url => fancifyURL(url, {language}))),
-              })),
-
-          group.description &&
-            html.tag('blockquote',
-              transformMultiline(group.description)),
+export function pathsForTarget(group) {
+  const hasGalleryPage = !empty(group.albums);
 
-          ...html.fragment(
-            group.albums && [
-              html.tag('h2',
-                {class: ['content-heading']},
-                language.$('groupInfoPage.albumList.title')),
+  return [
+    {
+      type: 'page',
+      path: ['groupInfo', group.directory],
 
-              html.tag('p',
-                language.$('groupInfoPage.viewAlbumGallery', {
-                  link: link.groupGallery(group, {
-                    text: language.$('groupInfoPage.viewAlbumGallery.link'),
-                  }),
-                })),
-
-              html.tag('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);
-                })),
-            ]),
-        ],
+      contentFunction: {
+        name: 'generateGroupInfoPage',
+        args: [group],
       },
+    },
 
-      sidebarLeft: generateGroupSidebar(group, false, {
-        getLinkThemeString,
-        html,
-        language,
-        link,
-        wikiData,
-      }),
-
-      nav: generateGroupNav(group, false, {
-        generateInfoGalleryLinks,
-        generateNavigationLinks,
-        language,
-        link,
-        wikiData,
-      }),
-    }),
-  };
-
-  const galleryPage = {
-    type: 'page',
-    path: ['groupGallery', group.directory],
-    page: ({
-      generateInfoGalleryLinks,
-      generateNavigationLinks,
-      getAlbumGridHTML,
-      getLinkThemeString,
-      getThemeString,
-      html,
-      language,
-      link,
-    }) => ({
-      title: language.$('groupGalleryPage.title', {group: group.name}),
-
-      themeColor: group.color,
-      theme: getThemeString(group.color),
+    hasGalleryPage && {
+      type: 'page',
+      path: ['groupGallery', group.directory],
 
-      main: {
-        classes: ['top-index'],
-        content: [
-          html.tag('h1',
-            language.$('groupGalleryPage.title', {
-              group: group.name,
-            })),
-
-          html.tag('p',
-            {class: 'quick-info'},
-            language.$('groupGalleryPage.infoLine', {
-              tracks: html.tag('b',
-                language.countTracks(tracks.length, {
-                  unit: true,
-                })),
-              albums: html.tag('b',
-                language.countAlbums(albums.length, {
-                  unit: true,
-                })),
-              time: html.tag('b',
-                language.formatDuration(totalDuration, {
-                  unit: true,
-                })),
-            })),
-
-          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'),
-                  }),
-              })),
-
-          html.tag('div',
-            {class: 'grid-listing'},
-            getAlbumGridHTML({
-              entries: sortChronologically(
-                group.albums.map(album => ({
-                  item: album,
-                  directory: album.directory,
-                  name: album.name,
-                  date: album.date,
-                }))
-              ).reverse(),
-              details: true,
-            })),
-        ],
+      contentFunction: {
+        name: 'generateGroupGalleryPage',
+        args: [group],
       },
-
-      sidebarLeft: generateGroupSidebar(group, true, {
-        getLinkThemeString,
-        html,
-        language,
-        link,
-        wikiData,
-      }),
-
-      nav: generateGroupNav(group, true, {
-        generateInfoGalleryLinks,
-        generateNavigationLinks,
-        language,
-        link,
-        wikiData,
-      }),
-    }),
-  };
-
-  return [infoPage, galleryPage];
-}
-
-// Utility functions
-
-function generateGroupSidebar(currentGroup, isGallery, {
-  getLinkThemeString,
-  html,
-  language,
-  link,
-  wikiData,
-}) {
-  const {groupCategoryData, wikiInfo} = wikiData;
-
-  if (!wikiInfo.enableGroupUI) {
-    return null;
-  }
-
-  const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
-
-  return {
-    content: [
-      html.tag('h1',
-        language.$('groupSidebar.title')),
-
-      ...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),
-                  })))),
-          ])),
-    ],
-  };
+    },
+  ];
 }
 
-function generateGroupNav(currentGroup, isGallery, {
-  generateInfoGalleryLinks,
-  generateNavigationLinks,
-  link,
-  language,
-  wikiData,
-}) {
-  const {groupData, wikiInfo} = wikiData;
-
-  if (!wikiInfo.enableGroupUI) {
-    return {simple: true};
-  }
-
-  const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
-
-  const infoGalleryLinks = generateInfoGalleryLinks(currentGroup, isGallery, {
-    linkKeyGallery: 'groupGallery',
-    linkKeyInfo: 'groupInfo',
-  });
-
-  const previousNextLinks = generateNavigationLinks(currentGroup, {
-    data: groupData,
-    linkKey,
-  });
-
-  return {
-    linkContainerClasses: ['nav-links-hierarchy'],
-    links: [
-      {toHome: true},
-      wikiInfo.enableListings && {
-        path: ['localized.listingIndex'],
-        title: language.$('listingIndex.title'),
-      },
+export function pathsTargetless({wikiData: {wikiInfo}}) {
+  return [
+    wikiInfo.canonicalBase === 'https://hsmusic.wiki/' &&
       {
-        html: language.$('groupPage.nav.group', {
-          group: link[linkKey](currentGroup, {class: 'current'}),
-        }),
+        type: 'redirect',
+        fromPath: ['page', 'albums/fandom'],
+        toPath: ['groupGallery', 'fandom'],
+        title: 'Fandom - Gallery',
       },
+
+    wikiInfo.canonicalBase === 'https://hsmusic.wiki/' &&
       {
-        divider: false,
-        html: previousNextLinks
-          ? `(${infoGalleryLinks}; ${previousNextLinks})`
-          : `(${previousNextLinks})`,
+        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 105c402..53ee6e4 100644
--- a/src/page/homepage.js
+++ b/src/page/homepage.js
@@ -1,190 +1,15 @@
-// Homepage specification.
+export const description = `main wiki homepage`;
 
-import {empty} from '../util/sugar.js';
+export function pathsTargetless({wikiData}) {
+  return [
+    {
+      type: 'page',
+      path: ['home'],
 
-import {
-  getNewAdditions,
-  getNewReleases,
-} from '../util/wiki-data.js';
-
-export function writeTargetless({wikiData}) {
-  const {newsData, staticPageData, homepageLayout, wikiInfo} = wikiData;
-
-  const rowData = homepageLayout.rows?.map(row => {
-    const {color, name, type} = row;
-    const entry = {row, color, name, type};
-
-    switch (type) {
-      case 'albums': {
-        switch (row.sourceGroupByRef) {
-          case 'new-releases':
-            entry.gridEntries = getNewReleases(row.countAlbumsFromGroup, {wikiData});
-            break;
-          case 'new-additions':
-            entry.gridEntries = getNewAdditions(row.countAlbumsFromGroup, {wikiData});
-            break;
-          default:
-            entry.gridEntries = row.sourceGroup
-              ? row.sourceGroup.albums
-                  .slice()
-                  .reverse()
-                  .filter(album => album.isListedOnHomepage)
-                  .slice(0, row.countAlbumsFromGroup)
-                  .map(album => ({item: album}))
-              : [];
-        }
-
-        if (!empty(row.sourceAlbums)) {
-          entry.gridEntries.push(...row.sourceAlbums.map(album => ({item: album})));
-        }
-
-        entry.actionLinks = row.actionLinks ?? [];
-        break;
-      }
-    }
-
-    return entry;
-  });
-
-  const page = {
-    type: 'page',
-    path: ['home'],
-    page: ({
-      getAlbumGridHTML,
-      getLinkThemeString,
-      html,
-      language,
-      link,
-      to,
-      transformInline,
-      transformMultiline,
-    }) => ({
-      title: wikiInfo.name,
-      showWikiNameInTitle: false,
-
-      meta: {
-        description: wikiInfo.description,
-      },
-
-      main: {
-        classes: ['top-index'],
-        content: html.fragment([
-          html.tag('h1',
-            wikiInfo.name),
-
-          ...html.fragment(
-            rowData.map((entry, i) =>
-              html.tag('section',
-                {
-                  class: 'row',
-                  style: getLinkThemeString(entry.color),
-                },
-                [
-                  html.tag('h2',
-                    entry.name),
-
-                  entry.type === 'albums' &&
-                    html.tag('div', {class: 'grid-listing'}, [
-                      ...html.fragment(
-                        getAlbumGridHTML({
-                          entries: entry.gridEntries,
-                          lazy: i > 0,
-                        })),
-
-                      html.tag('div',
-                        {
-                          [html.onlyIfContent]: true,
-                          class: 'grid-actions',
-                        },
-                        entry.actionLinks?.map(action =>
-                          transformInline(action)
-                            .replace('<a', '<a class="box grid-item"'))),
-                    ]),
-                ]))),
-        ]),
+      contentFunction: {
+        name: 'generateWikiHomePage',
+        args: [wikiData.homepageLayout],
       },
-
-      sidebarLeft: homepageLayout.sidebarContent && {
-        wide: true,
-        collapse: false,
-        stickyMode: 'none',
-        // 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
-                ? [
-                    html.tag('h1',
-                      language.$('homepage.news.title')),
-
-                    ...newsData
-                      .slice(0, 3)
-                      .map((entry, i) =>
-                        html.tag('article',
-                          {
-                            class: [
-                              'news-entry',
-                              i === 0 && 'first-news-entry',
-                            ],
-                          },
-                          [
-                            html.tag('h2', [
-                              html.tag('time',
-                                language.formatDate(entry.date)),
-                              link.newsEntry(entry),
-                            ]),
-
-                            transformMultiline(entry.contentShort),
-
-                            entry.contentShort !== entry.content &&
-                              link.newsEntry(entry, {
-                                text: language.$('homepage.news.entry.viewRest')
-                              }),
-                          ])),
-                  ].join('\n')
-                : html.tag('p',
-                    html.tag('i',
-                      `News requested in content description but this feature isn't enabled`))),
-      },
-
-      nav: {
-        linkContainerClasses: ['nav-links-index'],
-        links: [
-          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((html) => ({html})),
-      },
-    }),
-  };
-
-  return [page];
+    },
+  ];
 }
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 fc643b1..bb22c21 100644
--- a/src/page/listing.js
+++ b/src/page/listing.js
@@ -1,239 +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.
-
-import {empty} from '../util/sugar.js';
-
-import {getTotalDuration} from '../util/wiki-data.js';
-
-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 data = listing.data ? listing.data({wikiData}) : null;
-
-  const page = {
-    type: 'page',
-    path: ['listing', listing.directory],
-    page: (opts) => {
-      const {
-        generateStickyHeadingContainer,
-        getLinkThemeString,
-        html,
-        language,
-        link,
-      } = opts;
-
-      const titleKey = `listingPage.${listing.stringsKey}.title`;
-
-      return {
-        title: language.$(titleKey),
-
-        main: {
-          content: [
-            generateStickyHeadingContainer(
-              language.$(titleKey)),
-
-            ...html.fragment(
-              listing.html &&
-                (listing.data
-                  ? listing.html(data, opts)
-                  : listing.html(opts))),
-
-            listing.row &&
-              html.tag('ul',
-                data.map((item) =>
-                  html.tag('li',
-                    listing.row(item, opts)))),
-          ],
-        },
-
-        sidebarLeft: {
-          content: generateSidebarForListings(listing, {
-            getLinkThemeString,
-            html,
-            language,
-            link,
-            wikiData,
-          }),
-        },
-
-        nav: {
-          linkContainerClasses: ['nav-links-hierarchy'],
-          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,
-      html,
-      language,
-      link,
-    }) => ({
-      title: language.$('listingIndex.title'),
-
-      main: {
-        content: [
-          html.tag('h1',
-            language.$('listingIndex.title')),
-
-          html.tag('p',
-            language.$('listingIndex.infoLine', {
-              wiki: wikiInfo.name,
-              tracks: html.tag('b',
-                language.countTracks(trackData.length, {
-                  unit: true,
-                })),
-              albums: html.tag('b',
-                language.countAlbums(albumData.length, {
-                  unit: true,
-                })),
-              duration: html.tag('b',
-                language.formatDuration(totalDuration, {
-                  approximate: true,
-                  unit: true,
-                })),
-            })),
-
-          html.tag('hr'),
-
-          html.tag('p',
-            language.$('listingIndex.exploreList')),
-
-          ...html.fragment(
-            generateLinkIndexForListings(null, false, {
-              html,
-              link,
-              language,
-              wikiData,
-            })),
-        ],
-      },
-
-      sidebarLeft: {
-        content: generateSidebarForListings(null, {
-          getLinkThemeString,
-          html,
-          language,
-          link,
-          wikiData,
-        }),
+export function pathsForTarget(listing) {
+  return [
+    {
+      type: 'page',
+      path: ['listing', listing.directory],
+      contentFunction: {
+        name: listing.contentFunction,
+        args: [listing],
       },
-
-      nav: {simple: true},
-    }),
-  };
-
-  return [page];
+    },
+  ];
 }
 
-// Utility functions
-
-function generateSidebarForListings(currentListing, {
-  getLinkThemeString,
-  html,
-  language,
-  link,
-  wikiData,
-}) {
+export function pathsTargetless() {
   return [
-    html.tag('h1',
-      link.listingIndex('', {
-        text: language.$('listingIndex.title'),
-      })),
-
-    ...html.fragment(
-      generateLinkIndexForListings(currentListing, true, {
-        getLinkThemeString,
-        html,
-        language,
-        link,
-        wikiData,
-      })),
+    {
+      type: 'page',
+      path: ['listingIndex'],
+      contentFunction: {name: 'generateListingsIndexPage'},
+    },
   ];
 }
-
-function generateLinkIndexForListings(currentListing, forSidebar, {
-  getLinkThemeString,
-  html,
-  language,
-  link,
-  wikiData,
-}) {
-  const {listingTargetSpec, wikiInfo} = wikiData;
-
-  const filteredByCondition = listingTargetSpec
-    .map(({listings, ...rest}) => ({
-      ...rest,
-      listings: listings.filter(({condition: c}) => !c || c({wikiData})),
-    }))
-    .filter(({listings}) => !empty(listings));
-
-  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`),
-          }))));
-
-  return forSidebar
-    ? filteredByCondition.map(({title, listings}) =>
-        html.tag('details',
-          {
-            open: 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),
-          ]))
-    : html.tag('dl',
-        filteredByCondition.flatMap(({title, listings}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            title({language})),
-          html.tag('dd',
-            genUL(listings)),
-        ]));
-}
diff --git a/src/page/news.js b/src/page/news.js
index 78e25f4..194ffdc 100644
--- a/src/page/news.js
+++ b/src/page/news.js
@@ -1,4 +1,4 @@
-// News entry & index page specifications.
+export const description = `per-entry news pages & index`;
 
 export function condition({wikiData}) {
   return wikiData.wikiInfo.enableNews;
@@ -8,139 +8,25 @@ export function targets({wikiData}) {
   return wikiData.newsData;
 }
 
-export function write(entry, {wikiData}) {
-  const page = {
-    type: 'page',
-    path: ['newsEntry', entry.directory],
-    page: ({
-      generateNavigationLinks,
-      generateStickyHeadingContainer,
-      html,
-      language,
-      link,
-      transformMultiline,
-    }) => ({
-      title: language.$('newsEntryPage.title', {entry: entry.name}),
-
-      main: {
-        content:
-          html.tag('div', {class: 'long-content'}, [
-            generateStickyHeadingContainer(
-              language.$('newsEntryPage.title', {
-                entry: entry.name,
-              })),
-
-            html.tag('p',
-              language.$('newsEntryPage.published', {
-                date: language.formatDate(entry.date),
-              })),
-
-            transformMultiline(entry.content)
-          ]),
-      },
-
-      nav: generateNewsEntryNav(entry, {
-        generateNavigationLinks,
-        html,
-        language,
-        link,
-        wikiData,
-      }),
-    }),
-  };
-
-  return [page];
-}
-
-export function writeTargetless({wikiData}) {
-  const {newsData} = wikiData;
-
-  const page = {
-    type: 'page',
-    path: ['newsIndex'],
-    page: ({
-      generateStickyHeadingContainer,
-      html,
-      language,
-      link,
-      transformMultiline,
-    }) => ({
-      title: language.$('newsIndex.title'),
-
-      main: {
-        content:
-          html.tag('div',
-            {class: ['long-content', 'news-index']},
-            [
-              generateStickyHeadingContainer(
-                language.$('newsIndex.title')),
-
-              ...newsData.map(entry =>
-                html.tag('article',
-                  {id: entry.directory},
-                  [
-                    html.tag('h2', [
-                      html.tag('time',
-                        language.formatDate(entry.date)),
-                      link.newsEntry(entry),
-                    ]),
-
-                    transformMultiline(entry.contentShort),
-
-                    entry.contentShort !== entry.content &&
-                      html.tag('p',
-                        link.newsEntry(entry, {
-                          text: language.$(
-                            'newsIndex.entry.viewRest'
-                          ),
-                        })),
-                  ])),
-            ]),
+export function pathsForTarget(newsEntry) {
+  return [
+    {
+      type: 'page',
+      path: ['newsEntry', newsEntry.directory],
+      contentFunction: {
+        name: 'generateNewsEntryPage',
+        args: [newsEntry],
       },
-
-      nav: {simple: true},
-    }),
-  };
-
-  return [page];
+    },
+  ];
 }
 
-function generateNewsEntryNav(entry, {
-  generateNavigationLinks,
-  html,
-  language,
-  link,
-  wikiData: {newsData},
-}) {
-  // The newsData list is sorted reverse chronologically (newest ones first),
-  // so the way we find next/previous entries is flipped from normal.
-  const previousNextLinks = generateNavigationLinks(entry, {
-    data: newsData.slice().reverse(),
-    linkKey: 'newsEntry',
-
-    html,
-    language,
-    link,
-  });
-
-  return {
-    linkContainerClasses: ['nav-links-hierarchy'],
-    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 2a0f5e5..c9d806f 100644
--- a/src/page/static.js
+++ b/src/page/static.js
@@ -1,29 +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.)
+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;
 }
 
-export function write(staticPage) {
-  const page = {
-    type: 'page',
-    path: ['staticPage', staticPage.directory],
-    page: ({html, transformMultiline}) => ({
-      title: staticPage.name,
-      stylesheet: staticPage.stylesheet,
+export function pathsForTarget(staticPage) {
+  return [
+    {
+      type: 'page',
+      path: ['staticPage', staticPage.directory],
 
-      main: {
-        content: html.tag('div', {class: 'long-content'}, [
-          html.tag('h1', staticPage.name),
-          transformMultiline(staticPage.content),
-        ]),
+      contentFunction: {
+        name: 'generateStaticPage',
+        args: [staticPage],
       },
-
-      nav: {simple: true},
-    }),
-  };
-
-  return [page];
+    },
+  ];
 }
diff --git a/src/page/tag.js b/src/page/tag.js
index da4f194..8942aea 100644
--- a/src/page/tag.js
+++ b/src/page/tag.js
@@ -1,5 +1,7 @@
 // Art tag page specification.
 
+export const description = `per-artwork-tag gallery pages`;
+
 export function condition({wikiData}) {
   return wikiData.wikiInfo.enableArtTagUI;
 }
@@ -8,93 +10,16 @@ export function targets({wikiData}) {
   return wikiData.artTagData.filter((tag) => !tag.isContentWarning);
 }
 
-export function write(tag, {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: ({
-      getAlbumCover,
-      getGridHTML,
-      getThemeString,
-      getTrackCover,
-      html,
-      language,
-      link,
-    }) => ({
-      title: language.$('tagPage.title', {tag: tag.name}),
-      theme: getThemeString(tag.color),
+export function pathsForTarget(tag) {
+  return [
+    {
+      type: 'page',
+      path: ['tag', tag.directory],
 
-      main: {
-        classes: ['top-index'],
-        content: [
-          html.tag('h1',
-            language.$('tagPage.title', {
-              tag: tag.name,
-            })),
-
-          html.tag('p',
-            {class: 'quick-info'},
-            language.$('tagPage.infoLine', {
-              coverArts: language.countCoverArts(things.length, {
-                unit: true,
-              }),
-            })),
-
-          html.tag('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),
-            })),
-        ],
-      },
-
-      nav: generateTagNav(tag, {
-        link,
-        language,
-        wikiData,
-      }),
-    }),
-  };
-
-  return [page];
-}
-
-// Utility functions
-
-function generateTagNav(tag, {
-  language,
-  link,
-  wikiData,
-}) {
-  return {
-    linkContainerClasses: ['nav-links-hierarchy'],
-    links: [
-      {toHome: true},
-      wikiData.wikiInfo.enableListings && {
-        path: ['localized.listingIndex'],
-        title: language.$('listingIndex.title'),
-      },
-      {
-        html: language.$('tagPage.nav.tag', {
-          tag: link.tag(tag, {class: 'current'}),
-        }),
+      contentFunction: {
+        name: 'generateArtTagGalleryPage',
+        args: [tag],
       },
-    ],
-  };
+    },
+  ];
 }
diff --git a/src/page/track.js b/src/page/track.js
index 09c472a..e75b695 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -1,453 +1,21 @@
 // Track page specification.
 
-import {
-  generateAlbumChronologyLinks,
-  generateAlbumNavLinks,
-  generateAlbumSecondaryNav,
-  generateAlbumSidebar,
-} from './album.js';
-
-import {
-  bindOpts,
-  empty,
-} from '../util/sugar.js';
-
-import {
-  getTrackCover,
-  getAlbumListTag,
-  sortChronologically,
-} from '../util/wiki-data.js';
+export const description = `per-track info pages`;
 
 export function targets({wikiData}) {
   return wikiData.trackData;
 }
 
-export function write(track, {wikiData}) {
-  const {wikiInfo} = wikiData;
-
-  const {
-    album,
-    contributorContribs,
-    referencedByTracks,
-    referencedTracks,
-    sampledByTracks,
-    sampledTracks,
-    otherReleases,
-  } = track;
-
-  const listTag = getAlbumListTag(album);
-
-  let flashesThatFeature;
-  if (wikiInfo.enableFlashesAndGames) {
-    flashesThatFeature = sortChronologically(
-      [track, ...otherReleases].flatMap((track) =>
-        track.featuredInFlashes.map((flash) => ({
-          flash,
-          as: track,
-          directory: flash.directory,
-          name: flash.name,
-          date: flash.date,
-        }))
-      )
-    );
-  }
-
-  const unbound_getTrackItem = (track, {
-    getArtistString,
-    html,
-    language,
-    link,
-  }) =>
-    html.tag('li',
-      language.$('trackList.item.withArtists', {
-        track: link.track(track),
-        by: html.tag('span',
-          {class: 'by'},
-          language.$('trackList.item.withArtists.by', {
-            artists: getArtistString(track.artistContribs),
-          })),
-      }));
+export function pathsForTarget(track) {
+  return [
+    {
+      type: 'page',
+      path: ['track', track.directory],
 
-  const hasCommentary =
-    track.commentary || otherReleases.some((t) => t.commentary);
-
-  const generateCommentary = ({language, link, transformMultiline}) =>
-    transformMultiline([
-      track.commentary,
-      ...otherReleases.map((track) =>
-        track.commentary
-          ?.split('\n')
-          .filter((line) => line.replace(/<\/b>/g, '').includes(':</i>'))
-          .flatMap(line => [
-            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,
+      contentFunction: {
+        name: 'generateTrackInfoPage',
+        args: [track],
       },
-      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 = !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)
-      )
-    );
-  };
-
-  const page = {
-    type: 'page',
-    path: ['track', track.directory],
-    page: ({
-      absoluteTo,
-      fancifyURL,
-      generateChronologyLinks,
-      generateCoverLink,
-      generateNavigationLinks,
-      generateStickyHeadingContainer,
-      generateTrackListDividedByGroups,
-      getAlbumStylesheet,
-      getArtistString,
-      getLinkThemeString,
-      getThemeString,
-      getTrackCover,
-      html,
-      link,
-      language,
-      transformLyrics,
-      transformMultiline,
-      to,
-      urls,
-    }) => {
-      const getTrackItem = bindOpts(unbound_getTrackItem, {
-        getArtistString,
-        html,
-        language,
-        link,
-      });
-      const cover = getTrackCover(track);
-
-      return {
-        title: language.$('trackPage.title', {track: track.name}),
-        stylesheet: getAlbumStylesheet(album, {to}),
-
-        themeColor: track.color,
-        theme:
-          getThemeString(track.color, {
-            additionalVariables: [
-              `--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: !empty(album.bannerArtistContribs) && {
-          classes: ['dim'],
-          dimensions: album.bannerDimensions,
-          path: ['media.albumBanner', album.directory, album.bannerFileExtension],
-          alt: language.$('misc.alt.albumBanner'),
-          position: 'bottom'
-        },
-        */
-
-        main: {
-          content: [
-            cover && generateCoverLink({
-              src: cover,
-              alt: language.$('misc.alt.trackCover'),
-              tags: track.artTags,
-            }),
-
-            generateStickyHeadingContainer(
-              language.$('trackPage.title', {track: track.name})),
-
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                !empty(track.artistContribs) &&
-                  language.$('releaseInfo.by', {
-                    artists: getArtistString(track.artistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(track.coverArtistContribs) &&
-                  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
-                    ),
-                  }),
-              ]),
-
-            html.tag('p',
-              (empty(track.urls)
-                ? language.$('releaseInfo.listenOn.noLinks')
-                : language.$('releaseInfo.listenOn', {
-                    links: language.formatDisjunctionList(
-                      track.urls.map(url => fancifyURL(url, {language}))),
-                  }))),
-
-            ...html.fragment(
-              !empty(otherReleases) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.alsoReleasedAs')),
-
-                html.tag('ul', otherReleases.map(track =>
-                  html.tag('li', language.$('releaseInfo.alsoReleasedAs.item', {
-                    track: link.track(track),
-                    album: link.album(track.album),
-                  })))),
-              ]),
-
-            ...html.fragment(
-              !empty(contributorContribs) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.contributors')),
-
-                html.tag('ul', contributorContribs.map(contrib =>
-                  html.tag('li', getArtistString([contrib], {
-                    showContrib: true,
-                    showIcons: true,
-                  })))),
-              ]),
-
-            ...html.fragment(
-              !empty(referencedTracks) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.tracksReferenced', {
-                    track: html.tag('i', track.name),
-                  })),
-
-                html.tag('ul', referencedTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              !empty(referencedByTracks) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.tracksThatReference', {
-                    track: html.tag('i', track.name),
-                  })),
-
-                generateTrackListDividedByGroups(referencedByTracks, {
-                  getTrackItem,
-                  wikiData,
-                }),
-              ]),
-
-            ...html.fragment(
-              !empty(sampledTracks) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.tracksSampled', {
-                    track: html.tag('i', track.name),
-                  })),
-
-                html.tag('ul', sampledTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              !empty(sampledByTracks) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.tracksThatSample', {
-                    track: html.tag('i', track.name),
-                  })),
-
-                html.tag('ul', sampledByTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              wikiInfo.enableFlashesAndGames &&
-              !empty(flashesThatFeature) && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.flashesThatFeature', {
-                    track: html.tag('i', track.name),
-                  })),
-
-                html.tag('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),
-                      }))))),
-              ]),
-
-            ...html.fragment(
-              track.lyrics && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.lyrics')),
-
-                html.tag('blockquote', transformLyrics(track.lyrics)),
-              ]),
-
-            ...html.fragment(
-              hasCommentary && [
-                html.tag('p', {class: ['content-heading']},
-                  language.$('releaseInfo.artistCommentary')),
-
-                html.tag('blockquote', generateCommentary({
-                  link,
-                  language,
-                  transformMultiline,
-                })),
-              ]),
-          ],
-        },
-
-        sidebarLeft: generateAlbumSidebar(album, track, {
-          fancifyURL,
-          getLinkThemeString,
-          html,
-          language,
-          link,
-          transformMultiline,
-          wikiData,
-        }),
-
-        nav: {
-          linkContainerClasses: ['nav-links-hierarchy'],
-          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}),
-                }),
-              },
-            listTag === 'ul' &&
-              {
-                html: language.$('trackPage.nav.track', {
-                  track: link.track(track, {class: 'current', to}),
-                }),
-              },
-          ].filter(Boolean),
-
-          content: generateAlbumChronologyLinks(album, track, {
-            generateChronologyLinks,
-            html,
-          }),
-
-          bottomRowContent:
-            album.tracks.length > 1 &&
-              generateAlbumNavLinks(album, track, {
-                generateNavigationLinks,
-                html,
-                language,
-              }),
-        },
-
-        secondaryNav: generateAlbumSecondaryNav(album, track, {
-          getLinkThemeString,
-          html,
-          language,
-          link,
-        }),
-      };
     },
-  };
-
-  return [data, page];
+  ];
 }
diff --git a/src/repl.js b/src/repl.js
deleted file mode 100644
index 5497ef5..0000000
--- a/src/repl.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import * as os from 'os';
-import * as path from 'path';
-import * as repl from 'repl';
-
-import {quickLoadAllFromYAML} from './data/yaml.js';
-import {logError, parseOptions} from './util/cli.js';
-import {showAggregate} from './util/sugar.js';
-
-async function main() {
-  const miscOptions = await parseOptions(process.argv.slice(2), {
-    'data-path': {
-      type: 'value',
-    },
-
-    'no-history': {
-      type: 'flag',
-    },
-  });
-
-  const dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA;
-  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 ebe8604..0000000
--- a/src/static/client.js
+++ /dev/null
@@ -1,532 +0,0 @@
-/* eslint-env browser */
-
-// This is the JS file that gets loaded on the client! It's only really used for
-// the random track feature right now - the idea is we only use it for stuff
-// that cannot 8e done at static-site compile time, 8y its fundamentally
-// ephemeral nature.
-//
-// 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;
-let officialAlbumData, fandomAlbumData;
-
-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);
-}
-
-// 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()
-  );
-}
-
-// 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;
-
-    officialAlbumData = albumData.filter((album) =>
-      album.groups.includes('group:official')
-    );
-    fandomAlbumData = albumData.filter(
-      (album) => !album.groups.includes('group:official')
-    );
-
-    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) {
-  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,
-  };
-})();
-
-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() {
-      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');
-}
-
-// Sticky content heading ---------------------------------
-
-const stickyHeadingInfo = Array.from(document.querySelectorAll('.content-sticky-heading-container'))
-  .map(stickyContainer => {
-    const {parentElement: contentContainer} = stickyContainer;
-    const stickySubheading = stickyContainer.querySelector('.content-sticky-subheading');
-    const contentHeadings = Array.from(contentContainer.querySelectorAll('.content-heading'));
-
-    return {
-      contentContainer,
-      contentHeadings,
-      stickyContainer,
-      stickySubheading,
-      state: {
-        displayedHeading: null,
-      },
-    };
-  });
-
-const topOfViewInside = (el, scroll = window.scrollY) => (
-  scroll > el.offsetTop &&
-  scroll < el.offsetTop + el.offsetHeight
-);
-
-function updateStickyHeading() {
-  for (const {
-    contentContainer,
-    contentHeadings,
-    stickyContainer,
-    stickySubheading,
-    state,
-  } of stickyHeadingInfo) {
-    let closestHeading = null;
-
-    if (topOfViewInside(contentContainer)) {
-      if (stickySubheading.childNodes.length === 0) {
-        // &nbsp; to ensure correct basic line height
-        stickySubheading.appendChild(document.createTextNode('\xA0'));
-      }
-
-      const stickyRect = stickyContainer.getBoundingClientRect();
-      const subheadingRect = stickySubheading.getBoundingClientRect();
-      const stickyBottom = stickyRect.bottom + subheadingRect.height;
-
-      // This array is reversed so that we're starting from the bottom when
-      // iterating over it.
-      for (let i = contentHeadings.length - 1; i >= 0; i--) {
-        const heading = contentHeadings[i];
-        const headingRect = heading.getBoundingClientRect();
-        if (headingRect.y + headingRect.height / 1.5 < stickyBottom) {
-          closestHeading = heading;
-          break;
-        }
-      }
-    }
-
-    if (state.displayedHeading !== closestHeading) {
-      if (closestHeading) {
-        // Array.from needed to iterate over a live array with for..of
-        for (const child of Array.from(stickySubheading.childNodes)) {
-          child.remove();
-        }
-
-        for (const child of closestHeading.childNodes) {
-          if (child.tagName === 'A') {
-            for (const grandchild of child.childNodes) {
-              stickySubheading.appendChild(grandchild.cloneNode(true));
-            }
-          } else {
-            stickySubheading.appendChild(child.cloneNode(true));
-          }
-        }
-
-        stickySubheading.classList.add('visible');
-      } else {
-        stickySubheading.classList.remove('visible');
-      }
-
-      state.displayedHeading = closestHeading;
-    }
-  }
-}
-
-document.addEventListener('scroll', updateStickyHeading);
-
-updateStickyHeading();
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 b04ad7c..1df56f0 100644
--- a/src/static/lazy-loading.js
+++ b/src/static/lazy-loading.js
@@ -37,7 +37,7 @@ function lazyLoadMain() {
   if (window.IntersectionObserver) {
     observer = new IntersectionObserver(lazyLoad, {
       rootMargin: '200px',
-      threshold: 1.0,
+      threshold: 0,
     });
     for (i = 0; i < lazyElements.length; i++) {
       observer.observe(lazyElements[i]);
diff --git a/src/static/site2.css b/src/static/site2.css
deleted file mode 100644
index 26c7ae3..0000000
--- a/src/static/site2.css
+++ /dev/null
@@ -1,1232 +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;
-}
-
-/* Layout - Common
- *
- */
-
-body {
-  margin: 10px;
-  overflow-y: scroll;
-}
-
-body::before {
-  content: "";
-  position: fixed;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  z-index: -1;
-}
-
-#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-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;
-}
-
-.sidebar.wide {
-  max-width: 350px;
-  flex-basis: 300px;
-  flex-shrink: 0;
-  flex-grow: 1;
-}
-
-#content {
-  --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%;
-}
-
-#cover-art-container {
-  float: right;
-  width: 40%;
-  max-width: 400px;
-  margin: -5px 0 10px 10px;
-}
-
-/* Layout - Wide (most computers) */
-
-@media not all and (max-width: 900px) {
-  #secondary-nav:not(.no-hide) {
-    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 not all and (min-width: 900px), (max-width: 800px) {
-  body {
-    background: blue !important;
-  }
-}
-
-/* Layout - Medium or Thin */
-
-@media (max-width: 900px) {
-  .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;
-  }
-
-  .sidebar .news-entry:not(.first-news-entry) {
-    display: none;
-  }
-}
-
-/* Layout - Thin (phones) */
-
-@media (max-width: 600px) {
-  .content-columns {
-    columns: 1;
-  }
-
-  #cover-art-container {
-    float: none;
-    margin: 0 0 40px 0;
-    width: 100%;
-    max-width: unset;
-  }
-
-  /* Disable grid features, just line header children up vertically */
-
-  #header {
-    display: block;
-  }
-
-  #header > div:not(:first-child) {
-    margin-top: 0.5em;
-  }
-}
-
-/* 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 > .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;
-}
-
-.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;
-}
-
-#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;
-}
-
-.nav-main-links > span {
-  white-space: nowrap;
-}
-
-.nav-main-links > span > a.current {
-  font-weight: 800;
-}
-
-.nav-links-index > span:not(:first-child):not(.no-divider)::before,
-.nav-links-groups > span:not(:first-child):not(.no-divider)::before {
-  content: "\0020\00b7\0020";
-  font-weight: 800;
-}
-
-.nav-links-hierarchy > span:not(:first-child):not(.no-divider)::before {
-  content: "\0020/\0020";
-}
-
-#header .chronology .heading,
-#header .chronology .buttons {
-  white-space: nowrap;
-}
-
-#secondary-nav {
-  text-align: center;
-}
-
-.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;
-}
-
-#cover-art-container {
-  font-size: 0.8em;
-  box-shadow: 0 0 3px 3px rgba(0, 0, 0, 0.25);
-}
-
-#cover-art img {
-  display: block;
-  width: 100%;
-  height: 100%;
-}
-
-#cover-art-container p {
-  margin-top: 5px;
-}
-
-.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;
-}
-
-#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;
-}
-
-/* Images */
-
-.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;
-    */
-}
-
-.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;
-}
-
-/* Grid listings */
-
-.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;
-  align-self: center;
-}
-
-.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;
-}
-
-/* Squares */
-
-.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%;
-}
-
-/* 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;
-}
-
-/* Sticky heading */
-
-#content:not(.no-sticky-heading) > h1:first-of-type,
-.sidebar:not(.no-sticky-heading) h1:first-of-type {
-  position: sticky;
-  top: 0;
-}
-
-#content .content-sticky-heading-container h1,
-#content:not(.no-sticky-heading) > h1:first-of-type,
-.sidebar:not(.no-sticky-heading) h1:first-of-type {
-  margin: calc(-1 * var(--content-padding));
-  margin-bottom: calc(0.5 * var(--content-padding));
-  padding:
-    calc(1.25 * var(--content-padding))
-    20px
-    calc(0.75 * var(--content-padding))
-    20px;
-
-  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 .content-sticky-heading-container {
-  position: sticky;
-  top: 0;
-
-  /* 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;
-}
-
-#content .content-sticky-heading-container h1 {
-  margin-bottom: 0;
-}
-
-#content .content-sticky-heading-container h2 {
-  position: absolute;
-  margin: 0 calc(-1 * var(--content-padding));
-  width: 100%;
-  padding: 10px 40px 5px 20px;
-
-  font-size: 0.9em;
-  font-weight: normal;
-  font-style: oblique;
-  color: #eee;
-
-  background: var(--bg-black-color);
-  border-bottom: 1px dotted rgba(220, 220, 220, 0.4);
-
-  -webkit-backdrop-filter: blur(3px);
-          backdrop-filter: blur(3px);
-
-  transition: margin-top 0.35s, opacity 0.25s;
-}
-
-#content .content-sticky-heading-container h2:not(.visible) {
-  margin-top: -20px;
-  opacity: 0;
-}
-
-#content .content-sticky-heading-container h2.visible {
-  margin-top: 0;
-  opacity: 1;
-}
-
-#content:not(.no-sticky-heading) > h1:first-of-type {
-  z-index: 1;
-  box-shadow:
-    inset 0 10px 10px -5px var(--shadow-color),
-    0 4px 4px rgba(0, 0, 0, 0.8);
-}
-
-#content .content-sticky-heading-container h1 {
-  box-shadow:
-    inset 0 10px 10px -5px var(--shadow-color),
-    0 4px 4px rgba(0, 0, 0, 0.8);
-}
-
-#content .content-sticky-heading-container h2.visible {
-  box-shadow:
-    inset 0 10px 10px -5px var(--shadow-color),
-    0 4px 4px rgba(0, 0, 0, 0.8);
-}
-
-#content:not(.no-sticky-heading) .long-content h1:first-of-type {
-  margin-left: -40%;
-  margin-right: -40%;
-  padding-left: 40%;
-}
-
-#cover-art-container {
-  z-index: 2;
-  position: relative;
-}
-
-.sidebar:not(.no-sticky-heading) h1:first-of-type {
-  box-shadow:
-    inset 0 8px 8px -6px var(--shadow-color),
-    0 4px 4px rgba(0, 0, 0, 0.8);
-}
-
-#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;
-}
-
-/* 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)";
-}
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 d24f04a..0000000
--- a/src/strings-default.json
+++ /dev/null
@@ -1,388 +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.additionalFiles": "{FILES}",
-  "count.additionalFiles.withUnit.zero": "",
-  "count.additionalFiles.withUnit.one": "{FILES} additional file",
-  "count.additionalFiles.withUnit.two": "",
-  "count.additionalFiles.withUnit.few": "",
-  "count.additionalFiles.withUnit.many": "",
-  "count.additionalFiles.withUnit.other": "{FILES} additional files",
-  "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.tracksSampled": "Tracks that {TRACK} samples:",
-  "releaseInfo.tracksThatSample": "Tracks that sample {TRACK}:",
-  "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:",
-  "releaseInfo.flashesThatFeature.item": "{FLASH}",
-  "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})",
-  "releaseInfo.tracksFeatured": "Tracks that {FLASH} features:",
-  "releaseInfo.lyrics": "Lyrics:",
-  "releaseInfo.artistCommentary": "Artist commentary:",
-  "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!",
-  "releaseInfo.artTags": "Tags:",
-  "releaseInfo.artTags.inline": "Tags: {TAGS}",
-  "releaseInfo.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}",
-  "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:",
-  "releaseInfo.additionalFiles.heading": "Has {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.artistLink": "{ARTIST}",
-  "misc.artistLink.withContribution": "{ARTIST} ({CONTRIB})",
-  "misc.artistLink.withExternalLinks": "{ARTIST} ({LINKS})",
-  "misc.artistLink.withContribution.withExternalLinks": "{ARTIST} ({CONTRIB}) ({LINKS})",
-  "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.chronology.withNavigation": "{HEADING} ({NAVIGATION})",
-  "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 217c985..6bd52da 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -31,126 +31,66 @@
 // 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 {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';
 
-// It stands for "HTML Entities", apparently. Cursed.
-import he from 'he';
+import wrap from 'word-wrap';
 
-import chroma from 'chroma-js';
+// 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';
-
-import { execSync } from 'child_process';
-
-import genThumbs from './gen-thumbs.js';
-import {listingSpec, listingTargetSpec} from './listing-spec.js';
-import urlSpec from './url-spec.js';
-import * as pageSpecs from './page/index.js';
-
-import find, {bindFind} from './util/find.js';
-import * as html from './util/html.js';
-import {getColors} from './util/colors.js';
-import {findFiles} from './util/io.js';
-
-import CacheableObject from './data/things/cacheable-object.js';
-
-import {serializeThings} from './data/serialize.js';
-
-import T from './data/things/index.js';
-
-import {
-  filterDuplicateDirectories,
   filterReferenceErrors,
-  linkWikiDataArrays,
-  loadAndProcessDataDocuments,
-  sortWikiDataArrays,
-  WIKI_INFO_FILE,
-} from './data/yaml.js';
+  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 {
-  fancifyFlashURL,
-  fancifyURL,
-  generateAdditionalFilesShortcut,
-  generateAdditionalFilesList,
-  generateChronologyLinks,
-  generateCoverLink,
-  generateInfoGalleryLinks,
-  generateNavigationLinks,
-  generateStickyHeadingContainer,
-  generateTrackListDividedByGroups,
-  getAlbumGridHTML,
-  getAlbumStylesheet,
-  getArtistString,
-  getFlashGridHTML,
-  getFooterLocalizationLinks,
-  getGridHTML,
-  getRevealStringFromTags,
-  getRevealStringFromWarnings,
-  getThemeString as unbound_getThemeString,
-  iconifyURL,
-} from './misc-templates.js';
-
-import unbound_link, {
-  getLinkThemeString as unbound_getLinkThemeString,
-} from './util/link.js';
-
-import {
-  color,
+  colors,
   decorateTime,
+  fileIssue,
   logWarn,
   logInfo,
   logError,
   parseOptions,
   progressCallAll,
-  progressPromiseAll,
-} from './util/cli.js';
-
-import {validateReplacerSpec, transformInline} from './util/replacer.js';
+} from '#cli';
 
-import {
-  getAlbumCover,
-  getArtistAvatar,
-  getFlashCover,
-  getTrackCover,
-} from './util/wiki-data.js';
-
-/*
-import {
-  serializeContribs,
-  serializeCover,
-  serializeGroupsForAlbum,
-  serializeGroupsForTrack,
-  serializeImagePaths,
-  serializeLink,
-} from './util/serialize.js';
-*/
-
-import {
-  bindOpts,
-  queue,
-  showAggregate,
-  withEntries,
-} from './util/sugar.js';
-
-import {generateURLs, thumb} from './util/urls.js';
-
-// Pensive emoji!
-import { OFFICIAL_GROUP_DIRECTORY } from './util/magic-constants.js';
+import genThumbs, {
+  CACHE_FILE as thumbsCacheFile,
+  defaultMagickThreads,
+  determineMediaCachePath,
+  isThumb,
+  migrateThumbsIntoDedicatedCacheDirectory,
+  verifyImagePaths,
+} from '#thumbs';
 
 import FileSizePreloader from './file-size-preloader.js';
+import {listingSpec, listingTargetSpec} from './listing-spec.js';
+import * as buildModes from './write/build-modes/index.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-const CACHEBUST = 12;
+const CACHEBUST = 23;
 
 let COMMIT;
 try {
@@ -161,1723 +101,887 @@ try {
 
 const BUILD_TIME = new Date();
 
-const DEFAULT_STRINGS_FILE = 'strings-default.json';
+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`;
 
-// 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';
+const defaultStepStatus = {status: STATUS_NOT_STARTED, annotation: null};
 
-// 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';
+// 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;
 
-// This exists adjacent to index.html for any page with oEmbed metadata.
-const OEMBED_JSON_FILE = 'oembed.json';
+async function main() {
+  Error.stackTraceLimit = Infinity;
 
-// Automatically copied (if present) from media directory to site root.
-const FAVICON_FILE = 'favicon.ico';
+  stepStatusSummary = {
+    determineMediaCachePath:
+      {...defaultStepStatus, name: `determine media cache path`},
 
-// 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;
+    migrateThumbnails:
+      {...defaultStepStatus, name: `migrate thumbnails`},
 
-// 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 = {};
+    loadThumbnailCache:
+      {...defaultStepStatus, name: `load thumbnail cache file`},
 
-let queueSize;
+    generateThumbnails:
+      {...defaultStepStatus, name: `generate thumbnails`},
 
-const urls = generateURLs(urlSpec);
+    loadDataFiles:
+      {...defaultStepStatus, name: `load and process data files`},
 
-function splitLines(text) {
-  return text.split(/\r\n|\r|\n/);
-}
+    linkWikiDataArrays:
+      {...defaultStepStatus, name: `link wiki data arrays`},
 
-const replacerSpec = {
-  album: {
-    find: 'album',
-    link: 'album',
-  },
-  'album-commentary': {
-    find: 'album',
-    link: 'albumCommentary',
-  },
-  artist: {
-    find: 'artist',
-    link: 'artist',
-  },
-  'artist-gallery': {
-    find: 'artist',
-    link: 'artistGallery',
-  },
-  'commentary-index': {
-    find: null,
-    link: 'commentaryIndex',
-  },
-  date: {
-    find: null,
-    value: (ref) => new Date(ref),
-    html: (date, {language}) =>
-      html.tag('time',
-        {datetime: date.toString()},
-        language.formatDate(date)),
-  },
-  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;
-      }
-    },
-  },
-  group: {
-    find: 'group',
-    link: 'groupInfo',
-  },
-  'group-gallery': {
-    find: 'group',
-    link: 'groupGallery',
-  },
-  home: {
-    find: null,
-    link: 'home',
-  },
-  'listing-index': {
-    find: null,
-    link: 'listingIndex',
-  },
-  listing: {
-    find: 'listing',
-    link: 'listing',
-  },
-  media: {
-    find: null,
-    link: 'media',
-  },
-  'news-index': {
-    find: null,
-    link: 'newsIndex',
-  },
-  'news-entry': {
-    find: 'newsEntry',
-    link: 'newsEntry',
-  },
-  root: {
-    find: null,
-    link: 'root',
-  },
-  site: {
-    find: null,
-    link: 'site',
-  },
-  static: {
-    find: 'staticPage',
-    link: 'staticPage',
-  },
-  string: {
-    find: null,
-    value: (ref) => ref,
-    html: (ref, {language, args}) => language.$(ref, args),
-  },
-  tag: {
-    find: 'artTag',
-    link: 'tag',
-  },
-  track: {
-    find: 'track',
-    link: 'track',
-  },
-};
-
-if (!validateReplacerSpec(replacerSpec, {find, link: unbound_link})) {
-  process.exit();
-}
+    precacheCommonData:
+      {...defaultStepStatus, name: `precache common data`},
 
-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;
-    }
-  };
+    reportDuplicateDirectories:
+      {...defaultStepStatus, name: `report duplicate directories`},
 
-  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;
-    }
-  }
-  return Object.fromEntries(
-    Object.entries(attributes).map(([key, val]) => [
-      key,
-      val === 'true'
-        ? true
-        : val === 'false'
-        ? false
-        : val === key
-        ? true
-        : val,
-    ])
-  );
-}
+    filterReferenceErrors:
+      {...defaultStepStatus, name: `filter reference errors`},
 
-function joinLineBreaks(sourceLines) {
-  const outLines = [];
+    reportContentTextErrors:
+      {...defaultStepStatus, name: `report content text errors`},
 
-  let lineSoFar = '';
-  for (let i = 0; i < sourceLines.length; i++) {
-    const line = sourceLines[i];
-    lineSoFar += line;
-    if (!line.endsWith('<br>')) {
-      outLines.push(lineSoFar);
-      lineSoFar = '';
-    }
-  }
+    sortWikiDataArrays:
+      {...defaultStepStatus, name: `sort wiki data arrays`},
 
-  if (lineSoFar) {
-    outLines.push(lineSoFar);
-  }
+    precacheAllData:
+      {...defaultStepStatus, name: `precache nearly all data`},
 
-  return outLines;
-}
+    // TODO: This should be split into load/watch steps.
+    loadInternalDefaultLanguage:
+      {...defaultStepStatus, name: `load internal default language`},
 
-function transformMultiline(text, {parseAttributes, transformInline}) {
-  // Heck yes, HTML magics.
+    loadLanguageFiles:
+      {...defaultStepStatus, name: `statically load custom language files`},
 
-  text = transformInline(text.trim());
+    watchLanguageFiles:
+      {...defaultStepStatus, name: `watch custom language files`},
 
-  const outLines = [];
+    initializeDefaultLanguage:
+      {...defaultStepStatus, name: `initialize default language`},
 
-  const indentString = ' '.repeat(4);
+    verifyImagePaths:
+      {...defaultStepStatus, name: `verify missing/misplaced image paths`},
 
-  let levelIndents = [];
-  const openLevel = (indent) => {
-    // opening a sublist is a pain: to be semantically *and* visually
-    // correct, we have to append the <ul> at the end of the existing
-    // previous <li>
-    const previousLine = outLines[outLines.length - 1];
-    if (previousLine?.endsWith('</li>')) {
-      // we will re-close the <li> later
-      outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>';
-    } else {
-      // if the previous line isn't a list item, this is the opening of
-      // the first list level, so no need for indent
-      outLines.push('<ul>');
-    }
-    levelIndents.push(indent);
-  };
-  const closeLevel = () => {
-    levelIndents.pop();
-    if (levelIndents.length) {
-      // closing a sublist, so close the list item containing it too
-      outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>');
-    } else {
-      // closing the final list level! no need for indent here
-      outLines.push('</ul>');
-    }
+    preloadFileSizes:
+      {...defaultStepStatus, name: `preload file sizes`},
+
+    performBuild:
+      {...defaultStepStatus, name: `perform selected build mode`},
   };
 
-  // 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>');
-      }
+  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;
+  }
 
-      // 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('\\>', '>');
-      }
-    }
+  const selectedBuildMode = buildModes[selectedBuildModeFlag];
 
-    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 = '';
-      }
-    }
+  // This is about to get a whole lot more stuff put in it.
+  const wikiData = {
+    listingSpec,
+    listingTargetSpec,
+  };
 
-    let pushString = indentString.repeat(indentThisLine);
-    if (lineTag) {
-      pushString += `<${lineTag}>${lineContent}</${lineTag}>`;
-    } else {
-      pushString += lineContent;
-    }
-    outLines.push(pushString);
-  }
+  const buildOptions = selectedBuildMode.getCLIOptions();
 
-  // after processing all lines...
+  const commonOptions = {
+    'help': {
+      help: `Display usage info and basic information for the \`hsmusic\` command`,
+      type: 'flag',
+    },
 
-  // if still in a list, close all levels
-  while (levelIndents.length) closeLevel();
+    // 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',
+    },
 
-  // if still in a blockquote, close its tag
-  if (inBlockquote) {
-    inBlockquote = false;
-    outLines.push('</blockquote>');
-  }
+    // 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',
+    },
 
-  return outLines.join('\n');
-}
+    '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',
+    },
 
-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).
+    // 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',
+    },
 
-  // If it looks like old data, then like, oh god.
-  // Use the normal transformMultiline tool.
-  if (text.includes('<br')) {
-    return transformMultiline(text);
-  }
+    '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',
+    },
 
-  text = transformInline(text.trim());
+    // 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',
+    },
 
-  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');
-}
+    // 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',
+    },
 
-function stringifyThings(thingData) {
-  return JSON.stringify(serializeThings(thingData));
-}
+    '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',
+    },
 
-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 = {
-    id: link ? '' : id,
-    class: className,
-    alt,
-    width,
-    height,
-  };
+    'skip-file-sizes': {
+      help: `Skips preloading file sizes for images and additional files, which will be left blank in the build`,
+      type: 'flag',
+    },
 
-  const noSrcHTML =
-    !src &&
-      wrap(
-        html.tag('div',
-          {class: 'image-text-area'},
-          noSrcText));
-
-  const nonlazyHTML =
-    src &&
-      wrap(
-        html.tag('img', {
-          ...imgAttributes,
-          src: thumbSrc,
-        }));
-
-  const lazyHTML =
-    src &&
-    lazy &&
-      wrap(
-        html.tag('img',
-          {
-            ...imgAttributes,
-            class: [className, 'lazy'],
-            'data-original': thumbSrc,
-          }),
-        true);
-
-  if (!src) {
-    return noSrcHTML;
-  } else if (lazy) {
-    return html.tag('noscript', nonlazyHTML) + '\n' + lazyHTML;
-  } else {
-    return nonlazyHTML;
-  }
+    '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',
+    },
 
-  function wrap(input, hide = false) {
-    let wrapped = input;
+    // 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',
+    },
 
-    wrapped = html.tag('div', {class: 'image-inner-area'}, wrapped);
-    wrapped = html.tag('div', {class: 'image-container'}, wrapped);
+    'no-input': {
+      help: `Don't wait on input from stdin - assume the device is headless`,
+      type: 'flag',
+    },
 
-    if (reveal) {
-      wrapped = html.tag('div', {class: 'reveal'}, [
-        wrapped,
-        html.tag('span', {class: 'reveal-text'}, reveal),
-      ]);
-    }
+    'no-language-reloading': {
+      help: `Don't reload language files while the build is running\n\nApplied by default for --static-build`,
+      type: 'flag',
+    },
 
-    if (willSquare) {
-      wrapped = html.tag('div', {class: 'square-content'}, wrapped);
-      wrapped = html.tag('div',
-        {class: ['square', hide && !willLink && 'js-hide']},
-        wrapped);
-    }
+    'no-language-reload': {alias: 'no-language-reloading'},
 
-    if (willLink) {
-      wrapped = html.tag('a',
-        {
-          id,
-          class: ['box', hide && 'js-hide'],
-          href: typeof link === 'string' ? link : originalSrc,
-        },
-        wrapped);
-    }
+    // 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',
+    },
 
-    return wrapped;
-  }
-}
+    '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',
+    },
 
-function validateWritePath(path, urlGroup) {
-  if (!Array.isArray(path)) {
-    return {error: `Expected array, got ${path}`};
-  }
+    'queue-size': {
+      help: `Process more or fewer disk files at once to optimize performance or avoid I/O errors, unlimited if set to 0 (between 500 and 700 is usually a safe range for building HSMusic on Windows machines)\nDefaults to ${defaultQueueSize}`,
+      type: 'value',
+      validate(size) {
+        if (parseInt(size) !== parseFloat(size)) return 'an integer';
+        if (parseInt(size) < 0) return 'a counting number or zero';
+        return true;
+      },
+    },
+    queue: {alias: 'queue-size'},
 
-  const {paths} = urlGroup;
+    'magick-threads': {
+      help: `Process more or fewer thumbnail files at once with ImageMagick when generating thumbnails. (Each ImageMagick thread may also make use of multi-core processing at its own utility.)`,
+      type: 'value',
+      validate(threads) {
+        if (parseInt(threads) !== parseFloat(threads)) return 'an integer';
+        if (parseInt(threads) < 0) return 'a counting number or zero';
+        return true;
+      }
+    },
+    magick: {alias: 'magick-threads'},
 
-  const definedKeys = Object.keys(paths);
-  const specifiedKey = path[0];
+    // 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',
+    },
 
-  if (!definedKeys.includes(specifiedKey)) {
-    return {error: `Specified key ${specifiedKey} isn't defined`};
-  }
+    'precache-mode': {
+      help:
+        `Change the way certain runtime-computed values are preemptively evaluated and cached\n\n` +
+        `common: Preemptively compute certain properties which are needed for basic data loading and site generation\n\n` +
+        `all: Compute every visible data property, optimizing rate of content generation, but causing a long stall before the build actually starts\n\n` +
+        `none: Don't preemptively compute any values - strictly the most efficient, but may result in unpredictably "lopsided" performance for individual steps of loading data and building the site\n\n` +
+        `Defaults to 'common'`,
+      type: 'value',
+      validate(value) {
+        if (['common', 'all', 'none'].includes(value)) return true;
+        return 'common, all, or none';
+      },
+    },
+  };
 
-  const expectedArgs = paths[specifiedKey].match(/<>/g)?.length ?? 0;
-  const specifiedArgs = path.length - 1;
+  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,
 
-  if (specifiedArgs !== expectedArgs) {
-    return {
-      error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`,
-    };
-  }
+    ...commonOptions,
+    ...buildOptions,
+  });
 
-  return {success: true};
-}
+  if (cliOptions['help']) {
+    const indentWrap = (spaces, str) => wrap(str, {width: 60 - spaces, indent: ' '.repeat(spaces)});
 
-function validateWriteObject(obj) {
-  if (typeof obj !== 'object') {
-    return {error: `Expected object, got ${typeof obj}`};
-  }
+    const showOptions = (msg, options) => {
+      console.log(colors.bright(msg));
 
-  if (typeof obj.type !== 'string') {
-    return {error: `Expected type to be string, got ${obj.type}`};
-  }
+      const entries = Object.entries(options);
+      const sortedOptions = sortByName(entries
+        .map(([name, descriptor]) => ({name, descriptor})));
 
-  switch (obj.type) {
-    case 'legacy': {
-      if (typeof obj.write !== 'function') {
-        return {error: `Expected write to be string, got ${obj.write}`};
+      if (!sortedOptions.length) {
+        console.log(`(No options available)`)
       }
 
-      break;
-    }
-
-    case 'page': {
-      const path = validateWritePath(obj.path, urlSpec.localized);
-      if (path.error) {
-        return {error: `Path validation failed: ${path.error}`};
-      }
+      let justInsertedPaddingLine = false;
 
-      if (typeof obj.page !== 'function') {
-        return {error: `Expected page to be function, got ${obj.content}`};
-      }
+      for (const {name, descriptor} of sortedOptions) {
+        if (descriptor.alias) {
+          continue;
+        }
 
-      break;
-    }
+        const aliases = entries
+          .filter(([_name, {alias}]) => alias === name)
+          .map(([name]) => name);
 
-    case 'data': {
-      const path = validateWritePath(obj.path, urlSpec.data);
-      if (path.error) {
-        return {error: `Path validation failed: ${path.error}`};
-      }
+        let wrappedHelp, wrappedHelpLines = 0;
+        if (descriptor.help) {
+          wrappedHelp = indentWrap(4, descriptor.help);
+          wrappedHelpLines = wrappedHelp.split('\n').length;
+        }
 
-      if (typeof obj.data !== 'function') {
-        return {error: `Expected data to be function, got ${obj.data}`};
-      }
+        if (wrappedHelpLines > 0 && !justInsertedPaddingLine) {
+          console.log('');
+        }
 
-      break;
-    }
+        console.log(colors.bright(` --` + name) +
+          (aliases.length
+            ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})`
+            : '') +
+          (descriptor.help
+            ? ''
+            : colors.dim('  (no help provided)')));
 
-    case 'redirect': {
-      const fromPath = validateWritePath(obj.fromPath, urlSpec.localized);
-      if (fromPath.error) {
-        return {
-          error: `Path (fromPath) validation failed: ${fromPath.error}`,
-        };
-      }
+        if (wrappedHelp) {
+          console.log(wrappedHelp);
+        }
 
-      const toPath = validateWritePath(obj.toPath, urlSpec.localized);
-      if (toPath.error) {
-        return {error: `Path (toPath) validation failed: ${toPath.error}`};
+        if (wrappedHelpLines > 1) {
+          console.log('');
+          justInsertedPaddingLine = true;
+        } else {
+          justInsertedPaddingLine = false;
+        }
       }
 
-      if (typeof obj.title !== 'function') {
-        return {error: `Expected title to be function, got ${obj.title}`};
+      if (!justInsertedPaddingLine) {
+        console.log(``);
       }
+    };
 
-      break;
+    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);
     }
 
-    default: {
-      return {error: `Unknown type: ${obj.type}`};
-    }
+    return true;
   }
 
-  return {success: true};
-}
+  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!
 
-/*
-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;
-    }
+  const thumbsOnly = cliOptions['thumbs-only'] ?? false;
+  const noInput = cliOptions['no-input'] ?? false;
 
-    path += urls.from(from).to(to, ...args);
+  showStepStatusSummary = cliOptions['show-step-summary'] ?? false;
 
-    return path;
-  };
+  const showAggregateTraces = cliOptions['show-traces'] ?? false;
+
+  const precacheMode = cliOptions['precache-mode'] ?? 'common';
+  const showInvalidPropertyAccesses = cliOptions['show-invalid-property-accesses'] ?? false;
 
-writePage.html = (pageInfo, {
-  defaultLanguage,
-  getThemeString,
-  language,
-  languages,
-  localizedPaths,
-  paths,
-  oEmbedJSONHref,
-  to,
-  transformMultiline,
-  wikiData,
-}) => {
-  const {wikiInfo} = wikiData;
-
-  let {
-    title = '',
-    meta = {},
-    theme = '',
-    stylesheet = '',
-
-    showWikiNameInTitle = true,
-    themeColor = '',
-
-    // missing properties are auto-filled, see below!
-    body = {},
-    banner = {},
-    main = {},
-    sidebarLeft = {},
-    sidebarRight = {},
-    nav = {},
-    secondaryNav = {},
-    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;
+  // 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`}`;
   }
 
-  nav.classes ??= [];
-  nav.content ??= '';
-  nav.bottomRowContent ??= '';
-  nav.links ??= [];
-  nav.linkContainerClasses ??= [];
-
-  secondaryNav ??= {};
-  secondaryNav.content ??= '';
-  secondaryNav.content ??= '';
-
-  footer.classes ??= [];
-  footer.content ??= wikiInfo.footerContent
-    ? transformMultiline(wikiInfo.footerContent)
-    : '';
-
-  const colors = themeColor
-    ? getColors(themeColor, {chroma})
-    : null;
-
-  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 =
-    html.tag('footer',
-      {
-        [html.onlyIfContent]: true,
-        id: 'footer',
-        class: footer.classes,
-      },
-      [
-        html.tag('div',
-          {
-            [html.onlyIfContent]: true,
-            class: 'footer-content',
-          },
-          footer.content),
-
-        getFooterLocalizationLinks(paths.pathname, {
-          defaultLanguage,
-          html,
-          language,
-          languages,
-          paths,
-          to,
-        }),
-      ]);
-
-  const generateSidebarHTML = (id, {
-    content,
-    multiple,
-    classes,
-    collapse = true,
-    wide = false,
-
-    // 'last' - last or only sidebar box is sticky
-    // 'column' - entire column, incl. multiple boxes from top, is sticky
-    // 'none' - sidebar not sticky at all, stays at top of page
-    stickyMode = 'last',
-  }) =>
-    content
-      ? html.tag('div',
-          {
-            id,
-            class: [
-              'sidebar-column',
-              'sidebar',
-              wide && 'wide',
-              !collapse && 'no-hide',
-              stickyMode !== 'none' && 'sticky-' + stickyMode,
-              ...classes,
-            ],
-          },
-          content)
-      : multiple
-      ? html.tag('div',
-          {
-            id,
-            class: [
-              'sidebar-column',
-              'sidebar-multiple',
-              wide && 'wide',
-              !collapse && 'no-hide',
-              stickyMode !== 'none' && 'sticky-' + stickyMode,
-            ],
-          },
-          multiple
-            .map((infoOrContent) =>
-              (typeof infoOrContent === 'object' && !Array.isArray(infoOrContent))
-                ? infoOrContent
-                : {content: infoOrContent})
-            .filter(({content}) => content)
-            .map(({
-              content,
-              classes: classes2 = [],
-            }) =>
-              html.tag('div',
-                {
-                  class: ['sidebar', ...classes, ...classes2],
-                },
-                html.fragment(content))))
-      : '';
-
-  const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft);
-  const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight);
-
-  if (nav.simple) {
-    nav.linkContainerClasses = ['nav-links-hierarchy'];
-    nav.links = [{toHome: true}, {toCurrentPage: true}];
+  if (!mediaPath) {
+    logError`${`Expected --media-path option or HSMUSIC_MEDIA to be set`}`;
   }
 
-  const links = (nav.links || []).filter(Boolean);
+  if (!dataPath || !mediaPath) {
+    return false;
+  }
 
-  const navLinkParts = [];
-  for (let i = 0; i < links.length; i++) {
-    let cur = links[i];
+  if (cliOptions['no-build']) {
+    logInfo`Won't generate any site or page files this run (--no-build passed).`;
 
-    let {title: linkTitle} = cur;
+    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}`;
+    }
+  }
 
-    if (cur.toHome) {
-      linkTitle ??= wikiInfo.nameShort;
-    } else if (cur.toCurrentPage) {
-      linkTitle ??= title;
+  // 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 {
+          logWarn`${cliPart} provided, but this step isn't applicable for ${modePart}`;
+          logWarn`Ignoring option ${cliPart}`;
+        }
+      } else if (buildConfig?.required === true) {
+        if (cliFlagNegates) {
+          logWarn`${cliPart} provided, but ${modePart} requires this step`;
+          logWarn`Ignoring option ${cliPart}`;
+        } else {
+          logWarn`${cliPart} provided, but ${modePart} already requires this step`;
+          logWarn`Redundant option ${cliPart}`;
+        }
+      } else {
+        if (cliFlagNegates) {
+          step.status = STATUS_NOT_APPLICABLE;
+          step.annotation = `--${cliFlag} provided`;
+        }
+        if (cliFlagWarning) {
+          for (const line of cliFlagWarning.split('\n')) {
+            logWarn(line);
+          }
+        }
+      }
     }
 
-    let partContent;
+    if (buildConfig?.applicable === false) {
+      step.status = STATUS_NOT_APPLICABLE;
+      step.annotation = `N/A for --${selectedBuildModeFlag}`;
+      return;
+    }
 
-    if (typeof cur.html === 'string') {
-      if (!cur.html) {
-        logWarn`Empty HTML in nav link ${JSON.stringify(cur)}`;
-        console.trace();
-      }
-      partContent = cur.html;
-    } 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
-          )})`
-        );
-      }
-      partContent = html.tag('a', attributes, linkTitle);
+    if (buildConfig?.default === 'skip') {
+      step.status = STATUS_NOT_APPLICABLE;
+      step.annotation = `default for --${selectedBuildModeFlag}`;
+      return;
     }
 
-    const part = html.tag('span',
-      {class: cur.divider === false && 'no-divider'},
-      partContent);
+    switch (defaultValue) {
+      case 'skip':
+        step.status = STATUS_NOT_APPLICABLE;
+        if (cliFlag && !cliFlagNegates) {
+          step.annotation = `--${cliFlag} not provided`;
+        }
+        break;
 
-    navLinkParts.push(part);
-  }
+      case 'perform':
+        break;
 
-  const navHTML = html.tag('nav',
-    {
-      [html.onlyIfContent]: true,
-      id: 'header',
-      class: [
-        ...nav.classes,
-        links.length && 'nav-has-main-links',
-        nav.content && 'nav-has-content',
-        nav.bottomRowContent && 'nav-has-bottom-row',
-      ],
-    },
-    [
-      links.length &&
-        html.tag(
-          'div',
-          {class: ['nav-main-links', ...nav.linkContainerClasses]},
-          navLinkParts
-        ),
-      nav.bottomRowContent &&
-        html.tag('div', {class: 'nav-bottom-row'}, nav.bottomRowContent),
-      nav.content && html.tag('div', {class: 'nav-content'}, nav.content),
-    ]);
-
-  const secondaryNavHTML = html.tag('nav',
-    {
-      [html.onlyIfContent]: true,
-      id: 'secondary-nav',
-      class: secondaryNav.classes,
-    },
-    secondaryNav.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,
-    secondaryNavHTML,
-    html.tag('div',
-      {
-        class: [
-          'layout-columns',
-          !collapseSidebars && 'vertical-when-thin',
-        ],
-      },
-      [
-        sidebarLeftHTML,
-        mainHTML,
-        sidebarRightHTML,
-      ]),
-    banner.position === 'bottom' && bannerHTML,
-    footerHTML,
-  ].filter(Boolean).join('\n');
-
-  const infoCardHTML = html.tag('div', {id: 'info-card-container'},
-    html.tag('div', {id: 'info-card-decor'},
-      html.tag('div', {id: 'info-card'}, [
-        html.tag('div', {class: ['info-card-art-container', 'no-reveal']},
-          img({
-            class: 'info-card-art',
-            src: '',
-            link: true,
-            square: true,
-          })),
-        html.tag('div', {class: ['info-card-art-container', 'reveal']},
-          img({
-            class: 'info-card-art',
-            src: '',
-            link: true,
-            square: true,
-            reveal: getRevealStringFromWarnings(
-              html.tag('span', {class: 'info-card-art-warnings'}),
-              {html, language}),
-          })),
-        html.tag('h1', {class: 'info-card-name'},
-          html.tag('a')),
-        html.tag('p', {class: 'info-card-album'},
-          language.$('releaseInfo.from', {
-            album: html.tag('a'),
-          })),
-        html.tag('p', {class: 'info-card-artists'},
-          language.$('releaseInfo.by', {
-            artists: html.tag('span'),
-          })),
-        html.tag('p', {class: 'info-card-cover-artists'},
-          language.$('releaseInfo.coverArtBy', {
-            artists: html.tag('span'),
-          })),
-      ])));
-
-  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}),
-
-    ...html.fragment(
-      colors && [
-        // Safari only respects the first media-matching meta tag here,
-        // so position the dark-specific entry first
-        html.tag('meta', {
-          name: 'theme-color',
-          content: colors.dark,
-          media: '(prefers-color-scheme: dark)'
-        }),
-
-        html.tag('meta', {
-          name: 'theme-color',
-          content: colors.primary,
-        }),
-      ]),
+      default:
+        throw new Error(`Invalid default step status ${defaultValue}`);
+    }
+  };
 
-    oEmbedJSONHref &&
-      html.tag('link', {
-        type: 'application/json+oembed',
-        href: oEmbedJSONHref,
-      }),
-  ].filter(Boolean).join('\n');
-
-  return `<!DOCTYPE html>\n` + html.tag('html',
-    {
-      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'),
-    },
-    [
-      `<!--\n` + [
-        wikiInfo.canonicalBase
-          ? `hsmusic.wiki - ${wikiInfo.name}, ${wikiInfo.canonicalBase}`
-          : `hsmusic.wiki - ${wikiInfo.name}`,
-        'Code copyright 2019-2022 Quasar Nebula et al (MIT License)',
-        ...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/',
-        ],
-        '***',
-        `Site built: ${BUILD_TIME.toLocaleString('en-US', {
-          dateStyle: 'long',
-          timeStyle: 'long',
-        })}`,
-        `Latest code commit: ${COMMIT}`,
-      ]
-        .filter(Boolean)
-        .map(line => `    ` + line)
-        .join('\n') + `\n-->`,
-
-      html.tag('head', [
-        html.tag('title',
-          showWikiNameInTitle
-            ? language.formatString('misc.pageTitle.withWikiName', {
-                title,
-                wikiName: wikiInfo.nameShort,
-              })
-            : language.formatString('misc.pageTitle', {title})),
-
-        html.tag('meta', {charset: 'utf-8'}),
-        html.tag('meta', {
-          name: 'viewport',
-          content: 'width=device-width, initial-scale=1',
-        }),
-
-        ...(
-          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,
-            }))),
-
-        socialEmbedHTML,
-
-        html.tag('link', {
-          rel: 'stylesheet',
-          href: to('shared.staticFile', `site2.css?${CACHEBUST}`),
-        }),
-
-        html.tag('style',
-          {[html.onlyIfContent]: true},
-          [
-            theme,
-            stylesheet,
-          ]),
+  {
+    let errored = false;
 
-        html.tag('script', {
-          src: to('shared.staticFile', `lazy-loading.js?${CACHEBUST}`),
-        }),
-      ]),
+    const fallbackStep = (stepKey, options) => {
+      try {
+        _fallbackStep(stepKey, options);
+      } catch (error) {
+        logError`Error determining fallback for step ${stepKey}`;
+        showAggregate(error);
+        errored = true;
+      }
+    };
 
-      html.tag('body',
-        {style: body.style || ''},
-        [
-          html.tag('div', {id: 'page-container'}, [
-            mainHTML &&
-              html.tag('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]) =>
-                    html.tag('span', {class: 'skipper'},
-                      html.tag('a', {href}, title)))),
-            layoutHTML,
-          ]),
+    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.`,
+      }
+    });
 
-          infoCardHTML,
-
-          html.tag('script', {
-            type: 'module',
-            src: to('shared.staticFile', `client.js?${CACHEBUST}`),
-          }),
-        ]),
-    ]);
-};
-
-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,
-  };
-};
+    fallbackStep('generateThumbnails', {
+      default: 'perform',
+      buildConfig: 'thumbs',
+      cli: {
+        flag: 'skip-thumbs',
+        negate: true,
+      },
+    });
 
-async function writeFavicon() {
-  try {
-    await stat(path.join(mediaPath, FAVICON_FILE));
-  } catch (error) {
-    return;
-  }
+    fallbackStep('migrateThumbnails', {
+      default: 'skip',
+      buildConfig: null,
+      cli: {
+        flag: 'migrate-thumbs',
+      },
+    });
 
-  try {
-    await copyFile(
-      path.join(mediaPath, FAVICON_FILE),
-      path.join(outputPath, FAVICON_FILE)
-    );
-  } catch (error) {
-    logWarn`Failed to copy favicon! ${error.message}`;
-    return;
-  }
+    fallbackStep('preloadFileSizes', {
+      default: 'perform',
+      buildConfig: 'fileSizes',
+      cli: {
+        flag: 'skip-file-sizes',
+        negate: true,
+      },
+    });
 
-  logInfo`Copied favicon to site root.`;
-}
+    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.`,
+      },
+    });
 
-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'),
-  ]);
+    fallbackStep('watchLanguageFiles', {
+      default: 'perform',
+      buildConfig: 'languageReloading',
+      cli: {
+        flag: 'no-language-reloading',
+        negate: true,
+      },
+    });
 
-  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');
-      }
+    if (errored) {
+      return false;
     }
   }
-}
 
-function writeSharedFilesAndPages({language, wikiData}) {
-  const {groupData, wikiInfo} = wikiData;
-
-  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);
-  };
+  if (stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.loadThumbnailCache, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `using cache from thumbnail generation`,
+    });
+  }
 
-  return progressPromiseAll(`Writing files & pages shared across languages.`, [
-    groupData?.some((group) => group.directory === 'fandom') &&
-      redirect(
-        'Fandom - Gallery',
-        'albums/fandom',
-        'localized.groupGallery',
-        'fandom'
-      ),
+  if (stepStatusSummary.watchLanguageFiles.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.loadLanguageFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `watching for changes instead`,
+    });
+  }
 
-    groupData?.some((group) => group.directory === 'official') &&
-      redirect(
-        'Official - Gallery',
-        'albums/official',
-        'localized.groupGallery',
-        'official'
-      ),
+  switch (precacheMode) {
+    case 'common':
+      Object.assign(stepStatusSummary.precacheAllData, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--precache-mode is common, not all`,
+      });
 
-    wikiInfo.enableListings &&
-      redirect(
-        'Album Commentary',
-        'list/all-commentary',
-        'localized.commentaryIndex',
-        ''
-      ),
+      break;
 
-    writeFile(
-      path.join(outputPath, 'data.json'),
-      (
-        '{\n' +
-        [
-          `"albumData": ${stringifyThings(wikiData.albumData)},`,
-          wikiInfo.enableFlashesAndGames &&
-            `"flashData": ${stringifyThings(wikiData.flashData)},`,
-          `"artistData": ${stringifyThings(wikiData.artistData)}`,
-        ]
-          .filter(Boolean)
-          .map(line => '  ' + line)
-          .join('\n') +
-        '\n}')),
-  ].filter(Boolean));
-}
+    case 'all':
+      Object.assign(stepStatusSummary.precacheCommonData, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--precache-mode is all, not common`,
+      });
 
-function generateRedirectPage(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),
-          })),
-      ])),
-  ]);
-}
+      break;
 
-async function processLanguageFile(file) {
-  const contents = await readFile(file, 'utf-8');
-  const json = JSON.parse(contents);
+    case 'none':
+      Object.assign(stepStatusSummary.precacheCommonData, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--precache-mode is none`,
+      });
 
-  const code = json['meta.languageCode'];
-  if (!code) {
-    throw new Error(`Missing language code (file: ${file})`);
+      Object.assign(stepStatusSummary.precacheAllData, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--precache-mode is none`,
+      });
+
+      break;
   }
-  delete json['meta.languageCode'];
 
-  const intlCode = json['meta.languageIntlCode'] ?? null;
-  delete json['meta.languageIntlCode'];
+  if (!langPath) {
+    Object.assign(stepStatusSummary.loadLanguageFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `neither --lang-path nor HSMUSIC_LANG provided`,
+    });
 
-  const name = json['meta.languageName'];
-  if (!name) {
-    throw new Error(`Missing language name (${code})`);
+    Object.assign(stepStatusSummary.watchLanguageFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `neither --lang-path nor HSMUSIC_LANG provided`,
+    });
   }
-  delete json['meta.languageName'];
-
-  const hidden = json['meta.hidden'] ?? false;
-  delete json['meta.hidden'];
 
-  if (json['meta.baseDirectory']) {
-    logWarn`(${code}) Language JSON still has unused meta.baseDirectory`;
-    delete json['meta.baseDirectory'];
+  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;
   }
 
-  const language = new T.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;
-}
+  Object.assign(stepStatusSummary.determineMediaCachePath, {
+    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;
+  const {mediaCachePath, annotation: mediaCachePathAnnotation} =
+    await determineMediaCachePath({
+      mediaPath,
+      providedMediaCachePath:
+        cliOptions['media-cache-path'] || process.env.HSMUSIC_MEDIA_CACHE,
+      disallowDoubling:
+        stepStatusSummary.migrateThumbnails.status === STATUS_NOT_STARTED,
+    });
 
-  const entries = Object.entries(languagesToRun).filter(
-    ([key]) => key !== 'default'
-  );
+  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;
+    }
 
-  for (let i = 0; i < entries.length; i++) {
-    const [_key, language] = entries[i];
+    Object.assign(stepStatusSummary.determineMediaCachePath, {
+      status: STATUS_FATAL_ERROR,
+      annotation: mediaCachePathAnnotation,
+      timeEnd: Date.now(),
+    });
 
-    await fn(language, i, entries);
+    return false;
   }
-}
 
-async function main() {
-  Error.stackTraceLimit = Infinity;
+  logInfo`Using media cache at: ${mediaCachePath} (${mediaCachePathAnnotation})`;
 
-  const WD = wikiData;
+  Object.assign(stepStatusSummary.determineMediaCachePath, {
+    status: STATUS_DONE_CLEAN,
+    annotation: mediaCachePathAnnotation,
+    timeEnd: Date.now(),
+  });
 
-  WD.listingSpec = listingSpec;
-  WD.listingTargetSpec = listingTargetSpec;
+  if (stepStatusSummary.migrateThumbnails.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.migrateThumbnails, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-  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',
-    },
+    const result = await migrateThumbsIntoDedicatedCacheDirectory({
+      mediaPath,
+      mediaCachePath,
+      queueSize,
+    });
 
-    // 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',
-    },
+    if (result.succses) {
+      Object.assign(stepStatusSummary.migrateThumbnails, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
 
-    // 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',
-    },
+      return false;
+    }
 
-    // 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',
-    },
+    logInfo`Good to go! Run hsmusic again without ${'--migrate-thumbs'} to start`;
+    logInfo`using the migrated media cache.`;
 
-    // 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',
-    },
+    Object.assign(stepStatusSummary.migrateThumbnails, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
 
-    // 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',
-    },
+    return true;
+  }
 
-    // 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',
-    },
+  const niceShowAggregate = (error, ...opts) => {
+    showAggregate(error, {
+      showTraces: showAggregateTraces,
+      pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
+      ...opts,
+    });
+  };
 
-    // 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',
-    },
+  if (
+    stepStatusSummary.loadThumbnailCache.status === STATUS_NOT_STARTED &&
+    stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED
+  ) {
+    throw new Error(`Unable to continue with both loadThumbnailCache and generateThumbnails`);
+  }
 
-    // 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',
-    },
+  let thumbsCache;
 
-    // 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',
-    },
+  if (stepStatusSummary.loadThumbnailCache.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.loadThumbnailCache, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-    '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 thumbsCachePath = path.join(mediaCachePath, thumbsCacheFile);
 
-    // 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',
-    },
+    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(),
+        });
 
-    // Compute ALL data properties before moving on to building. This ensures
-    // writes are processed at a stable speed (since they don't have to perform
-    // any additional data computation besides what is done for the page
-    // itself), but it'll also take a long while for the initial caching to
-    // complete. This shouldn't have any overall difference on efficiency as
-    // it's the same amount of processing being done regardless; the option is
-    // mostly present for optimization testing (i.e. if you want to focus on
-    // efficiency of data calculation or write generation separately instead of
-    // mixed together).
-    'precache-data': {
-      type: 'flag',
-    },
+        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(),
+        });
 
-    [parseOptions.handleUnknown]: () => {},
-  });
+        return false;
+      }
+    }
 
-  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;
+    logInfo`Thumbnail cache file successfully read.`;
 
-  const writeOneLanguage = miscOptions['lang'];
+    Object.assign(stepStatusSummary.loadThumbnailCache, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
 
-  {
-    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;
-    }
-  }
+    logInfo`Skipping thumbnail generation.`;
+  } else if (stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.generateThumbnails, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-  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;
-  }
+    logInfo`Begin thumbnail generation... -----+`;
 
-  const skipThumbs = miscOptions['skip-thumbs'] ?? false;
-  const thumbsOnly = miscOptions['thumbs-only'] ?? false;
-  const noBuild = miscOptions['no-build'] ?? false;
-  const showAggregateTraces = miscOptions['show-traces'] ?? false;
-  const precacheData = miscOptions['precache-data'] ?? false;
-
-  // 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.
-
-    // Kinda a hack t8h!
-    ...Object.fromEntries(
-      Object.keys(pageSpecs).map((key) => [key, {type: 'flag'}])
-    ),
-
-    [parseOptions.handleUnknown]: () => {},
-  });
+    const result = await genThumbs({
+      mediaPath,
+      mediaCachePath,
 
-  const writeAll = !Object.keys(writeFlags).length || writeFlags.all;
+      queueSize,
+      magickThreads,
+      quiet: !thumbsOnly,
+    });
 
-  logInfo`Writing site pages: ${
-    writeAll ? 'all' : Object.keys(writeFlags).join(', ')
-  }`;
+    logInfo`Done thumbnail generation! --------+`;
 
-  const niceShowAggregate = (error, ...opts) => {
-    showAggregate(error, {
-      showTraces: showAggregateTraces,
-      pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
-      ...opts,
+    if (!result.success) {
+      Object.assign(stepStatusSummary.generateThumbnails, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
+    }
+
+    Object.assign(stepStatusSummary.generateThumbnails, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
     });
-  };
 
-  if (skipThumbs && thumbsOnly) {
-    logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`;
-    return;
-  }
+    if (thumbsOnly) {
+      return true;
+    }
 
-  if (skipThumbs) {
-    logInfo`Skipping thumbnail generation.`;
+    thumbsCache = result.cache;
   } else {
-    logInfo`Begin thumbnail generation... -----+`;
-    const result = await genThumbs(mediaPath, {queueSize, quiet: true});
-    logInfo`Done thumbnail generation! --------+`;
-    if (!result) return;
-    if (thumbsOnly) return;
+    thumbsCache = {};
   }
 
-  const showInvalidPropertyAccesses =
-    miscOptions['show-invalid-property-accesses'] ?? false;
-
   if (showInvalidPropertyAccesses) {
     CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
   }
 
-  const {aggregate: processDataAggregate, result: wikiDataResult} =
-    await loadAndProcessDataDocuments({dataPath});
+  Object.assign(stepStatusSummary.loadDataFiles, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
+  let processDataAggregate, wikiDataResult;
+
+  try {
+    ({aggregate: processDataAggregate, result: wikiDataResult} =
+        await loadAndProcessDataDocuments({dataPath}));
+  } catch (error) {
+    console.error(error);
+
+    logError`There was a JavaScript error loading data files.`;
+    fileIssue();
+
+    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 = (thingDataProp, label) =>
-      logInfo` - ${wikiData[thingDataProp]?.length ?? color.red('(Missing!)')} ${color.normal(color.dim(label))}`;
+    const logThings = (prop, label) => {
+      const array =
+        (Array.isArray(prop)
+          ? prop
+          : wikiData[prop]);
+
+      logInfo` - ${array?.length ?? colors.red('(Missing!)')} ${colors.normal(colors.dim(label))}`;
+    }
+
     try {
       logInfo`Loaded data and processed objects:`;
       logThings('albumData', 'albums');
       logThings('trackData', 'tracks');
-      logThings('artistData', 'artists');
+      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');
@@ -1904,719 +1008,1006 @@ async function main() {
     } 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;
     }
 
-    if (errorless) {
-      logInfo`All data processed without any errors - nice!`;
-      logInfo`(This means all source files will be fully accounted for during page generation.)`;
-    }
-  }
-
-  if (!WD.wikiInfo) {
-    logError`Can't proceed without wiki info file (${WIKI_INFO_FILE}) successfully loading`;
-    return;
-  }
+    if (!wikiData.wikiInfo) {
+      logError`Can't proceed without wiki info file successfully loading`;
 
-  let duplicateDirectoriesErrored = false;
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `wiki info object not available`,
+        timeEnd: Date.now(),
+      });
 
-  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;
+      return false;
     }
+
     if (errorless) {
-      logInfo`No duplicate directories found - nice!`;
-    }
-  }
+      logInfo`All data files processed without any errors - nice!`;
 
-  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.`;
-      }
+      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!)`;
-      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.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',
+      ]),
+    };
+
+    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];
+        }
+      }
+    }
+
+    Object.assign(stepStatusSummary.precacheCommonData, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
+  }
+
   // Filter out any things with duplicate directories throughout the data,
   // warning about them too.
-  filterAndShowDuplicateDirectories();
+
+  Object.assign(stepStatusSummary.reportDuplicateDirectories, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
+  try {
+    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
+    logInfo`No duplicate directories found - nice!`;
+
+    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(),
+    });
+
+    return false;
+  }
 
   // Filter out any reference errors throughout the data, warning about them
   // too.
-  filterAndShowReferenceErrors();
+
+  if (stepStatusSummary.filterReferenceErrors.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.filterReferenceErrors, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const filterReferenceErrorsAggregate =
+      filterReferenceErrors(wikiData, {bindFind});
+
+    try {
+      filterReferenceErrorsAggregate.close();
+
+      logInfo`All references validated without any errors - nice!`;
+
+      Object.assign(stepStatusSummary.filterReferenceErrors, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    } catch (error) {
+      niceShowAggregate(error);
+
+      logWarn`The above errors were detected while validating references in data files.`;
+      logWarn`The wiki will still build, but these connections between data objects`;
+      logWarn`will be completely skipped. Resolve the errors for more complete output.`;
+
+      Object.assign(stepStatusSummary.filterReferenceErrors, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
+    }
+  }
+
+  if (stepStatusSummary.reportContentTextErrors.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.reportContentTextErrors, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    try {
+      reportContentTextErrors(wikiData, {bindFind});
+      logInfo`All content text validated without any errors - nice!`;
+
+      Object.assign(stepStatusSummary.reportContentTextErrors, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    } catch (error) {
+      niceShowAggregate(error);
+
+      logWarn`The above errors were detected while processing content text in data files.`;
+      logWarn`The wiki will still build, but placeholders will be displayed in these spots.`;
+      logWarn`Resolve the errors for more complete output.`;
+
+      Object.assign(stepStatusSummary.reportContentTextErrors, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
+    }
+  }
 
   // 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);
 
-  if (precacheData) {
+  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(),
+    });
+
+    // TODO: Aggregate errors here, instead of just throwing.
     progressCallAll('Caching all data values', Object.entries(wikiData)
       .filter(([key]) =>
         key !== 'listingSpec' &&
-        key !== 'listingTargetSpec' &&
-        key !== 'officialAlbumData' &&
-        key !== 'fandomAlbumData')
+        key !== 'listingTargetSpec')
       .map(([key, value]) =>
         key === 'wikiInfo' ? [key, [value]] :
         key === 'homepageLayout' ? [key, [value]] :
         [key, value])
       .flatMap(([_key, things]) => things)
       .map(thing => () => CacheableObject.cacheAllExposedProperties(thing)));
-  }
-
-  const internalDefaultLanguage = await processLanguageFile(
-    path.join(__dirname, DEFAULT_STRINGS_FILE)
-  );
 
-  let languages;
-  if (langPath) {
-    const languageDataFiles = await findFiles(langPath, {
-      filter: (f) => path.extname(f) === '.json',
+    Object.assign(stepStatusSummary.precacheAllData, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
     });
-
-    const results = await progressPromiseAll(
-      `Reading & processing language files.`,
-      languageDataFiles.map((file) => processLanguageFile(file))
-    );
-
-    languages = Object.fromEntries(
-      results.map((language) => [language.code, language])
-    );
-  } else {
-    languages = {};
   }
 
-  const customDefaultLanguage =
-    languages[WD.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code];
-  let finalDefaultLanguage;
+  if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
+    displayCompositeCacheAnalysis();
 
-  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}?`;
-    } else {
-      logError`Be sure to specify ${'--lang'} or ${'HSMUSIC_LANG'} with the path to language files.`;
+    if (precacheMode === 'all') {
+      return true;
     }
-    return;
-  } else {
-    languages[internalDefaultLanguage.code] = internalDefaultLanguage;
-    finalDefaultLanguage = internalDefaultLanguage;
   }
 
-  for (const language of Object.values(languages)) {
-    if (language === finalDefaultLanguage) {
-      continue;
-    }
+  const languageReloading =
+    stepStatusSummary.watchLanguageFiles.status === STATUS_NOT_STARTED;
 
-    language.inheritedStrings = finalDefaultLanguage.strings;
-  }
+  Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
-  logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
+  let internalDefaultLanguage;
+  let internalDefaultLanguageWatcher;
 
-  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.`;
-  }
+  let errorLoadingInternalDefaultLanguage = false;
 
-  {
-    const tagRefs = new Set(
-      [...WD.trackData, ...WD.albumData]
-        .flatMap((thing) => thing.artTagsByRef ?? []));
+  if (languageReloading) {
+    internalDefaultLanguageWatcher = watchLanguageFile(internalDefaultStringsFile);
 
-    for (const ref of tagRefs) {
-      if (find.artTag(ref, WD.artTagData)) {
-        tagRefs.delete(ref);
-      }
+    try {
+      await new Promise((resolve, reject) => {
+        const watcher = internalDefaultLanguageWatcher;
+
+        const onReady = () => {
+          watcher.removeListener('ready', onReady);
+          watcher.removeListener('error', onError);
+          resolve();
+        };
+
+        const onError = error => {
+          watcher.removeListener('ready', onReady);
+          watcher.removeListener('error', onError);
+          watcher.close();
+          reject(error);
+        };
+
+        watcher.on('ready', onReady);
+        watcher.on('error', onError);
+      });
+
+      internalDefaultLanguage = internalDefaultLanguageWatcher.language;
+    } catch (_error) {
+      // No need to display the error here - it's already printed by
+      // watchLanguageFile.
+      errorLoadingInternalDefaultLanguage = true;
     }
+  } else {
+    internalDefaultLanguageWatcher = null;
 
-    if (tagRefs.size) {
-      for (const ref of Array.from(tagRefs).sort()) {
-        console.log(`\x1b[33;1m- Missing tag: "${ref}"\x1b[0m`);
-      }
-      return;
+    try {
+      internalDefaultLanguage = await processLanguageFile(internalDefaultStringsFile);
+    } catch (error) {
+      niceShowAggregate(error);
+      errorLoadingInternalDefaultLanguage = true;
     }
   }
 
-  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 fileSizePreloader = new FileSizePreloader();
-
-  // File sizes of additional files need to be precalculated before we can
-  // actually reference 'em in site building, so get those loading right
-  // away. We actually need to keep track of two things here - the on-device
-  // file paths we're actually reading, and the corresponding on-site media
-  // paths that will be exposed in site build code. We'll build a mapping
-  // function between them so that when site code requests a site path,
-  // it'll get the size of the file at the corresponding device path.
-  const additionalFilePaths = [
-    ...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),
-        }))
-    ),
-  ];
-
-  const getSizeOfAdditionalFile = (mediaPath) => {
-    const {device} =
-      additionalFilePaths.find(({media}) => media === mediaPath) || {};
-    if (!device) return null;
-    return fileSizePreloader.getSizeOfPath(device);
-  };
+  if (errorLoadingInternalDefaultLanguage) {
+    logError`There was an error reading the internal language file.`;
+    fileIssue();
+
+    Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
+      status: STATUS_FATAL_ERROR,
+      annotation: `see log for details`,
+      timeEnd: Date.now(),
+    });
 
-  logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`;
+    return false;
+  }
+
+  if (languageReloading) {
+    // Bypass node.js special-case handling for uncaught error events
+    internalDefaultLanguageWatcher.on('error', () => {});
+  }
 
-  fileSizePreloader.loadPaths(...additionalFilePaths.map((path) => path.device));
-  await fileSizePreloader.waitUntilDoneLoading();
+  Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+  });
 
-  logInfo`Done preloading filesizes!`;
+  let customLanguageWatchers;
+  let languages;
 
-  if (noBuild) return;
+  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(),
+      });
+    }
 
-  // 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);
+    const languageDataFiles =
+      (await readdir(langPath))
+        .filter(name => ['.json', '.yaml'].includes(path.extname(name)))
+        .map(name => path.join(langPath, name));
 
-  const buildDictionary = pageSpecs;
+    let errorLoadingCustomLanguages = false;
 
-  await writeFavicon();
-  await writeSymlinks();
-  await writeSharedFilesAndPages({language: finalDefaultLanguage, wikiData});
+    if (languageReloading) watchCustomLanguages: {
+      Object.assign(stepStatusSummary.watchLanguageFiles, {
+        status: STATUS_STARTED_NOT_DONE,
+        timeStart: Date.now(),
+      });
 
-  const buildSteps = writeAll
-    ? Object.entries(buildDictionary)
-    : Object.entries(buildDictionary).filter(([flag]) => writeFlags[flag]);
+      customLanguageWatchers =
+        languageDataFiles.map(file => {
+          const watcher = watchLanguageFile(file);
 
-  let writes;
-  {
-    let error = false;
+          // Bypass node.js special-case handling for uncaught error events
+          watcher.on('error', () => {});
 
-    const buildStepsWithTargets = buildSteps
-      .map(([flag, pageSpec]) => {
-        // Condition not met: skip this build step altogether.
-        if (pageSpec.condition && !pageSpec.condition({wikiData})) {
-          return null;
-        }
+          return watcher;
+        });
 
-        // May still call writeTargetless if present.
-        if (!pageSpec.targets) {
-          return {flag, pageSpec, targets: []};
-        }
+      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();
+          }
 
-        if (!pageSpec.write) {
-          logError`${flag + '.targets'} is specified, but ${flag + '.write'} is missing!`;
-          error = true;
-          return null;
-        }
+          Object.assign(stepStatusSummary.watchLanguageFiles, {
+            status: STATUS_FATAL_ERROR,
+            annotation: `see log for details`,
+            timeEnd: Date.now(),
+          });
 
-        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;
+          errorLoadingCustomLanguages = true;
+          break watchCustomLanguages;
         }
 
-        return {flag, pageSpec, targets};
-      })
-      .filter(Boolean);
-
-    if (error) {
-      return;
-    }
+        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();
+              }
+            });
+          }
+        });
+      }
 
-    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.
+      languages =
+        Object.fromEntries(
+          customLanguageWatchers
+            .map(({language}) => [language.code, language]));
 
-      if (!Array.isArray(writes)) {
-        logError`${fnName} didn't return an array!`;
-        error = true;
-        return false;
+      Object.assign(stepStatusSummary.watchLanguageFiles, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    } else {
+      languages = {};
+
+      const results =
+        await Promise.allSettled(
+          languageDataFiles
+            .map(file => processLanguageFile(file)));
+
+      for (const {status, value: language, reason: error} of results) {
+        if (status === 'rejected') {
+          errorLoadingCustomLanguages = true;
+          niceShowAggregate(error);
+        } else {
+          languages[language.code] = language;
+        }
       }
 
-      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;
+      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(),
+        });
       }
+    }
 
-      return true;
-    };
+    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 = {};
+  }
 
-    // return;
+  Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
-    writes = progressCallAll('Computing page & data writes.', buildStepsWithTargets.flatMap(({flag, pageSpec, targets}) => {
-      const writesFns = targets.map(target => () => {
-        const writes = pageSpec.write(target, {wikiData})?.slice() || [];
-        return validateWrites(writes, flag + '.write') ? writes : [];
-      });
+  let finalDefaultLanguage;
+  let finalDefaultLanguageWatcher;
+  let finalDefaultLanguageAnnotation;
 
-      if (pageSpec.writeTargetless) {
-        writesFns.push(() => {
-          const writes = pageSpec.writeTargetless({wikiData});
-          return validateWrites(writes, flag + '.writeTargetless') ? writes : [];
-        });
+  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.`;
       }
 
-      return writesFns;
-    })).flat();
+      Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `wiki specifies default language whose file is not available`,
+        timeEnd: Date.now(),
+      });
 
-    if (error) {
-      return;
+      return false;
     }
-  }
 
-  const pageWrites = writes.filter(({type}) => type === 'page');
-  const dataWrites = writes.filter(({type}) => type === 'data');
-  const redirectWrites = writes.filter(({type}) => type === 'redirect');
+    logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
+
+    finalDefaultLanguage = customDefaultLanguage;
+    finalDefaultLanguageAnnotation = `using wiki-specified custom default language`;
+
+    if (languageReloading) {
+      finalDefaultLanguageWatcher =
+        customLanguageWatchers
+          .find(({language}) => language === customDefaultLanguage);
+    }
+  } else if (languages[internalDefaultLanguage.code]) {
+    const customDefaultLanguage = languages[internalDefaultLanguage.code];
+
+    finalDefaultLanguage = customDefaultLanguage;
+    finalDefaultLanguageAnnotation = `using inferred custom default language`;
 
-  if (writes.length) {
-    logInfo`Total of ${writes.length} writes returned. (${pageWrites.length} page, ${dataWrites.length} data [currently skipped], ${redirectWrites.length} redirect)`;
+    if (languageReloading) {
+      finalDefaultLanguageWatcher =
+        customLanguageWatchers
+          .find(({language}) => language === customDefaultLanguage);
+    }
   } else {
-    logWarn`No writes returned at all, so exiting early. This is probably a bug!`;
-    return;
+    languages[internalDefaultLanguage.code] = internalDefaultLanguage;
+
+    finalDefaultLanguage = internalDefaultLanguage;
+    finalDefaultLanguageAnnotation = `no custom default language specified`;
+
+    if (languageReloading) {
+      finalDefaultLanguageWatcher = internalDefaultLanguageWatcher;
+    }
   }
 
-  /*
-  await progressPromiseAll(`Writing data files shared across languages.`, queue(
-    dataWrites.map(({path, data}) => () => {
-      const bound = {};
+  const closeLanguageWatchers = () => {
+    if (languageReloading) {
+      for (const watcher of [
+        internalDefaultLanguageWatcher,
+        ...customLanguageWatchers,
+      ]) {
+        watcher.close();
+      }
+    }
+  };
 
-      bound.serializeLink = bindOpts(serializeLink, {});
+  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});
+  };
 
-      bound.serializeContribs = bindOpts(serializeContribs, {});
+  const inheritStringsFromDefaultLanguage = () => {
+    const {strings: inheritedStrings} = finalDefaultLanguage;
+    for (const language of Object.values(languages)) {
+      if (language === finalDefaultLanguage) continue;
+      Object.assign(language, {inheritedStrings});
+    }
+  };
 
-      bound.serializeImagePaths = bindOpts(serializeImagePaths, {
-        thumb
-      });
+  if (finalDefaultLanguage !== internalDefaultLanguage) {
+    inheritStringsFromInternalLanguage();
+  }
 
-      bound.serializeCover = bindOpts(serializeCover, {
-        [bindOpts.bindIndex]: 2,
-        serializeImagePaths: bound.serializeImagePaths,
-        urls
-      });
+  inheritStringsFromDefaultLanguage();
 
-      bound.serializeGroupsForAlbum = bindOpts(serializeGroupsForAlbum, {
-        serializeLink
+  if (languageReloading) {
+    if (finalDefaultLanguage !== internalDefaultLanguage) {
+      internalDefaultLanguageWatcher.on('update', () => {
+        inheritStringsFromInternalLanguage();
+        inheritStringsFromDefaultLanguage();
       });
+    }
 
-      bound.serializeGroupsForTrack = bindOpts(serializeGroupsForTrack, {
-        serializeLink
-      });
+    finalDefaultLanguageWatcher.on('update', () => {
+      inheritStringsFromDefaultLanguage();
+    });
+  }
 
-      // 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((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,
-        });
+  logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
 
-        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))
-          );
-        };
+  Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+    status: STATUS_DONE_CLEAN,
+    annotation: finalDefaultLanguageAnnotation,
+    timeEnd: Date.now(),
+  });
 
-        // TODO: Is there some nicer way to define these,
-        // may8e without totally re-8inding everything for
-        // each page?
-        const bound = {};
+  const urls = generateURLs(urlSpec);
 
-        bound.html = html;
+  let missingImagePaths;
 
-        bound.getColors = bindOpts(getColors, {
-          chroma,
-        });
+  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(),
+    });
 
-        bound.getLinkThemeString = bindOpts(unbound_getLinkThemeString, {
-          getColors: bound.getColors,
-        });
+    const results =
+      await verifyImagePaths(mediaPath, {urls, wikiData});
 
-        bound.getThemeString = bindOpts(unbound_getThemeString, {
-          getColors: bound.getColors,
-        });
+    missingImagePaths = results.missing;
+    const misplacedImagePaths = results.misplaced;
 
-        bound.link = withEntries(unbound_link, (entries) =>
-          entries
-            .map(([key, fn]) => [key, bindOpts(fn, {
-              getLinkThemeString: bound.getLinkThemeString,
-              to,
-            })]));
+    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(),
+      });
+    }
+  }
 
-        bound.parseAttributes = bindOpts(parseAttributes, {
-          to,
-        });
+  let getSizeOfAdditionalFile;
+  let getSizeOfImagePath;
 
-        bound.find = bindFind(wikiData, {mode: 'warn'});
+  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(),
+    });
 
-        bound.transformInline = bindOpts(transformInline, {
-          find: bound.find,
-          link: bound.link,
-          replacerSpec,
-          language,
-          to,
-          wikiData,
-        });
+    const fileSizePreloader = new FileSizePreloader();
+
+    // File sizes of additional files need to be precalculated before we can
+    // actually reference 'em in site building, so get those loading right
+    // away. We actually need to keep track of two things here - the on-device
+    // file paths we're actually reading, and the corresponding on-site media
+    // paths that will be exposed in site build code. We'll build a mapping
+    // function between them so that when site code requests a site path,
+    // it'll get the size of the file at the corresponding device path.
+    const additionalFilePaths = [
+      ...wikiData.albumData.flatMap((album) =>
+        [
+          ...(album.additionalFiles ?? []),
+          ...album.tracks.flatMap((track) => [
+            ...(track.additionalFiles ?? []),
+            ...(track.sheetMusicFiles ?? []),
+            ...(track.midiProjectFiles ?? []),
+          ]),
+        ]
+          .flatMap((fileGroup) => fileGroup.files ?? [])
+          .map((file) => ({
+            device: path.join(
+              mediaPath,
+              urls
+                .from('media.root')
+                .toDevice('media.albumAdditionalFile', album.directory, file)
+            ),
+            media: urls
+              .from('media.root')
+              .to('media.albumAdditionalFile', album.directory, file),
+          }))
+      ),
+    ];
+
+    // Same dealio for images. Since just about any image can be embedded and
+    // we can't super easily know which ones are referenced at runtime, just
+    // cheat and get file sizes for all images under media. (This includes
+    // additional files which are images.)
+    const imageFilePaths =
+      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);
+    };
 
-        bound.transformMultiline = bindOpts(transformMultiline, {
-          transformInline: bound.transformInline,
-          parseAttributes: bound.parseAttributes,
-        });
+    getSizeOfAdditionalFile = getSizeOfMediaFileHelper(additionalFilePaths);
+    getSizeOfImagePath = getSizeOfMediaFileHelper(imageFilePaths);
 
-        bound.transformLyrics = bindOpts(transformLyrics, {
-          transformInline: bound.transformInline,
-          transformMultiline: bound.transformMultiline,
-        });
+    logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`;
 
-        bound.iconifyURL = bindOpts(iconifyURL, {
-          html,
-          language,
-          to,
-        });
+    fileSizePreloader.loadPaths(...additionalFilePaths.map((path) => path.device));
+    await fileSizePreloader.waitUntilDoneLoading();
 
-        bound.fancifyURL = bindOpts(fancifyURL, {
-          html,
-          language,
-        });
+    logInfo`Preloading filesizes for ${imageFilePaths.length} full-resolution images...`;
 
-        bound.fancifyFlashURL = bindOpts(fancifyFlashURL, {
-          [bindOpts.bindIndex]: 2,
-          html,
-          language,
+    fileSizePreloader.loadPaths(...imageFilePaths.map((path) => path.device));
+    await fileSizePreloader.waitUntilDoneLoading();
 
-          fancifyURL: bound.fancifyURL,
-        });
+    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!`;
 
-        bound.getRevealStringFromWarnings = bindOpts(getRevealStringFromWarnings, {
-          html,
-          language,
-        });
+      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!`;
 
-        bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, {
-          language,
+      Object.assign(stepStatusSummary.preloadFileSizes, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    }
+  }
 
-          getRevealStringFromWarnings: bound.getRevealStringFromWarnings,
-        });
+  if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
+    return true;
+  }
 
-        bound.getArtistString = bindOpts(getArtistString, {
-          html,
-          link: bound.link,
-          language,
+  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(),
+  });
 
-          iconifyURL: bound.iconifyURL,
-        });
+  let buildModeResult;
 
-        bound.getAlbumCover = bindOpts(getAlbumCover, {
-          to,
-        });
+  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);
 
-        bound.getTrackCover = bindOpts(getTrackCover, {
-          to,
-        });
+    logError`There was a JavaScript error performing the build.`;
+    fileIssue();
 
-        bound.getFlashCover = bindOpts(getFlashCover, {
-          to,
-        });
+    Object.assign(stepStatusSummary.performBuild, {
+      status: STATUS_FATAL_ERROR,
+      message: `javascript error - view log for details`,
+      timeEnd: Date.now(),
+    });
 
-        bound.getArtistAvatar = bindOpts(getArtistAvatar, {
-          to,
-        });
+    return false;
+  }
 
-        bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, {
-          html,
-          language,
-        });
+  if (buildModeResult !== true) {
+    Object.assign(stepStatusSummary.performBuild, {
+      status: STATUS_HAS_WARNINGS,
+      annotation: `may not have completed - view log for details`,
+      timeEnd: Date.now(),
+    });
 
-        bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, {
-          html,
-          language,
-        });
+    return false;
+  }
 
-        bound.generateNavigationLinks = bindOpts(generateNavigationLinks, {
-          link: bound.link,
-          language,
-        });
+  Object.assign(stepStatusSummary.performBuild, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+  });
 
-        bound.generateStickyHeadingContainer = bindOpts(generateStickyHeadingContainer, {
-          html,
-        });
+  return true;
+}
 
-        bound.generateChronologyLinks = bindOpts(generateChronologyLinks, {
-          html,
-          language,
-          link: bound.link,
-          wikiData,
+// TODO: isMain detection isn't consistent across platforms here
+/* eslint-disable-next-line no-constant-condition */
+if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmusic') {
+  (async () => {
+    let result;
 
-          generateNavigationLinks: bound.generateNavigationLinks,
-        });
+    const totalTimeStart = Date.now();
 
-        bound.generateCoverLink = bindOpts(generateCoverLink, {
-          [bindOpts.bindIndex]: 0,
-          html,
-          img,
-          link: bound.link,
-          language,
-          to,
-          wikiData,
+    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);
+      }
+    }
 
-          getRevealStringFromTags: bound.getRevealStringFromTags,
-        });
+    const totalTimeEnd = Date.now();
 
-        bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, {
-          [bindOpts.bindIndex]: 2,
-          link: bound.link,
-          language,
-        });
+    const formatDuration = timeDelta => {
+      const seconds = timeDelta / 1000;
 
-        bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, {
-          html,
-          language,
-          wikiData,
-        });
+      if (seconds > 90) {
+        const modSeconds = Math.floor(seconds % 60);
+        const minutes = Math.floor(seconds - seconds % 60) / 60;
+        return `${minutes}m${modSeconds}s`;
+      }
 
-        bound.getGridHTML = bindOpts(getGridHTML, {
-          [bindOpts.bindIndex]: 0,
-          img,
-          html,
-          language,
+      if (seconds < 0.1) {
+        return 'instant';
+      }
 
-          getRevealStringFromTags: bound.getRevealStringFromTags,
-        });
+      const precision = (seconds > 1 ? 3 : 2);
+      return `${seconds.toPrecision(precision)}s`;
+    };
 
-        bound.getAlbumGridHTML = bindOpts(getAlbumGridHTML, {
-          [bindOpts.bindIndex]: 0,
-          link: bound.link,
-          language,
+    if (showStepStatusSummary) {
+      const totalDuration = formatDuration(totalTimeEnd - totalTimeStart);
 
-          getAlbumCover: bound.getAlbumCover,
-          getGridHTML: bound.getGridHTML,
-        });
+      console.error(colors.bright(`Step summary:`));
 
-        bound.getFlashGridHTML = bindOpts(getFlashGridHTML, {
-          [bindOpts.bindIndex]: 0,
-          link: bound.link,
+      const longestNameLength =
+        Math.max(...
+          Object.values(stepStatusSummary)
+            .map(({name}) => name.length));
 
-          getFlashCover: bound.getFlashCover,
-          getGridHTML: bound.getGridHTML,
-        });
+      const stepsNotClean =
+        Object.values(stepStatusSummary)
+          .map(({status}) =>
+            status === STATUS_HAS_WARNINGS ||
+            status === STATUS_FATAL_ERROR ||
+            status === STATUS_STARTED_NOT_DONE);
 
-        bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, {
-          to,
-        });
+      const anyStepsNotClean =
+        stepsNotClean.includes(true);
 
-        const pageInfo = page({
-          ...bound,
+      const stepDetails = Object.values(stepStatusSummary);
 
-          language,
+      const stepDurations =
+        stepDetails.map(({status, timeStart, timeEnd}) => {
+          if (
+            status === STATUS_NOT_APPLICABLE ||
+            status === STATUS_NOT_STARTED ||
+            status === STATUS_STARTED_NOT_DONE
+          ) {
+            return '-';
+          }
 
-          absoluteTo,
-          relativeTo: to,
-          to,
-          urls,
+          if (typeof timeStart !== 'number' || typeof timeEnd !== 'number') {
+            return 'unknown';
+          }
 
-          getSizeOfAdditionalFile,
+          return formatDuration(timeEnd - timeStart);
         });
 
-        const oEmbedJSON = writePage.oEmbedJSON(pageInfo, {
-          language,
-          wikiData,
-        });
+      const longestDurationLength =
+        Math.max(...stepDurations.map(duration => duration.length));
 
-        const oEmbedJSONHref =
-          oEmbedJSON &&
-          wikiData.wikiInfo.canonicalBase &&
-          wikiData.wikiInfo.canonicalBase +
-            urls
-              .from('shared.root')
-              .to('shared.path', paths.pathname + OEMBED_JSON_FILE);
-
-        const pageHTML = writePage.html(pageInfo, {
-          defaultLanguage: finalDefaultLanguage,
-          getThemeString: bound.getThemeString,
-          language,
-          languages,
-          localizedPaths,
-          oEmbedJSONHref,
-          paths,
-          to,
-          transformMultiline: bound.transformMultiline,
-          wikiData,
-        });
+      for (let index = 0; index < stepDetails.length; index++) {
+        const {name, status, annotation} = stepDetails[index];
+        const duration = stepDurations[index];
 
-        return writePage.write({
-          html: pageHTML,
-          oEmbedJSON,
-          paths,
-        });
-      }),
-      ...redirectWrites.map(({fromPath, toPath, title: titleFn}) => () => {
-        const title = titleFn({
-          language,
-        });
+        let message =
+          (stepsNotClean[index]
+            ? `!! `
+            : ` - `);
 
-        // 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,
-        });
+        message += `(${duration})`.padStart(longestDurationLength + 2, ' ');
+        message += ` `;
+        message += `${name}: `.padEnd(longestNameLength + 4, '.');
+        message += ` `;
+        message += status;
 
-        const target = to('localized.' + toPath[0], ...toPath.slice(1));
-        const html = generateRedirectPage(title, target, {language});
-        return writePage.write({html, paths: fromPaths});
-      }),
-    ], queueSize));
-  };
+        if (annotation) {
+          message += ` (${annotation})`;
+        }
 
-  await wrapLanguages(perLanguageFn, {
-    languages,
-    writeOneLanguage,
-  });
+        switch (status) {
+          case STATUS_DONE_CLEAN:
+            console.error(colors.green(message));
+            break;
 
-  // The single most important step.
-  logInfo`Written!`;
-}
+          case STATUS_NOT_STARTED:
+          case STATUS_NOT_APPLICABLE:
+            console.error(colors.dim(message));
+            break;
 
-main()
-  .catch((error) => {
-    if (error instanceof AggregateError) {
-      showAggregate(error);
-    } else {
-      console.error(error);
+          case STATUS_HAS_WARNINGS:
+          case STATUS_STARTED_NOT_DONE:
+            console.error(colors.yellow(message));
+            break;
+
+          case STATUS_FATAL_ERROR:
+            console.error(colors.red(message));
+            break;
+
+          default:
+            console.error(message);
+            break;
+        }
+      }
+
+      console.error(colors.bright(`Done in ${totalDuration}.`));
+
+      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}).`));
+      }
+    }
+
+    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 ce47926..ea5337a 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -1,4 +1,4 @@
-import {withEntries} from './util/sugar.js';
+import {withEntries} from '#sugar';
 
 const urlSpec = {
   data: {
@@ -21,11 +21,13 @@ const urlSpec = {
     paths: {
       root: '',
       path: '<>',
+      page: '<>/',
 
       home: '',
 
       album: 'album/<>/',
       albumCommentary: 'commentary/album/<>/',
+      albumGallery: 'album/<>/gallery/',
 
       artist: 'artist/<>/',
       artistGallery: 'artist/<>/gallery/',
@@ -33,19 +35,26 @@ const urlSpec = {
       commentaryIndex: 'commentary/',
 
       flashIndex: 'flash/',
+
       flash: 'flash/<>/',
 
+      flashActGallery: 'flash-act/<>/',
+
       groupInfo: 'group/<>/',
       groupGallery: 'group/<>/gallery/',
 
       listingIndex: 'list/',
+
       listing: 'list/<>/',
 
       newsIndex: 'news/',
+
       newsEntry: 'news/<>/',
 
       staticPage: '<>/',
+
       tag: 'tag/<>/',
+
       track: 'track/<>/',
     },
   },
@@ -59,7 +68,9 @@ const urlSpec = {
       staticRoot: 'static',
 
       utilityFile: 'util/<>',
-      staticFile: 'static/<>',
+      staticFile: 'static/<>?<>',
+
+      staticIcon: 'static/icons.svg#icon-<>',
     },
   },
 
@@ -70,13 +81,25 @@ const urlSpec = {
       root: '',
       path: '<>',
 
+      albumAdditionalFile: 'album-additional/<>/<>',
+      albumBanner: 'album-art/<>/banner.<>',
       albumCover: 'album-art/<>/cover.<>',
       albumWallpaper: 'album-art/<>/bg.<>',
-      albumBanner: 'album-art/<>/banner.<>',
-      trackCover: 'album-art/<>/<>.<>',
+
       artistAvatar: 'artist-avatar/<>.<>',
+
       flashArt: 'flash-art/<>.<>',
-      albumAdditionalFile: 'album-additional/<>/<>',
+
+      trackCover: 'album-art/<>/<>.<>',
+    },
+  },
+
+  thumb: {
+    prefix: 'thumb/',
+
+    paths: {
+      root: '',
+      path: '<>',
     },
   },
 };
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 f1a3190..ce513f0 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -17,7 +17,7 @@ export const ENABLE_COLOR =
 const C = (n) =>
   ENABLE_COLOR ? (text) => `\x1b[${n}m${text}\x1b[0m` : (text) => text;
 
-export const color = {
+export const colors = {
   bright: C('1'),
   dim: C('2'),
   normal: C('22'),
@@ -64,8 +64,10 @@ export async function parseOptions(options, optionDescriptorMap) {
   // 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.
+  //
+  // 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:
@@ -95,11 +97,10 @@ export async function parseOptions(options, optionDescriptorMap) {
   // ['--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];
@@ -107,6 +108,7 @@ export async function parseOptions(options, optionDescriptorMap) {
       // --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);
@@ -116,36 +118,49 @@ export async function parseOptions(options, optionDescriptorMap) {
         }
         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;
-          }
+
+      switch (descriptor.type) {
+        case 'flag': {
+          result[name] = true;
+          break;
         }
-        if (!value) {
-          console.error(`Expected a value for --${name}`);
-          process.exit(1);
+
+        case 'value': {
+          let value = option.slice(2).split('=')[1];
+          if (!value) {
+            value = options[++i];
+            if (!value || value.startsWith('-')) {
+              value = null;
+            }
+          }
+
+          if (!value) {
+            console.error(`Expected a value for --${name}`);
+            process.exit(1);
+          }
+
+          result[name] = value;
+          break;
         }
-        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);
+
+        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;
         }
-        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) {
@@ -167,10 +182,12 @@ export async function parseOptions(options, optionDescriptorMap) {
         }
         continue;
       }
+
       if (descriptor.alias) {
         name = descriptor.alias;
         descriptor = optionDescriptorMap[name];
       }
+
       if (descriptor.type === 'flag') {
         result[name] = true;
       } else {
@@ -198,12 +215,30 @@ export function decorateTime(arg1, arg2) {
     timeSpent: 0,
     timesCalled: 0,
     displayTime() {
-      const averageTime = meta.timeSpent / meta.timesCalled;
+      const align1 = 48;
+      const align2 = 22;
+
+      const averageTime = (meta.timeSpent / meta.timesCalled).toExponential(1);
+      const idPart = typeof id === 'symbol' ? id.description : id;
+      const timePart = `${meta.timeSpent} ms / ${meta.timesCalled} calls`;
+      const avgPart = `(avg: ${averageTime} ms)`;
+
+      const alignPart1 =
+        (idPart.length >= align1
+          ? ' '
+          : ' ' + '.'.repeat(Math.max(0, align1 - 2 - idPart.length)) + ' ');
+
+      const alignPart2 =
+        (timePart.length >= align2
+          ? ' '
+          : ' '.repeat(Math.max(0, align2 - timePart.length)));
+
       console.log(
-        `\x1b[1m${typeof id === 'symbol' ? id.description : id}(...):\x1b[0m ${
-          meta.timeSpent
-        } ms / ${meta.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`
-      );
+        colors.bright(idPart) +
+        alignPart1 +
+        timePart +
+        alignPart2 +
+        colors.dim(avgPart));
     },
   };
 
@@ -211,7 +246,7 @@ export function decorateTime(arg1, arg2) {
 
   const fn = function (...args) {
     const start = Date.now();
-    const ret = functionToBeWrapped(...args);
+    const ret = functionToBeWrapped.apply(this, args);
     const end = Date.now();
     meta.timeSpent += end - start;
     meta.timesCalled++;
@@ -233,11 +268,20 @@ decorateTime.displayTime = function () {
     ...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();
   }
 };
 
@@ -313,3 +357,44 @@ export function progressCallAll(msgOrMsgFn, array) {
 
   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 dea6712..50339cd 100644
--- a/src/util/colors.js
+++ b/src/util/colors.js
@@ -12,6 +12,9 @@ export function getColors(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);
@@ -25,6 +28,9 @@ export function getColors(themeColor, {
 
     dark: dark.hex(),
     dim: dim.hex(),
+    deep: deep.hex(),
+    deepGhost: deepGhost.hex(),
+    light: light.hex(),
 
     bg: bg.hex(),
     bgBlack: bgBlack.hex(),
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 ed0a680..0000000
--- a/src/util/find.js
+++ /dev/null
@@ -1,162 +0,0 @@
-import {color, logWarn} from './cli.js';
-
-import {inspect} from 'util';
-
-function warnOrThrow(mode, message) {
-  if (mode === 'error') {
-    throw new Error(message);
-  }
-
-  if (mode === 'warn') {
-    logWarn(message);
-  }
-
-  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) {
-  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 a6b0d62..d1d509e 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -1,4 +1,25 @@
-// 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
@@ -18,6 +39,20 @@ export const selfClosingTags = [
   'wbr',
 ];
 
+// Not so comprehensive!!
+export const attributeSpec = {
+  'class': {
+    arraylike: true,
+    join: ' ',
+    unique: true,
+  },
+
+  'style': {
+    arraylike: true,
+    join: '; ',
+  },
+};
+
 // Pass to tag() as an attributes key to make tag() return a 8lank 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.
@@ -38,118 +73,1786 @@ export const joinChildren = Symbol();
 // 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);
+
+  const lastArgIsAttributes =
+    typeof lastArg === 'object' && lastArg !== null &&
+    !Array.isArray(lastArg) &&
+    !(lastArg instanceof Tag) &&
+    !(lastArg instanceof Template);
+
+  const content =
+    (lastArgIsAttributes
+      ? null
+      : args.at(-1));
+
+  const attributes =
+    (lastArgIsAttributes
+      ? args
+      : args.slice(0, -1));
+
+  return new Tag(tagName, attributes, content);
+}
+
+export function tags(content, ...attributes) {
+  return new Tag(null, attributes, content);
+}
 
-  let openTag;
+export function metatag(identifier, ...args) {
   let content;
-  let attrs;
+  let opts = {};
 
-  if (typeof args[0] === 'object' && !Array.isArray(args[0])) {
-    attrs = args[0];
+  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];
   }
 
-  if (selfClosing && content) {
-    throw new Error(`Tag <${tagName}> is self-closing but got content!`);
+  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}"`);
   }
+}
 
-  if (Array.isArray(content)) {
-    if (content.some(item => Array.isArray(item))) {
-      throw new Error(`Found array instead of string (tag) or null/falsey, did you forget to \`...\` spread an array or fragment?`);
+export function normalize(content) {
+  return Tag.normalize(content);
+}
+
+export class Tag {
+  #tagName = '';
+  #content = null;
+  #attributes = null;
+
+  #traceError = null;
+
+  constructor(tagName, attributes, content) {
+    this.tagName = tagName;
+    this.attributes = attributes;
+    this.content = content;
+
+    this.#traceError = new Error();
+  }
+
+  clone() {
+    return Reflect.construct(this.constructor, [
+      this.tagName,
+      this.attributes,
+      this.content,
+    ]);
+  }
+
+  set tagName(value) {
+    if (value === undefined || value === null) {
+      this.tagName = '';
+      return;
+    }
+
+    if (typeof value !== 'string') {
+      throw new Error(`Expected tagName to be a string`);
+    }
+
+    if (selfClosingTags.includes(value) && this.content.length) {
+      throw new Error(`Tag <${value}> is self-closing but this tag has content`);
+    }
+
+    this.#tagName = value;
+  }
+
+  get tagName() {
+    return this.#tagName;
+  }
+
+  set attributes(attributes) {
+    if (attributes instanceof Attributes) {
+      this.#attributes = attributes;
+    } else {
+      this.#attributes = new Attributes(attributes);
+    }
+  }
+
+  get attributes() {
+    if (this.#attributes === null) {
+      this.attributes = {};
+    }
+
+    return this.#attributes;
+  }
+
+  set content(value) {
+    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`);
     }
 
-    const joiner = attrs?.[joinChildren];
-    content = content.filter(Boolean).join(
-      (joiner
-        ? `\n${joiner}\n`
+    const contentArray =
+      (Array.isArray(value)
+        ? value.flat(Infinity).filter(Boolean)
+     : value
+        ? [value]
+        : []);
+
+    if (this.chunkwrap) {
+      if (contentArray.some(content => content?.blockwrap)) {
+        throw new Error(`No support for blockwrap as a direct descendant of chunkwrap`);
+      }
+    }
+
+    this.#content = contentArray;
+    this.#content.toString = () => this.#stringifyContent();
+  }
+
+  get content() {
+    if (this.#content === null) {
+      this.#content = [];
+    }
+
+    return this.#content;
+  }
+
+  get selfClosing() {
+    if (this.tagName) {
+      return selfClosingTags.includes(this.tagName);
+    } else {
+      return false;
+    }
+  }
+
+  get blank() {
+    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'));
   }
 
-  if (attrs?.[onlyIfContent] && !content) {
-    return '';
+  #getContentJoiner() {
+    if (this.joinChildren === undefined) {
+      return '\n';
+    }
+
+    if (this.joinChildren === '') {
+      return '';
+    }
+
+    return `\n${this.joinChildren}\n`;
   }
 
-  if (attrs) {
-    const attrString = attributes(attrs);
-    if (attrString) {
-      openTag = `${tagName} ${attrString}`;
+  #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]);
+    }
+
+    const joiner = this.#getContentJoiner();
+
+    const result = [];
+    const attributes = {};
+
+    // Don't use built-in item joining, since we'll be handling it here -
+    // we need to account for descendants having custom joiners too, and
+    // simply using *this* tag's joiner would overwrite those descendants'
+    // differing joiners.
+    attributes[joinChildren] = '';
+
+    let workingText = '';
+
+    for (const item of this.content) {
+      const smushed = smush(item);
+      const smushedItems = smushed.content.slice();
+
+      if (empty(smushedItems)) {
+        continue;
+      }
+
+      if (typeof smushedItems[0] === 'string') {
+        if (workingText) {
+          workingText += joiner;
+        }
+
+        workingText += smushedItems.shift();
+      }
+
+      if (empty(smushedItems)) {
+        continue;
+      }
+
+      if (workingText) {
+        result.push(workingText + joiner);
+      } else if (!empty(result)) {
+        result.push(joiner);
+      }
+
+      if (typeof smushedItems.at(-1) === 'string') {
+        // The last smushed item already had its joiner processed from its own
+        // parent - this isn't an appropriate place for us to insert our own
+        // joiner.
+        workingText = smushedItems.pop();
+      } else {
+        workingText = '';
+      }
+
+      result.push(...smushedItems);
+    }
+
+    if (workingText) {
+      result.push(workingText);
+    }
+
+    return new Tag(null, attributes, result);
+  }
+
+  [inspect.custom](depth, opts) {
+    const lines = [];
+
+    const niceAttributes = ['id', 'class'];
+    const attributes = blankAttributes();
+
+    for (const attribute of niceAttributes) {
+      if (this.attributes.has(attribute)) {
+        const value = this.attributes.get(attribute);
+
+        if (!value) continue;
+        if (Array.isArray(value) && empty(value)) continue;
+
+        let string;
+        let suffix = '';
+
+        if (Array.isArray(value)) {
+          string = value[0].toString();
+          if (value.length > 1) {
+            suffix = ` (+${value.length - 1})`;
+          }
+        } else {
+          string = value.toString();
+        }
+
+        const trim =
+          (string.length > 15
+            ? `${string.slice(0, 12)}...`
+            : string);
+
+        attributes.set(attribute, trim + suffix);
+      }
+    }
+
+    const attributesPart =
+      (attributes.blank
+        ? ``
+        : ` ${attributes.toString({color: true})}`);
+
+    const tagNamePart =
+      (this.tagName
+        ? colors.bright(colors.blue(this.tagName))
+        : ``);
+
+    const tagPart =
+      (this.tagName
+        ? [
+            `<`,
+            tagNamePart,
+            attributesPart,
+            (empty(this.content) ? ` />` : `>`),
+          ].join(``)
+        : ``);
+
+    const accentText =
+      (this.tagName
+        ? (empty(this.content)
+            ? ``
+            : `(${this.content.length} items)`)
+        : (empty(this.content)
+            ? `(no name)`
+            : `(no name, ${this.content.length} items)`));
+
+    const accentPart =
+      (accentText
+        ? `${colors.dim(accentText)}`
+        : ``);
+
+    const headingParts = [
+      `Tag`,
+      tagPart,
+      accentPart,
+    ];
+
+    const heading = headingParts.filter(Boolean).join(` `);
+
+    lines.push(heading);
+
+    if (!opts.compact && (depth === null || depth >= 0)) {
+      const nextDepth =
+        (depth === null
+          ? null
+          : depth - 1);
+
+      for (const child of this.content) {
+        const childLines = [];
+
+        if (typeof child === 'string') {
+          const childFlat = child.replace(/\n/g, String.raw`\n`);
+          const childTrim =
+            (childFlat.length >= 40
+              ? childFlat.slice(0, 37) + '...'
+              : childFlat);
+
+          childLines.push(
+            `  Text: ${opts.stylize(`"${childTrim}"`, 'string')}`);
+        } else {
+          childLines.push(...
+            inspect(child, {depth: nextDepth})
+              .split('\n')
+              .map(line => `  ${line}`));
+        }
+
+        lines.push(...childLines);
+      }
+    }
+
+    return lines.join('\n');
+  }
+}
+
+export function attributes(attributes) {
+  return new Attributes(attributes);
+}
+
+export function parseAttributes(string) {
+  return Attributes.parse(string);
+}
+
+export class Attributes {
+  #attributes = Object.create(null);
+
+  constructor(attributes) {
+    this.attributes = attributes;
+  }
+
+  clone() {
+    return new Attributes(this);
   }
 
-  if (!openTag) {
-    openTag = tagName;
+  set attributes(value) {
+    this.#attributes = Object.create(null);
+
+    if (value === undefined || value === null) {
+      return;
+    }
+
+    this.add(value);
   }
 
-  if (content) {
-    if (content.includes('\n')) {
-      return [
-        `<${openTag}>`,
-        content
-          .split('\n')
-          .map((line, i) =>
-            (i === 0 && attrs?.[noEdgeWhitespace]
-              ? line
-              : '    ' + line))
-          .join('\n'),
-        `</${tagName}>`,
-      ].join(
-        (attrs?.[noEdgeWhitespace]
-          ? ''
-          : '\n'));
+  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 {
-      return `<${openTag}>${content}</${tagName}>`;
+      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;
     }
-  } else if (selfClosing) {
-    return `<${openTag}>`;
+
+    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
+      .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 `<${openTag}></${tagName}>`;
+    return Template.resolve(tagOrTemplate);
   }
 }
 
-export function escapeAttributeValue(value) {
-  return value.replaceAll('"', '&quot;').replaceAll("'", '&apos;');
+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 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(' ');
+export function stationery(description) {
+  return new Stationery(description);
 }
 
-// Ensures the passed value is an array of elements, for usage in [...spread]
-// syntax. This may be used when it's not guaranteed whether the return value of
-// an external function is one child or an array, or in combination with
-// conditionals, e.g. fragment(cond && [x, y, z]).
-export function fragment(childOrChildren) {
-  if (!childOrChildren) {
-    return [];
+export class Stationery {
+  #templateDescription = null;
+
+  static validated = Symbol('Stationery.validated');
+
+  constructor(templateDescription) {
+    Template.validateDescription(templateDescription);
+    templateDescription[Stationery.validated] = true;
+    this.#templateDescription = templateDescription;
   }
 
-  if (Array.isArray(childOrChildren)) {
-    return childOrChildren;
+  template() {
+    return new Template(this.#templateDescription);
   }
 
-  return [childOrChildren];
+  [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 6cc89b5..0000000
--- a/src/util/io.js
+++ /dev/null
@@ -1,21 +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 = () => true,
-  joinParentDirectory = true,
-} = {}) {
-  let files;
-  try {
-    files = await readdir(dataPath);
-  } catch (error) {
-    throw new AggregateError([error], `Failed to list files from ${dataPath}`);
-  }
-
-  return files
-    .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 bc3bd50..0000000
--- a/src/util/link.js
+++ /dev/null
@@ -1,188 +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 T from '../data/things/index.js';
-
-export function unbound_getLinkThemeString(color, {
-  getColors,
-}) {
-  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, {
-    getLinkThemeString,
-    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);
-
-// Mapping of Thing constructor classes to the key for a link.x() function.
-// These represent a sensible "default" link, i.e. to the primary page for
-// the given thing based on what it's an instance of. This is used for the
-// link.anything() function.
-const linkAnythingMapping = [
-  [T.Album, 'album'],
-  [T.Artist, 'artist'],
-  [T.ArtTag, 'tag'],
-  [T.Flash, 'flash'],
-  [T.Group, 'groupInfo'],
-  [T.NewsEntry, 'newsEntry'],
-  [T.StaticPage, 'staticPage'],
-  [T.Track, 'track'],
-];
-
-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}),
-
-  // This is NOT an arrow functions because it should be callable for other
-  // "this" objects - i.e, if we bind arguments in other functions on the same
-  // link object, link.anything() should use those bound functions, not the
-  // original ones we're exporting here.
-  //
-  // This function has been through a lot of names:
-  //   - getHrefOfAnythingMan (2020-05-25)
-  //   - toAnythingMan (2021-03-02)
-  //   - linkAnythingMan (2021-05-14)
-  //   - link.anything (2022-09-15)
-  // ...And it'll probably end up being renamed yet again one day!
-  //
-  anything(...args) {
-    if (!this) {
-      throw new Error(`Missing value for \`this\` - investigate JS call stack`);
-    }
-
-    const [thing] = args;
-
-    for (const [constructor, fnKey] of linkAnythingMapping) {
-      if (thing instanceof constructor) {
-        return Reflect.apply(this[fnKey], this, args);
-      }
-    }
-
-    throw new Error(`Unrecognized type of thing for linking: ${thing}`);
-  },
-};
-
-export {
-  unbound_getLinkThemeString as getLinkThemeString,
-};
-
-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 252e920..345d10a 100644
--- a/src/util/node-utils.js
+++ b/src/util/node-utils.js
@@ -1,6 +1,8 @@
 // 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';
 
@@ -45,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 9d602ca..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, 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 = '[[';
@@ -75,6 +203,8 @@ function parseNodes(input, i, stopAt, textOnly) {
       string = string.trimEnd();
     }
 
+    string = cleanRawText(string);
+
     if (string.length) {
       nodes.push({i: iString, iEnd: i, type: 'text', data: string});
       string = '';
@@ -221,11 +351,10 @@ function parseNodes(input, i, stopAt, textOnly) {
       let hash;
 
       if (stop_literal === tagHash) {
-        N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+        N = parseOneTextNode(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
 
         if (!stopped) throw endOfInput(i, `reading hash`);
-
-        if (!N) throw makeError(i, `Expected content (hash).`);
+        if (!N) throw makeError(i, `Expected text (hash).`);
 
         hash = N;
         i = stop_iParse;
@@ -293,9 +422,257 @@ function parseNodes(input, i, stopAt, textOnly) {
   return nodes;
 }
 
+export function squashBackslashes(text) {
+  // Squash backslashes which aren't themselves escaped into
+  // the following character, unless that character is one of
+  // a set of characters where the backslash carries meaning
+  // into later formatting (i.e. markdown). Note that we do
+  // NOT compress double backslashes into single backslashes.
+  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_-])/g, '$1');
+}
+
+export function restoreRawHTMLTags(text) {
+  // Replace stuff like <html:a> with <a>; these signal that
+  // the tag shouldn't be processed by the replacer system,
+  // and should just be embedded into the content as raw HTML.
+  return text.replace(/<html:(.*?)(?=[ >])/g, '<$1');
+}
+
+export function cleanRawText(text) {
+  text = squashBackslashes(text);
+  text = restoreRawHTMLTags(text);
+  return text;
+}
+
+export function postprocessImages(inputNodes) {
+  const outputNodes = [];
+
+  let atStartOfLine = true;
+
+  const lastNode = inputNodes.at(-1);
+
+  for (const node of inputNodes) {
+    if (node.type === 'tag') {
+      atStartOfLine = false;
+    }
+
+    if (node.type === 'text') {
+      const imageRegexp = /<img (.*?)>/g;
+
+      let match = null, parseFrom = 0;
+      while (match = imageRegexp.exec(node.data)) {
+        const previousText = node.data.slice(parseFrom, match.index);
+
+        outputNodes.push({
+          type: 'text',
+          data: previousText,
+          i: node.i + parseFrom,
+          iEnd: node.i + parseFrom + match.index,
+        });
+
+        parseFrom = match.index + match[0].length;
+
+        const imageNode = {type: 'image'};
+        const attributes = html.parseAttributes(match[1]);
+
+        imageNode.src = attributes.get('src');
+
+        if (previousText.endsWith('\n')) {
+          atStartOfLine = true;
+        } else if (previousText.length) {
+          atStartOfLine = false;
+        }
+
+        imageNode.inline = (() => {
+          // Images can force themselves to be rendered inline using a custom
+          // attribute - this style just works better for certain embeds,
+          // usually jokes or small images.
+          if (attributes.get('inline')) return true;
+
+          // 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(', ');
+        }
+
+        outputNodes.push(imageNode);
+
+        // 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;
+      }
+
+      if (parseFrom !== node.data.length) {
+        outputNodes.push({
+          type: 'text',
+          data: node.data.slice(parseFrom),
+          i: node.i + parseFrom,
+          iEnd: node.iEnd,
+        });
+      }
+
+      continue;
+    }
+
+    outputNodes.push(node);
+  }
+
+  return outputNodes;
+}
+
+export function postprocessHeadings(inputNodes) {
+  const outputNodes = [];
+
+  for (const node of inputNodes) {
+    if (node.type !== 'text') {
+      outputNodes.push(node);
+      continue;
+    }
+
+    const headingRegexp = /<h2 (.*?)>/g;
+
+    let textContent = '';
+
+    let match = null, parseFrom = 0;
+    while (match = headingRegexp.exec(node.data)) {
+      textContent += node.data.slice(parseFrom, match.index);
+      parseFrom = match.index + match[0].length;
+
+      const attributes = html.parseAttributes(match[1]);
+      attributes.push('class', 'content-heading');
+
+      // We're only modifying the opening tag here. The remaining content,
+      // including the closing tag, will be pushed as-is.
+      textContent += `<h2 ${attributes}>`;
+    }
+
+    if (parseFrom !== node.data.length) {
+      textContent += node.data.slice(parseFrom);
+    }
+
+    outputNodes.push({
+      type: 'text',
+      data: textContent,
+      i: node.i,
+      iEnd: node.iEnd,
+    });
+  }
+
+  return outputNodes;
+}
+
+export function postprocessExternalLinks(inputNodes) {
+  const outputNodes = [];
+
+  for (const node of inputNodes) {
+    if (node.type !== 'text') {
+      outputNodes.push(node);
+      continue;
+    }
+
+    const plausibleLinkRegexp = /\[.*?\)/g;
+
+    let textContent = '';
+
+    let plausibleMatch = null, parseFrom = 0;
+    while (plausibleMatch = plausibleLinkRegexp.exec(node.data)) {
+      textContent += node.data.slice(parseFrom, plausibleMatch.index);
+
+      // Pedantic rules use more particular parentheses detection in link
+      // destinations - they allow one level of balanced parentheses, and
+      // otherwise, parentheses must be escaped. This allows for entire links
+      // to be wrapped in parentheses, e.g below:
+      //
+      //   This is so cool. ([You know??](https://example.com))
+      //
+      const definiteMatch =
+        marked.Lexer.rules.inline.pedantic.link
+          .exec(node.data.slice(plausibleMatch.index));
+
+      if (definiteMatch) {
+        const {1: label, 2: href} = definiteMatch;
+
+        // Split the containing text node into two - the second of these will
+        // be added after iterating over matches, or by the next match.
+        if (textContent.length) {
+          outputNodes.push({type: 'text', data: textContent});
+          textContent = '';
+        }
+
+        const offset = plausibleMatch.index + definiteMatch.index;
+        const length = definiteMatch[0].length;
+
+        outputNodes.push({
+          i: node.i + offset,
+          iEnd: node.i + offset + length,
+          type: 'external-link',
+          data: {label, href},
+        });
+
+        parseFrom = offset + length;
+      } else {
+        parseFrom = plausibleMatch.index;
+      }
+    }
+
+    if (parseFrom !== node.data.length) {
+      textContent += node.data.slice(parseFrom);
+    }
+
+    if (textContent.length) {
+      outputNodes.push({type: 'text', data: textContent});
+    }
+  }
+
+  return outputNodes;
+}
+
 export function parseInput(input) {
+  if (typeof input !== 'string') {
+    throw new TypeError(`Expected input to be string, got ${typeAppearance(input)}`);
+  }
+
   try {
-    return parseNodes(input, 0);
+    let output = parseNodes(input, 0);
+    output = postprocessImages(output);
+    output = postprocessHeadings(output);
+    output = postprocessExternalLinks(output);
+    return output;
   } catch (errorNode) {
     if (errorNode.type !== 'error') {
       throw errorNode;
@@ -331,129 +708,3 @@ export function parseInput(input) {
     ].join('\n'));
   }
 }
-
-function evaluateTag(node, opts) {
-  const {find, input, language, link, replacerSpec, to} = opts;
-
-  const source = input.slice(node.i, node.iEnd);
-
-  const replacerKeyImplied = !node.data.replacerKey;
-  const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data;
-
-  if (!replacerSpec[replacerKey]) {
-    logWarn`The link ${source} has an invalid replacer key!`;
-    return source;
-  }
-
-  const {
-    find: findKey,
-    link: linkKey,
-    value: valueFn,
-    html: htmlFn,
-    transformName,
-  } = replacerSpec[replacerKey];
-
-  const replacerValue = transformNodes(node.data.replacerValue, opts);
-
-  const value = valueFn
-    ? valueFn(replacerValue)
-    : findKey
-    ? find[findKey](
-        replacerKeyImplied ? replacerValue : replacerKey + `:` + replacerValue
-      )
-    : {
-        directory: replacerValue,
-        name: null,
-      };
-
-  if (!value) {
-    logWarn`The link ${source} does not match anything!`;
-    return source;
-  }
-
-  const enteredLabel = node.data.label && transformNode(node.data.label, opts);
-
-  const label =
-    enteredLabel ||
-    (transformName && transformName(value.name, node, input)) ||
-    value.name;
-
-  if (!valueFn && !label) {
-    logWarn`The link ${source} requires a label be entered!`;
-    return source;
-  }
-
-  const hash = node.data.hash && transformNodes(node.data.hash, opts);
-
-  const args =
-    node.data.args &&
-    Object.fromEntries(
-      node.data.args.map(({key, value}) => [
-        transformNode(key, opts),
-        transformNodes(value, opts),
-      ])
-    );
-
-  const fn = htmlFn ? htmlFn : link[linkKey];
-
-  try {
-    return fn(value, {text: label, hash, args, language, to});
-  } catch (error) {
-    logError`The link ${source} failed to be processed: ${error}`;
-    return source;
-  }
-}
-
-function transformNode(node, opts) {
-  if (!node) {
-    throw new Error('Expected a node!');
-  }
-
-  if (Array.isArray(node)) {
-    throw new Error('Got an array - use transformNodes here!');
-  }
-
-  switch (node.type) {
-    case 'text':
-      return node.data;
-    case 'tag':
-      return evaluateTag(node, opts);
-    default:
-      throw new Error(`Unknown node type ${node.type}`);
-  }
-}
-
-function transformNodes(nodes, opts) {
-  if (!nodes || !Array.isArray(nodes)) {
-    throw new Error(`Expected an array of nodes! Got: ${nodes}`);
-  }
-
-  return nodes.map((node) => transformNode(node, opts)).join('');
-}
-
-export function transformInline(input, {
-  replacerSpec,
-  find,
-  language,
-  link,
-  to,
-  wikiData,
-}) {
-  if (!replacerSpec) throw new Error('Expected replacerSpec');
-  if (!find) throw new Error('Expected find');
-  if (!language) throw new Error('Expected language');
-  if (!link) throw new Error('Expected link');
-  if (!to) throw new Error('Expected to');
-  if (!wikiData) throw new Error('Expected wikiData');
-
-  const nodes = parseInput(input);
-  return transformNodes(nodes, {
-    input,
-    find,
-    link,
-    replacerSpec,
-    language,
-    to,
-    wikiData,
-  });
-}
diff --git a/src/util/serialize.js b/src/util/serialize.js
index 73a3137..4992e2b 100644
--- a/src/util/serialize.js
+++ b/src/util/serialize.js
@@ -1,3 +1,10 @@
+// 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;
@@ -67,3 +74,4 @@ export function serializeGroupsForTrack(track, {serializeLink}) {
     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 e8fdf93..e060f45 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -6,7 +6,7 @@
 // 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
@@ -26,18 +26,66 @@ export function* splitArray(array, fn) {
   }
 }
 
-// Null-accepting function to check if an array is empty. Accepts null (and
-// treats 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 (instead of undefined).
-export function empty(arrayOrNull) {
-  if (arrayOrNull === null) {
+// 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;
-  } else if (Array.isArray(arrayOrNull)) {
-    return arrayOrNull.length === 0;
-  } else {
-    throw new Error(`Expected array or null`);
   }
+
+  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;
+}
+
+// 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
@@ -53,6 +101,76 @@ export function accumulateSum(array, fn = x => x) {
     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 mapInPlace = (array, fn) =>
   array.splice(0, array.length, ...array.map(fn));
 
@@ -68,6 +186,46 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
 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 filterProperties(object, properties, {
+  preserveOriginalOrder = false,
+} = {}) {
+  if (typeof object !== 'object' || object === null) {
+    throw new TypeError(`Expected object to be an object, got ${typeAppearance(object)}`);
+  }
+
+  if (!Array.isArray(properties)) {
+    throw new TypeError(`Expected properties to be an array, got ${typeAppearance(properties)}`);
+  }
+
+  const filteredObject = {};
+
+  if (preserveOriginalOrder) {
+    for (const property of Object.keys(object)) {
+      if (properties.includes(property)) {
+        filteredObject[property] = object[property];
+      }
+    }
+  } else {
+    for (const property of properties) {
+      if (Object.hasOwn(object, property)) {
+        filteredObject[property] = object[property];
+      }
+    }
+  }
+
+  return filteredObject;
+}
+
 export function queue(array, max = 50) {
   if (max === 0) {
     return array.map((fn) => fn());
@@ -105,11 +263,160 @@ export function delay(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, '\\$&');
 }
 
+export function splitKeys(key) {
+  return key.split(/(?<=(?<!\\)(?:\\\\)*)\./);
+}
+
+// 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)));
+
+  return recursive(obj, splitKeys(key));
+}
+
+// Gets the "look" of some arbitrary value. It's like typeof, but smarter.
+// Don't use this for actually validating types - it's only suitable for
+// inclusion in error messages.
+export function typeAppearance(value) {
+  if (value === null) return 'null';
+  if (value === undefined) return 'undefined';
+  if (Array.isArray(value)) return 'array';
+  return typeof value;
+}
+
+// Limits a string to the desired length, filling in an ellipsis at the end
+// if it cuts any text off.
+export function cut(text, length = 40) {
+  if (text.length >= length) {
+    const index = Math.max(1, length - 3);
+    return text.slice(0, index) + '...';
+  } else {
+    return text;
+  }
+}
+
+// Limits a string to the desired length, filling in an ellipsis at the start
+// if it cuts any text off.
+export function cutStart(text, length = 40) {
+  if (text.length >= length) {
+    const index = Math.min(text.length - 1, text.length - length + 3);
+    return '...' + text.slice(index);
+  } else {
+    return text;
+  }
+}
+
+// Annotates {index, length} results from another iterator with contextual
+// details, including:
+//
+// * its line and column numbers;
+// * if `formatWhere` is true (the default), a pretty-formatted,
+//   human-readable indication of the match's placement in the string;
+// * if `getContainingLine` is true, the entire line (or multiple lines)
+//   of text containing the match.
+//
+export function* iterateMultiline(content, iterator, {
+  formatWhere = true,
+  getContainingLine = false,
+} = {}) {
+  const lineRegexp = /\n/g;
+  const isMultiline = content.includes('\n');
+
+  let lineNumber = 0;
+  let startOfLine = 0;
+  let previousIndex = 0;
+
+  const countLineBreaks = (index, length) => {
+    const range = content.slice(index, index + length);
+    const lineBreaks = Array.from(range.matchAll(lineRegexp));
+    if (!empty(lineBreaks)) {
+      lineNumber += lineBreaks.length;
+      startOfLine = index + lineBreaks.at(-1).index + 1;
+    }
+  };
+
+  for (const result of iterator) {
+    const {index, length} = result;
+
+    countLineBreaks(previousIndex, index - previousIndex);
+
+    const matchStartOfLine = startOfLine;
+
+    previousIndex = index + length;
+
+    const columnNumber = index - startOfLine;
+
+    let where = null;
+    if (formatWhere) {
+      where =
+        colors.yellow(
+          (isMultiline
+            ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}`
+            : `pos: ${index + 1}`));
+    }
+
+    countLineBreaks(index, length);
+
+    let containingLine = null;
+    if (getContainingLine) {
+      const nextLineResult =
+        content
+          .slice(previousIndex)
+          .matchAll(lineRegexp)
+          .next();
+
+      const nextStartOfLine =
+        (nextLineResult.done
+          ? content.length
+          : previousIndex + nextLineResult.value.index);
+
+      containingLine =
+        content.slice(matchStartOfLine, nextStartOfLine);
+    }
+
+    yield {
+      ...result,
+      lineNumber,
+      columnNumber,
+      where,
+      containingLine,
+    };
+  }
+}
+
+// Iterates over regular expression matches within a single- or multiline
+// string, yielding each match as well as contextual details; this accepts
+// the same options (and provides the same context) as iterateMultiline.
+export function* matchMultiline(content, matchRegexp, options) {
+  const matchAllIterator =
+    content.matchAll(matchRegexp);
+
+  const cleanMatchAllIterator =
+    (function*() {
+      for (const match of matchAllIterator) {
+        yield {
+          index: match.index,
+          length: match[0].length,
+          match,
+        };
+      }
+    })();
+
+  const multilineIterator =
+    iterateMultiline(content, cleanMatchAllIterator, options);
+
+  yield* multilineIterator;
+}
+
 // Binds default values for arguments in a {key: value} type function argument
 // (typically the second argument, but may be overridden by providing a
 // [bindOpts.bindIndex] argument). Typically useful for preparing a function for
@@ -132,335 +439,305 @@ export function bindOpts(fn, bind) {
     ]);
   };
 
-  Object.defineProperty(bound, 'name', {
-    value: fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`,
+  annotateFunction(bound, {
+    name: fn,
+    trait: 'options-bound',
   });
 
+  for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(fn))) {
+    if (key === 'length') continue;
+    if (key === 'name') continue;
+    if (key === 'arguments') continue;
+    if (key === 'caller') continue;
+    if (key === 'prototype') continue;
+    Object.defineProperty(bound, key, descriptor);
+  }
+
   return bound;
 }
 
 bindOpts.bindIndex = Symbol();
 
-// Utility function for providing useful interfaces to the JS AggregateError
-// class.
+// Sorts multiple arrays by an arbitrary function (which is the last argument).
+// Paired values from each array are provided to the callback sequentially:
 //
-// Generally, this works by returning a set of interfaces which operate on
-// functions: wrap() takes a function and returns a new function which passes
-// its arguments through and appends any resulting error to the internal error
-// list; call() simplifies this process by wrapping the provided function and
-// then calling it immediately. Once the process for which errors should be
-// aggregated is complete, close() constructs and throws an AggregateError
-// object containing all caught errors (or doesn't throw anything if there were
-// no errors).
-export function openAggregate({
-  // Constructor to use, defaulting to the builtin AggregateError class.
-  // Anything passed here should probably extend from that! May be used for
-  // letting callers programatically distinguish between multiple aggregate
-  // errors.
-  //
-  // This should be provided using the aggregateThrows utility function.
-  [openAggregate.errorClassSymbol]: errorClass = AggregateError,
-
-  // Optional human-readable message to describe the aggregate error, if
-  // constructed.
-  message = '',
-
-  // Value to return when a provided function throws an error. If this is a
-  // function, it will be called with the arguments given to the function.
-  // (This is primarily useful when wrapping a function and then providing it
-  // to another utility, e.g. array.map().)
-  returnOnFail = null,
-} = {}) {
-  const errors = [];
-
-  const aggregate = {};
-
-  aggregate.wrap =
-    (fn) =>
-    (...args) => {
-      try {
-        return fn(...args);
-      } catch (error) {
-        errors.push(error);
-        return typeof returnOnFail === 'function'
-          ? returnOnFail(...args)
-          : returnOnFail;
-      }
-    };
-
-  aggregate.wrapAsync =
-    (fn) =>
-    (...args) => {
-      return fn(...args).then(
-        (value) => value,
-        (error) => {
-          errors.push(error);
-          return typeof returnOnFail === 'function'
-            ? returnOnFail(...args)
-            : returnOnFail;
-        }
-      );
-    };
-
-  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;
-  };
+//   (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]);
+    }
 
-  aggregate.mapAsync = async (...args) => {
-    const parent = aggregate;
-    const {result, aggregate: child} = await mapAggregateAsync(...args);
-    parent.call(child.close);
-    return result;
-  };
+    return fn(...args);
+  });
 
-  aggregate.filter = (...args) => {
-    const parent = aggregate;
-    const {result, aggregate: child} = filterAggregate(...args);
-    parent.call(child.close);
-    return result;
-  };
+  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]]));
+  }
 
-  aggregate.throws = aggregateThrows;
+  return arrays;
+}
 
-  aggregate.close = () => {
-    if (errors.length) {
-      throw Reflect.construct(errorClass, [errors, message]);
+// Filters multiple arrays by an arbitrary function (which is the last argument).
+// Values from each array are provided to the callback sequentially:
+//
+//   (value_fromFirstArray,
+//    value_fromSecondArray,
+//    value_fromThirdArray,
+//    index,
+//    [firstArray, secondArray, thirdArray]) =>
+//      true or false
+//
+// Please be aware that this is a mutating function, unlike native single-array
+// filter. The mutated arrays are returned. Also attached under `.removed` are
+// corresponding arrays of items filtered out.
+export function filterMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
+
+  const removed = new Array(arrays.length).fill(null).map(() => []);
+
+  for (let i = arrays[0].length - 1; i >= 0; i--) {
+    const args = arrays.map(array => array[i]);
+    args.push(i, arrays);
+
+    if (!fn(...args)) {
+      for (let j = 0; j < arrays.length; j++) {
+        const item = arrays[j][i];
+        arrays[j].splice(i, 1);
+        removed[j].unshift(item);
+      }
     }
-  };
+  }
 
-  return aggregate;
+  Object.assign(arrays, {removed});
+  return arrays;
 }
 
-openAggregate.errorClassSymbol = Symbol('error class');
-
-// Utility function for providing {errorClass} parameter to aggregate functions.
-export function aggregateThrows(errorClass) {
-  return {[openAggregate.errorClassSymbol]: errorClass};
+// Corresponding filter function for sortByCount. By default, items whose
+// corresponding count is zero will be removed.
+export function filterByCount(data, counts, {
+  min = 1,
+  max = Infinity,
+} = {}) {
+  filterMultipleArrays(data, counts, (data, count) =>
+    count >= min && count <= max);
 }
 
-// Performs an ordinary array map with the given function, collating into a
-// results array (with errored inputs filtered out) and an error aggregate.
+// 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:
 //
-// Optionally, override returnOnFail to disable filtering and map errored inputs
-// to a particular output.
+//   (accumulator1,
+//    accumulator2,
+//    value_fromFirstArray,
+//    value_fromSecondArray,
+//    index,
+//    [firstArray, secondArray]) =>
+//      [newAccumulator1, newAccumulator2]
 //
-// 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);
-}
+// 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`);
+  }
 
-export function mapAggregateAsync(array, fn, {
-  promiseAll = Promise.all.bind(Promise),
-  ...aggregateOpts
-} = {}) {
-  return _mapAggregate('async', promiseAll, array, fn, aggregateOpts);
+  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;
 }
 
-// Helper function for mapAggregate which holds code common between sync and
-// async versions.
-export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) {
-  const failureSymbol = Symbol();
+export function chunkByConditions(array, conditions) {
+  if (empty(array)) {
+    return [];
+  }
 
-  const aggregate = openAggregate({
-    returnOnFail: failureSymbol,
-    ...aggregateOpts,
-  });
+  if (empty(conditions)) {
+    return [array];
+  }
 
-  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};
-      });
+  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;
 }
 
-// Performs an ordinary array filter with the given function, collating into a
-// results array (with errored inputs filtered out) and an error aggregate.
-//
-// Optionally, override returnOnFail to disable filtering errors and map errored
-// inputs to a particular output.
-//
-// As with mapAggregate, the returned aggregate property is not yet closed.
-export function filterAggregate(array, fn, aggregateOpts) {
-  return _filterAggregate('sync', null, array, fn, aggregateOpts);
-}
+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];
 
-export async function filterAggregateAsync(array, fn, {
-  promiseAll = Promise.all.bind(Promise),
-  ...aggregateOpts
-} = {}) {
-  return _filterAggregate('async', promiseAll, array, fn, aggregateOpts);
+      if (a[p] !== b[p]) return true;
+
+      // Not sure if this line is still necessary with the specific check for
+      // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....?
+      if (a[p] != b[p]) return true;
+
+      return false;
+    })
+  ).map((chunk) => ({
+    ...Object.fromEntries(properties.map((p) => [p, chunk[0][p]])),
+    chunk,
+  }));
 }
 
-// Helper function for filterAggregate which holds code common between sync and
-// async versions.
-function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) {
-  const failureSymbol = Symbol();
+export function chunkMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
 
-  const aggregate = openAggregate({
-    returnOnFail: failureSymbol,
-    ...aggregateOpts,
-  });
+  if (arrays[0].length === 0) {
+    return [];
+  }
+
+  const newChunk = index => arrays.map(array => [array[index]]);
+  const results = [newChunk(0)];
+
+  for (let i = 1; i < arrays[0].length; i++) {
+    const current = results.at(-1);
 
-  function filterFunction(value) {
-    // Filter out results which match the failureSymbol, i.e. errored
-    // inputs.
-    if (value === failureSymbol) return false;
+    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);
+    }
 
-    // Always keep results which match the overridden returnOnFail
-    // value, if provided.
-    if (value === aggregateOpts.returnOnFail) return true;
+    if (fn(...args)) {
+      results.push(newChunk(i));
+      continue;
+    }
 
-    // Otherwise, filter according to the returned value of the wrapped
-    // function.
-    return value.output;
+    for (let j = 0; j < arrays.length; j++) {
+      current[j].push(arrays[j][i]);
+    }
   }
 
-  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 results;
+}
+
+// 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;
   }
 
-  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);
+  name ??= fn.name ?? 'anonymous';
 
-    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);
+  const match = name.match(/^ *(?<prefix>.*?) *\((?<description>.*)( #(?<trait>.*))?\) *(?<suffix>.*) *$/);
 
-      return {result, aggregate};
-    });
+  let prefix, suffix, description, trait;
+  if (match) {
+    ({prefix, suffix, description, trait} = match.groups);
   }
-}
 
-// 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);
-}
+  prefix ??= '';
+  suffix ??= name;
+  description ??= '';
+  trait ??= '';
 
-export function withAggregateAsync(aggregateOpts, fn) {
-  return _withAggregate('async', aggregateOpts, fn);
-}
+  if (newDescription) {
+    if (description) {
+      description += '; ' + newDescription;
+    } else {
+      description = newDescription;
+    }
+  }
 
-export function _withAggregate(mode, aggregateOpts, fn) {
-  if (typeof aggregateOpts === 'function') {
-    fn = aggregateOpts;
-    aggregateOpts = {};
+  if (newTrait) {
+    if (trait) {
+      trait += ' #' + newTrait;
+    } else {
+      trait = '#' + newTrait;
+    }
   }
 
-  const aggregate = openAggregate(aggregateOpts);
+  let parenthesesPart;
 
-  if (mode === 'sync') {
-    const result = fn(aggregate);
-    aggregate.close();
-    return result;
+  if (description && trait) {
+    parenthesesPart = `${description} ${trait}`;
+  } else if (description || trait) {
+    parenthesesPart = description || trait;
   } else {
-    return fn(aggregate).then((result) => {
-      aggregate.close();
-      return result;
-    });
+    parenthesesPart = '';
   }
-}
 
-export function showAggregate(topError, {
-  pathToFileURL = f => f,
-  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) => pathToFileURL(match))
-        : '(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) => i === 0 ? ` ${head} ${line}` : ` ${bar} ${line}`)
-          .join('\n')
-      );
-    } else {
-      return header;
-    }
-  };
+  let finalName;
 
-  console.error(recursive(topError, {level: 0}));
-}
+  if (prefix && parenthesesPart) {
+    finalName = `${prefix} (${parenthesesPart}) ${suffix}`;
+  } else if (parenthesesPart) {
+    finalName = `(${parenthesesPart}) ${suffix}`;
+  } else {
+    finalName = suffix;
+  }
 
-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;
-    }
-  };
+  Object.defineProperty(fn, 'name', {value: finalName});
 }
diff --git a/src/util/urls.js b/src/util/urls.js
index 1f9cd9c..11b9b8b 100644
--- a/src/util/urls.js
+++ b/src/util/urls.js
@@ -3,14 +3,20 @@
 // 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 * as path from 'node:path';
+
+import {withEntries} from '#sugar';
 
-import {withEntries} from './sugar.js';
+// 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) => {
@@ -79,16 +85,25 @@ export function generateURLs(urlSpec) {
     );
 
     const toHelper =
-      (delimiterMode) =>
+      ({device}) =>
       (key, ...args) => {
         const {
-          value: {[delimiterMode]: template},
+          value: {
+            [device ? 'device' : 'posix']: template,
+          },
         } = getValueForFullKey(relative, key);
 
         let missing = 0;
         let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => {
           if (n < args.length) {
-            return args[n];
+            const value = args[n];
+            if (device) {
+              return value;
+            } else {
+              let encoded = encodeURIComponent(value);
+              encoded = encoded.replaceAll('%2F', '/');
+              return encoded;
+            }
           } else {
             missing++;
           }
@@ -106,8 +121,8 @@ export function generateURLs(urlSpec) {
       };
 
     return {
-      to: toHelper('posix'),
-      toDevice: toHelper('device'),
+      to: toHelper({device: false}),
+      toDevice: toHelper({device: true}),
     };
   };
 
@@ -133,6 +148,104 @@ const thumbnailHelper = (name) => (file) =>
   file.replace(/\.(jpg|png)$/, name + '.jpg');
 
 export const thumb = {
+  large: thumbnailHelper('.large'),
   medium: thumbnailHelper('.medium'),
   small: thumbnailHelper('.small'),
 };
+
+// Makes the generally-used and wiki-specialized "to" page utility.
+// "to" returns a relative path from the current page to the target.
+export function getURLsFrom({
+  baseDirectory,
+  pagePath,
+  urls,
+}) {
+  const pageSubKey = pagePath[0];
+  const subdirectoryPrefix = getPageSubdirectoryPrefix({pagePath});
+
+  return (targetFullKey, ...args) => {
+    const [groupKey, subKey] = targetFullKey.split('.');
+    let from, to;
+
+    // When linking to *outside* the localized area of the site, we need to
+    // make sure the result is correctly relative to the 8ase directory.
+    if (
+      groupKey !== 'localized' &&
+      groupKey !== 'localizedDefaultLanguage' &&
+      baseDirectory
+    ) {
+      from = 'localizedWithBaseDirectory.' + pageSubKey;
+      to = targetFullKey;
+    } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) {
+      // Special case for specifically linking *from* a page with base
+      // directory *to* a page without! Used for the language switcher and
+      // hopefully nothing else oh god.
+      from = 'localizedWithBaseDirectory.' + pageSubKey;
+      to = 'localized.' + subKey;
+    } else if (groupKey === 'localizedDefaultLanguage') {
+      // Linking to the default, except surprise, we're already IN the default
+      // (no baseDirectory set).
+      from = 'localized.' + pageSubKey;
+      to = 'localized.' + subKey;
+    } else {
+      // If we're linking inside the localized area (or there just is no
+      // 8ase directory), the 8ase directory doesn't matter.
+      from = 'localized.' + pageSubKey;
+      to = targetFullKey;
+    }
+
+    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 c93cb66..f8ab3ef 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -1,344 +1,102 @@
 // Utility functions for interacting with wiki data.
 
-import {
-  accumulateSum,
-  empty,
-} from './sugar.js';
+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
+
+    // Spaces to dashes
     .split(' ')
     .join('-')
-    .replace(/&/g, 'and')
-    .replace(/[^a-zA-Z0-9-]/g, '')
-    .replace(/-{2,}/g, '-')
-    .replace(/^-+|-+$/g, '')
-    .toLowerCase();
-}
 
-export function chunkByConditions(array, conditions) {
-  if (empty(array)) {
-    return [];
-  }
+    // Punctuation as words
+    .replace(/&/g, '-and-')
+    .replace(/\+/g, '-plus-')
+    .replace(/%/g, '-percent-')
 
-  if (empty(conditions)) {
-    return [array];
-  }
+    // Punctuation which only divides words, not single characters
+    .replace(/(\b[^\s-.]{2,})\./g, '$1-')
+    .replace(/\.([^\s-.]{2,})\b/g, '-$1')
 
-  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 which doesn't divide a number following a non-number
+    .replace(/(?<=[0-9])\^/g, '-')
+    .replace(/\^(?![0-9])/g, '-')
 
-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];
+    // General punctuation which always separates surrounding words
+    .replace(/[/@#$%*()_=,[\]{}|\\;:<>?`~]/g, '-')
 
-      if (a[p] !== b[p]) return true;
+    // Accented characters
+    .replace(/[áâäàå]/gi, 'a')
+    .replace(/[çč]/gi, 'c')
+    .replace(/[éêëè]/gi, 'e')
+    .replace(/[íîïì]/gi, 'i')
+    .replace(/[óôöò]/gi, 'o')
+    .replace(/[úûüù]/gi, 'u')
 
-      // 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;
+    // Strip other characters
+    .replace(/[^a-z0-9-]/gi, '')
 
-      return false;
-    })
-  ).map((chunk) => ({
-    ...Object.fromEntries(properties.map((p) => [p, chunk[0][p]])),
-    chunk,
-  }));
-}
-
-// 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.)
-
-// 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();
+    // Combine consecutive dashes
+    .replace(/-{2,}/g, '-')
 
-  return al === bl
-    ? a.localeCompare(b, undefined, {numeric: true})
-    : al.localeCompare(bl, undefined, {numeric: true});
-}
+    // Trim dashes on boundaries
+    .replace(/^-+|-+$/g, '')
 
-// 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;
+    // Always lowercase
+    .toLowerCase();
 }
 
-// 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.
+// Specific data utilities
 
-// 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:
+// Matches heading details from commentary data in roughly the formats:
 //
-//  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>.
+//    <i>artistReference:</i> (annotation, date)
+//    <i>artistReference|artistDisplayText:</i> (annotation, date)
 //
-//  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.
+// 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:
 //
-// 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 = (o) => o.directory,
-} = {}) {
-  return data.sort((a, b) => {
-    const ad = getDirectory(a);
-    const bd = getDirectory(b);
-    return compareCaseLessSensitive(ad, bd);
-  });
-}
-
-export function sortByName(data, {
-  getName = (o) => o.name,
-} = {}) {
-  const nameMap = new Map();
-  const normalizedNameMap = new Map();
-  for (const o of data) {
-    const name = getName(o);
-    const normalizedName = normalizeName(name);
-    nameMap.set(o, name);
-    normalizedNameMap.set(o, normalizedName);
-  }
-
-  return data.sort((a, b) => {
-    const ann = normalizedNameMap.get(a);
-    const bnn = normalizedNameMap.get(b);
-    const comparison = compareCaseLessSensitive(ann, bnn);
-    if (comparison !== 0)
-      return comparison;
-
-    const an = nameMap.get(a);
-    const bn = nameMap.get(b);
-    return compareCaseLessSensitive(an, bn);
-  });
-}
-
-export function sortByDate(data, {
-  getDate = (o) => o.date,
-} = {}) {
-  return data.sort((a, b) => {
-    const ad = getDate(a);
-    const bd = getDate(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 (ad && bd) {
-      return ad - bd;
-    } else if (ad) {
-      return -1;
-    } else if (bd) {
-      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;
-    }
-  });
-}
-
-export function sortByPositionInAlbum(data) {
-  return data.sort((a, b) => {
-    const aa = a.album;
-    const ba = b.album;
-
-    // Don't change the sort when the two tracks are from separate albums.
-    // This function doesn't change the order of albums or try to "merge"
-    // two separated chunks of tracks from the same album together.
-    if (aa !== ba) {
-      return 0;
-    }
-
-    // Don't change the sort when only one (or neither) item is actually
-    // a track (i.e. has an album).
-    if (!aa || !ba) {
-      return 0;
-    }
-
-    const ai = aa.tracks.indexOf(a);
-    const bi = ba.tracks.indexOf(b);
-
-    // There's no reason this two-way reference (a track's album and the
-    // album's track list) should be broken, but if for any reason it is,
-    // don't change the sort.
-    if (ai === -1 || bi === -1) {
-      return 0;
-    }
-
-    return ai - bi;
-  });
-}
-
-// 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) {
-  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.
+//   * "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 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,
-} = {}) {
-  if (latestFirst) {
-    // Double reverse: Since we reverse after sorting by date, also reverse
-    // after sorting A-Z, so the second reverse restores A-Z relative
-    // positioning (for entries with the same date).
-    sortAlphabetically(data, {getDirectory, getName});
-    data.reverse();
-    sortByDate(data, {getDate});
-    data.reverse();
-  } else {
-    sortAlphabetically(data, {getDirectory, getName});
-    sortByDate(data, {getDate});
-  }
-  return data;
-}
-
-// 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.
+// 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.
 //
-// This function also works for data lists which contain only tracks.
-export function sortAlbumsTracksChronologically(data, {
-  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, {getDate});
-
-  return data;
-}
-
-// Specific data utilities
+// 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
@@ -410,7 +168,7 @@ export function getTrackCover(track, {to}) {
   // 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) {
+  if (!track.hasUniqueCoverArt) {
     return getAlbumCover(track.album, {to});
   } else {
     return to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension);
@@ -423,27 +181,15 @@ export function getArtistAvatar(artist, {to}) {
 
 // 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).
+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.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;
+      return 0;
     });
 
   // When multiple al8ums are added to the wiki at a time, we want to show
@@ -515,28 +261,142 @@ export function getNewAdditions(numAlbums, {wikiData}) {
     }
   }
 
-  // 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;
-
-  const latestFirst = albumData
+export function getNewReleases(numReleases, {albumData}) {
+  return albumData
     .filter((album) => album.isListedOnHomepage)
-    .reverse();
+    .reverse()
+    .slice(0, numReleases);
+}
 
-  const majorReleases = latestFirst.filter((album) => album.isMajorRelease);
-  majorReleases.splice(1);
+// Carousel layout and utilities
 
-  const otherReleases = latestFirst
-    .filter((album) => !majorReleases.includes(album))
-    .slice(0, numReleases - majorReleases.length);
+// 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);
+}
+
+// Ridiculous caching support nonsense
+
+export class TupleMap {
+  static maxNestedTupleLength = 25;
+
+  #store = [undefined, null, null, null];
+
+  #lifetime(value) {
+    if (Array.isArray(value) && value.length <= TupleMap.maxNestedTupleLength) {
+      return 'tuple';
+    } else if (
+      typeof value === 'object' && value !== null ||
+      typeof value === 'function'
+    ) {
+      return 'weak';
+    } else {
+      return 'strong';
+    }
+  }
+
+  #getSubstoreShallow(value, store) {
+    const lifetime = this.#lifetime(value);
+    const mapIndex = {weak: 1, strong: 2, tuple: 3}[lifetime];
+
+    let map = store[mapIndex];
+    if (map === null) {
+      map = store[mapIndex] =
+        (lifetime === 'weak' ? new WeakMap()
+       : lifetime === 'strong' ? new Map()
+       : lifetime === 'tuple' ? new TupleMap()
+       : null);
+    }
+
+    if (map.has(value)) {
+      return map.get(value);
+    } else {
+      const substore = [undefined, null, null, null];
+      map.set(value, substore);
+      return substore;
+    }
+  }
+
+  #getSubstoreDeep(tuple, store = this.#store) {
+    if (tuple.length === 0) {
+      return store;
+    } else {
+      const [first, ...rest] = tuple;
+      return this.#getSubstoreDeep(rest, this.#getSubstoreShallow(first, store));
+    }
+  }
+
+  get(tuple) {
+    const store = this.#getSubstoreDeep(tuple);
+    return store[0];
+  }
 
-  return [
-    ...majorReleases.map((album) => ({large: true, item: album})),
-    ...otherReleases.map((album) => ({large: false, item: album})),
-  ];
+  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 664a648..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 f13f3f0..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 f36a499..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>`);
+});