« 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.json26
-rw-r--r--eslint.config.js139
-rw-r--r--package-lock.json933
-rw-r--r--package.json7
-rw-r--r--src/aggregate.js48
-rw-r--r--src/cli.js134
-rw-r--r--src/common-util/search-spec.js217
-rw-r--r--src/common-util/sort.js29
-rw-r--r--src/common-util/sugar.js144
-rw-r--r--src/common-util/wiki-data.js98
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js28
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js77
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunkItem.js30
-rw-r--r--src/content/dependencies/generateAdditionalNamesBox.js1
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js96
-rw-r--r--src/content/dependencies/generateAlbumArtInfoBox.js39
-rw-r--r--src/content/dependencies/generateAlbumArtworkColumn.js38
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js76
-rw-r--r--src/content/dependencies/generateAlbumCommentarySidebar.js6
-rw-r--r--src/content/dependencies/generateAlbumCoverArtwork.js100
-rw-r--r--src/content/dependencies/generateAlbumGalleryAlbumGrid.js90
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js246
-rw-r--r--src/content/dependencies/generateAlbumGalleryTrackGrid.js122
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js84
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js6
-rw-r--r--src/content/dependencies/generateAlbumReferencedArtworksPage.js20
-rw-r--r--src/content/dependencies/generateAlbumReferencingArtworksPage.js20
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js61
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavGroupPart.js2
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js2
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js56
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js116
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js5
-rw-r--r--src/content/dependencies/generateAlbumStyleRules.js107
-rw-r--r--src/content/dependencies/generateAlbumStyleTags.js65
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js12
-rw-r--r--src/content/dependencies/generateAlbumWallpaperStyleTag.js38
-rw-r--r--src/content/dependencies/generateArtTagAncestorDescendantMapList.js153
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js238
-rw-r--r--src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js23
-rw-r--r--src/content/dependencies/generateArtTagGalleryPageShowingLine.js22
-rw-r--r--src/content/dependencies/generateArtTagInfoPage.js281
-rw-r--r--src/content/dependencies/generateArtTagNavLinks.js81
-rw-r--r--src/content/dependencies/generateArtTagSidebar.js124
-rw-r--r--src/content/dependencies/generateArtistArtworkColumn.js13
-rw-r--r--src/content/dependencies/generateArtistCredit.js44
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js150
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js137
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js72
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js47
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js7
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunk.js8
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js107
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js15
-rw-r--r--src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js75
-rw-r--r--src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js61
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunk.js2
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkItem.js39
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js48
-rw-r--r--src/content/dependencies/generateColorStyleRules.js42
-rw-r--r--src/content/dependencies/generateColorStyleTag.js51
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js18
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js9
-rw-r--r--src/content/dependencies/generateCommentarySection.js53
-rw-r--r--src/content/dependencies/generateContentContentHeading.js39
-rw-r--r--src/content/dependencies/generateContributionTooltipChronologySection.js24
-rw-r--r--src/content/dependencies/generateCoverArtwork.js195
-rw-r--r--src/content/dependencies/generateCoverArtworkArtTagDetails.js69
-rw-r--r--src/content/dependencies/generateCoverArtworkArtistDetails.js6
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js170
-rw-r--r--src/content/dependencies/generateCoverArtworkReferenceDetails.js26
-rw-r--r--src/content/dependencies/generateCoverCarousel.js2
-rw-r--r--src/content/dependencies/generateCoverGrid.js59
-rw-r--r--src/content/dependencies/generateDatetimestampTemplate.js8
-rw-r--r--src/content/dependencies/generateExpandableGallerySection.js92
-rw-r--r--src/content/dependencies/generateFlashActGalleryPage.js16
-rw-r--r--src/content/dependencies/generateFlashArtworkColumn.js11
-rw-r--r--src/content/dependencies/generateFlashCoverArtwork.js41
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js17
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js55
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js237
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumGrid.js66
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js39
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js26
-rw-r--r--src/content/dependencies/generateGroupGalleryPageSeriesSection.js156
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js3
-rw-r--r--src/content/dependencies/generateImageOverlay.js50
-rw-r--r--src/content/dependencies/generateIntrapageDotSwitcher.js2
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js22
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js51
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesChunk.js151
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js23
-rw-r--r--src/content/dependencies/generateLyricsEntry.js91
-rw-r--r--src/content/dependencies/generateLyricsSection.js81
-rw-r--r--src/content/dependencies/generatePageLayout.js199
-rw-r--r--src/content/dependencies/generatePageSidebarConjoinedBox.js6
-rw-r--r--src/content/dependencies/generateReferencedArtworksPage.js76
-rw-r--r--src/content/dependencies/generateReferencingArtworksPage.js76
-rw-r--r--src/content/dependencies/generateReleaseInfoListenLine.js150
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js20
-rw-r--r--src/content/dependencies/generateStaticPage.js14
-rw-r--r--src/content/dependencies/generateStaticURLStyleTag.js23
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js52
-rw-r--r--src/content/dependencies/generateStyleTag.js48
-rw-r--r--src/content/dependencies/generateTrackArtistCommentarySection.js147
-rw-r--r--src/content/dependencies/generateTrackArtworkColumn.js33
-rw-r--r--src/content/dependencies/generateTrackCoverArtwork.js143
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js144
-rw-r--r--src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js5
-rw-r--r--src/content/dependencies/generateTrackInfoPageOtherReleasesList.js88
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js17
-rw-r--r--src/content/dependencies/generateTrackListItem.js3
-rw-r--r--src/content/dependencies/generateTrackNavLinks.js2
-rw-r--r--src/content/dependencies/generateTrackReferencedArtworksPage.js20
-rw-r--r--src/content/dependencies/generateTrackReferencingArtworksPage.js20
-rw-r--r--src/content/dependencies/generateTrackReleaseBox.js46
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js47
-rw-r--r--src/content/dependencies/generateTrackSocialEmbed.js16
-rw-r--r--src/content/dependencies/generateWallpaperStyleTag.js80
-rw-r--r--src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js25
-rw-r--r--src/content/dependencies/generateWikiHomepageAlbumGridRow.js17
-rw-r--r--src/content/dependencies/generateWikiWallpaperStyleTag.js38
-rw-r--r--src/content/dependencies/image.js130
-rw-r--r--src/content/dependencies/index.js9
-rw-r--r--src/content/dependencies/linkAdditionalFile.js29
-rw-r--r--src/content/dependencies/linkAlbumAdditionalFile.js24
-rw-r--r--src/content/dependencies/linkAlbumDynamically.js4
-rw-r--r--src/content/dependencies/linkAnythingMan.js3
-rw-r--r--src/content/dependencies/linkArtTagDynamically.js14
-rw-r--r--src/content/dependencies/linkArtTagGallery.js8
-rw-r--r--src/content/dependencies/linkArtTagInfo.js (renamed from src/content/dependencies/linkArtTag.js)2
-rw-r--r--src/content/dependencies/linkArtwork.js20
-rw-r--r--src/content/dependencies/linkExternal.js11
-rw-r--r--src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js62
-rw-r--r--src/content/dependencies/linkPathFromMedia.js57
-rw-r--r--src/content/dependencies/linkReferencedArtworks.js24
-rw-r--r--src/content/dependencies/linkReferencingArtworks.js24
-rw-r--r--src/content/dependencies/linkTemplate.js24
-rw-r--r--src/content/dependencies/linkTrackDynamically.js4
-rw-r--r--src/content/dependencies/listAllAdditionalFilesTemplate.js197
-rw-r--r--src/content/dependencies/listArtTagNetwork.js367
-rw-r--r--src/content/dependencies/listArtTagsByName.js (renamed from src/content/dependencies/listTagsByName.js)15
-rw-r--r--src/content/dependencies/listArtTagsByUses.js54
-rw-r--r--src/content/dependencies/listArtistsByContributions.js52
-rw-r--r--src/content/dependencies/listArtistsByGroup.js23
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js5
-rw-r--r--src/content/dependencies/listGroupsByDuration.js2
-rw-r--r--src/content/dependencies/listTagsByUses.js59
-rw-r--r--src/content/dependencies/listTracksByDate.js75
-rw-r--r--src/content/dependencies/listTracksWithLyrics.js2
-rw-r--r--src/content/dependencies/transformContent.js326
-rw-r--r--src/data/cacheable-object.js12
-rw-r--r--src/data/checks.js229
-rw-r--r--src/data/composite/control-flow/flipFilter.js36
-rw-r--r--src/data/composite/control-flow/index.js1
-rw-r--r--src/data/composite/control-flow/withAvailabilityFilter.js1
-rw-r--r--src/data/composite/data/index.js1
-rw-r--r--src/data/composite/data/withFilteredList.js16
-rw-r--r--src/data/composite/data/withLengthOfList.js54
-rw-r--r--src/data/composite/data/withNearbyItemFromList.js52
-rw-r--r--src/data/composite/data/withPropertyFromList.js22
-rw-r--r--src/data/composite/data/withPropertyFromObject.js38
-rw-r--r--src/data/composite/things/album/index.js1
-rw-r--r--src/data/composite/things/album/withHasCoverArt.js64
-rw-r--r--src/data/composite/things/art-tag/index.js2
-rw-r--r--src/data/composite/things/art-tag/withAllDescendantArtTags.js44
-rw-r--r--src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js46
-rw-r--r--src/data/composite/things/artist/artistTotalDuration.js8
-rw-r--r--src/data/composite/things/artwork/index.js5
-rw-r--r--src/data/composite/things/artwork/withAttachedArtwork.js43
-rw-r--r--src/data/composite/things/artwork/withContainingArtworkList.js46
-rw-r--r--src/data/composite/things/artwork/withContribsFromAttachedArtwork.js27
-rw-r--r--src/data/composite/things/artwork/withDate.js41
-rw-r--r--src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js65
-rw-r--r--src/data/composite/things/content/contentArtists.js40
-rw-r--r--src/data/composite/things/content/hasAnnotationPart.js25
-rw-r--r--src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js60
-rw-r--r--src/data/composite/things/content/index.js7
-rw-r--r--src/data/composite/things/content/withAnnotationParts.js93
-rw-r--r--src/data/composite/things/content/withHasAnnotationPart.js43
-rw-r--r--src/data/composite/things/content/withSourceText.js53
-rw-r--r--src/data/composite/things/content/withSourceURLs.js62
-rw-r--r--src/data/composite/things/content/withWebArchiveDate.js41
-rw-r--r--src/data/composite/things/contribution/thingPropertyMatches.js18
-rw-r--r--src/data/composite/things/contribution/thingReferenceTypeMatches.js29
-rw-r--r--src/data/composite/things/contribution/withContainingReverseContributionList.js46
-rw-r--r--src/data/composite/things/track-section/index.js2
-rw-r--r--src/data/composite/things/track-section/withContinueCountingFrom.js25
-rw-r--r--src/data/composite/things/track-section/withStartCountingFrom.js64
-rw-r--r--src/data/composite/things/track/index.js12
-rw-r--r--src/data/composite/things/track/inheritContributionListFromMainRelease.js (renamed from src/data/composite/things/track/inheritContributionListFromOriginalRelease.js)20
-rw-r--r--src/data/composite/things/track/inheritFromMainRelease.js (renamed from src/data/composite/things/track/inheritFromOriginalRelease.js)18
-rw-r--r--src/data/composite/things/track/trackAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/withAlbum.js22
-rw-r--r--src/data/composite/things/track/withAllReleases.js46
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js43
-rw-r--r--src/data/composite/things/track/withCoverArtistContribs.js73
-rw-r--r--src/data/composite/things/track/withHasUniqueCoverArt.js76
-rw-r--r--src/data/composite/things/track/withMainRelease.js (renamed from src/data/composite/things/track/withOriginalRelease.js)28
-rw-r--r--src/data/composite/things/track/withOtherReleases.js30
-rw-r--r--src/data/composite/things/track/withPropertyFromAlbum.js17
-rw-r--r--src/data/composite/things/track/withPropertyFromMainRelease.js (renamed from src/data/composite/things/track/withPropertyFromOriginalRelease.js)36
-rw-r--r--src/data/composite/things/track/withTrackArtDate.js24
-rw-r--r--src/data/composite/things/track/withTrackNumber.js50
-rw-r--r--src/data/composite/wiki-data/index.js5
-rw-r--r--src/data/composite/wiki-data/splitContentNodesAround.js87
-rw-r--r--src/data/composite/wiki-data/withConstitutedArtwork.js60
-rw-r--r--src/data/composite/wiki-data/withContentNodes.js25
-rw-r--r--src/data/composite/wiki-data/withCoverArtDate.js23
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js260
-rw-r--r--src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js18
-rw-r--r--src/data/composite/wiki-data/withResolvedSeriesList.js130
-rw-r--r--src/data/composite/wiki-properties/additionalFiles.js30
-rw-r--r--src/data/composite/wiki-properties/additionalNameList.js14
-rw-r--r--src/data/composite/wiki-properties/annotatedReferenceList.js8
-rw-r--r--src/data/composite/wiki-properties/commentary.js30
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js11
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtwork.js70
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtworkList.js72
-rw-r--r--src/data/composite/wiki-properties/directory.js1
-rw-r--r--src/data/composite/wiki-properties/index.js6
-rw-r--r--src/data/composite/wiki-properties/referencedArtworkList.js37
-rw-r--r--src/data/composite/wiki-properties/seriesList.js31
-rw-r--r--src/data/composite/wiki-properties/soupyReverse.js15
-rw-r--r--src/data/thing.js16
-rw-r--r--src/data/things/additional-file.js47
-rw-r--r--src/data/things/additional-name.js21
-rw-r--r--src/data/things/album.js420
-rw-r--r--src/data/things/art-tag.js130
-rw-r--r--src/data/things/artist.js45
-rw-r--r--src/data/things/artwork.js510
-rw-r--r--src/data/things/content.js205
-rw-r--r--src/data/things/contribution.js82
-rw-r--r--src/data/things/flash.js82
-rw-r--r--src/data/things/group.js89
-rw-r--r--src/data/things/homepage-layout.js4
-rw-r--r--src/data/things/index.js29
-rw-r--r--src/data/things/language.js57
-rw-r--r--src/data/things/sorting-rule.js385
-rw-r--r--src/data/things/track.js397
-rw-r--r--src/data/things/wiki-info.js37
-rw-r--r--src/data/yaml.js659
-rw-r--r--src/external-links.js29
-rw-r--r--src/find-reverse.js11
-rw-r--r--src/find.js72
-rw-r--r--src/gen-thumbs.js175
-rw-r--r--src/html.js174
-rw-r--r--src/listing-spec.js15
-rw-r--r--src/page/album.js6
-rw-r--r--src/page/art-tag.js (renamed from src/page/tag.js)14
-rw-r--r--src/page/index.js2
-rw-r--r--src/page/track.js6
-rw-r--r--src/replacer.js464
-rw-r--r--src/reverse.js27
-rw-r--r--src/static/css/site.css1143
-rw-r--r--src/static/js/client/additional-names-box.js69
-rw-r--r--src/static/js/client/art-tag-gallery-filter.js151
-rw-r--r--src/static/js/client/art-tag-network.js147
-rw-r--r--src/static/js/client/css-compatibility-assistant.js26
-rw-r--r--src/static/js/client/expandable-gallery-section.js77
-rw-r--r--src/static/js/client/hoverable-tooltip.js53
-rw-r--r--src/static/js/client/image-overlay.js50
-rw-r--r--src/static/js/client/index.js6
-rw-r--r--src/static/js/client/quick-description.js2
-rw-r--r--src/static/js/client/sidebar-search.js260
-rw-r--r--src/static/js/client/sticky-heading.js132
-rw-r--r--src/static/js/rectangles.js42
-rw-r--r--src/static/js/search-worker.js180
-rw-r--r--src/static/misc/image.svg11
-rw-r--r--src/strings-default.yaml445
-rwxr-xr-xsrc/upd8.js502
-rw-r--r--src/urls-default.yaml7
-rw-r--r--src/urls.js14
-rw-r--r--src/validators.js165
-rw-r--r--src/web-routes.js19
-rw-r--r--src/write/bind-utilities.js2
-rw-r--r--src/write/build-modes/index.js1
-rw-r--r--src/write/build-modes/live-dev-server.js23
-rw-r--r--src/write/build-modes/sort.js76
-rw-r--r--src/write/build-modes/static-build.js42
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs56
-rw-r--r--test/snapshot/generateAlbumAdditionalFilesList.js84
-rw-r--r--test/snapshot/linkThing.js2
283 files changed, 15412 insertions, 5760 deletions
diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index ac1a4e63..00000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
-  "env": {
-    "es2021": true,
-    "node": true
-  },
-  "extends": "eslint:recommended",
-  "parserOptions": {
-    "ecmaVersion": "latest",
-    "sourceType": "module"
-  },
-  "rules": {
-    "indent": ["off"],
-    "no-empty": ["error", {
-      "allowEmptyCatch": true
-    }],
-    "no-unexpected-multiline": ["off"],
-    "no-unused-labels": ["off"],
-    "no-unused-vars": ["error", {
-      "argsIgnorePattern": "^_",
-      "destructuredArrayIgnorePattern": "^"
-    }],
-    "no-cond-assign": ["off"],
-    "no-constant-condition": ["off"],
-    "no-unsafe-finally": ["off"]
-  }
-}
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 00000000..9d969f57
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,139 @@
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+import {defineConfig} from 'eslint/config';
+import js from '@eslint/js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const CLIENT_JAVASCRIPT_PATHS = [
+  'src/static/js/**/*.js',
+];
+
+const WEB_WORKER_PATHS = [
+  'src/static/js/search-worker.js',
+];
+
+const COMMON_GLOBALS = {
+  supportedWhenever: {
+    URL: 'readonly',
+
+    clearInterval: 'readonly',
+    clearTimeout: 'readonly',
+    console: 'readonly',
+    fetch: 'readonly',
+    setInterval: 'readonly',
+    setTimeout: 'readonly',
+    structuredClone: 'readonly',
+  },
+};
+
+export default defineConfig([
+  // basic config
+
+  {
+    files: ['src/**/*.js'],
+    extends: ['js/recommended'],
+    plugins: {js},
+
+    rules: {
+      indent: ['off'],
+
+      'no-empty': ['error', {
+        allowEmptyCatch: true,
+      }],
+
+      'no-unexpected-multiline': ['off'],
+      'no-unused-labels': ['off'],
+
+      'no-unused-vars': ['error', {
+        argsIgnorePattern: '^_',
+        destructuredArrayIgnorePattern: '^',
+      }],
+
+      'no-cond-assign': ['off'],
+      'no-constant-condition': ['off'],
+      'no-unsafe-finally': ['off'],
+      'no-self-assign': ['off'],
+      'require-yield': ['off'],
+    },
+  },
+
+  // node.js
+
+  {
+    files: ['src/**/*.js'],
+    ignores: [...CLIENT_JAVASCRIPT_PATHS],
+
+    languageOptions: {
+      ecmaVersion: 2022,
+      sourceType: 'module',
+
+      globals: {
+        ...COMMON_GLOBALS.supportedWhenever,
+
+        process: 'readonly',
+        setImmediate: 'readonly',
+      },
+    },
+  },
+
+  // client javascript - all
+
+  {
+    files: [...CLIENT_JAVASCRIPT_PATHS],
+
+    languageOptions: {
+      ecmaVersion: 2022,
+      sourceType: 'module',
+
+      globals: {
+        ...COMMON_GLOBALS.supportedWhenever,
+
+        CustomEvent: 'readonly',
+        Response: 'readonly',
+        Worker: 'readonly',
+        XMLHttpRequest: 'readonly',
+      },
+    },
+  },
+
+  // client javascript - not web workers
+
+  {
+    files: [...CLIENT_JAVASCRIPT_PATHS],
+    ignores: [...WEB_WORKER_PATHS],
+
+    languageOptions: {
+      globals: {
+        DOMException: 'readonly',
+        DOMRect: 'readonly',
+        MutationObserver: 'readonly',
+        IntersectionObserver: 'readonly',
+
+        document: 'readonly',
+        getComputedStyle: 'readonly',
+        history: 'readonly',
+        location: 'readonly',
+        localStorage: 'readonly',
+        sessionStorage: 'readonly',
+        window: 'readonly',
+      },
+    },
+  },
+
+  // client javascript - web workers
+
+  {
+    files: [WEB_WORKER_PATHS],
+
+    languageOptions: {
+      globals: {
+        onerror: 'writeable',
+        onmessage: 'writeable',
+        onunhandledrejection: 'writeable',
+        postMessage: 'readonly',
+      },
+    },
+  },
+]);
diff --git a/package-lock.json b/package-lock.json
index 11bc6a41..8429f5ea 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,7 @@
                 "chroma-js": "^2.4.2",
                 "command-exists": "^1.2.9",
                 "compress-json": "^3.0.5",
-                "eslint": "^8.37.0",
+                "eslint": "^9.27.0",
                 "flexsearch": "^0.7.43",
                 "he": "^1.2.0",
                 "image-size": "^1.0.2",
@@ -33,7 +33,7 @@
                 "tcompare": "^6.0.0"
             },
             "engines": {
-                "node": ">= 20.9.0"
+                "node": ">= 22.13.0"
             }
         },
         "node_modules/@alcalzone/ansi-tokenize": {
@@ -100,22 +100,59 @@
             }
         },
         "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==",
+            "version": "4.12.1",
+            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+            "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+            "license": "MIT",
             "engines": {
                 "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
             }
         },
+        "node_modules/@eslint/config-array": {
+            "version": "0.20.0",
+            "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
+            "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
+            "license": "Apache-2.0",
+            "dependencies": {
+                "@eslint/object-schema": "^2.1.6",
+                "debug": "^4.3.1",
+                "minimatch": "^3.1.2"
+            },
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            }
+        },
+        "node_modules/@eslint/config-helpers": {
+            "version": "0.2.2",
+            "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz",
+            "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==",
+            "license": "Apache-2.0",
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            }
+        },
+        "node_modules/@eslint/core": {
+            "version": "0.14.0",
+            "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
+            "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
+            "license": "Apache-2.0",
+            "dependencies": {
+                "@types/json-schema": "^7.0.15"
+            },
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.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==",
+            "version": "3.3.1",
+            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+            "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+            "license": "MIT",
             "dependencies": {
                 "ajv": "^6.12.4",
                 "debug": "^4.3.2",
-                "espree": "^9.5.1",
-                "globals": "^13.19.0",
+                "espree": "^10.0.1",
+                "globals": "^14.0.0",
                 "ignore": "^5.2.0",
                 "import-fresh": "^3.2.1",
                 "js-yaml": "^4.1.0",
@@ -123,31 +160,79 @@
                 "strip-json-comments": "^3.1.1"
             },
             "engines": {
-                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.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==",
+            "version": "9.27.0",
+            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz",
+            "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==",
+            "license": "MIT",
             "engines": {
-                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "url": "https://eslint.org/donate"
+            }
+        },
+        "node_modules/@eslint/object-schema": {
+            "version": "2.1.6",
+            "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
+            "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+            "license": "Apache-2.0",
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            }
+        },
+        "node_modules/@eslint/plugin-kit": {
+            "version": "0.3.1",
+            "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz",
+            "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==",
+            "license": "Apache-2.0",
+            "dependencies": {
+                "@eslint/core": "^0.14.0",
+                "levn": "^0.4.1"
+            },
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            }
+        },
+        "node_modules/@humanfs/core": {
+            "version": "0.19.1",
+            "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+            "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+            "license": "Apache-2.0",
+            "engines": {
+                "node": ">=18.18.0"
             }
         },
-        "node_modules/@humanwhocodes/config-array": {
-            "version": "0.11.8",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
-            "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
+        "node_modules/@humanfs/node": {
+            "version": "0.16.6",
+            "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz",
+            "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==",
+            "license": "Apache-2.0",
             "dependencies": {
-                "@humanwhocodes/object-schema": "^1.2.1",
-                "debug": "^4.1.1",
-                "minimatch": "^3.0.5"
+                "@humanfs/core": "^0.19.1",
+                "@humanwhocodes/retry": "^0.3.0"
             },
             "engines": {
-                "node": ">=10.10.0"
+                "node": ">=18.18.0"
+            }
+        },
+        "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
+            "version": "0.3.1",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
+            "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
+            "license": "Apache-2.0",
+            "engines": {
+                "node": ">=18.18"
+            },
+            "funding": {
+                "type": "github",
+                "url": "https://github.com/sponsors/nzakas"
             }
         },
         "node_modules/@humanwhocodes/module-importer": {
@@ -162,10 +247,18 @@
                 "url": "https://github.com/sponsors/nzakas"
             }
         },
-        "node_modules/@humanwhocodes/object-schema": {
-            "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
-            "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
+        "node_modules/@humanwhocodes/retry": {
+            "version": "0.4.3",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+            "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+            "license": "Apache-2.0",
+            "engines": {
+                "node": ">=18.18"
+            },
+            "funding": {
+                "type": "github",
+                "url": "https://github.com/sponsors/nzakas"
+            }
         },
         "node_modules/@isaacs/cliui": {
             "version": "8.0.2",
@@ -375,38 +468,6 @@
                 "win32"
             ]
         },
-        "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.2",
             "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz",
@@ -1480,12 +1541,24 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
+        "node_modules/@types/estree": {
+            "version": "1.0.7",
+            "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
+            "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
+            "license": "MIT"
+        },
         "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/json-schema": {
+            "version": "7.0.15",
+            "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+            "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+            "license": "MIT"
+        },
         "node_modules/@types/node": {
             "version": "20.13.0",
             "resolved": "https://registry.npmjs.org/@types/node/-/node-20.13.0.tgz",
@@ -1506,9 +1579,10 @@
             }
         },
         "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==",
+            "version": "8.14.1",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
+            "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
+            "license": "MIT",
             "bin": {
                 "acorn": "bin/acorn"
             },
@@ -1520,6 +1594,7 @@
             "version": "5.3.2",
             "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
             "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+            "license": "MIT",
             "peerDependencies": {
                 "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
             }
@@ -1571,6 +1646,7 @@
             "version": "6.12.6",
             "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
             "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+            "license": "MIT",
             "dependencies": {
                 "fast-deep-equal": "^3.1.1",
                 "fast-json-stable-stringify": "^2.0.0",
@@ -1795,6 +1871,7 @@
             "version": "3.1.0",
             "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
             "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+            "license": "MIT",
             "engines": {
                 "node": ">=6"
             }
@@ -2078,9 +2155,10 @@
             }
         },
         "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==",
+            "version": "7.0.6",
+            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+            "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+            "license": "MIT",
             "dependencies": {
                 "path-key": "^3.1.0",
                 "shebang-command": "^2.0.0",
@@ -2109,7 +2187,8 @@
         "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=="
+            "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+            "license": "MIT"
         },
         "node_modules/diff": {
             "version": "5.2.0",
@@ -2120,17 +2199,6 @@
                 "node": ">=0.3.1"
             }
         },
-        "node_modules/doctrine": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
-            "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
-            "dependencies": {
-                "esutils": "^2.0.2"
-            },
-            "engines": {
-                "node": ">=6.0.0"
-            }
-        },
         "node_modules/eastasianwidth": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -2187,71 +2255,79 @@
             }
         },
         "node_modules/eslint": {
-            "version": "8.37.0",
-            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.37.0.tgz",
-            "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==",
+            "version": "9.27.0",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz",
+            "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==",
+            "license": "MIT",
             "dependencies": {
                 "@eslint-community/eslint-utils": "^4.2.0",
-                "@eslint-community/regexpp": "^4.4.0",
-                "@eslint/eslintrc": "^2.0.2",
-                "@eslint/js": "8.37.0",
-                "@humanwhocodes/config-array": "^0.11.8",
+                "@eslint-community/regexpp": "^4.12.1",
+                "@eslint/config-array": "^0.20.0",
+                "@eslint/config-helpers": "^0.2.1",
+                "@eslint/core": "^0.14.0",
+                "@eslint/eslintrc": "^3.3.1",
+                "@eslint/js": "9.27.0",
+                "@eslint/plugin-kit": "^0.3.1",
+                "@humanfs/node": "^0.16.6",
                 "@humanwhocodes/module-importer": "^1.0.1",
-                "@nodelib/fs.walk": "^1.2.8",
-                "ajv": "^6.10.0",
+                "@humanwhocodes/retry": "^0.4.2",
+                "@types/estree": "^1.0.6",
+                "@types/json-schema": "^7.0.15",
+                "ajv": "^6.12.4",
                 "chalk": "^4.0.0",
-                "cross-spawn": "^7.0.2",
+                "cross-spawn": "^7.0.6",
                 "debug": "^4.3.2",
-                "doctrine": "^3.0.0",
                 "escape-string-regexp": "^4.0.0",
-                "eslint-scope": "^7.1.1",
-                "eslint-visitor-keys": "^3.4.0",
-                "espree": "^9.5.1",
-                "esquery": "^1.4.2",
+                "eslint-scope": "^8.3.0",
+                "eslint-visitor-keys": "^4.2.0",
+                "espree": "^10.3.0",
+                "esquery": "^1.5.0",
                 "esutils": "^2.0.2",
                 "fast-deep-equal": "^3.1.3",
-                "file-entry-cache": "^6.0.1",
+                "file-entry-cache": "^8.0.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",
                 "lodash.merge": "^4.6.2",
                 "minimatch": "^3.1.2",
                 "natural-compare": "^1.4.0",
-                "optionator": "^0.9.1",
-                "strip-ansi": "^6.0.1",
-                "strip-json-comments": "^3.1.0",
-                "text-table": "^0.2.0"
+                "optionator": "^0.9.3"
             },
             "bin": {
                 "eslint": "bin/eslint.js"
             },
             "engines": {
-                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
             },
             "funding": {
-                "url": "https://opencollective.com/eslint"
+                "url": "https://eslint.org/donate"
+            },
+            "peerDependencies": {
+                "jiti": "*"
+            },
+            "peerDependenciesMeta": {
+                "jiti": {
+                    "optional": true
+                }
             }
         },
         "node_modules/eslint-scope": {
-            "version": "7.1.1",
-            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
-            "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+            "version": "8.3.0",
+            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
+            "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
+            "license": "BSD-2-Clause",
             "dependencies": {
                 "esrecurse": "^4.3.0",
                 "estraverse": "^5.2.0"
             },
             "engines": {
-                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
             }
         },
         "node_modules/eslint-visitor-keys": {
@@ -2265,17 +2341,42 @@
                 "url": "https://opencollective.com/eslint"
             }
         },
+        "node_modules/eslint/node_modules/eslint-visitor-keys": {
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
+            "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
+            "license": "Apache-2.0",
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
+            }
+        },
         "node_modules/espree": {
-            "version": "9.5.1",
-            "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
-            "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==",
+            "version": "10.3.0",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
+            "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
+            "license": "BSD-2-Clause",
             "dependencies": {
-                "acorn": "^8.8.0",
+                "acorn": "^8.14.0",
                 "acorn-jsx": "^5.3.2",
-                "eslint-visitor-keys": "^3.4.0"
+                "eslint-visitor-keys": "^4.2.0"
             },
             "engines": {
-                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
+            }
+        },
+        "node_modules/espree/node_modules/eslint-visitor-keys": {
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
+            "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
+            "license": "Apache-2.0",
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
             },
             "funding": {
                 "url": "https://opencollective.com/eslint"
@@ -2296,6 +2397,7 @@
             "version": "4.3.0",
             "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
             "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+            "license": "BSD-2-Clause",
             "dependencies": {
                 "estraverse": "^5.2.0"
             },
@@ -2315,6 +2417,7 @@
             "version": "2.0.3",
             "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
             "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+            "license": "BSD-2-Clause",
             "engines": {
                 "node": ">=0.10.0"
             }
@@ -2337,35 +2440,31 @@
         "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=="
+            "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+            "license": "MIT"
         },
         "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=="
+            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+            "license": "MIT"
         },
         "node_modules/fast-levenshtein": {
             "version": "2.0.6",
             "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
-            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
-        },
-        "node_modules/fastq": {
-            "version": "1.15.0",
-            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
-            "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
-            "dependencies": {
-                "reusify": "^1.0.4"
-            }
+            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+            "license": "MIT"
         },
         "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==",
+            "version": "8.0.0",
+            "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+            "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+            "license": "MIT",
             "dependencies": {
-                "flat-cache": "^3.0.4"
+                "flat-cache": "^4.0.0"
             },
             "engines": {
-                "node": "^10.12.0 || >=12.0.0"
+                "node": ">=16.0.0"
             }
         },
         "node_modules/fill-range": {
@@ -2397,37 +2496,23 @@
             }
         },
         "node_modules/flat-cache": {
-            "version": "3.0.4",
-            "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
-            "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+            "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+            "license": "MIT",
             "dependencies": {
-                "flatted": "^3.1.0",
-                "rimraf": "^3.0.2"
+                "flatted": "^3.2.9",
+                "keyv": "^4.5.4"
             },
             "engines": {
-                "node": "^10.12.0 || >=12.0.0"
-            }
-        },
-        "node_modules/flat-cache/node_modules/rimraf": {
-            "version": "3.0.2",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
-            "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
-            "deprecated": "Rimraf versions prior to v4 are no longer supported",
-            "license": "ISC",
-            "dependencies": {
-                "glob": "^7.1.3"
-            },
-            "bin": {
-                "rimraf": "bin.js"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
+                "node": ">=16"
             }
         },
         "node_modules/flatted": {
-            "version": "3.2.5",
-            "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
-            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
+            "version": "3.3.3",
+            "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+            "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+            "license": "ISC"
         },
         "node_modules/flexsearch": {
             "version": "0.7.43",
@@ -2484,7 +2569,8 @@
         "node_modules/fs.realpath": {
             "version": "1.0.0",
             "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
-            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+            "dev": true
         },
         "node_modules/fsevents": {
             "version": "2.3.2",
@@ -2528,6 +2614,7 @@
             "version": "7.2.3",
             "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
             "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+            "dev": true,
             "dependencies": {
                 "fs.realpath": "^1.0.0",
                 "inflight": "^1.0.4",
@@ -2555,14 +2642,12 @@
             }
         },
         "node_modules/globals": {
-            "version": "13.20.0",
-            "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
-            "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
-            "dependencies": {
-                "type-fest": "^0.20.2"
-            },
+            "version": "14.0.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+            "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+            "license": "MIT",
             "engines": {
-                "node": ">=8"
+                "node": ">=18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/sindresorhus"
@@ -2574,11 +2659,6 @@
             "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",
@@ -2671,9 +2751,10 @@
             }
         },
         "node_modules/ignore": {
-            "version": "5.2.4",
-            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
-            "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+            "version": "5.3.2",
+            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+            "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+            "license": "MIT",
             "engines": {
                 "node": ">= 4"
             }
@@ -2729,9 +2810,10 @@
             }
         },
         "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==",
+            "version": "3.3.1",
+            "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+            "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+            "license": "MIT",
             "dependencies": {
                 "parent-module": "^1.0.0",
                 "resolve-from": "^4.0.0"
@@ -2767,6 +2849,7 @@
             "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"
@@ -2967,14 +3050,6 @@
                 "node": ">=0.12.0"
             }
         },
-        "node_modules/is-path-inside": {
-            "version": "3.0.3",
-            "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
-            "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
-            "engines": {
-                "node": ">=8"
-            }
-        },
         "node_modules/is-plain-object": {
             "version": "5.0.0",
             "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
@@ -3051,15 +3126,6 @@
                 "@pkgjs/parseargs": "^0.11.0"
             }
         },
-        "node_modules/js-sdsl": {
-            "version": "4.4.0",
-            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
-            "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==",
-            "funding": {
-                "type": "opencollective",
-                "url": "https://opencollective.com/js-sdsl"
-            }
-        },
         "node_modules/js-tokens": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3088,6 +3154,12 @@
             "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
             "dev": true
         },
+        "node_modules/json-buffer": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+            "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+            "license": "MIT"
+        },
         "node_modules/json-parse-even-better-errors": {
             "version": "3.0.2",
             "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz",
@@ -3100,7 +3172,8 @@
         "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=="
+            "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+            "license": "MIT"
         },
         "node_modules/json-stable-stringify-without-jsonify": {
             "version": "1.0.1",
@@ -3116,10 +3189,20 @@
                 "node >= 0.2.0"
             ]
         },
+        "node_modules/keyv": {
+            "version": "4.5.4",
+            "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+            "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+            "license": "MIT",
+            "dependencies": {
+                "json-buffer": "3.0.1"
+            }
+        },
         "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==",
+            "license": "MIT",
             "dependencies": {
                 "prelude-ls": "^1.2.1",
                 "type-check": "~0.4.0"
@@ -3718,6 +3801,7 @@
             "version": "1.4.0",
             "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
             "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+            "dev": true,
             "dependencies": {
                 "wrappy": "1"
             }
@@ -3747,16 +3831,17 @@
             }
         },
         "node_modules/optionator": {
-            "version": "0.9.1",
-            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
-            "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+            "version": "0.9.4",
+            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+            "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+            "license": "MIT",
             "dependencies": {
                 "deep-is": "^0.1.3",
                 "fast-levenshtein": "^2.0.6",
                 "levn": "^0.4.1",
                 "prelude-ls": "^1.2.1",
                 "type-check": "^0.4.0",
-                "word-wrap": "^1.2.3"
+                "word-wrap": "^1.2.5"
             },
             "engines": {
                 "node": ">= 0.8.0"
@@ -3841,6 +3926,7 @@
             "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==",
+            "license": "MIT",
             "dependencies": {
                 "callsites": "^3.0.0"
             },
@@ -3869,6 +3955,7 @@
             "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"
             }
@@ -3933,6 +4020,7 @@
             "version": "1.2.1",
             "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
             "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+            "license": "MIT",
             "engines": {
                 "node": ">= 0.8.0"
             }
@@ -4016,9 +4104,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==",
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+            "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+            "license": "MIT",
             "engines": {
                 "node": ">=6"
             }
@@ -4031,25 +4120,6 @@
                 "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.3.1",
             "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -4213,6 +4283,7 @@
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
             "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+            "license": "MIT",
             "engines": {
                 "node": ">=4"
             }
@@ -4310,15 +4381,6 @@
                 "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": "5.0.7",
             "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
@@ -4383,28 +4445,6 @@
                 "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",
@@ -4754,6 +4794,7 @@
             "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==",
+            "license": "MIT",
             "engines": {
                 "node": ">=8"
             },
@@ -4998,11 +5039,6 @@
                 "node": ">=8"
             }
         },
-        "node_modules/text-table": {
-            "version": "0.2.0",
-            "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
-            "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="
-        },
         "node_modules/to-regex-range": {
             "version": "5.0.1",
             "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -5109,6 +5145,7 @@
             "version": "0.4.0",
             "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
             "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+            "license": "MIT",
             "dependencies": {
                 "prelude-ls": "^1.2.1"
             },
@@ -5116,17 +5153,6 @@
                 "node": ">= 0.8.0"
             }
         },
-        "node_modules/type-fest": {
-            "version": "0.20.2",
-            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
-            "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
-            "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
         "node_modules/typescript": {
             "version": "5.4.5",
             "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
@@ -5175,6 +5201,7 @@
             "version": "4.4.1",
             "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
             "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+            "license": "BSD-2-Clause",
             "dependencies": {
                 "punycode": "^2.1.0"
             }
@@ -5378,7 +5405,8 @@
         "node_modules/wrappy": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+            "dev": true
         },
         "node_modules/ws": {
             "version": "8.17.1",
@@ -5565,19 +5593,42 @@
             }
         },
         "@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=="
+            "version": "4.12.1",
+            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+            "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="
+        },
+        "@eslint/config-array": {
+            "version": "0.20.0",
+            "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
+            "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
+            "requires": {
+                "@eslint/object-schema": "^2.1.6",
+                "debug": "^4.3.1",
+                "minimatch": "^3.1.2"
+            }
+        },
+        "@eslint/config-helpers": {
+            "version": "0.2.2",
+            "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz",
+            "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg=="
+        },
+        "@eslint/core": {
+            "version": "0.14.0",
+            "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
+            "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
+            "requires": {
+                "@types/json-schema": "^7.0.15"
+            }
         },
         "@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==",
+            "version": "3.3.1",
+            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+            "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
             "requires": {
                 "ajv": "^6.12.4",
                 "debug": "^4.3.2",
-                "espree": "^9.5.1",
-                "globals": "^13.19.0",
+                "espree": "^10.0.1",
+                "globals": "^14.0.0",
                 "ignore": "^5.2.0",
                 "import-fresh": "^3.2.1",
                 "js-yaml": "^4.1.0",
@@ -5586,18 +5637,43 @@
             }
         },
         "@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=="
+            "version": "9.27.0",
+            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz",
+            "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA=="
+        },
+        "@eslint/object-schema": {
+            "version": "2.1.6",
+            "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
+            "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="
+        },
+        "@eslint/plugin-kit": {
+            "version": "0.3.1",
+            "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz",
+            "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==",
+            "requires": {
+                "@eslint/core": "^0.14.0",
+                "levn": "^0.4.1"
+            }
+        },
+        "@humanfs/core": {
+            "version": "0.19.1",
+            "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+            "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="
         },
-        "@humanwhocodes/config-array": {
-            "version": "0.11.8",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
-            "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
+        "@humanfs/node": {
+            "version": "0.16.6",
+            "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz",
+            "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==",
             "requires": {
-                "@humanwhocodes/object-schema": "^1.2.1",
-                "debug": "^4.1.1",
-                "minimatch": "^3.0.5"
+                "@humanfs/core": "^0.19.1",
+                "@humanwhocodes/retry": "^0.3.0"
+            },
+            "dependencies": {
+                "@humanwhocodes/retry": {
+                    "version": "0.3.1",
+                    "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
+                    "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="
+                }
             }
         },
         "@humanwhocodes/module-importer": {
@@ -5605,10 +5681,10 @@
             "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=="
+        "@humanwhocodes/retry": {
+            "version": "0.4.3",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+            "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="
         },
         "@isaacs/cliui": {
             "version": "8.0.2",
@@ -5738,29 +5814,6 @@
             "integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==",
             "optional": true
         },
-        "@nodelib/fs.scandir": {
-            "version": "2.1.5",
-            "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
-            "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.2",
             "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz",
@@ -6519,12 +6572,22 @@
                 }
             }
         },
+        "@types/estree": {
+            "version": "1.0.7",
+            "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
+            "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
+        },
         "@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/json-schema": {
+            "version": "7.0.15",
+            "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+            "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
+        },
         "@types/node": {
             "version": "20.13.0",
             "resolved": "https://registry.npmjs.org/@types/node/-/node-20.13.0.tgz",
@@ -6542,9 +6605,9 @@
             "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=="
+            "version": "8.14.1",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
+            "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="
         },
         "acorn-jsx": {
             "version": "5.3.2",
@@ -6953,9 +7016,9 @@
             "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==",
+            "version": "7.0.6",
+            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+            "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
             "requires": {
                 "path-key": "^3.1.0",
                 "shebang-command": "^2.0.0",
@@ -6981,14 +7044,6 @@
             "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
             "dev": true
         },
-        "doctrine": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
-            "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
-            "requires": {
-                "esutils": "^2.0.2"
-            }
-        },
         "eastasianwidth": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -7033,56 +7088,58 @@
             "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
         },
         "eslint": {
-            "version": "8.37.0",
-            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.37.0.tgz",
-            "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==",
+            "version": "9.27.0",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz",
+            "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==",
             "requires": {
                 "@eslint-community/eslint-utils": "^4.2.0",
-                "@eslint-community/regexpp": "^4.4.0",
-                "@eslint/eslintrc": "^2.0.2",
-                "@eslint/js": "8.37.0",
-                "@humanwhocodes/config-array": "^0.11.8",
+                "@eslint-community/regexpp": "^4.12.1",
+                "@eslint/config-array": "^0.20.0",
+                "@eslint/config-helpers": "^0.2.1",
+                "@eslint/core": "^0.14.0",
+                "@eslint/eslintrc": "^3.3.1",
+                "@eslint/js": "9.27.0",
+                "@eslint/plugin-kit": "^0.3.1",
+                "@humanfs/node": "^0.16.6",
                 "@humanwhocodes/module-importer": "^1.0.1",
-                "@nodelib/fs.walk": "^1.2.8",
-                "ajv": "^6.10.0",
+                "@humanwhocodes/retry": "^0.4.2",
+                "@types/estree": "^1.0.6",
+                "@types/json-schema": "^7.0.15",
+                "ajv": "^6.12.4",
                 "chalk": "^4.0.0",
-                "cross-spawn": "^7.0.2",
+                "cross-spawn": "^7.0.6",
                 "debug": "^4.3.2",
-                "doctrine": "^3.0.0",
                 "escape-string-regexp": "^4.0.0",
-                "eslint-scope": "^7.1.1",
-                "eslint-visitor-keys": "^3.4.0",
-                "espree": "^9.5.1",
-                "esquery": "^1.4.2",
+                "eslint-scope": "^8.3.0",
+                "eslint-visitor-keys": "^4.2.0",
+                "espree": "^10.3.0",
+                "esquery": "^1.5.0",
                 "esutils": "^2.0.2",
                 "fast-deep-equal": "^3.1.3",
-                "file-entry-cache": "^6.0.1",
+                "file-entry-cache": "^8.0.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",
                 "lodash.merge": "^4.6.2",
                 "minimatch": "^3.1.2",
                 "natural-compare": "^1.4.0",
-                "optionator": "^0.9.1",
-                "strip-ansi": "^6.0.1",
-                "strip-json-comments": "^3.1.0",
-                "text-table": "^0.2.0"
+                "optionator": "^0.9.3"
+            },
+            "dependencies": {
+                "eslint-visitor-keys": {
+                    "version": "4.2.0",
+                    "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
+                    "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="
+                }
             }
         },
         "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==",
+            "version": "8.3.0",
+            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
+            "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
             "requires": {
                 "esrecurse": "^4.3.0",
                 "estraverse": "^5.2.0"
@@ -7094,13 +7151,20 @@
             "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ=="
         },
         "espree": {
-            "version": "9.5.1",
-            "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
-            "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==",
+            "version": "10.3.0",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
+            "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
             "requires": {
-                "acorn": "^8.8.0",
+                "acorn": "^8.14.0",
                 "acorn-jsx": "^5.3.2",
-                "eslint-visitor-keys": "^3.4.0"
+                "eslint-visitor-keys": "^4.2.0"
+            },
+            "dependencies": {
+                "eslint-visitor-keys": {
+                    "version": "4.2.0",
+                    "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
+                    "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="
+                }
             }
         },
         "esquery": {
@@ -7156,20 +7220,12 @@
             "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
             "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
         },
-        "fastq": {
-            "version": "1.15.0",
-            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
-            "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
-            "requires": {
-                "reusify": "^1.0.4"
-            }
-        },
         "file-entry-cache": {
-            "version": "6.0.1",
-            "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
-            "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+            "version": "8.0.0",
+            "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+            "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
             "requires": {
-                "flat-cache": "^3.0.4"
+                "flat-cache": "^4.0.0"
             }
         },
         "fill-range": {
@@ -7191,28 +7247,18 @@
             }
         },
         "flat-cache": {
-            "version": "3.0.4",
-            "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
-            "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+            "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
             "requires": {
-                "flatted": "^3.1.0",
-                "rimraf": "^3.0.2"
-            },
-            "dependencies": {
-                "rimraf": {
-                    "version": "3.0.2",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
-                    "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
-                    "requires": {
-                        "glob": "^7.1.3"
-                    }
-                }
+                "flatted": "^3.2.9",
+                "keyv": "^4.5.4"
             }
         },
         "flatted": {
-            "version": "3.2.5",
-            "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
-            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
+            "version": "3.3.3",
+            "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+            "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="
         },
         "flexsearch": {
             "version": "0.7.43",
@@ -7246,7 +7292,8 @@
         "fs.realpath": {
             "version": "1.0.0",
             "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
-            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+            "dev": true
         },
         "fsevents": {
             "version": "2.3.2",
@@ -7277,6 +7324,7 @@
             "version": "7.2.3",
             "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
             "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+            "dev": true,
             "requires": {
                 "fs.realpath": "^1.0.0",
                 "inflight": "^1.0.4",
@@ -7295,12 +7343,9 @@
             }
         },
         "globals": {
-            "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"
-            }
+            "version": "14.0.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+            "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="
         },
         "graceful-fs": {
             "version": "4.2.11",
@@ -7308,11 +7353,6 @@
             "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",
@@ -7384,9 +7424,9 @@
             }
         },
         "ignore": {
-            "version": "5.2.4",
-            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
-            "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ=="
+            "version": "5.3.2",
+            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+            "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="
         },
         "ignore-walk": {
             "version": "6.0.5",
@@ -7426,9 +7466,9 @@
             }
         },
         "import-fresh": {
-            "version": "3.3.0",
-            "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
-            "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+            "version": "3.3.1",
+            "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+            "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
             "requires": {
                 "parent-module": "^1.0.0",
                 "resolve-from": "^4.0.0"
@@ -7449,6 +7489,7 @@
             "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"
@@ -7595,11 +7636,6 @@
             "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
             "dev": true
         },
-        "is-path-inside": {
-            "version": "3.0.3",
-            "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
-            "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="
-        },
         "is-plain-object": {
             "version": "5.0.0",
             "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
@@ -7656,11 +7692,6 @@
                 "@pkgjs/parseargs": "^0.11.0"
             }
         },
-        "js-sdsl": {
-            "version": "4.4.0",
-            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
-            "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg=="
-        },
         "js-tokens": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7686,6 +7717,11 @@
             "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
             "dev": true
         },
+        "json-buffer": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+            "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
+        },
         "json-parse-even-better-errors": {
             "version": "3.0.2",
             "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz",
@@ -7708,6 +7744,14 @@
             "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==",
             "dev": true
         },
+        "keyv": {
+            "version": "4.5.4",
+            "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+            "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+            "requires": {
+                "json-buffer": "3.0.1"
+            }
+        },
         "levn": {
             "version": "0.4.1",
             "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -8159,6 +8203,7 @@
             "version": "1.4.0",
             "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
             "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+            "dev": true,
             "requires": {
                 "wrappy": "1"
             }
@@ -8179,16 +8224,16 @@
             "dev": true
         },
         "optionator": {
-            "version": "0.9.1",
-            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
-            "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+            "version": "0.9.4",
+            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+            "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
             "requires": {
                 "deep-is": "^0.1.3",
                 "fast-levenshtein": "^2.0.6",
                 "levn": "^0.4.1",
                 "prelude-ls": "^1.2.1",
                 "type-check": "^0.4.0",
-                "word-wrap": "^1.2.3"
+                "word-wrap": "^1.2.5"
             }
         },
         "p-limit": {
@@ -8264,7 +8309,8 @@
         "path-is-absolute": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
-            "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+            "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+            "dev": true
         },
         "path-key": {
             "version": "3.1.1",
@@ -8360,9 +8406,9 @@
             }
         },
         "punycode": {
-            "version": "2.1.1",
-            "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
-            "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+            "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
         },
         "queue": {
             "version": "6.0.2",
@@ -8372,11 +8418,6 @@
                 "inherits": "~2.0.3"
             }
         },
-        "queue-microtask": {
-            "version": "1.2.3",
-            "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
-            "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
-        },
         "react": {
             "version": "18.3.1",
             "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -8566,11 +8607,6 @@
             "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": "5.0.7",
             "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
@@ -8609,14 +8645,6 @@
                 }
             }
         },
-        "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",
@@ -9048,11 +9076,6 @@
                 "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=="
-        },
         "to-regex-range": {
             "version": "5.0.1",
             "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -9137,11 +9160,6 @@
                 "prelude-ls": "^1.2.1"
             }
         },
-        "type-fest": {
-            "version": "0.20.2",
-            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
-            "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="
-        },
         "typescript": {
             "version": "5.4.5",
             "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
@@ -9325,7 +9343,8 @@
         "wrappy": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+            "dev": true
         },
         "ws": {
             "version": "8.17.1",
diff --git a/package.json b/package.json
index 73bd40a1..7ad8fd07 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
     },
     "scripts": {
         "test": "tap",
-        "dev": "eslint src && node src/upd8.js"
+        "lint": "eslint src"
     },
     "imports": {
         "#aggregate": "./src/aggregate.js",
@@ -21,7 +21,10 @@
         "#composite/wiki-data": "./src/data/composite/wiki-data/index.js",
         "#composite/wiki-properties": "./src/data/composite/wiki-properties/index.js",
         "#composite/things/album": "./src/data/composite/things/album/index.js",
+        "#composite/things/art-tag": "./src/data/composite/things/art-tag/index.js",
         "#composite/things/artist": "./src/data/composite/things/artist/index.js",
+        "#composite/things/artwork": "./src/data/composite/things/artwork/index.js",
+        "#composite/things/content": "./src/data/composite/things/content/index.js",
         "#composite/things/contribution": "./src/data/composite/things/contribution/index.js",
         "#composite/things/flash": "./src/data/composite/things/flash/index.js",
         "#composite/things/flash-act": "./src/data/composite/things/flash-act/index.js",
@@ -64,7 +67,7 @@
         "chroma-js": "^2.4.2",
         "command-exists": "^1.2.9",
         "compress-json": "^3.0.5",
-        "eslint": "^8.37.0",
+        "eslint": "^9.27.0",
         "flexsearch": "^0.7.43",
         "he": "^1.2.0",
         "image-size": "^1.0.2",
diff --git a/src/aggregate.js b/src/aggregate.js
index 92c66b73..d5ea2d73 100644
--- a/src/aggregate.js
+++ b/src/aggregate.js
@@ -440,7 +440,15 @@ export function showAggregate(topError, {
       }
     }
 
-    return determineCauseHelper(cause.cause);
+    if (cause.cause) {
+      return determineCauseHelper(cause.cause);
+    }
+
+    if (cause.errors) {
+      return determineErrorsHelper(cause);
+    }
+
+    return cause;
   };
 
   const determineCause = error =>
@@ -478,7 +486,7 @@ export function showAggregate(topError, {
       : error.errors?.flatMap(determineErrorsHelper) ?? null);
 
   const flattenErrorStructure = (error, level = 0) => {
-    const cause = determineCause(error);
+    const cause = determineCause(error); // may be an array!
     const errors = determineErrors(error);
 
     return {
@@ -493,7 +501,9 @@ export function showAggregate(topError, {
           : error.stack),
 
       cause:
-        (cause
+        (Array.isArray(cause)
+          ? cause.map(cause => flattenErrorStructure(cause, level + 1))
+       : cause
           ? flattenErrorStructure(cause, level + 1)
           : null),
 
@@ -528,15 +538,29 @@ export function showAggregate(topError, {
       unhelpfulTraceLines: ownUnhelpfulTraceLines,
     },
   }, index, apparentSiblings) => {
+    const causeSingle = Array.isArray(cause) ? null : cause;
+    const causeArray = Array.isArray(cause) ? cause : null;
+
     const subApparentSiblings =
-      (cause && errors
-        ? [cause, ...errors]
-     : cause
-        ? [cause]
+      (causeSingle && errors
+        ? [causeSingle, ...errors]
+     : causeSingle
+        ? [causeSingle]
+     : causeArray && errors
+        ? [...causeArray, ...errors]
+     : causeArray
+        ? causeArray
      : errors
         ? errors
         : []);
 
+    const presentedAsErrors =
+      (causeArray && errors
+        ? [...causeArray, ...errors]
+     : causeArray
+        ? causeArray
+        : errors);
+
     const anythingHasErrorsThisLayer =
       apparentSiblings.some(({errors}) => !empty(errors));
 
@@ -580,12 +604,12 @@ export function showAggregate(topError, {
       headerPart += ` ${colors.dim(tracePart)}`;
     }
 
-    const head1 = level % 2 === 0 ? '\u21aa' : colors.dim('\u21aa');
+    const head1 = '\u21aa';
     const bar1 = ' ';
 
     const causePart =
-      (cause
-        ? recursive(cause, 0, subApparentSiblings)
+      (causeSingle
+        ? recursive(causeSingle, 0, subApparentSiblings)
             .split('\n')
             .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`)
             .join('\n')
@@ -595,8 +619,8 @@ export function showAggregate(topError, {
     const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e');
 
     const errorsPart =
-      (errors
-        ? errors
+      (presentedAsErrors
+        ? presentedAsErrors
             .map((error, index) => recursive(error, index + 1, subApparentSiblings))
             .flatMap(str => str.split('\n'))
             .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`)
diff --git a/src/cli.js b/src/cli.js
index bd4ec685..ec72a625 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -376,77 +376,79 @@ decorateTime.displayTime = function () {
   }
 };
 
-export function progressPromiseAll(msgOrMsgFn, array) {
+const progressUpdateInterval = 1000 / 60;
+
+function progressShow(message, total) {
+  let start = Date.now(), last = 0, done = 0;
+
+  const progress = () => {
+    const messagePart =
+      (typeof message === 'function'
+        ? message()
+        : message);
+
+    const percent =
+      Math.round((done / total) * 1000) / 10 + '%';
+
+    const percentPart =
+      percent.padEnd('99.9%'.length, ' ');
+
+    return `${messagePart} [${percentPart}]`;
+  };
+
+  process.stdout.write(`\r` + progress());
+
+  return () => {
+    done++;
+
+    if (done === total) {
+      process.stdout.write(
+        `\r\x1b[2m` + progress() +
+        `\x1b[0;32m Done! ` +
+        `\x1b[0;2m(${formatDuration(Date.now() - start)}) ` +
+        `\x1b[0m\n`
+      );
+    } else if (Date.now() - last >= progressUpdateInterval) {
+      process.stdout.write('\r' + progress());
+      last = Date.now();
+    }
+  };
+}
+
+export function progressPromiseAll(message, array) {
   if (!array.length) {
     return Promise.resolve([]);
   }
 
-  const msgFn =
-    typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn;
-
-  let done = 0,
-    total = array.length;
-  process.stdout.write(`\r${msgFn()} [0/${total}]`);
-  const start = Date.now();
-  return Promise.all(
-    array.map((promise) =>
-      Promise.resolve(promise).then((val) => {
-        done++;
-        // const pc = `${done}/${total}`;
-        const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd(
-          '99.9%'.length,
-          ' '
-        );
-        if (done === total) {
-          const time = Date.now() - start;
-          process.stdout.write(
-            `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`
-          );
-        } else {
-          process.stdout.write(`\r${msgFn()} [${pc}] `);
-        }
-        return val;
-      })
-    )
-  );
+  const show = progressShow(message, array.length);
+
+  const next = value => {
+    show();
+
+    return value;
+  };
+
+  const promises =
+    array.map(promise => Promise.resolve(promise).then(next));
+
+  return Promise.all(promises);
 }
 
-export function progressCallAll(msgOrMsgFn, array) {
+export function progressCallAll(message, array) {
   if (!array.length) {
     return [];
   }
 
-  const msgFn =
-    typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn;
+  const show = progressShow(message, array.length);
 
-  const updateInterval = 1000 / 60;
-
-  let done = 0,
-    total = array.length;
-  process.stdout.write(`\r${msgFn()} [0/${total}]`);
-  const start = Date.now();
-  const vals = [];
-  let lastTime = 0;
+  const values = [];
 
   for (const fn of array) {
-    const val = fn();
-    done++;
-
-    if (done === total) {
-      const pc = '100%'.padEnd('99.9%'.length, ' ');
-      const time = Date.now() - start;
-      process.stdout.write(
-        `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`
-      );
-    } else if (Date.now() - lastTime >= updateInterval) {
-      const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd('99.9%'.length, ' ');
-      process.stdout.write(`\r${msgFn()} [${pc}] `);
-      lastTime = Date.now();
-    }
-    vals.push(val);
+    values.push(fn());
+    show();
   }
 
-  return vals;
+  return values;
 }
 
 export function fileIssue({
@@ -459,6 +461,24 @@ export function fileIssue({
   console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`));
 }
 
+// Quick'n dirty function to present a duration nicely for command-line use.
+export function formatDuration(timeDelta) {
+  const seconds = timeDelta / 1000;
+
+  if (seconds > 90) {
+    const modSeconds = Math.floor(seconds % 60);
+    const minutes = Math.floor(seconds - seconds % 60) / 60;
+    return `${minutes}m${modSeconds}s`;
+  }
+
+  if (seconds < 0.1) {
+    return 'instant';
+  }
+
+  const precision = (seconds > 1 ? 3 : 2);
+  return `${seconds.toPrecision(precision)}s`;
+}
+
 export async function logicalCWD() {
   if (process.env.PWD) {
     return process.env.PWD;
@@ -469,7 +489,7 @@ export async function logicalCWD() {
 
   try {
     await stat('/bin/sh');
-  } catch (error) {
+  } catch {
     // Not logical, so sad.
     return process.cwd();
   }
diff --git a/src/common-util/search-spec.js b/src/common-util/search-spec.js
index 3d05c021..af5ec201 100644
--- a/src/common-util/search-spec.js
+++ b/src/common-util/search-spec.js
@@ -85,107 +85,116 @@ function prepareArtwork(thing, {
   return serializeSrc;
 }
 
-export const searchSpec = {
-  generic: {
-    query: ({
-      albumData,
-      artTagData,
-      artistData,
-      flashData,
-      groupData,
-      trackData,
-    }) => [
-      albumData,
-
-      artTagData,
-
-      artistData
-        .filter(artist => !artist.isAlias),
-
-      flashData,
-
-      groupData,
-
-      trackData
-        // Exclude rereleases - there's no reasonable way to differentiate
-        // them from the main release as part of this query.
-        .filter(track => !track.originalReleaseTrack),
-    ].flat(),
-
-    process(thing, opts) {
-      const fields = {};
-
-      fields.primaryName =
-        thing.name;
-
-      const kind =
-        thing.constructor[Symbol.for('Thing.referenceType')];
-
-      fields.parentName =
-        (kind === 'track'
-          ? thing.album.name
-       : kind === 'group'
-          ? thing.category.name
-       : kind === 'flash'
-          ? thing.act.name
-          : null);
-
-      fields.color =
-        thing.color;
-
-      fields.artTags =
-        (thing.constructor.hasPropertyDescriptor('artTags')
-          ? thing.artTags.map(artTag => artTag.nameShort)
-          : []);
-
-      fields.additionalNames =
-        (thing.constructor.hasPropertyDescriptor('additionalNames')
-          ? thing.additionalNames.map(entry => entry.name)
-       : thing.constructor.hasPropertyDescriptor('aliasNames')
-          ? thing.aliasNames
-          : []);
-
-      const contribKeys = [
-        'artistContribs',
-        'bannerArtistContribs',
-        'contributorContribs',
-        'coverArtistContribs',
-        'wallpaperArtistContribs',
-      ];
+function baselineProcess(thing, opts) {
+  const fields = {};
+
+  fields.primaryName =
+    thing.name;
 
-      const contributions =
-        contribKeys
-          .filter(key => Object.hasOwn(thing, key))
-          .flatMap(key => thing[key]);
+  fields.artwork =
+    prepareArtwork(thing, opts);
+
+  fields.color =
+    thing.color;
+
+  return fields;
+}
 
-      fields.contributors =
-        contributions
-          .flatMap(({artist}) => [
-            artist.name,
-            ...artist.aliasNames,
-          ]);
+const baselineStore = [
+  'primaryName',
+  'artwork',
+  'color',
+];
 
-      const groups =
-         (Object.hasOwn(thing, 'groups')
-           ? thing.groups
-        : Object.hasOwn(thing, 'album')
-           ? thing.album.groups
-           : []);
+function genericQuery(wikiData) {
+  return [
+    wikiData.albumData,
 
-      const mainContributorNames =
-        contributions
-          .map(({artist}) => artist.name);
+    wikiData.artTagData,
 
-      fields.groups =
-        groups
-          .filter(group => !mainContributorNames.includes(group.name))
-          .map(group => group.name);
+    wikiData.artistData
+      .filter(artist => !artist.isAlias),
+
+    wikiData.flashData,
+
+    wikiData.groupData,
+
+    wikiData.trackData
+      // Exclude rereleases - there's no reasonable way to differentiate
+      // them from the main release as part of this query.
+      .filter(track => !track.mainReleaseTrack),
+  ].flat();
+}
+
+function genericProcess(thing, opts) {
+  const fields = baselineProcess(thing, opts);
+
+  const kind =
+    thing.constructor[Symbol.for('Thing.referenceType')];
+
+  fields.parentName =
+    (kind === 'track'
+      ? thing.album.name
+   : kind === 'group'
+      ? thing.category.name
+   : kind === 'flash'
+      ? thing.act.name
+      : null);
+
+  fields.artTags =
+    (thing.constructor.hasPropertyDescriptor('artTags')
+      ? thing.artTags.map(artTag => artTag.nameShort)
+      : []);
+
+  fields.additionalNames =
+    (thing.constructor.hasPropertyDescriptor('additionalNames')
+      ? thing.additionalNames.map(entry => entry.name)
+   : thing.constructor.hasPropertyDescriptor('aliasNames')
+      ? thing.aliasNames
+      : []);
+
+  const contribKeys = [
+    'artistContribs',
+    'contributorContribs',
+  ];
+
+  const contributions =
+    contribKeys
+      .filter(key => Object.hasOwn(thing, key))
+      .flatMap(key => thing[key]);
+
+  fields.contributors =
+    contributions
+      .flatMap(({artist}) => [
+        artist.name,
+        ...artist.aliasNames,
+      ]);
+
+  const groups =
+     (Object.hasOwn(thing, 'groups')
+       ? thing.groups
+    : Object.hasOwn(thing, 'album')
+       ? thing.album.groups
+       : []);
+
+  const mainContributorNames =
+    contributions
+      .map(({artist}) => artist.name);
+
+  fields.groups =
+    groups
+      .filter(group => !mainContributorNames.includes(group.name))
+      .map(group => group.name);
+
+  return fields;
+}
 
-      fields.artwork =
-        prepareArtwork(thing, opts);
+const genericStore = baselineStore;
 
-      return fields;
-    },
+export const searchSpec = {
+  generic: {
+    query: genericQuery,
+    process: genericProcess,
 
     index: [
       'primaryName',
@@ -194,13 +203,25 @@ export const searchSpec = {
       'additionalNames',
       'contributors',
       'groups',
-    ],
+    ].map(field => ({field, tokenize: 'forward'})),
 
-    store: [
+    store: genericStore,
+  },
+
+  verbatim: {
+    query: genericQuery,
+    process: genericProcess,
+
+    index: [
       'primaryName',
-      'artwork',
-      'color',
+      'parentName',
+      'artTags',
+      'additionalNames',
+      'contributors',
+      'groups',
     ],
+
+    store: genericStore,
   },
 };
 
diff --git a/src/common-util/sort.js b/src/common-util/sort.js
index ea1e024a..d93d94c1 100644
--- a/src/common-util/sort.js
+++ b/src/common-util/sort.js
@@ -3,6 +3,12 @@
 // initial sort matters! (Spoilers: If what you're doing involves any kind of
 // parallelization, it definitely matters.)
 
+// TODO: This is obviously limiting. It does describe the behavior
+// we've been *assuming* for the entire time the wiki is around,
+// but it would be nice to support sorting in different locales
+// somehow.
+export const SORTING_LOCALE = 'en';
+
 import {empty, sortMultipleArrays, unique}
   from './sugar.js';
 
@@ -17,8 +23,8 @@ export function compareCaseLessSensitive(a, b) {
   const bl = b.toLowerCase();
 
   return al === bl
-    ? a.localeCompare(b, undefined, {numeric: true})
-    : al.localeCompare(bl, undefined, {numeric: true});
+    ? a.localeCompare(b, SORTING_LOCALE, {numeric: true})
+    : al.localeCompare(bl, SORTING_LOCALE, {numeric: true});
 }
 
 // Subtract common prefixes and other characters which some people don't like
@@ -383,6 +389,22 @@ export function sortAlbumsTracksChronologically(data, {
   return data;
 }
 
+export function sortArtworksChronologically(data, {
+  latestFirst = false,
+} = {}) {
+  // Artworks conveniently describe their things as artwork.thing, so they
+  // work in sortEntryThingPairs. (Yes, this is just assuming the artworks
+  // are only for albums and tracks... sorry... TODO...)
+  sortEntryThingPairs(data, things =>
+    sortAlbumsTracksChronologically(things, {latestFirst}));
+
+  // Artworks' own dates always matter before however the thing places itself,
+  // and accommodate per-thing properties like coverArtDate anyway.
+  sortByDate(data, {latestFirst});
+
+  return data;
+}
+
 export function sortFlashesChronologically(data, {
   latestFirst = false,
   getDate,
@@ -407,6 +429,7 @@ export function sortFlashesChronologically(data, {
 
 export function sortContributionsChronologically(data, sortThings, {
   latestFirst = false,
+  getThing = contrib => contrib.thing,
 } = {}) {
   // Contributions only have one date property (which is provided when
   // the contribution is created). They're sorted by this most primarily,
@@ -415,7 +438,7 @@ export function sortContributionsChronologically(data, sortThings, {
   const entries =
     data.map(contrib => ({
       entry: contrib,
-      thing: contrib.thing,
+      thing: getThing(contrib),
     }));
 
   sortEntryThingPairs(
diff --git a/src/common-util/sugar.js b/src/common-util/sugar.js
index 89699f60..e931ad59 100644
--- a/src/common-util/sugar.js
+++ b/src/common-util/sugar.js
@@ -116,10 +116,14 @@ export function findIndexOrEnd(array, fn) {
 // returns null (or values in the array are nullish), they'll just be skipped in
 // the sum.
 export function accumulateSum(array, fn = x => x) {
+  if (!Array.isArray(array)) {
+    return accumulateSum(Array.from(array, fn));
+  }
+
   return array.reduce(
     (accumulator, value, index, array) =>
       accumulator +
-        fn(value, index, array) ?? 0,
+      (fn(value, index, array) ?? 0),
     0);
 }
 
@@ -221,6 +225,9 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
     ? arr1.every((x, i) => arr2[i] === x)
     : arr1.every((x) => arr2.includes(x)));
 
+export const exhaust = (generatorFunction) =>
+  Array.from(generatorFunction());
+
 export function compareObjects(obj1, obj2, {
   checkOrder = false,
   checkSymbols = true,
@@ -251,11 +258,20 @@ export function compareObjects(obj1, obj2, {
 
 // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
 export const withEntries = (obj, fn) => {
-  const result = fn(Object.entries(obj));
-  if (result instanceof Promise) {
-    return result.then(entries => Object.fromEntries(entries));
+  if (obj instanceof Map) {
+    const result = fn(Array.from(obj.entries()));
+    if (result instanceof Promise) {
+      return result.then(entries => new map(entries));
+    } else {
+      return new Map(result);
+    }
   } else {
-    return Object.fromEntries(result);
+    const result = fn(Object.entries(obj));
+    if (result instanceof Promise) {
+      return result.then(entries => Object.fromEntries(entries));
+    } else {
+      return Object.fromEntries(result);
+    }
   }
 }
 
@@ -299,34 +315,74 @@ export function filterProperties(object, properties, {
   return filteredObject;
 }
 
-export function queue(array, max = 50) {
-  if (max === 0) {
-    return array.map((fn) => fn());
+export function queue(functionList, queueSize = 50) {
+  if (queueSize === 0) {
+    return functionList.map(fn => fn());
   }
 
-  const begin = [];
-  let current = 0;
-  const ret = array.map(
-    (fn) =>
-      new Promise((resolve, reject) => {
-        begin.push(() => {
-          current++;
-          Promise.resolve(fn()).then((value) => {
-            current--;
-            if (current < max && begin.length) {
-              begin.shift()();
-            }
-            resolve(value);
-          }, reject);
-        });
-      })
-  );
+  const promiseList = [];
+  const resolveList = [];
+  const rejectList = [];
 
-  for (let i = 0; i < max && begin.length; i++) {
-    begin.shift()();
+  for (let i = 0; i < functionList.length; i++) {
+    const promiseWithResolvers = Promise.withResolvers();
+    promiseList.push(promiseWithResolvers.promise);
+    resolveList.push(promiseWithResolvers.resolve);
+    rejectList.push(promiseWithResolvers.reject);
   }
 
-  return ret;
+  let cursor = 0;
+  let running = 0;
+
+  const next = async () => {
+    if (running >= queueSize) {
+      return;
+    }
+
+    if (cursor === functionList.length) {
+      return;
+    }
+
+    const thisFunction = functionList[cursor];
+    const thisResolve = resolveList[cursor];
+    const thisReject = rejectList[cursor];
+
+    delete functionList[cursor];
+    delete resolveList[cursor];
+    delete rejectList[cursor];
+
+    cursor++;
+    running++;
+
+    try {
+      thisResolve(await thisFunction());
+    } catch (error) {
+      thisReject(error);
+    } finally {
+      running--;
+
+      // If the cursor is at 1, this is the first promise that resolved,
+      // so we're now done the "kick start", and can start the remaining
+      // promises (up to queueSize).
+      if (cursor === 1) {
+        // Since only one promise is used for the "kick start", and that one
+        // has just resolved, we know there's none running at all right now,
+        // and can start as many as specified in the queueSize right away.
+        for (let i = 0; i < queueSize; i++) {
+          next();
+        }
+      } else {
+        next();
+      }
+    }
+  };
+
+  // Only start a single promise, as a "kick start", so that it resolves as
+  // early as possible (it will resolve before we use CPU to start the rest
+  // of the promises, up to queueSize).
+  next();
+
+  return promiseList;
 }
 
 export function delay(ms) {
@@ -781,6 +837,38 @@ export function chunkMultipleArrays(...args) {
   return results;
 }
 
+// This (or its helper function) should probably be a generator, but generators
+// are scary... Note that the root node is never considered a leaf, even if it
+// doesn't have any branches. It does NOT pay attention to the *values* of the
+// leaf nodes - it's suited to handle this kind of form:
+//
+//   {
+//     foo: {
+//       bar: {},
+//       baz: {},
+//       qux: {
+//         woz: {},
+//       },
+//     },
+//   }
+//
+// for which it outputs ['bar', 'baz', 'woz'].
+//
+export function collectTreeLeaves(tree) {
+  const recursive = ([key, value]) =>
+    (value instanceof Map
+      ? (value.size === 0
+          ? [key]
+          : Array.from(value.entries()).flatMap(recursive))
+      : (empty(Object.keys(value))
+          ? [key]
+          : Object.entries(value).flatMap(recursive)));
+
+  const root = Symbol();
+  const leaves = recursive([root, tree]);
+  return (leaves[0] === root ? [] : leaves);
+}
+
 // Delicious function annotations, such as:
 //
 //   (*bound) soWeAreBackInTheMine
diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js
index f97ecd63..546f1ad9 100644
--- a/src/common-util/wiki-data.js
+++ b/src/common-util/wiki-data.js
@@ -1,6 +1,6 @@
 // Utility functions for interacting with wiki data.
 
-import {accumulateSum, empty, unique} from './sugar.js';
+import {accumulateSum, chunkByConditions, empty, unique} from './sugar.js';
 import {sortByDate} from './sort.js';
 
 // This is a duplicate binding of filterMultipleArrays that's included purely
@@ -57,10 +57,9 @@ export function getKebabCase(name) {
 
 // Specific data utilities
 
-// Matches heading details from commentary data in roughly the formats:
+// Matches heading details from commentary data in roughly the format:
 //
-//    <i>artistReference:</i> (annotation, date)
-//    <i>artistReference|artistDisplayText:</i> (annotation, date)
+//    <i>artistText:</i> (annotation, date)
 //
 // where capturing group "annotation" can be any text at all, except that the
 // last entry (past a comma or the only content within parentheses), if parsed
@@ -83,8 +82,9 @@ export function getKebabCase(name) {
 // parentheses can be part of the actual annotation content.
 //
 // Capturing group "artistReference" is all the characters between <i> and </i>
-// (apart from the pipe and "artistDisplayText" text, if present), and is either
-// the name of an artist or an "artist:directory"-style reference.
+// (apart from the pipe and the "artistText" group, if present), and is either
+// the name of one or more artist or "artist:directory"-style references,
+// joined by commas, if multiple.
 //
 // 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.
@@ -94,7 +94,7 @@ const dateRegex = groupName =>
   String.raw`(?<${groupName}>[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4})`;
 
 const commentaryRegexRaw =
-  String.raw`^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?<dateKind>sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?<accessKind>captured|accessed) ${dateRegex('accessDate')})?)?\))?`;
+  String.raw`^<i>(?<artistText>.+?):<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?<dateKind>sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?<accessKind>captured|accessed) ${dateRegex('accessDate')})?)?\))?`;
 export const commentaryRegexCaseInsensitive =
   new RegExp(commentaryRegexRaw, 'gmi');
 export const commentaryRegexCaseSensitive =
@@ -102,6 +102,36 @@ export const commentaryRegexCaseSensitive =
 export const commentaryRegexCaseSensitiveOneShot =
   new RegExp(commentaryRegexRaw);
 
+// The #validators function isOldStyleLyrics() describes
+// what this regular expression detects against.
+export const multipleLyricsDetectionRegex =
+  /^<i>.*:<\/i>/m;
+
+export function matchContentEntries(sourceText) {
+  const matchEntries = [];
+
+  let previousMatchEntry = null;
+  let previousEndIndex = null;
+
+  for (const {0: matchText, index: startIndex, groups: matchEntry}
+          of sourceText.matchAll(commentaryRegexCaseSensitive)) {
+    if (previousMatchEntry) {
+      previousMatchEntry.body = sourceText.slice(previousEndIndex, startIndex);
+    }
+
+    matchEntries.push(matchEntry);
+
+    previousMatchEntry = matchEntry;
+    previousEndIndex = startIndex + matchText.length;
+  }
+
+  if (previousMatchEntry) {
+    previousMatchEntry.body = sourceText.slice(previousEndIndex);
+  }
+
+  return matchEntries;
+}
+
 export function filterAlbumsByCommentary(albums) {
   return albums
     .filter((album) => [album, ...album.tracks].some((x) => x.commentary));
@@ -167,10 +197,10 @@ export function getFlashLink(flash) {
 }
 
 export function getTotalDuration(tracks, {
-  originalReleasesOnly = false,
+  mainReleasesOnly = false,
 } = {}) {
-  if (originalReleasesOnly) {
-    tracks = tracks.filter(t => !t.originalReleaseTrack);
+  if (mainReleasesOnly) {
+    tracks = tracks.filter(t => !t.mainReleaseTrack);
   }
 
   return accumulateSum(tracks, track => track.duration);
@@ -192,6 +222,25 @@ export function getArtistAvatar(artist, {to}) {
   return to('media.artistAvatar', artist.directory, artist.avatarFileExtension);
 }
 
+// Used in multiple content functions for the artist info page,
+// because shared logic is torture oooooooooooooooo.
+export function chunkArtistTrackContributions(contributions) {
+  return (
+    // First chunk by (contribution) date and album.
+    chunkByConditions(contributions, [
+      ({date: date1}, {date: date2}) =>
+        +date1 !== +date2,
+      ({thing: track1}, {thing: track2}) =>
+        track1.album !== track2.album,
+    ]).map(contribs =>
+        // Then, *within* the boundaries of the existing chunks,
+        // chunk contributions to the same thing together.
+        chunkByConditions(contribs, [
+          ({thing: thing1}, {thing: thing2}) =>
+            thing1 !== thing2,
+        ])));
+}
+
 // Big-ass homepage row functions
 
 export function getNewAdditions(numAlbums, {albumData}) {
@@ -342,7 +391,7 @@ export function filterItemsForCarousel(items) {
 
   return items
     .filter(item => item.hasCoverArt)
-    .filter(item => item.artTags.every(tag => !tag.isContentWarning))
+    .filter(item => item.artTags.every(artTag => !artTag.isContentWarning))
     .slice(0, maxCarouselLayoutItems + 1);
 }
 
@@ -473,3 +522,30 @@ export function combineWikiDataArrays(arrays) {
     return combined;
   }
 }
+
+// Markdown stuff
+
+export function* matchMarkdownLinks(markdownSource, {marked}) {
+  const plausibleLinkRegexp = /\[.*?\)/g;
+
+  let plausibleMatch = null;
+  while (plausibleMatch = plausibleLinkRegexp.exec(markdownSource)) {
+    // 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(markdownSource.slice(plausibleMatch.index));
+
+    if (definiteMatch) {
+      const [{length}, label, href] = definiteMatch;
+      const index = plausibleMatch.index + definiteMatch.index;
+
+      yield {label, href, index, length};
+    }
+  }
+}
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
index 68120b23..7e05b5b5 100644
--- a/src/content/dependencies/generateAdditionalFilesList.js
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -1,26 +1,22 @@
-import {stitchArrays} from '#sugar';
-
 export default {
+  contentDependencies: ['generateAdditionalFilesListChunk'],
   extraDependencies: ['html'],
 
-  slots: {
-    chunks: {
-      validate: v => v.strictArrayOf(v.isHTML),
-    },
+  relations: (relation, additionalFiles) => ({
+    chunks:
+      additionalFiles
+        .map(file => relation('generateAdditionalFilesListChunk', file)),
+  }),
 
-    chunkItems: {
-      validate: v => v.strictArrayOf(v.isHTML),
-    },
+  slots: {
+    showFileSizes: {type: 'boolean', default: true},
   },
 
-  generate: (slots, {html}) =>
+  generate: (relations, slots, {html}) =>
     html.tag('ul', {class: 'additional-files-list'},
       {[html.onlyIfContent]: true},
 
-      stitchArrays({
-        chunk: slots.chunks,
-        items: slots.chunkItems,
-      }).map(({chunk, items}) =>
-          chunk.clone()
-            .slot('items', items))),
+      relations.chunks.map(chunk => chunk.slots({
+        showFileSizes: slots.showFileSizes,
+      }))),
 };
diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js
index 507b2329..3cac851b 100644
--- a/src/content/dependencies/generateAdditionalFilesListChunk.js
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -1,46 +1,81 @@
+import {stitchArrays} from '#sugar';
+
 export default {
-  extraDependencies: ['html', 'language'],
+  contentDependencies: ['linkAdditionalFile', 'transformContent'],
+  extraDependencies: ['getSizeOfMediaFile', 'html', 'language', 'urls'],
 
-  slots: {
-    title: {
-      type: 'html',
-      mutable: false,
-    },
+  relations: (relation, file) => ({
+    description:
+      relation('transformContent', file.description),
 
-    description: {
-      type: 'html',
-      mutable: false,
-    },
+    links:
+      file.filenames
+        .map(filename => relation('linkAdditionalFile', file, filename)),
+  }),
+
+  data: (file) => ({
+    title:
+      file.title,
+
+    paths:
+      file.paths,
+  }),
 
-    items: {
-      validate: v => v.looseArrayOf(v.isHTML),
+  slots: {
+    showFileSizes: {
+      type: 'boolean',
     },
   },
 
-  generate: (slots, {html, language}) =>
-    language.encapsulate('releaseInfo.additionalFiles.entry', capsule =>
+  generate: (data, relations, slots, {getSizeOfMediaFile, html, language, urls}) =>
+    language.encapsulate('releaseInfo.additionalFiles', capsule =>
       html.tag('li',
         html.tag('details',
-          html.isBlank(slots.items) &&
+          html.isBlank(relations.links) &&
             {open: true},
 
           [
             html.tag('summary',
               html.tag('span',
-                language.$(capsule, {
+                language.$(capsule, 'entry', {
                   title:
-                    html.tag('b', slots.title),
+                    html.tag('b', data.title),
                 }))),
 
             html.tag('ul', [
               html.tag('li', {class: 'entry-description'},
                 {[html.onlyIfContent]: true},
-                slots.description),
 
-              (html.isBlank(slots.items)
+                relations.description.slot('mode', 'inline')),
+
+              (html.isBlank(relations.links)
                 ? html.tag('li',
-                    language.$(capsule, 'noFilesAvailable'))
-                : slots.items),
+                    language.$(capsule, 'entry.noFilesAvailable'))
+
+                : stitchArrays({
+                    link: relations.links,
+                    path: data.paths,
+                  }).map(({link, path}) =>
+                      html.tag('li',
+                        language.encapsulate(capsule, 'file', workingCapsule => {
+                          const workingOptions = {file: link};
+
+                          if (slots.showFileSizes) {
+                            const fileSize =
+                              getSizeOfMediaFile(
+                                urls
+                                  .from('media.root')
+                                  .to(...path));
+
+                            if (fileSize) {
+                              workingCapsule += '.withSize';
+                              workingOptions.size =
+                                language.formatFileSize(fileSize);
+                            }
+                          }
+
+                          return language.$(workingCapsule, workingOptions);
+                        })))),
             ]),
           ]))),
 };
diff --git a/src/content/dependencies/generateAdditionalFilesListChunkItem.js b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
deleted file mode 100644
index c37d6bb2..00000000
--- a/src/content/dependencies/generateAdditionalFilesListChunkItem.js
+++ /dev/null
@@ -1,30 +0,0 @@
-export default {
-  extraDependencies: ['html', 'language'],
-
-  slots: {
-    fileLink: {
-      type: 'html',
-      mutable: false,
-    },
-
-    fileSize: {
-      validate: v => v.isWholeNumber,
-    },
-  },
-
-  generate(slots, {html, language}) {
-    const itemParts = ['releaseInfo.additionalFiles.file'];
-    const itemOptions = {file: slots.fileLink};
-
-    if (slots.fileSize) {
-      itemParts.push('withSize');
-      itemOptions.size = language.formatFileSize(slots.fileSize);
-    }
-
-    const li =
-      html.tag('li',
-        language.$(...itemParts, itemOptions));
-
-    return li;
-  },
-};
diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js
index 4f92580d..b7392dfd 100644
--- a/src/content/dependencies/generateAdditionalNamesBox.js
+++ b/src/content/dependencies/generateAdditionalNamesBox.js
@@ -10,6 +10,7 @@ export default {
 
   generate: (relations, {html, language}) =>
     html.tag('div', {id: 'additional-names-box'},
+      {class: 'drop'},
       {[html.onlyIfContent]: true},
 
       [
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
deleted file mode 100644
index ad17206f..00000000
--- a/src/content/dependencies/generateAlbumAdditionalFilesList.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import {stitchArrays} from '#sugar';
-
-export default {
-  contentDependencies: [
-    'generateAdditionalFilesList',
-    'generateAdditionalFilesListChunk',
-    'generateAdditionalFilesListChunkItem',
-    'linkAlbumAdditionalFile',
-    'transformContent',
-  ],
-
-  extraDependencies: ['getSizeOfMediaFile', 'html', 'urls'],
-
-  relations: (relation, album, additionalFiles) => ({
-    list:
-      relation('generateAdditionalFilesList', additionalFiles),
-
-    chunks:
-      additionalFiles
-        .map(() => relation('generateAdditionalFilesListChunk')),
-
-    chunkDescriptions:
-      additionalFiles
-        .map(({description}) =>
-          (description
-            ? relation('transformContent', description)
-            : null)),
-
-    chunkItems:
-      additionalFiles
-        .map(({files}) =>
-          (files ?? [])
-            .map(() => relation('generateAdditionalFilesListChunkItem'))),
-
-    chunkItemFileLinks:
-      additionalFiles
-        .map(({files}) =>
-          (files ?? [])
-            .map(file => relation('linkAlbumAdditionalFile', album, file))),
-  }),
-
-  data: (album, additionalFiles) => ({
-    albumDirectory: album.directory,
-
-    chunkTitles:
-      additionalFiles
-        .map(({title}) => title),
-
-    chunkItemLocations:
-      additionalFiles
-        .map(({files}) => files ?? []),
-  }),
-
-  slots: {
-    showFileSizes: {type: 'boolean', default: true},
-  },
-
-  generate: (data, relations, slots, {getSizeOfMediaFile, urls}) =>
-    relations.list.slots({
-      chunks:
-        stitchArrays({
-          chunk: relations.chunks,
-          description: relations.chunkDescriptions,
-          title: data.chunkTitles,
-        }).map(({chunk, title, description}) =>
-            chunk.slots({
-              title,
-              description:
-                (description
-                  ? description.slot('mode', 'inline')
-                  : null),
-            })),
-
-      chunkItems:
-        stitchArrays({
-          items: relations.chunkItems,
-          fileLinks: relations.chunkItemFileLinks,
-          locations: data.chunkItemLocations,
-        }).map(({items, fileLinks, locations}) =>
-            stitchArrays({
-              item: items,
-              fileLink: fileLinks,
-              location: locations,
-            }).map(({item, fileLink, location}) =>
-                item.slots({
-                  fileLink: fileLink,
-                  fileSize:
-                    (slots.showFileSizes
-                      ? getSizeOfMediaFile(
-                          urls
-                            .from('media.root')
-                            .to('media.albumAdditionalFile', data.albumDirectory, location))
-                      : 0),
-                }))),
-    }),
-};
diff --git a/src/content/dependencies/generateAlbumArtInfoBox.js b/src/content/dependencies/generateAlbumArtInfoBox.js
new file mode 100644
index 00000000..8c44c930
--- /dev/null
+++ b/src/content/dependencies/generateAlbumArtInfoBox.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generateReleaseInfoContributionsLine'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    wallpaperArtistContributionsLine:
+      (album.wallpaperArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.wallpaperArtwork.artistContribs)
+        : null),
+
+    bannerArtistContributionsLine:
+      (album.bannerArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.bannerArtwork.artistContribs)
+        : null),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule =>
+      html.tag('div', {class: 'album-art-info'},
+        {[html.onlyIfContent]: true},
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.wallpaperArtistContributionsLine?.slots({
+              stringKey: capsule + '.wallpaperArtBy',
+              chronologyKind: 'wallpaperArt',
+            }),
+
+            relations.bannerArtistContributionsLine?.slots({
+              stringKey: capsule + '.bannerArtBy',
+              chronologyKind: 'bannerArt',
+            }),
+          ]))),
+};
diff --git a/src/content/dependencies/generateAlbumArtworkColumn.js b/src/content/dependencies/generateAlbumArtworkColumn.js
new file mode 100644
index 00000000..e6762463
--- /dev/null
+++ b/src/content/dependencies/generateAlbumArtworkColumn.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateAlbumArtInfoBox', 'generateCoverArtwork'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    firstCover:
+      (album.hasCoverArt
+        ? relation('generateCoverArtwork', album.coverArtworks[0])
+        : null),
+
+    restCovers:
+      (album.hasCoverArt
+        ? album.coverArtworks.slice(1).map(artwork =>
+            relation('generateCoverArtwork', artwork))
+        : []),
+
+    albumArtInfoBox:
+      relation('generateAlbumArtInfoBox', album),
+  }),
+
+  generate: (relations, {html}) =>
+    html.tags([
+      relations.firstCover?.slots({
+        showOriginDetails: true,
+        showArtTagDetails: true,
+        showReferenceDetails: true,
+      }),
+
+      relations.albumArtInfoBox,
+
+      relations.restCovers.map(cover =>
+        cover.slots({
+          showOriginDetails: true,
+          showArtTagDetails: true,
+          showReferenceDetails: true,
+        })),
+    ]),
+};
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 91ffeb04..3529c4dc 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -1,15 +1,14 @@
-import {stitchArrays} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
     'generateAlbumCommentarySidebar',
-    'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateCommentaryEntry',
     'generateContentHeading',
-    'generateTrackCoverArtwork',
+    'generateCoverArtwork',
     'generatePageLayout',
     'linkAlbum',
     'linkExternal',
@@ -18,7 +17,22 @@ export default {
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, album) {
+  query(album) {
+    const query = {};
+
+    query.tracksWithCommentary =
+      album.tracks
+        .filter(({commentary}) => !empty(commentary));
+
+    query.thingsWithCommentary =
+      (empty(album.commentary)
+        ? query.tracksWithCommentary
+        : [album, ...query.tracksWithCommentary]);
+
+    return query;
+  },
+
+  relations(relation, query, album) {
     const relations = {};
 
     relations.layout =
@@ -30,8 +44,8 @@ export default {
     relations.sidebar =
       relation('generateAlbumCommentarySidebar', album);
 
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album, null);
+    relations.albumStyleTags =
+      relation('generateAlbumStyleTags', album, null);
 
     relations.albumLink =
       relation('linkAlbum', album);
@@ -39,7 +53,7 @@ export default {
     relations.albumNavAccent =
       relation('generateAlbumNavAccent', album, null);
 
-    if (album.commentary) {
+    if (!empty(album.commentary)) {
       relations.albumCommentaryHeading =
         relation('generateContentHeading');
 
@@ -51,7 +65,7 @@ export default {
 
       if (album.hasCoverArt) {
         relations.albumCommentaryCover =
-          relation('generateAlbumCoverArtwork', album);
+          relation('generateCoverArtwork', album.coverArtworks[0]);
       }
 
       relations.albumCommentaryEntries =
@@ -59,32 +73,28 @@ export default {
           .map(entry => relation('generateCommentaryEntry', entry));
     }
 
-    const tracksWithCommentary =
-      album.tracks
-        .filter(({commentary}) => commentary);
-
     relations.trackCommentaryHeadings =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(() => relation('generateContentHeading'));
 
     relations.trackCommentaryLinks =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track => relation('linkTrack', track));
 
     relations.trackCommentaryListeningLinks =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           track.urls.map(url => relation('linkExternal', url)));
 
     relations.trackCommentaryCovers =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           (track.hasUniqueCoverArt
-            ? relation('generateTrackCoverArtwork', track)
+            ? relation('generateCoverArtwork', track.trackArtworks[0])
             : null));
 
     relations.trackCommentaryEntries =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           track.commentary
             .map(entry => relation('generateCommentaryEntry', entry)));
@@ -92,29 +102,20 @@ export default {
     return relations;
   },
 
-  data(album) {
+  data(query, album) {
     const data = {};
 
     data.name = album.name;
     data.color = album.color;
     data.date = album.date;
 
-    const tracksWithCommentary =
-      album.tracks
-        .filter(({commentary}) => commentary);
-
-    const thingsWithCommentary =
-      (album.commentary
-        ? [album, ...tracksWithCommentary]
-        : tracksWithCommentary);
-
     data.entryCount =
-      thingsWithCommentary
+      query.thingsWithCommentary
         .flatMap(({commentary}) => commentary)
         .length;
 
     data.wordCount =
-      thingsWithCommentary
+      query.thingsWithCommentary
         .flatMap(({commentary}) => commentary)
         .map(({body}) => body)
         .join(' ')
@@ -122,15 +123,15 @@ export default {
         .length;
 
     data.trackCommentaryTrackDates =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track => track.dateFirstReleased);
 
     data.trackCommentaryDirectories =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track => track.directory);
 
     data.trackCommentaryColors =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           (track.color === album.color
             ? null
@@ -150,7 +151,7 @@ export default {
         headingMode: 'sticky',
 
         color: data.color,
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         mainClasses: ['long-content'],
         mainContent: [
@@ -265,7 +266,10 @@ export default {
                       }),
                   })),
 
-              cover?.slots({mode: 'commentary'}),
+              cover?.slots({
+                mode: 'commentary',
+                color: true,
+              }),
 
               trackDate &&
               trackDate !== data.date &&
diff --git a/src/content/dependencies/generateAlbumCommentarySidebar.js b/src/content/dependencies/generateAlbumCommentarySidebar.js
index 8313e6de..9ecec66d 100644
--- a/src/content/dependencies/generateAlbumCommentarySidebar.js
+++ b/src/content/dependencies/generateAlbumCommentarySidebar.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: [
     'generateAlbumSidebarTrackSection',
@@ -28,10 +30,10 @@ export default {
 
   data: (album) => ({
     albumHasCommentary:
-      !!album.commentary,
+      !empty(album.commentary),
 
     anyTrackHasCommentary:
-      album.tracks.some(track => track.commentary),
+      album.tracks.some(track => !empty(track.commentary)),
   }),
 
   generate: (data, relations, {html, language}) =>
diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js
deleted file mode 100644
index ff7d2b85..00000000
--- a/src/content/dependencies/generateAlbumCoverArtwork.js
+++ /dev/null
@@ -1,100 +0,0 @@
-export default {
-  contentDependencies: [
-    'generateCoverArtwork',
-    'generateCoverArtworkArtTagDetails',
-    'generateCoverArtworkArtistDetails',
-    'generateCoverArtworkReferenceDetails',
-    'image',
-    'linkAlbumReferencedArtworks',
-    'linkAlbumReferencingArtworks',
-  ],
-
-  extraDependencies: ['html', 'language'],
-
-  relations: (relation, album) => ({
-    coverArtwork:
-      relation('generateCoverArtwork'),
-
-    image:
-      relation('image'),
-
-    artTagDetails:
-      relation('generateCoverArtworkArtTagDetails', album.artTags),
-
-    artistDetails:
-      relation('generateCoverArtworkArtistDetails', album.coverArtistContribs),
-
-    referenceDetails:
-      relation('generateCoverArtworkReferenceDetails',
-        album.referencedArtworks,
-        album.referencedByArtworks),
-
-    referencedArtworksLink:
-      relation('linkAlbumReferencedArtworks', album),
-
-    referencingArtworksLink:
-      relation('linkAlbumReferencingArtworks', album),
-  }),
-
-  data: (album) => ({
-    path:
-      ['media.albumCover', album.directory, album.coverArtFileExtension],
-
-    color:
-      album.color,
-
-    dimensions:
-      album.coverArtDimensions,
-
-    warnings:
-      album.artTags
-        .filter(tag => tag.isContentWarning)
-        .map(tag => tag.name),
-  }),
-
-  slots: {
-    mode: {type: 'string'},
-
-    details: {
-      validate: v => v.is('tags', 'artists'),
-      default: 'tags',
-    },
-
-    showReferenceLinks: {
-      type: 'boolean',
-      default: false,
-    },
-  },
-
-  generate: (data, relations, slots, {language}) =>
-    relations.coverArtwork.slots({
-      mode: slots.mode,
-
-      image:
-        relations.image.slots({
-          path: data.path,
-          color: data.color,
-          alt: language.$('misc.alt.albumCover'),
-        }),
-
-      dimensions: data.dimensions,
-      warnings: data.warnings,
-
-      details: [
-        slots.details === 'tags' &&
-          relations.artTagDetails,
-
-        slots.details === 'artists' &&
-          relations.artistDetails,
-
-        slots.showReferenceLinks &&
-          relations.referenceDetails.slots({
-            referencedLink:
-              relations.referencedArtworksLink,
-
-            referencingLink:
-              relations.referencingArtworksLink,
-          }),
-      ],
-    }),
-};
diff --git a/src/content/dependencies/generateAlbumGalleryAlbumGrid.js b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js
new file mode 100644
index 00000000..7f152871
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js
@@ -0,0 +1,90 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCoverGrid',
+    'image',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (album) => ({
+    artworks:
+      (album.hasCoverArt
+        ? album.coverArtworks
+        : []),
+  }),
+
+  relations: (relation, query, album) => ({
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    albumLinks:
+      query.artworks.map(_artwork =>
+        relation('linkAlbum', album)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, album) => ({
+    albumName:
+      album.name,
+
+    artworkLabels:
+      query.artworks
+        .map(artwork => artwork.label),
+
+    artworkArtists:
+      query.artworks
+        .map(artwork => artwork.artistContribs
+          .map(contrib => contrib.artist.name)),
+  }),
+
+  slots: {
+    attributes: {type: 'attributes', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+
+      slots.attributes,
+
+      [
+        relations.coverArtistsLine,
+
+        relations.coverGrid.slots({
+          links:
+            relations.albumLinks,
+
+          names:
+            data.artworkLabels
+              .map(label => label ?? data.albumName),
+
+          images:
+            stitchArrays({
+              image: relations.images,
+              label: data.artworkLabels,
+            }).map(({image, label}) =>
+                image.slots({
+                  missingSourceContent:
+                    language.$('misc.albumGalleryGrid.noCoverArt', {
+                      name:
+                        label ?? data.albumName,
+                    }),
+                })),
+
+          info:
+            data.artworkArtists.map(artists =>
+              language.$('misc.coverGrid.details.coverArtists', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatUnitList(artists),
+              })),
+        }),
+      ]),
+};
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index b48d92af..516a7ca8 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -1,18 +1,18 @@
-import {compareArrays, stitchArrays} from '#sugar';
+import {stitchArrays, unique} from '#sugar';
+import {getKebabCase} from '#wiki-data';
 
 export default {
   contentDependencies: [
-    'generateAlbumGalleryCoverArtistsLine',
+    'generateAlbumGalleryAlbumGrid',
     'generateAlbumGalleryNoTrackArtworksLine',
     'generateAlbumGalleryStatsLine',
+    'generateAlbumGalleryTrackGrid',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
-    'generateAlbumStyleRules',
-    'generateCoverGrid',
+    'generateAlbumStyleTags',
+    'generateIntrapageDotSwitcher',
     'generatePageLayout',
-    'image',
     'linkAlbum',
-    'linkTrack',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -20,147 +20,82 @@ export default {
   query(album) {
     const query = {};
 
-    const tracksWithUniqueCoverArt =
+    const trackArtworkLabels =
       album.tracks
-        .filter(track => track.hasUniqueCoverArt);
-
-    // Don't display "all artwork by..." for albums where there's
-    // only one unique artwork in the first place.
-    if (tracksWithUniqueCoverArt.length > 1) {
-      const allCoverArtistArrays =
-        tracksWithUniqueCoverArt
-          .map(track => track.coverArtistContribs)
-          .map(contribs => contribs.map(contrib => contrib.artist));
-
-      const allSameCoverArtists =
-        allCoverArtistArrays
-          .slice(1)
-          .every(artists => compareArrays(artists, allCoverArtistArrays[0]));
-
-      if (allSameCoverArtists) {
-        query.coverArtistsForAllTracks =
-          allCoverArtistArrays[0];
-      }
-    }
+        .map(track => track.trackArtworks
+          .map(artwork => artwork.label));
+
+    const recurranceThreshold = 2;
+
+    // This list may include null, if some artworks are not labelled!
+    // That's expected.
+    query.recurringTrackArtworkLabels =
+      unique(trackArtworkLabels.flat())
+        .filter(label =>
+          trackArtworkLabels
+            .filter(labels => labels.includes(label))
+            .length >=
+          (label === null
+            ? 1
+            : recurranceThreshold));
 
     return query;
   },
 
-  relations(relation, query, album) {
-    const relations = {};
+  relations: (relation, query, album) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    relations.layout =
-      relation('generatePageLayout');
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album, null);
-
-    relations.albumLink =
-      relation('linkAlbum', album);
-
-    relations.albumNavAccent =
-      relation('generateAlbumNavAccent', album, null);
-
-    relations.secondaryNav =
-      relation('generateAlbumSecondaryNav', album);
-
-    relations.statsLine =
-      relation('generateAlbumGalleryStatsLine', album);
-
-    if (album.tracks.every(track => !track.hasUniqueCoverArt)) {
-      relations.noTrackArtworksLine =
-        relation('generateAlbumGalleryNoTrackArtworksLine');
-    }
-
-    if (query.coverArtistsForAllTracks) {
-      relations.coverArtistsLine =
-        relation('generateAlbumGalleryCoverArtistsLine', query.coverArtistsForAllTracks);
-    }
-
-    relations.coverGrid =
-      relation('generateCoverGrid');
-
-    relations.links = [
+    albumLink:
       relation('linkAlbum', album),
 
-      ...
-        album.tracks
-          .map(track => relation('linkTrack', track)),
-    ];
-
-    relations.images = [
-      (album.hasCoverArt
-        ? relation('image', album.artTags)
-        : relation('image')),
+    albumNavAccent:
+      relation('generateAlbumNavAccent', album, null),
 
-      ...
-        album.tracks.map(track =>
-          (track.hasUniqueCoverArt
-            ? relation('image', track.artTags)
-            : relation('image'))),
-    ];
-
-    return relations;
-  },
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', album),
 
-  data(query, album) {
-    const data = {};
+    statsLine:
+      relation('generateAlbumGalleryStatsLine', album),
 
-    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(({artist}) => artist.name)
+    noTrackArtworksLine:
+      (album.tracks.every(track => !track.hasUniqueCoverArt)
+        ? relation('generateAlbumGalleryNoTrackArtworksLine')
         : null),
 
-      ...
-        album.tracks.map(track => {
-          if (query.coverArtistsForAllTracks) {
-            return null;
-          }
+    setSwitcher:
+      relation('generateIntrapageDotSwitcher'),
 
-          if (track.hasUniqueCoverArt) {
-            return track.coverArtistContribs.map(({artist}) => artist.name);
-          }
+    albumGrid:
+      relation('generateAlbumGalleryAlbumGrid', album),
 
-          return null;
-        }),
-    ];
-
-    data.paths = [
-      (album.hasCoverArt
-        ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-        : null),
+    trackGrids:
+      query.recurringTrackArtworkLabels.map(label =>
+        relation('generateAlbumGalleryTrackGrid', album, label)),
+  }),
 
-      ...
-        album.tracks.map(track =>
-          (track.hasUniqueCoverArt
-            ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
-            : null)),
-    ];
+  data: (query, album) => ({
+    trackGridLabels:
+      query.recurringTrackArtworkLabels,
 
-    data.dimensions = [
-      (album.hasCoverArt
-        ? album.coverArtDimensions
-        : null),
+    trackGridIDs:
+      query.recurringTrackArtworkLabels.map(label =>
+        'track-grid-' +
+          (label
+            ? getKebabCase(label)
+            : 'no-label')),
 
-      ...
-        album.tracks.map(track =>
-          (track.hasUniqueCoverArt
-            ? track.coverArtDimensions
-            : null)),
-    ];
+    name:
+      album.name,
 
-    return data;
-  },
+    color:
+      album.color,
+  }),
 
-  generate: (data, relations, {language}) =>
+  generate: (data, relations, {html, language}) =>
     language.encapsulate('albumGalleryPage', pageCapsule =>
       relations.layout.slots({
         title:
@@ -171,39 +106,44 @@ export default {
         headingMode: 'static',
 
         color: data.color,
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         mainClasses: ['top-index'],
         mainContent: [
           relations.statsLine,
-          relations.coverArtistsLine,
+
+          relations.albumGrid,
+
           relations.noTrackArtworksLine,
 
-          relations.coverGrid
-            .slots({
-              links: relations.links,
-              names: data.names,
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                  dimensions: data.dimensions,
-                  name: data.names,
-                }).map(({image, path, dimensions, name}) =>
-                    image.slots({
-                      path,
-                      dimensions,
-                      missingSourceContent:
-                        language.$('misc.albumGalleryGrid.noCoverArt', {name}),
-                    })),
-              info:
-                data.coverArtists.map(names =>
-                  (names === null
-                    ? null
-                    : language.$('misc.coverGrid.details.coverArtists', {
-                        artists: language.formatUnitList(names),
-                      }))),
-            }),
+          data.trackGridLabels.some(value => value !== null) &&
+            html.tag('p', {class: 'gallery-set-switcher'},
+              language.encapsulate(pageCapsule, 'setSwitcher', switcherCapsule =>
+                language.$(switcherCapsule, {
+                  sets:
+                    relations.setSwitcher.slots({
+                      initialOptionIndex: 0,
+
+                      titles:
+                        data.trackGridLabels.map(label =>
+                          label ??
+                          language.$(switcherCapsule, 'unlabeledSet')),
+
+                      targetIDs:
+                        data.trackGridIDs,
+                    }),
+                }))),
+
+          stitchArrays({
+            grid: relations.trackGrids,
+            id: data.trackGridIDs,
+          }).map(({grid, id}, index) =>
+              grid.slots({
+                attributes: [
+                  {id},
+                  index >= 1 && {style: 'display: none'},
+                ],
+              })),
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
new file mode 100644
index 00000000..fb5ed7ea
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
@@ -0,0 +1,122 @@
+import {compareArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumGalleryCoverArtistsLine',
+    'generateCoverGrid',
+    'image',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, label) {
+    const query = {};
+
+    query.artworks =
+      album.tracks.map(track =>
+        track.trackArtworks.find(artwork => artwork.label === label) ??
+        null);
+
+    const presentArtworks =
+      query.artworks.filter(Boolean);
+
+    if (presentArtworks.length > 1) {
+      const allArtistArrays =
+        presentArtworks
+          .map(artwork => artwork.artistContribs
+            .map(contrib => contrib.artist));
+
+      const allSameArtists =
+        allArtistArrays
+          .slice(1)
+          .every(artists => compareArrays(artists, allArtistArrays[0]));
+
+      if (allSameArtists) {
+        query.artistsForAllTrackArtworks =
+          allArtistArrays[0];
+      }
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, album, _label) => ({
+    coverArtistsLine:
+      (query.artistsForAllTrackArtworks
+        ? relation('generateAlbumGalleryCoverArtistsLine',
+            query.artistsForAllTrackArtworks)
+        : null),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackLinks:
+      album.tracks
+        .map(track => relation('linkTrack', track)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, album, _label) => ({
+    trackNames:
+      album.tracks
+        .map(track => track.name),
+
+    artworkArtists:
+      query.artworks.map(artwork =>
+        (query.artistsForAllTrackArtworks
+          ? null
+       : artwork
+          ? artwork.artistContribs
+              .map(contrib => contrib.artist.name)
+          : null)),
+  }),
+
+  slots: {
+    attributes: {type: 'attributes', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+
+      slots.attributes,
+
+      [
+        relations.coverArtistsLine,
+
+        relations.coverGrid.slots({
+          links:
+            relations.trackLinks,
+
+          names:
+            data.trackNames,
+
+          images:
+            stitchArrays({
+              image: relations.images,
+              name: data.trackNames,
+            }).map(({image, name}) =>
+                image.slots({
+                  missingSourceContent:
+                    language.$('misc.albumGalleryGrid.noCoverArt', {name}),
+                })),
+
+          info:
+            data.artworkArtists.map(artists =>
+              language.$('misc.coverGrid.details.coverArtists', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatUnitList(artists),
+              })),
+        }),
+      ]),
+};
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 1f741a60..1664c788 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -1,17 +1,20 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: [
+    'generateAdditionalFilesList',
     'generateAdditionalNamesBox',
-    'generateAlbumAdditionalFilesList',
+    'generateAlbumArtworkColumn',
     'generateAlbumBanner',
-    'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
     'generateAlbumReleaseInfo',
     'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
     'generateAlbumSocialEmbed',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateAlbumTrackList',
-    'generateCommentarySection',
+    'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generatePageLayout',
     'linkAlbumCommentary',
@@ -24,8 +27,8 @@ export default {
     layout:
       relation('generatePageLayout'),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', album, null),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
     socialEmbed:
       relation('generateAlbumSocialEmbed', album),
@@ -42,10 +45,8 @@ export default {
     additionalNamesBox:
       relation('generateAdditionalNamesBox', album.additionalNames),
 
-    cover:
-      (album.hasCoverArt
-        ? relation('generateAlbumCoverArtwork', album)
-        : null),
+    artworkColumn:
+      relation('generateAlbumArtworkColumn', album),
 
     banner:
       (album.hasBannerArt
@@ -55,6 +56,9 @@ export default {
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', album),
+
     releaseInfo:
       relation('generateAlbumReleaseInfo', album),
 
@@ -64,7 +68,7 @@ export default {
         : null),
 
     commentaryLink:
-      (album.commentary || album.tracks.some(t => t.commentary)
+      ([album, ...album.tracks].some(({commentary}) => !empty(commentary))
         ? relation('linkAlbumCommentary', album)
         : null),
 
@@ -72,15 +76,15 @@ export default {
       relation('generateAlbumTrackList', album),
 
     additionalFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        album,
-        album.additionalFiles),
+      relation('generateAdditionalFilesList', album.additionalFiles),
 
-    artistCommentarySection:
-      relation('generateCommentarySection', album.commentary),
+    artistCommentaryEntries:
+      album.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
 
-    creditSourcesSection:
-      relation('generateCommentarySection', album.creditSources),
+    creditSourceEntries:
+      album.creditingSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
   }),
 
   data: (album) => ({
@@ -104,16 +108,12 @@ export default {
 
         color: data.color,
         headingMode: 'sticky',
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         additionalNames: relations.additionalNamesBox,
 
-        cover:
-          (relations.cover
-            ? relations.cover.slots({
-                showReferenceLinks: true,
-              })
-            : null),
+        artworkColumnContent:
+          relations.artworkColumn,
 
         mainContent: [
           relations.releaseInfo,
@@ -160,12 +160,12 @@ export default {
 
                 : html.blank()),
 
-              !html.isBlank(relations.creditSourcesSection) &&
-                language.encapsulate(capsule, 'readCreditSources', capsule =>
+              !html.isBlank(relations.creditSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
                   language.$(capsule, {
                     link:
                       html.tag('a',
-                        {href: '#credit-sources'},
+                        {href: '#crediting-sources'},
                         language.$(capsule, 'link')),
                   })),
             ])),
@@ -183,6 +183,11 @@ export default {
               }),
             ])),
 
+          (!html.isBlank(relations.artistCommentaryEntries) ||
+           !html.isBlank(relations.creditSourceEntries))
+          &&
+            html.tag('hr', {class: 'main-separator'}),
+
           language.encapsulate('releaseInfo.additionalFiles', capsule =>
             html.tags([
               relations.contentHeading.clone()
@@ -194,12 +199,25 @@ export default {
               relations.additionalFilesList,
             ])),
 
-          relations.artistCommentarySection,
+          html.tags([
+            relations.contentContentHeading.clone()
+              .slots({
+                attributes: {id: 'artist-commentary'},
+                string: 'misc.artistCommentary',
+              }),
 
-          relations.creditSourcesSection.slots({
-            id: 'credit-sources',
-            title: language.$('misc.creditSources'),
-          }),
+            relations.artistCommentaryEntries,
+          ]),
+
+          html.tags([
+            relations.contentContentHeading.clone()
+              .slots({
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
+              }),
+
+            relations.creditSourceEntries,
+          ]),
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
index 3adb01be..432c5f3d 100644
--- a/src/content/dependencies/generateAlbumNavAccent.js
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -1,4 +1,4 @@
-import {atOffset} from '#sugar';
+import {atOffset, empty} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -65,8 +65,8 @@ export default {
       album.tracks.length > 1,
 
     commentaryPageIsStub:
-      !album.commentary &&
-      album.tracks.every(t => !t.commentary),
+      [album, ...album.tracks]
+        .every(({commentary}) => empty(commentary)),
 
     galleryIsStub:
       album.tracks.every(t => !t.hasUniqueCoverArt),
diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
index 3f3d77b3..52c78dc2 100644
--- a/src/content/dependencies/generateAlbumReferencedArtworksPage.js
+++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
@@ -1,7 +1,6 @@
 export default {
   contentDependencies: [
-    'generateAlbumCoverArtwork',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToAlbumLink',
     'generateReferencedArtworksPage',
     'linkAlbum',
@@ -11,27 +10,21 @@ export default {
 
   relations: (relation, album) => ({
     page:
-      relation('generateReferencedArtworksPage', album.referencedArtworks),
+      relation('generateReferencedArtworksPage', album.coverArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', album, null),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
     albumLink:
       relation('linkAlbum', album),
 
     backToAlbumLink:
       relation('generateBackToAlbumLink', album),
-
-    cover:
-      relation('generateAlbumCoverArtwork', album),
   }),
 
   data: (album) => ({
     name:
       album.name,
-
-    color:
-      album.color,
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -42,10 +35,7 @@ export default {
             data.name,
         }),
 
-      color: data.color,
-      styleRules: [relations.albumStyleRules],
-
-      cover: relations.cover,
+      styleTags: relations.albumStyleTags,
 
       navLinks: [
         {auto: 'home'},
diff --git a/src/content/dependencies/generateAlbumReferencingArtworksPage.js b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
index 8f2349f9..bc36ae06 100644
--- a/src/content/dependencies/generateAlbumReferencingArtworksPage.js
+++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
@@ -1,7 +1,6 @@
 export default {
   contentDependencies: [
-    'generateAlbumCoverArtwork',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToAlbumLink',
     'generateReferencingArtworksPage',
     'linkAlbum',
@@ -11,27 +10,21 @@ export default {
 
   relations: (relation, album) => ({
     page:
-      relation('generateReferencingArtworksPage', album.referencedByArtworks),
+      relation('generateReferencingArtworksPage', album.coverArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', album, null),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
     albumLink:
       relation('linkAlbum', album),
 
     backToAlbumLink:
       relation('generateBackToAlbumLink', album),
-
-    cover:
-      relation('generateAlbumCoverArtwork', album),
   }),
 
   data: (album) => ({
     name:
       album.name,
-
-    color:
-      album.color,
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -42,10 +35,7 @@ export default {
             data.name,
         }),
 
-      color: data.color,
-      styleRules: [relations.albumStyleRules],
-
-      cover: relations.cover,
+      styleTags: relations.albumStyleTags,
 
       navLinks: [
         {auto: 'home'},
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 217282c0..2a958244 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -3,7 +3,7 @@ import {accumulateSum, empty} from '#sugar';
 export default {
   contentDependencies: [
     'generateReleaseInfoContributionsLine',
-    'linkExternal',
+    'generateReleaseInfoListenLine',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -14,18 +14,8 @@ export default {
     relations.artistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.artistContribs);
 
-    relations.coverArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.coverArtistContribs);
-
-    relations.wallpaperArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
-
-    relations.bannerArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
-
-    relations.externalLinks =
-      album.urls.map(url =>
-        relation('linkExternal', url));
+    relations.listenLine =
+      relation('generateReleaseInfoListenLine', album);
 
     return relations;
   },
@@ -73,31 +63,11 @@ export default {
               chronologyKind: 'album',
             }),
 
-            relations.coverArtistContributionsLine.slots({
-              stringKey: capsule + '.coverArtBy',
-              chronologyKind: 'coverArt',
-            }),
-
-            relations.wallpaperArtistContributionsLine.slots({
-              stringKey: capsule + '.wallpaperArtBy',
-              chronologyKind: 'wallpaperArt',
-            }),
-
-            relations.bannerArtistContributionsLine.slots({
-              stringKey: capsule + '.bannerArtBy',
-              chronologyKind: 'bannerArt',
-            }),
-
             language.$(capsule, 'released', {
               [language.onlyIfOptions]: ['date'],
               date: language.formatDate(data.date),
             }),
 
-            language.$(capsule, 'artReleased', {
-              [language.onlyIfOptions]: ['date'],
-              date: language.formatDate(data.coverArtDate),
-            }),
-
             language.$(capsule, 'duration', {
               [language.onlyIfOptions]: ['duration'],
               duration:
@@ -110,21 +80,16 @@ export default {
         html.tag('p',
           {[html.onlyIfContent]: true},
 
-          language.$(capsule, 'listenOn', {
-            [language.onlyIfOptions]: ['links'],
-
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link =>
-                    link.slot('context', [
-                      'album',
-                      (data.numTracks === 0
-                        ? 'albumNoTracks'
-                     : data.numTracks === 1
-                        ? 'albumOneTrack'
-                        : 'albumMultipleTracks'),
-                    ]))),
+          relations.listenLine.slots({
+            context: [
+              'album',
+
+              (data.numTracks === 0
+                ? 'albumNoTracks'
+             : data.numTracks === 1
+                ? 'albumOneTrack'
+                : 'albumMultipleTracks'),
+            ],
           })),
       ])),
 };
diff --git a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
index 9f9aaf23..22dfa51c 100644
--- a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
+++ b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
@@ -67,6 +67,8 @@ export default {
 
   generate: (relations, slots) =>
     relations.parentSiblingsPart.slots({
+      attributes: {class: 'group-nav-links'},
+
       showPreviousNext: slots.mode === 'album',
 
       colorStyle: relations.colorStyle,
diff --git a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
index f579cdc9..16f205e3 100644
--- a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
+++ b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
@@ -62,7 +62,7 @@ export default {
 
   generate: (data, relations, slots, {language}) =>
     relations.parentSiblingsPart.slots({
-      attributes: {class: 'series-nav-link'},
+      attributes: {class: 'series-nav-links'},
 
       showPreviousNext: slots.mode === 'album',
 
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
index bd53ef71..7cf689cc 100644
--- a/src/content/dependencies/generateAlbumSidebar.js
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -1,4 +1,5 @@
-import {stitchArrays} from '#sugar';
+import {sortAlbumsTracksChronologically} from '#sort';
+import {stitchArrays, transposeArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -7,6 +8,7 @@ export default {
     'generateAlbumSidebarTrackListBox',
     'generatePageSidebar',
     'generatePageSidebarConjoinedBox',
+    'generateTrackReleaseBox',
   ],
 
   extraDependencies: ['html', 'wikiData'],
@@ -17,7 +19,7 @@ export default {
       groupData.flatMap(group => group.serieses),
   }),
 
-  query(sprawl, album) {
+  query(sprawl, album, track) {
     const query = {};
 
     query.groups =
@@ -35,6 +37,33 @@ export default {
           series.albums.includes(album) &&
           !query.groups.includes(series.group));
 
+    if (track) {
+      const albumTrackMap =
+        new Map(transposeArrays([
+          track.allReleases.map(t => t.album),
+          track.allReleases,
+        ]));
+
+      const allReleaseAlbums =
+        sortAlbumsTracksChronologically(
+          Array.from(albumTrackMap.keys()));
+
+      const currentReleaseIndex =
+        allReleaseAlbums.indexOf(track.album);
+
+      const earlierReleaseAlbums =
+        allReleaseAlbums.slice(0, currentReleaseIndex);
+
+      const laterReleaseAlbums =
+        allReleaseAlbums.slice(currentReleaseIndex + 1);
+
+      query.earlierReleaseTracks =
+        earlierReleaseAlbums.map(album => albumTrackMap.get(album));
+
+      query.laterReleaseTracks =
+        laterReleaseAlbums.map(album => albumTrackMap.get(album));
+    }
+
     return query;
   },
 
@@ -63,10 +92,25 @@ export default {
       query.disconnectedSerieses
         .map(series =>
           relation('generateAlbumSidebarSeriesBox', album, series)),
+
+    earlierTrackReleaseBoxes:
+      (track
+        ? query.earlierReleaseTracks
+            .map(track =>
+              relation('generateTrackReleaseBox', track))
+        : null),
+
+    laterTrackReleaseBoxes:
+      (track
+        ? query.laterReleaseTracks
+            .map(track =>
+              relation('generateTrackReleaseBox', track))
+        : null),
   }),
 
   data: (_query, _sprawl, _album, track) => ({
     isAlbumPage: !track,
+    isTrackPage: !!track,
   }),
 
   generate(data, relations, {html}) {
@@ -98,9 +142,15 @@ export default {
             ]),
         ],
 
+        data.isTrackPage &&
+          relations.earlierTrackReleaseBoxes,
+
         relations.trackListBox,
 
-        !data.isAlbumPage &&
+        data.isTrackPage &&
+          relations.laterTrackReleaseBoxes,
+
+        data.isTrackPage &&
           relations.conjoinedBox.slots({
             attributes: {class: 'conjoined-group-sidebar-box'},
             boxes:
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index bb788d65..dae5fa03 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -1,3 +1,5 @@
+import {empty, stitchArrays} from '#sugar';
+
 export default {
   contentDependencies: ['linkTrack'],
   extraDependencies: ['getColors', 'html', 'language'],
@@ -15,23 +17,25 @@ export default {
   data(album, track, trackSection) {
     const data = {};
 
-    data.hasTrackNumbers = album.hasTrackNumbers;
+    data.hasTrackNumbers =
+      album.hasTrackNumbers &&
+      !empty(trackSection.tracks);
+
     data.isTrackPage = !!track;
 
     data.name = trackSection.name;
     data.color = trackSection.color;
     data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
 
-    data.firstTrackNumber = trackSection.startIndex + 1;
-    data.lastTrackNumber = trackSection.startIndex + trackSection.tracks.length;
+    data.firstTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(0).trackNumber
+        : null);
 
-    if (track) {
-      const index = trackSection.tracks.indexOf(track);
-      if (index !== -1) {
-        data.includesCurrentTrack = true;
-        data.currentTrackIndex = index;
-      }
-    }
+    data.lastTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(-1).trackNumber
+        : null);
 
     data.trackDirectories =
       trackSection.tracks
@@ -39,7 +43,14 @@ export default {
 
     data.tracksAreMissingCommentary =
       trackSection.tracks
-        .map(track => !track.commentary);
+        .map(track => empty(track.commentary));
+
+    data.tracksAreCurrentTrack =
+      trackSection.tracks
+        .map(traaaaaaaack => traaaaaaaack === track);
+
+    data.includesCurrentTrack =
+      data.tracksAreCurrentTrack.includes(true);
 
     return data;
   },
@@ -70,29 +81,39 @@ export default {
     }
 
     const trackListItems =
-      relations.trackLinks.map((trackLink, index) =>
-        html.tag('li',
-          data.includesCurrentTrack &&
-          index === data.currentTrackIndex &&
-            {class: 'current'},
-
-          slots.mode === 'commentary' &&
-          data.tracksAreMissingCommentary[index] &&
-            {class: 'no-commentary'},
-
-          language.$(capsule, 'item', {
-            track:
-              (slots.mode === 'commentary' && data.tracksAreMissingCommentary[index]
-                ? trackLink.slots({
-                    linkless: true,
-                  })
-             : slots.anchor
-                ? trackLink.slots({
-                    anchor: true,
-                    hash: data.trackDirectories[index],
-                  })
-                : trackLink),
-          })));
+      stitchArrays({
+        trackLink: relations.trackLinks,
+        directory: data.trackDirectories,
+        isCurrentTrack: data.tracksAreCurrentTrack,
+        missingCommentary: data.tracksAreMissingCommentary,
+      }).map(({
+          trackLink,
+          directory,
+          isCurrentTrack,
+          missingCommentary,
+        }) =>
+          html.tag('li',
+            data.includesCurrentTrack &&
+            isCurrentTrack &&
+              {class: 'current'},
+
+            slots.mode === 'commentary' &&
+            missingCommentary &&
+              {class: 'no-commentary'},
+
+            language.$(capsule, 'item', {
+              track:
+                (slots.mode === 'commentary' && missingCommentary
+                  ? trackLink.slots({
+                      linkless: true,
+                    })
+               : slots.anchor
+                  ? trackLink.slots({
+                      anchor: true,
+                      hash: directory,
+                    })
+                  : trackLink),
+            })));
 
     return html.tag('details',
       data.includesCurrentTrack &&
@@ -119,17 +140,22 @@ export default {
           colorStyle,
 
           html.tag('span',
-            language.encapsulate(capsule, 'group', workingCapsule => {
-              const workingOptions = {group: sectionName};
-
-              if (data.hasTrackNumbers) {
-                workingCapsule += '.withRange';
-                workingOptions.range =
-                  `${data.firstTrackNumber}–${data.lastTrackNumber}`;
-              }
-
-              return language.$(workingCapsule, workingOptions);
-            }))),
+            language.encapsulate(capsule, 'group', groupCapsule =>
+              language.encapsulate(groupCapsule, workingCapsule => {
+                const workingOptions = {group: sectionName};
+
+                if (data.hasTrackNumbers) {
+                  workingCapsule += '.withRange';
+                  workingOptions.rangePart =
+                    html.tag('span', {class: 'track-section-range'},
+                      language.$(groupCapsule, 'withRange.rangePart', {
+                        range:
+                          `${data.firstTrackNumber}–${data.lastTrackNumber}`,
+                      }));
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })))),
 
         (data.hasTrackNumbers
           ? html.tag('ol',
diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js
index ad02e180..e28a3fd0 100644
--- a/src/content/dependencies/generateAlbumSocialEmbed.js
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -32,8 +32,7 @@ export default {
     data.hasImage = album.hasCoverArt;
 
     if (data.hasImage) {
-      data.coverArtDirectory = album.directory;
-      data.coverArtFileExtension = album.coverArtFileExtension;
+      data.imagePath = album.coverArtworks[0].path;
     }
 
     data.albumName = album.name;
@@ -65,7 +64,7 @@ export default {
 
         imagePath:
           (data.hasImage
-            ? ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension]
+            ? data.imagePath
             : null),
       })),
 };
diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js
deleted file mode 100644
index 6bfcc62e..00000000
--- a/src/content/dependencies/generateAlbumStyleRules.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import {empty, stitchArrays} from '#sugar';
-
-export default {
-  extraDependencies: ['to'],
-
-  data(album, track) {
-    const data = {};
-
-    data.hasWallpaper = !empty(album.wallpaperArtistContribs);
-    data.hasBanner = !empty(album.bannerArtistContribs);
-
-    if (data.hasWallpaper) {
-      if (!empty(album.wallpaperParts)) {
-        data.wallpaperMode = 'parts';
-
-        data.wallpaperPaths =
-          album.wallpaperParts.map(part =>
-            (part.asset
-              ? ['media.albumWallpaperPart', album.directory, part.asset]
-              : null));
-
-        data.wallpaperStyles =
-          album.wallpaperParts.map(part => part.style);
-      } else {
-        data.wallpaperMode = 'one';
-        data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension];
-        data.wallpaperStyle = album.wallpaperStyle;
-      }
-    }
-
-    if (data.hasBanner) {
-      data.hasBannerStyle = !!album.bannerStyle;
-      data.bannerStyle = album.bannerStyle;
-    }
-
-    data.albumDirectory = album.directory;
-
-    if (track) {
-      data.trackDirectory = track.directory;
-    }
-
-    return data;
-  },
-
-  generate(data, {to}) {
-    const indent = parts =>
-      (parts ?? [])
-        .filter(Boolean)
-        .join('\n')
-        .split('\n')
-        .map(line => ' '.repeat(4) + line)
-        .join('\n');
-
-    const rule = (selector, parts) =>
-      (!empty(parts.filter(Boolean))
-        ? [`${selector} {`, indent(parts), `}`]
-        : []);
-
-    const oneWallpaperRule =
-      data.wallpaperMode === 'one' &&
-        rule(`body::before`, [
-          `background-image: url("${to(...data.wallpaperPath)}");`,
-          data.wallpaperStyle,
-        ]);
-
-    const wallpaperPartRules =
-      data.wallpaperMode === 'parts' &&
-        stitchArrays({
-          path: data.wallpaperPaths,
-          style: data.wallpaperStyles,
-        }).map(({path, style}, index) =>
-            rule(`.wallpaper-part:nth-child(${index + 1})`, [
-              path && `background-image: url("${to(...path)}");`,
-              style,
-            ]));
-
-    const nukeBasicWallpaperRule =
-      data.wallpaperMode === 'parts' &&
-        rule(`body::before`, ['display: none']);
-
-    const wallpaperRules = [
-      oneWallpaperRule,
-      ...wallpaperPartRules || [],
-      nukeBasicWallpaperRule,
-    ];
-
-    const bannerRule =
-      data.hasBanner &&
-        rule(`#banner img`, [
-          data.bannerStyle,
-        ]);
-
-    const dataRule =
-      rule(`:root`, [
-        data.albumDirectory &&
-          `--album-directory: ${data.albumDirectory};`,
-        data.trackDirectory &&
-          `--track-directory: ${data.trackDirectory};`,
-      ]);
-
-    return (
-      [...wallpaperRules, bannerRule, dataRule]
-        .filter(Boolean)
-        .flat()
-        .join('\n'));
-  },
-};
diff --git a/src/content/dependencies/generateAlbumStyleTags.js b/src/content/dependencies/generateAlbumStyleTags.js
new file mode 100644
index 00000000..4cdc6581
--- /dev/null
+++ b/src/content/dependencies/generateAlbumStyleTags.js
@@ -0,0 +1,65 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['generateAlbumWallpaperStyleTag', 'generateStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album, _track) => ({
+    styleTag:
+      relation('generateStyleTag'),
+
+    wallpaperStyleTag:
+      relation('generateAlbumWallpaperStyleTag', album),
+  }),
+
+  data(album, track) {
+    const data = {};
+
+    data.hasBanner = !empty(album.bannerArtistContribs);
+
+    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, relations, {html}) =>
+    html.tags([
+      relations.wallpaperStyleTag,
+
+      relations.styleTag.clone().slots({
+        attributes: {class: 'album-banner-style'},
+
+        rules: [
+          data.hasBanner && {
+            select: '#banner img',
+            declare: [data.bannerStyle],
+          },
+        ],
+      }),
+
+      relations.styleTag.clone().slots({
+        attributes: {class: 'album-directory-style'},
+
+        rules: [
+          {
+            select: ':root',
+            declare: [
+              data.albumDirectory &&
+                `--album-directory: ${data.albumDirectory};`,
+              data.trackDirectory &&
+                `--track-directory: ${data.trackDirectory};`,
+            ],
+          },
+        ]
+      }),
+    ], {[html.joinChildren]: ''}),
+};
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
index 9743c750..0a949ded 100644
--- a/src/content/dependencies/generateAlbumTrackList.js
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -102,11 +102,11 @@ export default {
             .map(section => section.tracks.length > 1);
 
         if (album.hasTrackNumbers) {
-          data.trackSectionStartIndices =
+          data.trackSectionsStartCountingFrom =
             album.trackSections
-              .map(section => section.startIndex);
+              .map(section => section.startCountingFrom);
         } else {
-          data.trackSectionStartIndices =
+          data.trackSectionsStartCountingFrom =
             album.trackSections
               .map(() => null);
         }
@@ -147,7 +147,7 @@ export default {
             name: data.trackSectionNames,
             duration: data.trackSectionDurations,
             durationApproximate: data.trackSectionDurationsApproximate,
-            startIndex: data.trackSectionStartIndices,
+            startCountingFrom: data.trackSectionsStartCountingFrom,
           }).map(({
               heading,
               description,
@@ -156,7 +156,7 @@ export default {
               name,
               duration,
               durationApproximate,
-              startIndex,
+              startCountingFrom,
             }) => [
               language.encapsulate('trackList.section', capsule =>
                 heading.slots({
@@ -190,7 +190,7 @@ export default {
 
                 html.tag(listTag,
                   data.hasTrackNumbers &&
-                    {start: startIndex + 1},
+                    {start: startCountingFrom},
 
                   slotItems(items)),
               ]),
diff --git a/src/content/dependencies/generateAlbumWallpaperStyleTag.js b/src/content/dependencies/generateAlbumWallpaperStyleTag.js
new file mode 100644
index 00000000..47864a1d
--- /dev/null
+++ b/src/content/dependencies/generateAlbumWallpaperStyleTag.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateWallpaperStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    wallpaperStyleTag:
+      (album.hasWallpaperArt
+        ? relation('generateWallpaperStyleTag')
+        : null),
+  }),
+
+  data: (album) => ({
+    singleWallpaperPath:
+      ['media.albumWallpaper', album.directory, album.wallpaperFileExtension],
+
+    singleWallpaperStyle:
+      album.wallpaperStyle,
+
+    wallpaperPartPaths:
+      album.wallpaperParts.map(part =>
+        (part.asset
+          ? ['media.albumWallpaperPart', album.directory, part.asset]
+          : null)),
+
+    wallpaperPartStyles:
+      album.wallpaperParts.map(part => part.style),
+  }),
+
+  generate: (data, relations, {html}) =>
+    (relations.wallpaperStyleTag
+      ? relations.wallpaperStyleTag.slots({
+          singleWallpaperPath: data.singleWallpaperPath,
+          singleWallpaperStyle: data.singleWallpaperStyle,
+          wallpaperPartPaths: data.wallpaperPartPaths,
+          wallpaperPartStyles: data.wallpaperPartStyles,
+        })
+      : html.blank()),
+};
diff --git a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
new file mode 100644
index 00000000..80d19b5a
--- /dev/null
+++ b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
@@ -0,0 +1,153 @@
+import {
+  filterMultipleArrays,
+  sortMultipleArrays,
+  stitchArrays,
+  unique,
+} from '#sugar';
+
+export default {
+  contentDependencies: ['linkArtTagDynamically'],
+  extraDependencies: ['html', 'language'],
+
+  // Recursion ain't too pretty!
+
+  query(ancestorArtTag, targetArtTag) {
+    const recursive = artTag => {
+      const artTags =
+        artTag.directDescendantArtTags.slice();
+
+      const displayBriefly =
+        !artTags.includes(targetArtTag) &&
+        artTags.length > 3;
+
+      const artTagsIncludeTargetArtTag =
+        artTags.map(artTag => artTag.allDescendantArtTags.includes(targetArtTag));
+
+      const numExemptArtTags =
+        (displayBriefly
+          ? artTagsIncludeTargetArtTag
+              .filter(includesTargetArtTag => !includesTargetArtTag)
+              .length
+          : null);
+
+      const artTagsTimesFeaturedTotal =
+        artTags.map(artTag =>
+          unique([
+            ...artTag.directlyFeaturedInArtworks,
+            ...artTag.indirectlyFeaturedInArtworks,
+          ]).length);
+
+      const sublists =
+        stitchArrays({
+          artTag: artTags,
+          includesTargetArtTag: artTagsIncludeTargetArtTag,
+        }).map(({artTag, includesTargetArtTag}) =>
+            (includesTargetArtTag
+              ? recursive(artTag)
+              : null));
+
+      if (displayBriefly) {
+        filterMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal,
+          (artTag, sublist) =>
+            artTag === targetArtTag ||
+            sublist !== null);
+      } else {
+        sortMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal,
+          (artTagA, artTagB, sublistA, sublistB) =>
+            (sublistA && sublistB
+              ? 0
+           : !sublistA && !sublistB
+              ? 0
+           : sublistA
+              ? 1
+              : -1));
+      }
+
+      return {
+        displayBriefly,
+        numExemptArtTags,
+        artTags,
+        artTagsTimesFeaturedTotal,
+        sublists,
+      };
+    };
+
+    return {root: recursive(ancestorArtTag)};
+  },
+
+  relations(relation, query, _ancestorArtTag, _targetArtTag) {
+    const recursive = ({artTags, sublists}) => ({
+      artTagLinks:
+        artTags
+          .map(artTag => relation('linkArtTagDynamically', artTag)),
+
+      sublists:
+        sublists
+          .map(sublist => (sublist ? recursive(sublist) : null)),
+    });
+
+    return {root: recursive(query.root)};
+  },
+
+  data(query, _ancestorArtTag, targetArtTag) {
+    const recursive = ({
+      displayBriefly,
+      numExemptArtTags,
+      artTags,
+      artTagsTimesFeaturedTotal,
+      sublists,
+    }) => ({
+      displayBriefly,
+      numExemptArtTags,
+      artTagsTimesFeaturedTotal,
+
+      artTagsAreTargetTag:
+        artTags
+          .map(artTag => artTag === targetArtTag),
+
+      sublists:
+        sublists
+          .map(sublist => (sublist ? recursive(sublist) : null)),
+    });
+
+    return {root: recursive(query.root)};
+  },
+
+  generate(data, relations, {html, language}) {
+    const recursive = (dataNode, relationsNode) =>
+      html.tag('dl', {class: dataNode === data.root && 'tree-list'}, [
+        dataNode.displayBriefly &&
+          html.tag('dt',
+            language.$('artTagPage.sidebar.otherTagsExempt', {
+              tags:
+                language.countArtTags(dataNode.numExemptArtTags, {unit: true}),
+            })),
+
+        stitchArrays({
+          isTargetTag: dataNode.artTagsAreTargetTag,
+          timesFeaturedTotal: dataNode.artTagsTimesFeaturedTotal,
+          dataSublist: dataNode.sublists,
+
+          artTagLink: relationsNode.artTagLinks,
+          relationsSublist: relationsNode.sublists,
+        }).map(({
+            isTargetTag, timesFeaturedTotal, dataSublist,
+            artTagLink, relationsSublist,
+          }) => [
+            html.tag('dt',
+              {class: (dataSublist || isTargetTag) && 'current'},
+              [
+                artTagLink,
+                html.tag('span', {class: 'times-used'},
+                  language.countTimesFeatured(timesFeaturedTotal)),
+              ]),
+
+            dataSublist &&
+              html.tag('dd',
+                recursive(dataSublist, relationsSublist)),
+          ]),
+      ]);
+
+    return recursive(data.root, relations.root);
+  },
+};
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index d55a628b..cfd6d03e 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -1,14 +1,19 @@
-import {sortAlbumsTracksChronologically} from '#sort';
-import {stitchArrays} from '#sugar';
+import {sortArtworksChronologically} from '#sort';
+import {empty, stitchArrays, unique} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateArtTagGalleryPageFeaturedLine',
+    'generateArtTagGalleryPageShowingLine',
+    'generateArtTagNavLinks',
     'generateCoverGrid',
     'generatePageLayout',
+    'generateQuickDescription',
     'image',
-    'linkAlbum',
-    'linkArtTag',
-    'linkTrack',
+    'linkAnythingMan',
+    'linkArtTagGallery',
+    'linkExternal',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -19,74 +24,107 @@ export default {
     };
   },
 
-  query(sprawl, tag) {
-    const things = tag.taggedInThings.slice();
+  query(sprawl, artTag) {
+    const directArtworks = artTag.directlyFeaturedInArtworks;
+    const indirectArtworks = artTag.indirectlyFeaturedInArtworks;
+    const allArtworks = unique([...directArtworks, ...indirectArtworks]);
 
-    sortAlbumsTracksChronologically(things, {
-      getDate: thing => thing.coverArtDate ?? thing.date,
-      latestFirst: true,
-    });
+    sortArtworksChronologically(allArtworks, {latestFirst: true});
 
-    return {things};
+    return {directArtworks, indirectArtworks, allArtworks};
   },
 
-  relations(relation, query, sprawl, tag) {
+  relations(relation, query, sprawl, artTag) {
     const relations = {};
 
     relations.layout =
       relation('generatePageLayout');
 
-    relations.artTagMainLink =
-      relation('linkArtTag', tag);
+    relations.navLinks =
+      relation('generateArtTagNavLinks', artTag);
+
+    relations.additionalNamesBox =
+      relation('generateAdditionalNamesBox', artTag.additionalNames);
+
+    relations.quickDescription =
+      relation('generateQuickDescription', artTag);
+
+    relations.featuredLine =
+      relation('generateArtTagGalleryPageFeaturedLine');
+
+    relations.showingLine =
+      relation('generateArtTagGalleryPageShowingLine');
+
+    if (!empty(artTag.extraReadingURLs)) {
+      relations.extraReadingLinks =
+        artTag.extraReadingURLs
+          .map(url => relation('linkExternal', url));
+    }
+
+    if (!empty(artTag.directAncestorArtTags)) {
+      relations.ancestorLinks =
+        artTag.directAncestorArtTags
+          .map(artTag => relation('linkArtTagGallery', artTag));
+    }
+
+    if (!empty(artTag.directDescendantArtTags)) {
+      relations.descendantLinks =
+        artTag.directDescendantArtTags
+          .map(artTag => relation('linkArtTagGallery', artTag));
+    }
 
     relations.coverGrid =
       relation('generateCoverGrid');
 
     relations.links =
-      query.things.map(thing =>
-        (thing.album
-          ? relation('linkTrack', thing)
-          : relation('linkAlbum', thing)));
+      query.allArtworks
+        .map(artwork => relation('linkAnythingMan', artwork.thing));
 
     relations.images =
-      query.things.map(thing =>
-        relation('image', thing.artTags));
+      query.allArtworks
+        .map(artwork => relation('image', artwork));
 
     return relations;
   },
 
-  data(query, sprawl, tag) {
+  data(query, sprawl, artTag) {
     const data = {};
 
     data.enableListings = sprawl.enableListings;
 
-    data.name = tag.name;
-    data.color = tag.color;
+    data.name = artTag.name;
+    data.color = artTag.color;
 
-    data.numArtworks = query.things.length;
+    data.numArtworksIndirectly = query.indirectArtworks.length;
+    data.numArtworksDirectly = query.directArtworks.length;
+    data.numArtworksTotal = query.allArtworks.length;
 
     data.names =
-      query.things.map(thing => thing.name);
+      query.allArtworks
+        .map(artwork => artwork.thing.name);
 
-    data.paths =
-      query.things.map(thing =>
-        (thing.album
-          ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
-          : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
+    data.artworkArtists =
+      query.allArtworks
+        .map(artwork => artwork.artistContribs
+          .map(contrib => contrib.artist.name));
 
-    data.dimensions =
-      query.things.map(thing => thing.coverArtDimensions);
+    data.artworkLabels =
+      query.allArtworks
+        .map(artwork => artwork.label)
 
-    data.coverArtists =
-      query.things.map(thing =>
-        thing.coverArtistContribs
-          .map(({artist}) => artist.name));
+    data.onlyFeaturedIndirectly =
+      query.allArtworks.map(artwork =>
+        !query.directArtworks.includes(artwork));
+
+    data.hasMixedDirectIndirect =
+      data.onlyFeaturedIndirectly.includes(true) &&
+      data.onlyFeaturedIndirectly.includes(false);
 
     return data;
   },
 
   generate: (data, relations, {html, language}) =>
-    language.encapsulate('tagPage', pageCapsule =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
       relations.layout.slots({
         title:
           language.$(pageCapsule, 'title', {
@@ -94,59 +132,107 @@ export default {
           }),
 
         headingMode: 'static',
-
         color: data.color,
 
+        additionalNames: relations.additionalNamesBox,
+
         mainClasses: ['top-index'],
         mainContent: [
-          html.tag('p', {class: 'quick-info'},
-            language.$(pageCapsule, 'infoLine', {
-              coverArts: language.countCoverArts(data.numArtworks, {
-                unit: true,
+          relations.quickDescription.slots({
+            extraReadingLinks: relations.extraReadingLinks ?? null,
+          }),
+
+          data.numArtworksTotal === 0 &&
+            html.tag('p', {class: 'quick-info'},
+              language.encapsulate(pageCapsule, 'featuredLine.notFeatured', capsule => [
+                language.$(capsule),
+                html.tag('br'),
+                language.$(capsule, 'callToAction'),
+              ])),
+
+          data.numArtworksTotal >= 1 &&
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'all',
+                count: data.numArtworksTotal,
+              }),
+
+          data.hasMixedDirectIndirect && [
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'direct',
+                count: data.numArtworksDirectly,
               }),
-            })),
+
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'indirect',
+                count: data.numArtworksIndirectly,
+              }),
+          ],
+
+          relations.ancestorLinks &&
+            html.tag('p', {id: 'descends-from-line'},
+              {class: 'quick-info'},
+              language.$(pageCapsule, 'descendsFrom', {
+                tags: language.formatUnitList(relations.ancestorLinks),
+              })),
+
+          relations.descendantLinks &&
+            html.tag('p', {id: 'descendants-line'},
+              {class: 'quick-info'},
+              language.$(pageCapsule, 'descendants', {
+                tags: language.formatUnitList(relations.descendantLinks),
+              })),
+
+          data.hasMixedDirectIndirect && [
+            relations.showingLine.clone()
+              .slot('showing', 'all'),
+
+            relations.showingLine.clone()
+              .slot('showing', 'direct'),
+
+            relations.showingLine.clone()
+              .slot('showing', 'indirect'),
+          ],
 
           relations.coverGrid
             .slots({
               links: relations.links,
+              images: relations.images,
               names: data.names,
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                  dimensions: data.dimensions,
-                }).map(({image, path, dimensions}) =>
-                    image.slots({
-                      path,
-                      dimensions,
-                    })),
+              lazy: 12,
+
+              classes:
+                data.onlyFeaturedIndirectly.map(onlyFeaturedIndirectly =>
+                  (onlyFeaturedIndirectly ? 'featured-indirectly' : '')),
 
               info:
-                data.coverArtists.map(names =>
-                  (names === null
-                    ? null
-                    : language.$('misc.coverGrid.details.coverArtists', {
-                        artists: language.formatUnitList(names),
-                      }))),
+                stitchArrays({
+                  artists: data.artworkArtists,
+                  label: data.artworkLabels,
+                }).map(({artists, label}) =>
+                    language.encapsulate('misc.coverGrid.details.coverArtists', workingCapsule => {
+                      const workingOptions = {};
+
+                      workingOptions[language.onlyIfOptions] = ['artists'];
+                      workingOptions.artists =
+                        language.formatUnitList(artists);
+
+                      if (label) {
+                        workingCapsule += '.customLabel';
+                        workingOptions.label = label;
+                      }
+
+                      return language.$(workingCapsule, workingOptions);
+                    })),
             }),
         ],
 
         navLinkStyle: 'hierarchical',
-        navLinks: [
-          {auto: 'home'},
-
-          data.enableListings &&
-            {
-              path: ['localized.listingIndex'],
-              title: language.$('listingIndex.title'),
-            },
-
-          {
-            html:
-              language.$(pageCapsule, 'nav.tag', {
-                tag: relations.artTagMainLink,
-              }),
-          },
-        ],
+        navLinks:
+          html.resolve(
+            relations.navLinks
+              .slot('currentExtra', 'gallery')),
       })),
 };
diff --git a/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js
new file mode 100644
index 00000000..b4620fa4
--- /dev/null
+++ b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js
@@ -0,0 +1,23 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    showing: {
+      validate: v => v.is('all', 'direct', 'indirect'),
+    },
+
+    count: {type: 'number'},
+  },
+
+  generate: (slots, {html, language}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      html.tag('p', {class: 'quick-info'},
+        {id: `featured-${slots.showing}-line`},
+
+        language.$(pageCapsule, 'featuredLine', slots.showing, {
+          coverArts:
+            language.countArtworks(slots.count, {
+              unit: true,
+            }),
+        }))),
+};
diff --git a/src/content/dependencies/generateArtTagGalleryPageShowingLine.js b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js
new file mode 100644
index 00000000..6df4d0e5
--- /dev/null
+++ b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js
@@ -0,0 +1,22 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    showing: {
+      validate: v => v.is('all', 'direct', 'indirect'),
+    },
+
+    count: {type: 'number'},
+  },
+
+  generate: (slots, {html, language}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      html.tag('p', {class: 'quick-info'},
+        {id: `showing-${slots.showing}-line`},
+
+        language.$(pageCapsule, 'showingLine', {
+          showing:
+            html.tag('a', {href: '#'},
+              language.$(pageCapsule, 'showingLine', slots.showing)),
+        }))),
+};
diff --git a/src/content/dependencies/generateArtTagInfoPage.js b/src/content/dependencies/generateArtTagInfoPage.js
new file mode 100644
index 00000000..9df51b77
--- /dev/null
+++ b/src/content/dependencies/generateArtTagInfoPage.js
@@ -0,0 +1,281 @@
+import {empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateArtTagNavLinks',
+    'generateArtTagSidebar',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkArtTagGallery',
+    'linkArtTagInfo',
+    'linkExternal',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableListings: wikiInfo.enableListings,
+  }),
+
+  query(sprawl, artTag) {
+    const query = {};
+
+    query.directThings =
+      artTag.directlyFeaturedInArtworks;
+
+    query.indirectThings =
+      artTag.indirectlyFeaturedInArtworks;
+
+    query.allThings =
+      unique([...query.directThings, ...query.indirectThings]);
+
+    query.allDescendantsHaveMoreDescendants =
+      artTag.directDescendantArtTags
+        .every(descendant => !empty(descendant.directDescendantArtTags));
+
+    return query;
+  },
+
+  relations: (relation, query, sprawl, artTag) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    navLinks:
+      relation('generateArtTagNavLinks', artTag),
+
+    sidebar:
+      relation('generateArtTagSidebar', artTag),
+
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', artTag.additionalNames),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    description:
+      relation('transformContent', artTag.description),
+
+    galleryLink:
+      (empty(query.allThings)
+        ? null
+        : relation('linkArtTagGallery', artTag)),
+
+    extraReadingLinks:
+      artTag.extraReadingURLs
+        .map(url => relation('linkExternal', url)),
+
+    relatedArtTagLinks:
+      artTag.relatedArtTags
+        .map(({artTag}) => relation('linkArtTagInfo', artTag)),
+
+    directAncestorLinks:
+      artTag.directAncestorArtTags
+        .map(artTag => relation('linkArtTagInfo', artTag)),
+
+    directDescendantInfoLinks:
+      artTag.directDescendantArtTags
+        .map(artTag => relation('linkArtTagInfo', artTag)),
+
+    directDescendantGalleryLinks:
+      artTag.directDescendantArtTags.map(artTag =>
+        (query.allDescendantsHaveMoreDescendants
+          ? null
+          : relation('linkArtTagGallery', artTag))),
+  }),
+
+  data: (query, sprawl, artTag) => ({
+    enableListings:
+      sprawl.enableListings,
+
+    name:
+      artTag.name,
+
+    color:
+      artTag.color,
+
+    numArtworksIndirectly:
+      query.indirectThings.length,
+
+    numArtworksDirectly:
+      query.directThings.length,
+
+    numArtworksTotal:
+      query.allThings.length,
+
+    relatedArtTagAnnotations:
+      artTag.relatedArtTags
+        .map(({annotation}) => annotation),
+
+    directDescendantTimesFeaturedTotal:
+      artTag.directDescendantArtTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artTagInfoPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            tag: language.sanitize(data.name),
+          }),
+
+        headingMode: 'sticky',
+        color: data.color,
+
+        additionalNames: relations.additionalNamesBox,
+
+        mainContent: [
+          html.tag('p',
+            language.encapsulate(pageCapsule, 'featuredIn', capsule =>
+              (data.numArtworksTotal === 0
+                ? language.$(capsule, 'notFeatured')
+
+             : data.numArtworksDirectly === 0
+                ? language.$(capsule, 'indirectlyOnly', {
+                    artworks:
+                      language.countArtworks(data.numArtworksIndirectly, {unit: true}),
+                  })
+
+             : data.numArtworksIndirectly === 0
+                ? language.$(capsule, 'directlyOnly', {
+                    artworks:
+                      language.countArtworks(data.numArtworksDirectly, {unit: true}),
+                  })
+
+                : language.$(capsule, 'directlyAndIndirectly', {
+                    artworksDirectly:
+                      language.countArtworks(data.numArtworksDirectly, {unit: true}),
+
+                    artworksIndirectly:
+                      language.countArtworks(data.numArtworksIndirectly, {unit: false}),
+
+                    artworksTotal:
+                      language.countArtworks(data.numArtworksTotal, {unit: false}),
+                  })))),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$(pageCapsule, 'viewArtGallery', {
+              [language.onlyIfOptions]: ['link'],
+
+              link:
+                relations.galleryLink
+                  ?.slot('content', language.$(pageCapsule, 'viewArtGallery.link')),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.encapsulate(pageCapsule, 'seeAlso', capsule =>
+              language.$(capsule, {
+                [language.onlyIfOptions]: ['tags'],
+
+                tags:
+                  language.formatUnitList(
+                    stitchArrays({
+                      artTagLink: relations.relatedArtTagLinks,
+                      annotation: data.relatedArtTagAnnotations,
+                    }).map(({artTagLink, annotation}) =>
+                        (html.isBlank(annotation)
+                          ? artTagLink
+                          : language.$(capsule, 'tagWithAnnotation', {
+                              tag: artTagLink,
+                              annotation,
+                            })))),
+              }))),
+
+          html.tag('blockquote',
+            {[html.onlyIfContent]: true},
+
+            relations.description
+              .slot('mode', 'multiline')),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$(pageCapsule, 'readMoreOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              tag: language.sanitize(data.name),
+              links: language.formatDisjunctionList(relations.extraReadingLinks),
+            })),
+
+          language.encapsulate(pageCapsule, 'descendsFromTags', listCapsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  title:
+                    language.$(listCapsule, {
+                      tag: language.sanitize(data.name),
+                    }),
+                }),
+
+              html.tag('ul',
+                {[html.onlyIfContent]: true},
+
+                relations.directAncestorLinks
+                  .map(link =>
+                    html.tag('li',
+                      language.$(listCapsule, 'item', {
+                        tag: link,
+                      })))),
+            ])),
+
+          language.encapsulate(pageCapsule, 'descendantTags', listCapsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  title:
+                    language.$(listCapsule, {
+                      tag: language.sanitize(data.name),
+                    }),
+                }),
+
+              html.tag('ul',
+                {[html.onlyIfContent]: true},
+
+                stitchArrays({
+                  infoLink: relations.directDescendantInfoLinks,
+                  galleryLink: relations.directDescendantGalleryLinks,
+                  timesFeaturedTotal: data.directDescendantTimesFeaturedTotal,
+                }).map(({infoLink, galleryLink, timesFeaturedTotal}) =>
+                    html.tag('li',
+                      language.encapsulate(listCapsule, 'item', itemCapsule =>
+                        language.encapsulate(itemCapsule, workingCapsule => {
+                          const workingOptions = {};
+
+                          workingOptions.tag = infoLink;
+
+                          if (!html.isBlank(galleryLink ?? html.blank())) {
+                            workingCapsule += '.withGallery';
+                            workingOptions.gallery =
+                              galleryLink.slot('content',
+                                language.$(itemCapsule, 'withGallery.gallery'));
+                          }
+
+                          if (timesFeaturedTotal >= 1) {
+                            workingCapsule += `.withTimesUsed`;
+                            workingOptions.timesUsed =
+                              language.countTimesFeatured(timesFeaturedTotal, {
+                                unit: true,
+                              });
+                          }
+
+                          return language.$(workingCapsule, workingOptions);
+                        }))))),
+            ])),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: relations.navLinks.content,
+
+        leftSidebar:
+          relations.sidebar,
+      })),
+};
diff --git a/src/content/dependencies/generateArtTagNavLinks.js b/src/content/dependencies/generateArtTagNavLinks.js
new file mode 100644
index 00000000..9061a09f
--- /dev/null
+++ b/src/content/dependencies/generateArtTagNavLinks.js
@@ -0,0 +1,81 @@
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'linkArtTagInfo',
+    'linkArtTagGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) =>
+    ({enableListings: wikiInfo.enableListings}),
+
+  relations: (relation, sprawl, tag) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    mainLink:
+      relation('linkArtTagInfo', tag),
+
+    infoLink:
+      relation('linkArtTagInfo', tag),
+
+    galleryLink:
+      relation('linkArtTagGallery', tag),
+  }),
+
+  data: (sprawl) =>
+    ({enableListings: sprawl.enableListings}),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (!data.enableListings) {
+      return [
+        {auto: 'home'},
+        {auto: 'current'},
+      ];
+    }
+
+    const infoLink =
+      relations.infoLink.slots({
+        attributes: {class: slots.currentExtra === null && 'current'},
+        content: language.$('misc.nav.info'),
+      });
+
+    const galleryLink =
+      relations.galleryLink.slots({
+        attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+        content: language.$('misc.nav.gallery'),
+      });
+
+    return [
+      {auto: 'home'},
+
+      data.enableListings &&
+        {
+          path: ['localized.listingIndex'],
+          title: language.$('listingIndex.title'),
+        },
+
+      {
+        html:
+          language.$('artTagPage.nav.tag', {
+            tag: relations.mainLink,
+          }),
+
+        accent:
+          relations.switcher.slots({
+            links: [
+              infoLink,
+              galleryLink,
+            ],
+          }),
+      },
+    ].filter(Boolean);
+  },
+};
diff --git a/src/content/dependencies/generateArtTagSidebar.js b/src/content/dependencies/generateArtTagSidebar.js
new file mode 100644
index 00000000..9e2f813c
--- /dev/null
+++ b/src/content/dependencies/generateArtTagSidebar.js
@@ -0,0 +1,124 @@
+import {collectTreeLeaves, empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'generateArtTagAncestorDescendantMapList',
+    'linkArtTagDynamically',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({artTagData}) =>
+    ({artTagData}),
+
+  query(sprawl, artTag) {
+    const baobab = artTag.ancestorArtTagBaobabTree;
+    const uniqueLeaves = new Set(collectTreeLeaves(baobab));
+
+    // Just match the order in tag data.
+    const furthestAncestorArtTags =
+      sprawl.artTagData
+        .filter(artTag => uniqueLeaves.has(artTag));
+
+    return {furthestAncestorArtTags};
+  },
+
+  relations: (relation, query, sprawl, artTag) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    artTagLink:
+      relation('linkArtTagDynamically', artTag),
+
+    directDescendantArtTagLinks:
+      artTag.directDescendantArtTags
+        .map(descendantArtTag =>
+          relation('linkArtTagDynamically', descendantArtTag)),
+
+    furthestAncestorArtTagMapLists:
+      query.furthestAncestorArtTags
+        .map(ancestorArtTag =>
+          relation('generateArtTagAncestorDescendantMapList',
+            ancestorArtTag,
+            artTag)),
+  }),
+
+  data: (query, sprawl, artTag) => ({
+    name: artTag.name,
+
+    directDescendantTimesFeaturedTotal:
+      artTag.directDescendantArtTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length),
+
+    furthestAncestorArtTagNames:
+      query.furthestAncestorArtTags
+        .map(ancestorArtTag => ancestorArtTag.name),
+  }),
+
+  generate(data, relations, {html, language}) {
+    if (
+      empty(relations.directDescendantArtTagLinks) &&
+      empty(relations.furthestAncestorArtTagMapLists)
+    ) {
+      return relations.sidebar;
+    }
+
+    return relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          content: [
+            html.tag('h1',
+              relations.artTagLink),
+
+            !empty(relations.directDescendantArtTagLinks) &&
+              html.tag('details', {class: 'current', open: true}, [
+                html.tag('summary',
+                  html.tag('span',
+                    html.tag('b',
+                      language.sanitize(data.name)))),
+
+                html.tag('ul',
+                  stitchArrays({
+                    link: relations.directDescendantArtTagLinks,
+                    timesFeaturedTotal: data.directDescendantTimesFeaturedTotal,
+                  }).map(({link, timesFeaturedTotal}) =>
+                      html.tag('li', [
+                        link,
+                        html.tag('span', {class: 'times-used'},
+                          language.countTimesFeatured(timesFeaturedTotal)),
+                      ]))),
+              ]),
+
+            stitchArrays({
+              name: data.furthestAncestorArtTagNames,
+              list: relations.furthestAncestorArtTagMapLists,
+            }).map(({name, list}) =>
+                html.tag('details',
+                  {
+                    class: 'has-tree-list',
+                    open:
+                      empty(relations.directDescendantArtTagLinks) &&
+                      relations.furthestAncestorArtTagMapLists.length === 1,
+                  },
+                  [
+                    html.tag('summary',
+                      html.tag('span',
+                        html.tag('b',
+                          language.sanitize(name)))),
+
+                      list,
+                    ])),
+          ],
+        }),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateArtistArtworkColumn.js b/src/content/dependencies/generateArtistArtworkColumn.js
new file mode 100644
index 00000000..a4135489
--- /dev/null
+++ b/src/content/dependencies/generateArtistArtworkColumn.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, artist) => ({
+    coverArtwork:
+      (artist.hasAvatar
+        ? relation('generateCoverArtwork', artist.avatarArtwork)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.coverArtwork,
+};
diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js
index 72d55854..bab32f7d 100644
--- a/src/content/dependencies/generateArtistCredit.js
+++ b/src/content/dependencies/generateArtistCredit.js
@@ -36,11 +36,18 @@ export default {
     // Note that the normal contributions will implicitly *always*
     // "differ from context" if no context contributions are given,
     // as in release info lines.
-    query.normalContributionsDifferFromContext =
+
+    query.normalContributionArtistsDifferFromContext =
       !compareArrays(
         query.normalContributions.map(({artist}) => artist),
         contextNormalContributions.map(({artist}) => artist),
-        {checkOrder: false});
+        {checkOrder: true});
+
+    query.normalContributionAnnotationsDifferFromContext =
+      !compareArrays(
+        query.normalContributions.map(({annotation}) => annotation),
+        contextNormalContributions.map(({annotation}) => annotation),
+        {checkOrder: true});
 
     return query;
   },
@@ -60,8 +67,11 @@ export default {
   }),
 
   data: (query, _creditContributions, _contextContributions) => ({
-    normalContributionsDifferFromContext:
-      query.normalContributionsDifferFromContext,
+    normalContributionArtistsDifferFromContext:
+      query.normalContributionArtistsDifferFromContext,
+
+    normalContributionAnnotationsDifferFromContext:
+      query.normalContributionAnnotationsDifferFromContext,
 
     hasWikiEdits:
       !empty(query.wikiEditContributions),
@@ -80,6 +90,8 @@ export default {
     // It won't be used if contextContributions isn't provided.
     featuringStringKey: {type: 'string'},
 
+    additionalStringOptions: {validate: v => v.isObject},
+
     showAnnotation: {type: 'boolean', default: false},
     showExternalLinks: {type: 'boolean', default: false},
     showChronology: {type: 'boolean', default: false},
@@ -146,23 +158,37 @@ export default {
         ...relations.featuringContributionLinks,
       ]);
 
+    const effectivelyDiffers =
+      (slots.showAnnotation && data.normalContributionAnnotationsDifferFromContext) ||
+      (data.normalContributionArtistsDifferFromContext);
+
     if (empty(relations.featuringContributionLinks)) {
-      if (data.normalContributionsDifferFromContext) {
-        return language.$(slots.normalStringKey, {artists: artistsList});
+      if (effectivelyDiffers) {
+        return language.$(slots.normalStringKey, {
+          ...slots.additionalStringOptions,
+          artists: artistsList,
+        });
       } else {
         return html.blank();
       }
     }
 
-    if (data.normalContributionsDifferFromContext && slots.normalFeaturingStringKey) {
+    if (effectivelyDiffers && slots.normalFeaturingStringKey) {
       return language.$(slots.normalFeaturingStringKey, {
+        ...slots.additionalStringOptions,
         artists: artistsList,
         featuring: featuringList,
       });
     } else if (slots.featuringStringKey) {
-      return language.$(slots.featuringStringKey, {artists: featuringList});
+      return language.$(slots.featuringStringKey, {
+        ...slots.additionalStringOptions,
+        artists: featuringList,
+      });
     } else {
-      return language.$(slots.normalStringKey, {artists: everyoneList});
+      return language.$(slots.normalStringKey, {
+        ...slots.additionalStringOptions,
+        artists: everyoneList,
+      });
     }
   },
 };
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index 7a76188a..6a24275e 100644
--- a/src/content/dependencies/generateArtistGalleryPage.js
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -1,5 +1,4 @@
-import {sortAlbumsTracksChronologically} from '#sort';
-import {stitchArrays} from '#sugar';
+import {sortArtworksChronologically} from '#sort';
 
 export default {
   contentDependencies: [
@@ -7,83 +6,59 @@ export default {
     'generateCoverGrid',
     'generatePageLayout',
     'image',
-    'linkAlbum',
-    'linkTrack',
+    'linkAnythingMan',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  query(artist) {
-    const things =
-      ([
-        artist.albumCoverArtistContributions,
-        artist.trackCoverArtistContributions,
-      ]).flat()
-        .filter(({annotation}) => !annotation?.startsWith(`edits for wiki`))
-        .map(({thing}) => thing);
-
-    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(({artist: otherArtist}) => otherArtist !== artist)
-              .map(({artist: otherArtist}) => otherArtist.name)
-          : null));
-
-    return data;
-  },
+  query: (artist) => ({
+    artworks:
+      sortArtworksChronologically(
+        ([
+          artist.albumCoverArtistContributions,
+          artist.trackCoverArtistContributions,
+        ]).flat()
+          .filter(contrib => !contrib.annotation?.startsWith(`edits for wiki`))
+          .map(contrib => contrib.thing),
+        {latestFirst: true}),
+  }),
+
+  relations: (relation, query, artist) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    artistNavLinks:
+      relation('generateArtistNavLinks', artist),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      query.artworks
+        .map(artwork => relation('linkAnythingMan', artwork.thing)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, artist) => ({
+    name:
+      artist.name,
+
+    numArtworks:
+      query.artworks.length,
+
+    names:
+      query.artworks
+        .map(artwork => artwork.thing.name),
+
+    otherCoverArtists:
+      query.artworks
+        .map(artwork => artwork.artistContribs
+          .filter(contrib => contrib.artist !== artist)
+          .map(contrib => contrib.artist.name)),
+  }),
 
   generate: (data, relations, {html, language}) =>
     language.encapsulate('artistGalleryPage', pageCapsule =>
@@ -100,7 +75,7 @@ export default {
           html.tag('p', {class: 'quick-info'},
             language.$(pageCapsule, 'infoLine', {
               coverArts:
-                language.countCoverArts(data.numArtworks, {
+                language.countArtworks(data.numArtworks, {
                   unit: true,
                 }),
             })),
@@ -108,27 +83,16 @@ export default {
           relations.coverGrid
             .slots({
               links: relations.links,
+              images: relations.images,
               names: data.names,
 
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                  dimensions: data.dimensions,
-                }).map(({image, path, dimensions}) =>
-                    image.slots({
-                      path,
-                      dimensions,
-                    })),
-
-              // TODO: Can this be [language.onlyIfOptions]?
               info:
                 data.otherCoverArtists.map(names =>
-                  (names === null
-                    ? null
-                    : language.$('misc.coverGrid.details.otherCoverArtists', {
-                        artists: language.formatUnitList(names),
-                      }))),
+                  language.$('misc.coverGrid.details.otherCoverArtists', {
+                    [language.onlyIfOptions]: ['artists'],
+
+                    artists: language.formatUnitList(names),
+                  })),
             }),
         ],
 
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index f84d00de..e1fa7a0b 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -1,83 +1,90 @@
-import {empty, filterProperties, stitchArrays, unique} from '#sugar';
+import {accumulateSum, empty, stitchArrays, withEntries} from '#sugar';
 
 export default {
   contentDependencies: ['linkGroup'],
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupCategoryData}) {
-    return {
-      groupOrder: groupCategoryData.flatMap(category => category.groups),
-    }
-  },
+  sprawl: ({groupCategoryData}) => ({
+    groupOrder:
+      groupCategoryData.flatMap(category => category.groups),
+  }),
 
-  query(sprawl, tracksAndAlbums) {
-    const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album);
-    const filteredTracks = tracksAndAlbums.filter(thing => thing.album);
+  query(sprawl, contributions) {
+    const allGroupsUnordered =
+      new Set(contributions.flatMap(contrib => contrib.groups));
 
-    const allAlbums = unique([
-      ...filteredAlbums,
-      ...filteredTracks.map(track => track.album),
-    ]);
+    const allGroupsOrdered =
+      sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
 
-    const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups));
-    const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
+    const groupToThingsCountedForContributions =
+      new Map(allGroupsOrdered.map(group => [group, new Set]));
 
-    const mapTemplate = allGroupsOrdered.map(group => [group, 0]);
-    const groupToCountMap = new Map(mapTemplate);
-    const groupToDurationMap = new Map(mapTemplate);
-    const groupToDurationCountMap = new Map(mapTemplate);
+    const groupToThingsCountedForDuration =
+      new Map(allGroupsOrdered.map(group => [group, new Set]));
 
-    for (const album of filteredAlbums) {
-      for (const group of album.groups) {
-        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
-      }
-    }
+    for (const contrib of contributions) {
+      for (const group of contrib.groups) {
+        if (contrib.countInContributionTotals) {
+          groupToThingsCountedForContributions.get(group).add(contrib.thing);
+        }
 
-    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);
+        if (contrib.countInDurationTotals) {
+          groupToThingsCountedForDuration.get(group).add(contrib.thing);
         }
       }
     }
 
+    const groupToTotalContributions =
+      withEntries(
+        groupToThingsCountedForContributions,
+        entries => entries.map(
+          ([group, things]) =>
+          ([group, things.size])));
+
+    const groupToTotalDuration =
+      withEntries(
+        groupToThingsCountedForDuration,
+        entries => entries.map(
+          ([group, things]) =>
+          ([group, accumulateSum(things, thing => thing.duration)])))
+
     const groupsSortedByCount =
       allGroupsOrdered
-        .slice()
-        .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a));
+        .filter(group => groupToTotalContributions.get(group) > 0)
+        .sort((a, b) =>
+          (groupToTotalContributions.get(b)
+         - groupToTotalContributions.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));
+        .filter(group => groupToTotalDuration.get(group) > 0)
+        .sort((a, b) =>
+          (groupToTotalDuration.get(b)
+         - groupToTotalDuration.get(a)));
 
     const groupCountsSortedByCount =
       groupsSortedByCount
-        .map(group => groupToCountMap.get(group));
+        .map(group => groupToTotalContributions.get(group));
 
     const groupDurationsSortedByCount =
       groupsSortedByCount
-        .map(group => groupToDurationMap.get(group));
+        .map(group => groupToTotalDuration.get(group));
 
     const groupDurationsApproximateSortedByCount =
       groupsSortedByCount
-        .map(group => groupToDurationCountMap.get(group) > 1);
+        .map(group => groupToThingsCountedForDuration.get(group).size > 1);
 
     const groupCountsSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToCountMap.get(group));
+        .map(group => groupToTotalContributions.get(group));
 
     const groupDurationsSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToDurationMap.get(group));
+        .map(group => groupToTotalDuration.get(group));
 
     const groupDurationsApproximateSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToDurationCountMap.get(group) > 1);
+        .map(group => groupToThingsCountedForDuration.get(group).size > 1);
 
     return {
       groupsSortedByCount,
@@ -93,29 +100,35 @@ export default {
     };
   },
 
-  relations(relation, query) {
-    return {
-      groupLinksSortedByCount:
-        query.groupsSortedByCount
-          .map(group => relation('linkGroup', group)),
+  relations: (relation, query) => ({
+    groupLinksSortedByCount:
+      query.groupsSortedByCount
+        .map(group => relation('linkGroup', group)),
 
-      groupLinksSortedByDuration:
-        query.groupsSortedByDuration
-          .map(group => relation('linkGroup', group)),
-    };
-  },
+    groupLinksSortedByDuration:
+      query.groupsSortedByDuration
+        .map(group => relation('linkGroup', group)),
+  }),
 
-  data(query) {
-    return filterProperties(query, [
-      'groupCountsSortedByCount',
-      'groupDurationsSortedByCount',
-      'groupDurationsApproximateSortedByCount',
+  data: (query) => ({
+    groupCountsSortedByCount:
+      query.groupCountsSortedByCount,
 
-      'groupCountsSortedByDuration',
-      'groupDurationsSortedByDuration',
-      'groupDurationsApproximateSortedByDuration',
-    ]);
-  },
+    groupDurationsSortedByCount:
+      query.groupDurationsSortedByCount,
+
+    groupDurationsApproximateSortedByCount:
+      query.groupDurationsApproximateSortedByCount,
+
+    groupCountsSortedByDuration:
+      query.groupCountsSortedByDuration,
+
+    groupDurationsSortedByDuration:
+      query.groupDurationsSortedByDuration,
+
+    groupDurationsApproximateSortedByDuration:
+      query.groupDurationsApproximateSortedByDuration,
+  }),
 
   slots: {
     title: {
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 9701341b..1f738de4 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -2,6 +2,7 @@ import {empty, stitchArrays, unique} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateArtistArtworkColumn',
     'generateArtistGroupContributionsInfo',
     'generateArtistInfoPageArtworksChunkedList',
     'generateArtistInfoPageCommentaryChunkedList',
@@ -9,7 +10,6 @@ export default {
     'generateArtistInfoPageTracksChunkedList',
     'generateArtistNavLinks',
     'generateContentHeading',
-    'generateCoverArtwork',
     'generatePageLayout',
     'linkArtistGallery',
     'linkExternal',
@@ -20,29 +20,17 @@ export default {
   extraDependencies: ['html', 'language'],
 
   query: (artist) => ({
-    // Even if an artist has served as both "artist" (compositional) and
-    // "contributor" (instruments, production, etc) on the same track, that
-    // track only counts as one unique contribution in the list.
-    allTracks:
-      unique(
-        ([
-          artist.trackArtistContributions,
-          artist.trackContributorContributions,
-        ]).flat()
-          .map(({thing}) => thing)),
-
-    // Artworks are different, though. We intentionally duplicate album data
-    // objects when the artist has contributed some combination of cover art,
-    // wallpaper, and banner - these each count as a unique contribution.
-    allArtworks:
-      ([
-        artist.albumCoverArtistContributions,
-        artist.albumWallpaperArtistContributions,
-        artist.albumBannerArtistContributions,
-        artist.trackCoverArtistContributions,
-      ]).flat()
-        .filter(({annotation}) => !annotation?.startsWith('edits for wiki'))
-        .map(({thing}) => thing),
+    trackContributions: [
+      ...artist.trackArtistContributions,
+      ...artist.trackContributorContributions,
+    ],
+
+    artworkContributions: [
+      ...artist.albumCoverArtistContributions,
+      ...artist.albumWallpaperArtistContributions,
+      ...artist.albumBannerArtistContributions,
+      ...artist.trackCoverArtistContributions,
+    ],
 
     // Banners and wallpapers don't show up in the artist gallery page, only
     // cover art.
@@ -68,10 +56,8 @@ export default {
     artistNavLinks:
       relation('generateArtistNavLinks', artist),
 
-    cover:
-      (artist.hasAvatar
-        ? relation('generateCoverArtwork', [], [])
-        : null),
+    artworkColumn:
+      relation('generateArtistArtworkColumn', artist),
 
     contentHeading:
       relation('generateContentHeading'),
@@ -95,7 +81,7 @@ export default {
       relation('generateArtistInfoPageTracksChunkedList', artist),
 
     tracksGroupInfo:
-      relation('generateArtistGroupContributionsInfo', query.allTracks),
+      relation('generateArtistGroupContributionsInfo', query.trackContributions),
 
     artworksChunkedList:
       relation('generateArtistInfoPageArtworksChunkedList', artist, false),
@@ -104,7 +90,7 @@ export default {
       relation('generateArtistInfoPageArtworksChunkedList', artist, true),
 
     artworksGroupInfo:
-      relation('generateArtistGroupContributionsInfo', query.allArtworks),
+      relation('generateArtistGroupContributionsInfo', query.artworkContributions),
 
     artistGalleryLink:
       (query.hasGallery
@@ -125,20 +111,16 @@ export default {
     name:
       artist.name,
 
-    directory:
-      artist.directory,
-
-    avatarFileExtension:
-      (artist.hasAvatar
-        ? artist.avatarFileExtension
-        : null),
-
     closeGroupAnnotations:
       query.generalLinkedGroups
         .map(({annotation}) => annotation),
 
     totalTrackCount:
-      query.allTracks.length,
+      unique(
+        query.trackContributions
+          .filter(contrib => contrib.countInContributionTotals)
+          .map(contrib => contrib.thing))
+        .length,
 
     totalDuration:
       artist.totalDuration,
@@ -150,16 +132,8 @@ export default {
         title: data.name,
         headingMode: 'sticky',
 
-        cover:
-          (relations.cover
-            ? relations.cover.slots({
-                path: [
-                  'media.artistAvatar',
-                  data.directory,
-                  data.avatarFileExtension,
-                ],
-              })
-            : null),
+        artworkColumnContent:
+          relations.artworkColumn,
 
         mainContent: [
           html.tags([
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
index 089cfb8d..cb436b0f 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
@@ -1,8 +1,11 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: [
     'generateArtistInfoPageChunkItem',
     'generateArtistInfoPageOtherArtistLinks',
     'linkTrack',
+    'transformContent',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -24,11 +27,14 @@ export default {
 
     trackLink:
       (query.kind === 'track-cover'
-        ? relation('linkTrack', contrib.thing)
+        ? relation('linkTrack', contrib.thing.thing)
         : null),
 
     otherArtistLinks:
       relation('generateArtistInfoPageOtherArtistLinks', [contrib]),
+
+    originDetails:
+      relation('transformContent', contrib.thing.originDetails),
   }),
 
   data: (query, contrib) => ({
@@ -37,6 +43,9 @@ export default {
 
     annotation:
       contrib.annotation,
+
+    label:
+      contrib.thing.label,
   }),
 
   slots: {
@@ -51,9 +60,33 @@ export default {
       otherArtistLinks: relations.otherArtistLinks,
 
       annotation:
-        (slots.filterEditsForWiki
-          ? data.annotation?.replace(/^edits for wiki(: )?/, '')
-          : data.annotation),
+        language.encapsulate('artistPage.creditList.entry.artwork.accent', workingCapsule => {
+          const workingOptions = {};
+
+          const artworkLabel = data.label;
+
+          if (artworkLabel) {
+            workingCapsule += '.withLabel';
+            workingOptions.label =
+              language.typicallyLowerCase(artworkLabel);
+          }
+
+          const contribAnnotation =
+            (slots.filterEditsForWiki
+              ? data.annotation?.replace(/^edits for wiki(: )?/, '')
+              : data.annotation);
+
+          if (contribAnnotation) {
+            workingCapsule += '.withAnnotation';
+            workingOptions.annotation = contribAnnotation;
+          }
+
+          if (empty(Object.keys(workingOptions))) {
+            return html.blank();
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
 
       content:
         language.encapsulate('artistPage.creditList.entry', capsule =>
@@ -68,5 +101,11 @@ export default {
                  : data.kind === 'banner'
                     ? language.$(capsule, 'bannerArt')
                     : language.$(capsule, 'coverArt')))))),
+
+      originDetails:
+        relations.originDetails.slots({
+          mode: 'inline',
+          absorbPunctuationFollowingExternalLinks: false,
+        }),
     }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
index 8b024147..75a4aa5a 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -27,20 +27,21 @@ export default {
 
     sortContributionsChronologically(
       filteredContributions,
-      sortAlbumsTracksChronologically);
+      sortAlbumsTracksChronologically,
+      {getThing: contrib => contrib.thing.thing});
 
     query.contribs =
       chunkByConditions(filteredContributions, [
         ({date: date1}, {date: date2}) =>
           +date1 !== +date2,
-        ({thing: thing1}, {thing: thing2}) =>
+        ({thing: {thing: thing1}}, {thing: {thing: thing2}}) =>
           (thing1.album ?? thing1) !==
           (thing2.album ?? thing2),
       ]);
 
     query.albums =
       query.contribs
-        .map(contribs => contribs[0].thing)
+        .map(contribs => contribs[0].thing.thing)
         .map(thing => thing.album ?? thing);
 
     return query;
diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js
index c16d50f3..fce68a7d 100644
--- a/src/content/dependencies/generateArtistInfoPageChunk.js
+++ b/src/content/dependencies/generateArtistInfoPageChunk.js
@@ -8,6 +8,8 @@ export default {
       validate: v => v.is('flash', 'album'),
     },
 
+    id: {type: 'string'},
+
     albumLink: {
       type: 'html',
       mutable: false,
@@ -99,9 +101,13 @@ export default {
     }
 
     return html.tags([
-      html.tag('dt', accentedLink),
+      html.tag('dt',
+        slots.id && {id: slots.id},
+        accentedLink),
+
       html.tag('dd',
         html.tag('ul',
+          {class: 'offset-tooltips'},
           slots.items)),
     ]);
   },
diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js
index 9d406c67..c80aeab7 100644
--- a/src/content/dependencies/generateArtistInfoPageChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -1,8 +1,14 @@
 import {empty} from '#sugar';
 
 export default {
+  contentDependencies: ['generateTextWithTooltip'],
   extraDependencies: ['html', 'language'],
 
+  relations: (relation) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+  }),
+
   slots: {
     content: {
       type: 'html',
@@ -18,41 +24,80 @@ export default {
       validate: v => v.strictArrayOf(v.isHTML),
     },
 
-    rerelease: {type: 'boolean'},
+    rereleaseTooltip: {
+      type: 'html',
+      mutable: false,
+    },
+
+    firstReleaseTooltip: {
+      type: 'html',
+      mutable: false,
+    },
+
+    originDetails: {
+      type: 'html',
+      mutable: false,
+    },
   },
 
-  generate: (slots, {html, language}) =>
+  generate: (relations, slots, {html, language}) =>
     language.encapsulate('artistPage.creditList.entry', entryCapsule =>
       html.tag('li',
         slots.rerelease && {class: 'rerelease'},
 
-        language.encapsulate(entryCapsule, workingCapsule => {
-          const workingOptions = {entry: slots.content};
-
-          if (slots.rerelease) {
-            workingCapsule += '.rerelease';
-            return language.$(workingCapsule, workingOptions);
-          }
-
-          let anyAccent = false;
-
-          if (!empty(slots.otherArtistLinks)) {
-            anyAccent = true;
-            workingCapsule += '.withArtists';
-            workingOptions.artists =
-              language.formatConjunctionList(slots.otherArtistLinks);
-          }
-
-          if (!html.isBlank(slots.annotation)) {
-            anyAccent = true;
-            workingCapsule += '.withAnnotation';
-            workingOptions.annotation = slots.annotation;
-          }
-
-          if (anyAccent) {
-            return language.$(workingCapsule, workingOptions);
-          } else {
-            return slots.content;
-          }
-        }))),
+        html.tags([
+          language.encapsulate(entryCapsule, workingCapsule => {
+            const workingOptions = {entry: slots.content};
+
+            if (!html.isBlank(slots.rereleaseTooltip)) {
+              workingCapsule += '.rerelease';
+              workingOptions.rerelease =
+                relations.textWithTooltip.slots({
+                  attributes: {class: 'rerelease'},
+                  text: language.$(entryCapsule, 'rerelease.term'),
+                  tooltip: slots.rereleaseTooltip,
+                });
+
+              return language.$(workingCapsule, workingOptions);
+            }
+
+            if (!html.isBlank(slots.firstReleaseTooltip)) {
+              workingCapsule += '.firstRelease';
+              workingOptions.firstRelease =
+                relations.textWithTooltip.slots({
+                  attributes: {class: 'first-release'},
+                  text: language.$(entryCapsule, 'firstRelease.term'),
+                  tooltip: slots.firstReleaseTooltip,
+                });
+
+              return language.$(workingCapsule, workingOptions);
+            }
+
+            let anyAccent = false;
+
+            if (!empty(slots.otherArtistLinks)) {
+              anyAccent = true;
+              workingCapsule += '.withArtists';
+              workingOptions.artists =
+                language.formatConjunctionList(slots.otherArtistLinks);
+            }
+
+            if (!html.isBlank(slots.annotation)) {
+              anyAccent = true;
+              workingCapsule += '.withAnnotation';
+              workingOptions.annotation = slots.annotation;
+            }
+
+            if (anyAccent) {
+              return language.$(workingCapsule, workingOptions);
+            } else {
+              return slots.content;
+            }
+          }),
+
+          html.tag('span', {class: 'origin-details'},
+            {[html.onlyIfContent]: true},
+
+            slots.originDetails),
+        ]))),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
index d0c5e14e..88c5ed54 100644
--- a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -43,6 +43,7 @@ export default {
         flash,
 
         annotation: entry.annotation,
+        annotationParts: entry.annotationParts,
       },
     });
 
@@ -88,10 +89,10 @@ export default {
           thing.commentary
             .filter(entry => entry.artists.includes(artist))
 
-            .filter(({annotation}) =>
+            .filter(entry =>
               (filterWikiEditorCommentary
-                ? annotation?.match(/^wiki editor/i)
-                : !annotation?.match(/^wiki editor/i)))
+                ? entry.isWikiEditorCommentary
+                : !entry.isWikiEditorCommentary))
 
             .map(entry => processEntry({thing, entry})));
 
@@ -184,11 +185,13 @@ export default {
     itemAnnotations:
       query.chunks
         .map(({chunk}) => chunk
-          .map(({annotation}) =>
+          .map(entry =>
             relation('transformContent',
               (filterWikiEditorCommentary
-                ? annotation?.replace(/^wiki editor(, )?/i, '')
-                : annotation)))),
+                ? entry.annotationParts
+                    .filter(part => part !== 'wiki editor')
+                    .join(', ')
+                : entry.annotation)))),
   }),
 
   data: (query, _artist, _filterWikiEditorCommentary) => ({
diff --git a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
new file mode 100644
index 00000000..f86dead7
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
@@ -0,0 +1,75 @@
+import {sortChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateTooltip',
+    'linkOtherReleaseOnArtistInfoPage',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    rereleases:
+      sortChronologically(track.allReleases).slice(1),
+  }),
+
+  relations: (relation, query, track, artist) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    firstReleaseColorStyle:
+      relation('generateColorStyleAttribute', track.color),
+
+    rereleaseLinks:
+      query.rereleases
+        .map(rerelease =>
+          relation('linkOtherReleaseOnArtistInfoPage', rerelease, artist)),
+  }),
+
+  data: (query, track) => ({
+    firstReleaseDate:
+      track.dateFirstReleased ??
+      track.album.date,
+
+    rereleaseDates:
+      query.rereleases
+        .map(rerelease =>
+          rerelease.dateFirstReleased ??
+          rerelease.album.date),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry.firstRelease', capsule =>
+      relations.tooltip.slots({
+        attributes: [
+          {class: 'first-release-tooltip'},
+          relations.firstReleaseColorStyle,
+        ],
+
+        contentAttributes: [
+          {[html.joinChildren]: html.tag('hr', {class: 'cute'})},
+        ],
+
+        content:
+          stitchArrays({
+            rereleaseLink: relations.rereleaseLinks,
+            rereleaseDate: data.rereleaseDates,
+          }).map(({rereleaseLink, rereleaseDate}) =>
+              html.tags([
+                language.$(capsule, 'rerelease', {
+                  album:
+                    html.metatag('blockwrap', rereleaseLink),
+                }),
+
+                html.tag('br'),
+
+                language.formatRelativeDate(rereleaseDate, data.firstReleaseDate, {
+                  considerRoundingDays: true,
+                  approximate: true,
+                  absolute: true,
+                }),
+              ])),
+      })),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
new file mode 100644
index 00000000..1d849919
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
@@ -0,0 +1,61 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateTooltip',
+    'linkOtherReleaseOnArtistInfoPage'
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    firstRelease:
+      sortChronologically(track.allReleases)[0],
+  }),
+
+  relations: (relation, query, track, artist) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    rereleaseColorStyle:
+      relation('generateColorStyleAttribute', track.color),
+
+    firstReleaseLink:
+      relation('linkOtherReleaseOnArtistInfoPage', query.firstRelease, artist),
+  }),
+
+  data: (query, track) => ({
+    rereleaseDate:
+      track.dateFirstReleased ??
+      track.album.date,
+
+    firstReleaseDate:
+      query.firstRelease.dateFirstReleased ??
+      query.firstRelease.album.date,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry.rerelease', capsule =>
+      relations.tooltip.slots({
+        attributes: [
+          {class: 'rerelease-tooltip'},
+          relations.rereleaseColorStyle,
+        ],
+
+        content: [
+          language.$(capsule, 'firstRelease', {
+            album:
+              html.metatag('blockwrap', relations.firstReleaseLink),
+          }),
+
+          html.tag('br'),
+
+          language.formatRelativeDate(data.firstReleaseDate, data.rereleaseDate, {
+            considerRoundingDays: true,
+            approximate: true,
+            absolute: true,
+          }),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunk.js b/src/content/dependencies/generateArtistInfoPageTracksChunk.js
index b42e4165..f6d70901 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunk.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunk.js
@@ -40,7 +40,7 @@ export default {
         contribs
           .filter(contrib => contrib.countInDurationTotals)
           .map(contrib => contrib.thing)
-          .filter(track => track.isOriginalRelease)
+          .filter(track => track.isMainRelease)
           .filter(track => track.duration > 0));
 
     data.duration =
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
index 96976826..a42d6fee 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
@@ -1,9 +1,12 @@
+import {sortChronologically} from '#sort';
 import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
     'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageFirstReleaseTooltip',
     'generateArtistInfoPageOtherArtistLinks',
+    'generateArtistInfoPageRereleaseTooltip',
     'linkTrack',
   ],
 
@@ -61,6 +64,26 @@ export default {
       ];
     }
 
+    // It's kinda awkward to perform this chronological sort here,
+    // per track, rather than just reusing the one that's done to
+    // sort all the items on the page altogether... but then, the
+    // sort for the page is actually *a different* sort, on purpsoe.
+    // That sort is according to the dates of the contributions;
+    // this is according to the dates of the tracks. Those can be
+    // different - and it's the latter that determines whether the
+    // track is a rerelease!
+    const allReleasesChronologically =
+      sortChronologically(query.track.allReleases);
+
+    query.isFirstRelease =
+      allReleasesChronologically[0] === query.track;
+
+    query.isRerelease =
+      allReleasesChronologically[0] !== query.track;
+
+    query.hasOtherReleases =
+      !empty(query.track.otherReleases);
+
     return query;
   },
 
@@ -73,15 +96,22 @@ export default {
 
     otherArtistLinks:
       relation('generateArtistInfoPageOtherArtistLinks', contribs),
+
+    rereleaseTooltip:
+      (query.isRerelease
+        ? relation('generateArtistInfoPageRereleaseTooltip', query.track, artist)
+        : null),
+
+    firstReleaseTooltip:
+      (query.isFirstRelease && query.hasOtherReleases
+        ? relation('generateArtistInfoPageFirstReleaseTooltip', query.track, artist)
+        : null),
   }),
 
   data: (query) => ({
     duration:
       query.track.duration,
 
-    rerelease:
-      query.track.isRerelease,
-
     contribAnnotations:
       (query.displayedContributions
         ? query.displayedContributions
@@ -92,7 +122,8 @@ export default {
   generate: (data, relations, {html, language}) =>
     relations.template.slots({
       otherArtistLinks: relations.otherArtistLinks,
-      rerelease: data.rerelease,
+      rereleaseTooltip: relations.rereleaseTooltip,
+      firstReleaseTooltip: relations.firstReleaseTooltip,
 
       annotation:
         (data.contribAnnotations
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
index 7c01accb..84eb29ac 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -1,6 +1,7 @@
 import {sortAlbumsTracksChronologically, sortContributionsChronologically}
   from '#sort';
-import {chunkByConditions, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
+import {chunkArtistTrackContributions} from '#wiki-data';
 
 export default {
   contentDependencies: [
@@ -21,19 +22,7 @@ export default {
       sortAlbumsTracksChronologically);
 
     query.contribs =
-      // First chunk by (contribution) date and album.
-      chunkByConditions(allContributions, [
-        ({date: date1}, {date: date2}) =>
-          +date1 !== +date2,
-        ({thing: track1}, {thing: track2}) =>
-          track1.album !== track2.album,
-      ]).map(contribs =>
-          // Then, *within* the boundaries of the existing chunks,
-          // chunk contributions to the same thing together.
-          chunkByConditions(contribs, [
-            ({thing: thing1}, {thing: thing2}) =>
-              thing1 !== thing2,
-          ]));
+      chunkArtistTrackContributions(allContributions);
 
     query.albums =
       query.contribs
@@ -58,8 +47,35 @@ export default {
             contribs)),
   }),
 
-  generate: (relations) =>
+  data: (query, _artist) => ({
+    albumDirectories:
+      query.albums
+        .map(album => album.directory),
+
+    albumChunkIndices:
+      query.albums
+        .reduce(([indices, map], album) => {
+          if (map.has(album)) {
+            const n = map.get(album);
+            indices.push(n);
+            map.set(album, n + 1);
+          } else {
+            indices.push(0);
+            map.set(album, 1);
+          }
+          return [indices, map];
+        }, [[], new Map()])
+        [0],
+  }),
+
+  generate: (data, relations) =>
     relations.chunkedList.slots({
-      chunks: relations.chunks,
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          albumDirectory: data.albumDirectories,
+          albumChunkIndex: data.albumChunkIndices,
+        }).map(({chunk, albumDirectory, albumChunkIndex}) =>
+            chunk.slot('id', `tracks-${albumDirectory}-${albumChunkIndex}`)),
     }),
 };
diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js
deleted file mode 100644
index c412b8f2..00000000
--- a/src/content/dependencies/generateColorStyleRules.js
+++ /dev/null
@@ -1,42 +0,0 @@
-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/generateColorStyleTag.js b/src/content/dependencies/generateColorStyleTag.js
new file mode 100644
index 00000000..2b1a21dd
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleTag.js
@@ -0,0 +1,51 @@
+export default {
+  contentDependencies: ['generateColorStyleVariables', 'generateStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+
+    variables:
+      relation('generateColorStyleVariables'),
+  }),
+
+  data: (color) => ({
+    color:
+      color ?? null,
+  }),
+
+  slots: {
+    color: {
+      validate: v => v.isColor,
+    },
+  },
+
+  generate(data, relations, slots, {html}) {
+    const color =
+      data.color ?? slots.color;
+
+    if (!color) {
+      return html.blank();
+    }
+
+    return relations.styleTag.slots({
+      attributes: [
+        {class: 'color-style'},
+        {'data-color': color},
+      ],
+
+      rules: [
+        {
+          select: ':root',
+          declare:
+            relations.variables.slots({
+              color,
+              context: 'page-root',
+              mode: 'declarations',
+            }).content,
+        },
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
index 5270dbe4..c872d0b6 100644
--- a/src/content/dependencies/generateColorStyleVariables.js
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -18,7 +18,7 @@ export default {
     },
 
     mode: {
-      validate: v => v.is('style', 'property-list'),
+      validate: v => v.is('style', 'declarations'),
       default: 'style',
     },
   },
@@ -50,15 +50,15 @@ export default {
       `--shadow-color: ${shadow}`,
     ];
 
-    let selectedProperties;
+    let selectedDeclarations;
 
     switch (slots.context) {
       case 'any-content':
-        selectedProperties = anyContent;
+        selectedDeclarations = anyContent;
         break;
 
       case 'image-box':
-        selectedProperties = [
+        selectedDeclarations = [
           `--primary-color: ${primary}`,
           `--dim-color: ${dim}`,
           `--deep-color: ${deep}`,
@@ -67,14 +67,14 @@ export default {
         break;
 
       case 'page-root':
-        selectedProperties = [
+        selectedDeclarations = [
           ...anyContent,
           `--page-primary-color: ${primary}`,
         ];
         break;
 
       case 'primary-only':
-        selectedProperties = [
+        selectedDeclarations = [
           `--primary-color: ${primary}`,
         ];
         break;
@@ -82,10 +82,10 @@ export default {
 
     switch (slots.mode) {
       case 'style':
-        return selectedProperties.join('; ');
+        return selectedDeclarations.join('; ');
 
-      case 'property-list':
-        return selectedProperties;
+      case 'declarations':
+        return selectedDeclarations.map(declaration => declaration + ';');
     }
   },
 };
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
index 9243a89c..367de506 100644
--- a/src/content/dependencies/generateCommentaryEntry.js
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -12,14 +12,14 @@ export default {
 
   relations: (relation, entry) => ({
     artistLinks:
-      (!empty(entry.artists) && !entry.artistDisplayText
+      (!empty(entry.artists) && !entry.artistText
         ? entry.artists
             .map(artist => relation('linkArtist', artist))
         : null),
 
     artistsContent:
-      (entry.artistDisplayText
-        ? relation('transformContent', entry.artistDisplayText)
+      (entry.artistText
+        ? relation('transformContent', entry.artistText)
         : null),
 
     annotationContent:
@@ -51,6 +51,9 @@ export default {
             relations.colorStyle.clone()
               .slot('color', slots.color),
 
+          !html.isBlank(relations.date) &&
+            {class: 'dated'},
+
           language.encapsulate(entryCapsule, 'title', titleCapsule => [
             html.tag('span', {class: 'commentary-entry-heading-text'},
               language.encapsulate(titleCapsule, workingCapsule => {
diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js
deleted file mode 100644
index ed871b47..00000000
--- a/src/content/dependencies/generateCommentarySection.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import {empty} from '#sugar';
-
-export default {
-  contentDependencies: [
-    'transformContent',
-    'generateCommentaryEntry',
-    'generateContentHeading',
-  ],
-
-  extraDependencies: ['html', 'language'],
-
-  relations: (relation, entries) => ({
-    heading:
-      relation('generateContentHeading'),
-
-    entries:
-      (entries
-        ? entries.map(entry =>
-            relation('generateCommentaryEntry', entry))
-        : []),
-  }),
-
-  data: (entries) => ({
-    firstEntryIsDated:
-      (empty(entries)
-        ? null
-        : !!entries[0].date),
-  }),
-
-  slots: {
-    title: {type: 'html', mutable: false},
-    id: {type: 'string', default: 'artist-commentary'},
-  },
-
-  generate: (data, relations, slots, {html, language}) =>
-    html.tags([
-      relations.heading
-        .slots({
-          title:
-            (html.isBlank(slots.title)
-              ? language.$('misc.artistCommentary')
-              : slots.title),
-
-          attributes: [
-            {id: slots.id},
-            data.firstEntryIsDated &&
-              {class: 'first-entry-is-dated'},
-          ],
-        }),
-
-      relations.entries,
-    ]),
-};
diff --git a/src/content/dependencies/generateContentContentHeading.js b/src/content/dependencies/generateContentContentHeading.js
new file mode 100644
index 00000000..314ef197
--- /dev/null
+++ b/src/content/dependencies/generateContentContentHeading.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generateContentHeading'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _thing) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+  }),
+
+  data: (thing) => ({
+    name:
+      thing.name,
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    string: {
+      type: 'string',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.contentHeading.slots({
+      attributes: slots.attributes,
+
+      title:
+        language.$(slots.string, {
+          thing:
+            html.tag('i', data.name),
+        }),
+
+      stickyTitle:
+        language.$(slots.string, 'sticky'),
+    }),
+};
diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js
index 78c9051c..378c0e1c 100644
--- a/src/content/dependencies/generateContributionTooltipChronologySection.js
+++ b/src/content/dependencies/generateContributionTooltipChronologySection.js
@@ -1,3 +1,19 @@
+import Thing from '#thing';
+
+function getName(thing) {
+  if (!thing) {
+    return null;
+  }
+
+  const referenceType = thing.constructor[Thing.referenceType];
+
+  if (referenceType === 'artwork') {
+    return thing.thing.name;
+  }
+
+  return thing.name;
+}
+
 export default {
   contentDependencies: ['linkAnythingMan'],
   extraDependencies: ['html', 'language'],
@@ -30,14 +46,10 @@ export default {
 
   data: (query, _contribution) => ({
     previousName:
-      (query.previous
-        ? query.previous.thing.name
-        : null),
+      getName(query.previous?.thing),
 
     nextName:
-      (query.next
-        ? query.next.thing.name
-        : null),
+      getName(query.next?.thing),
   }),
 
   slots: {
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 50ca89ae..78a6103b 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -1,11 +1,57 @@
 export default {
-  contentDependencies: ['image', 'linkArtistGallery'],
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateCoverArtworkArtTagDetails',
+    'generateCoverArtworkArtistDetails',
+    'generateCoverArtworkOriginDetails',
+    'generateCoverArtworkReferenceDetails',
+    'image',
+  ],
+
   extraDependencies: ['html'],
 
+  relations: (relation, artwork) => ({
+    colorStyleAttribute:
+      relation('generateColorStyleAttribute'),
+
+    image:
+      relation('image', artwork),
+
+    originDetails:
+      relation('generateCoverArtworkOriginDetails', artwork),
+
+    artTagDetails:
+      relation('generateCoverArtworkArtTagDetails', artwork),
+
+    artistDetails:
+      relation('generateCoverArtworkArtistDetails', artwork),
+
+    referenceDetails:
+      relation('generateCoverArtworkReferenceDetails', artwork),
+  }),
+
+  data: (artwork) => ({
+    attachAbove:
+      artwork.attachAbove,
+
+    attachedArtworkIsMainArtwork:
+      (artwork.attachAbove
+        ? artwork.attachedArtwork.isMainArtwork
+        : null),
+
+    color:
+      artwork.thing.color ?? null,
+
+    dimensions:
+      artwork.dimensions,
+  }),
+
   slots: {
-    image: {
-      type: 'html',
-      mutable: true,
+    alt: {type: 'string'},
+
+    color: {
+      validate: v => v.anyOf(v.isBoolean, v.isColor),
+      default: false,
     },
 
     mode: {
@@ -13,13 +59,10 @@ export default {
       default: 'primary',
     },
 
-    dimensions: {
-      validate: v => v.isDimensions,
-    },
-
-    warnings: {
-      validate: v => v.looseArrayOf(v.isString),
-    },
+    showOriginDetails: {type: 'boolean', default: false},
+    showArtTagDetails: {type: 'boolean', default: false},
+    showArtistDetails: {type: 'boolean', default: false},
+    showReferenceDetails: {type: 'boolean', default: false},
 
     details: {
       type: 'html',
@@ -27,60 +70,88 @@ export default {
     },
   },
 
-  generate(slots, {html}) {
+  generate(data, relations, slots, {html}) {
+    const {image} = relations;
+
+    image.setSlot('alt', slots.alt);
+
     const square =
-      (slots.dimensions
-        ? slots.dimensions[0] === slots.dimensions[1]
+      (data.dimensions
+        ? data.dimensions[0] === data.dimensions[1]
         : true);
 
-    const sizeSlots =
-      (square
-        ? {square: true}
-        : {dimensions: slots.dimensions});
-
-    switch (slots.mode) {
-      case 'primary':
-        return html.tags([
-          slots.image.slots({
-            thumb: 'medium',
-            reveal: true,
-            link: true,
-
-            warnings: slots.warnings,
-            ...sizeSlots,
-          }),
-
-          slots.details,
-        ]);
-
-      case 'thumbnail':
-        return (
-          slots.image.slots({
-            thumb: 'small',
-            reveal: false,
-            link: false,
-
-            warnings: slots.warnings,
-            ...sizeSlots,
-          }));
-
-      case 'commentary':
-        return (
-          slots.image.slots({
-            thumb: 'medium',
-            reveal: true,
-            link: true,
-            lazy: true,
-
-            warnings: slots.warnings,
-            ...sizeSlots,
-
-            attributes:
-              {class: 'commentary-art'},
-          }));
-
-      default:
-        return html.blank();
+    if (square) {
+      image.setSlot('square', true);
+    } else {
+      image.setSlot('dimensions', data.dimensions);
     }
+
+    const attributes = html.attributes();
+
+    let color = null;
+    if (typeof slots.color === 'boolean') {
+      if (slots.color) {
+        color = data.color;
+      }
+    } else if (slots.color) {
+      color = slots.color;
+    }
+
+    if (color) {
+      relations.colorStyleAttribute.setSlot('color', color);
+      attributes.add(relations.colorStyleAttribute);
+    }
+
+    return html.tags([
+      data.attachAbove &&
+        html.tag('div', {class: 'cover-artwork-joiner'}),
+
+      html.tag('div', {class: 'cover-artwork'},
+        slots.mode === 'commentary' &&
+          {class: 'commentary-art'},
+
+        data.attachAbove &&
+        data.attachedArtworkIsMainArtwork &&
+          {class: 'attached-artwork-is-main-artwork'},
+
+        attributes,
+
+        (slots.mode === 'primary'
+          ? [
+              relations.image.slots({
+                thumb: 'medium',
+                reveal: true,
+                link: true,
+              }),
+
+              slots.showOriginDetails &&
+                relations.originDetails,
+
+              slots.showArtTagDetails &&
+                relations.artTagDetails,
+
+              slots.showArtistDetails &&
+                relations.artistDetails,
+
+              slots.showReferenceDetails &&
+                relations.referenceDetails,
+
+              slots.details,
+            ]
+       : slots.mode === 'thumbnail'
+          ? relations.image.slots({
+              thumb: 'small',
+              reveal: false,
+              link: false,
+            })
+       : slots.mode === 'commentary'
+          ? relations.image.slots({
+              thumb: 'medium',
+              reveal: true,
+              link: true,
+              lazy: true,
+            })
+          : html.blank())),
+    ]);
   },
 };
diff --git a/src/content/dependencies/generateCoverArtworkArtTagDetails.js b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
index 81ead8a9..4d908665 100644
--- a/src/content/dependencies/generateCoverArtworkArtTagDetails.js
+++ b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
@@ -1,22 +1,42 @@
-import {stitchArrays} from '#sugar';
+import {compareArrays, empty, stitchArrays} from '#sugar';
+
+function linkable(tag) {
+  return !tag.isContentWarning;
+}
 
 export default {
-  contentDependencies: ['linkArtTag'],
-  extraDependencies: ['html'],
+  contentDependencies: ['linkArtTagGallery'],
+  extraDependencies: ['html', 'language'],
 
-  query: (artTags) => ({
+  query: (artwork) => ({
     linkableArtTags:
-      artTags
-        .filter(tag => !tag.isContentWarning),
+      artwork.artTags.filter(linkable),
+
+    mainArtworkLinkableArtTags:
+      (artwork.mainArtwork
+        ? artwork.mainArtwork.artTags.filter(linkable)
+        : null),
   }),
 
-  relations: (relation, query, _artTags) => ({
-    tagLinks:
+  relations: (relation, query, _artwork) => ({
+    artTagLinks:
       query.linkableArtTags
-        .map(tag => relation('linkArtTag', tag)),
+        .map(tag => relation('linkArtTagGallery', tag)),
   }),
 
-  data: (query, _artTags) => {
+  data: (query, artwork) => {
+    const data = {};
+
+    data.attachAbove = artwork.attachAbove;
+
+    data.sameAsMainArtwork =
+      !artwork.isMainArtwork &&
+      query.mainArtworkLinkableArtTags &&
+      !empty(query.mainArtworkLinkableArtTags) &&
+      compareArrays(
+        query.mainArtworkLinkableArtTags,
+        query.linkableArtTags);
+
     const seenShortNames = new Set();
     const duplicateShortNames = new Set();
 
@@ -28,23 +48,28 @@ export default {
       }
     }
 
-    const preferShortName =
+    data.preferShortName =
       query.linkableArtTags
         .map(artTag => !duplicateShortNames.has(artTag.nameShort));
 
-    return {preferShortName};
+    return data;
   },
 
-  generate: (data, relations, {html}) =>
-    html.tag('ul', {class: 'image-details'},
-      {[html.onlyIfContent]: true},
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.coverArtwork', capsule =>
+      html.tag('ul', {class: 'image-details'},
+        {[html.onlyIfContent]: true},
 
-      {class: 'art-tag-details'},
+        {class: 'art-tag-details'},
 
-      stitchArrays({
-        tagLink: relations.tagLinks,
-        preferShortName: data.preferShortName,
-      }).map(({tagLink, preferShortName}) =>
-          html.tag('li',
-            tagLink.slot('preferShortName', preferShortName)))),
+        (data.sameAsMainArtwork && data.attachAbove
+          ? html.blank()
+       : data.sameAsMainArtwork && relations.artTagLinks.length >= 3
+          ? language.$(capsule, 'sameTagsAsMainArtwork')
+          : stitchArrays({
+              artTagLink: relations.artTagLinks,
+              preferShortName: data.preferShortName,
+            }).map(({artTagLink, preferShortName}) =>
+                html.tag('li',
+                  artTagLink.slot('preferShortName', preferShortName)))))),
 };
diff --git a/src/content/dependencies/generateCoverArtworkArtistDetails.js b/src/content/dependencies/generateCoverArtworkArtistDetails.js
index 5b235353..3ead80ab 100644
--- a/src/content/dependencies/generateCoverArtworkArtistDetails.js
+++ b/src/content/dependencies/generateCoverArtworkArtistDetails.js
@@ -2,9 +2,9 @@ export default {
   contentDependencies: ['linkArtistGallery'],
   extraDependencies: ['html', 'language'],
 
-  relations: (relation, contributions) => ({
+  relations: (relation, artwork) => ({
     artistLinks:
-      contributions
+      artwork.artistContribs
         .map(contrib => contrib.artist)
         .map(artist =>
           relation('linkArtistGallery', artist)),
@@ -17,6 +17,8 @@ export default {
       {class: 'illustrator-details'},
 
       language.$('misc.coverGrid.details.coverArtists', {
+        [language.onlyIfOptions]: ['artists'],
+
         artists:
           language.formatConjunctionList(relations.artistLinks),
       })),
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
new file mode 100644
index 00000000..8628179e
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -0,0 +1,170 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'generateArtistCredit',
+    'generateAbsoluteDatetimestamp',
+    'linkAlbum',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'pagePath'],
+
+  query: (artwork) => ({
+    artworkThingType:
+      artwork.thing.constructor[Thing.referenceType],
+
+    attachedArtistContribs:
+      (artwork.attachedArtwork
+        ? artwork.attachedArtwork.artistContribs
+        : null)
+  }),
+
+  relations: (relation, query, artwork) => ({
+    credit:
+      relation('generateArtistCredit',
+        artwork.artistContribs,
+        query.attachedArtistContribs ?? []),
+
+    source:
+      relation('transformContent', artwork.source),
+
+    originDetails:
+      relation('transformContent', artwork.originDetails),
+
+    albumLink:
+      (query.artworkThingType === 'album'
+        ? relation('linkAlbum', artwork.thing)
+        : null),
+
+    datetimestamp:
+      (artwork.date && artwork.date !== artwork.thing.date
+        ? relation('generateAbsoluteDatetimestamp', artwork.date)
+        : null),
+  }),
+
+
+  data: (query, artwork) => ({
+    label:
+      artwork.label,
+
+    artworkThingType:
+      query.artworkThingType,
+  }),
+
+  generate: (data, relations, {html, language, pagePath}) =>
+    language.encapsulate('misc.coverArtwork', capsule =>
+      html.tag('p', {class: 'image-details'},
+        {[html.onlyIfContent]: true},
+        {[html.joinChildren]: html.tag('br')},
+
+        {class: 'origin-details'},
+
+        (() => {
+          relations.datetimestamp?.setSlots({
+            style: 'year',
+            tooltip: true,
+          });
+
+          const artworkBy =
+            language.encapsulate(capsule, 'artworkBy', workingCapsule => {
+              const workingOptions = {};
+
+              if (data.label) {
+                workingCapsule += '.customLabel';
+                workingOptions.label = data.label;
+              }
+
+              if (relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
+              return relations.credit.slots({
+                showAnnotation: true,
+                showExternalLinks: true,
+                showChronology: true,
+                showWikiEdits: true,
+
+                trimAnnotation: false,
+
+                chronologyKind: 'coverArt',
+
+                normalStringKey: workingCapsule,
+                additionalStringOptions: workingOptions,
+              });
+            });
+
+          const trackArtFromAlbum =
+            pagePath[0] === 'track' &&
+            data.artworkThingType === 'album' &&
+              language.$(capsule, 'trackArtFromAlbum', {
+                album:
+                  relations.albumLink.slot('color', false),
+              });
+
+          const source =
+            language.encapsulate(capsule, 'source', workingCapsule => {
+              const workingOptions = {
+                [language.onlyIfOptions]: ['source'],
+                source: relations.source.slot('mode', 'inline'),
+              };
+
+              if (html.isBlank(artworkBy) && data.label) {
+                workingCapsule += '.customLabel';
+                workingOptions.label = data.label;
+              }
+
+              if (html.isBlank(artworkBy) && relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            });
+
+          const label =
+            html.isBlank(artworkBy) &&
+            html.isBlank(source) &&
+            language.encapsulate(capsule, 'customLabel', workingCapsule => {
+              const workingOptions = {
+                [language.onlyIfOptions]: ['label'],
+                label: data.label,
+              };
+
+              if (relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            });
+
+          const year =
+            html.isBlank(artworkBy) &&
+            html.isBlank(source) &&
+            html.isBlank(label) &&
+            language.$(capsule, 'year', {
+              [language.onlyIfOptions]: ['year'],
+              year: relations.datetimestamp,
+            });
+
+          const originDetails =
+            html.tag('span', {class: 'origin-details'},
+              {[html.onlyIfContent]: true},
+
+              relations.originDetails.slots({
+                mode: 'inline',
+                absorbPunctuationFollowingExternalLinks: false,
+              }));
+
+          return [
+            artworkBy,
+            trackArtFromAlbum,
+            source,
+            label,
+            year,
+            originDetails,
+          ];
+        })())),
+};
diff --git a/src/content/dependencies/generateCoverArtworkReferenceDetails.js b/src/content/dependencies/generateCoverArtworkReferenceDetails.js
index 006b2b4b..035ab586 100644
--- a/src/content/dependencies/generateCoverArtworkReferenceDetails.js
+++ b/src/content/dependencies/generateCoverArtworkReferenceDetails.js
@@ -1,20 +1,24 @@
 export default {
+  contentDependencies: ['linkReferencedArtworks', 'linkReferencingArtworks'],
   extraDependencies: ['html', 'language'],
 
-  data: (referenced, referencedBy) => ({
+  relations: (relation, artwork) => ({
+    referencedArtworksLink:
+      relation('linkReferencedArtworks', artwork),
+
+    referencingArtworksLink:
+      relation('linkReferencingArtworks', artwork),
+  }),
+
+  data: (artwork) => ({
     referenced:
-      referenced.length,
+      artwork.referencedArtworks.length,
 
     referencedBy:
-      referencedBy.length,
+      artwork.referencedByArtworks.length,
   }),
 
-  slots: {
-    referencedLink: {type: 'html', mutable: true},
-    referencingLink: {type: 'html', mutable: true},
-  },
-
-  generate: (data, slots, {html, language}) =>
+  generate: (data, relations, {html, language}) =>
     language.encapsulate('releaseInfo', capsule => {
       const referencedText =
         language.$(capsule, 'referencesArtworks', {
@@ -47,10 +51,10 @@ export default {
 
           [
             !html.isBlank(referencedText) &&
-              slots.referencedLink.slot('content', referencedText),
+              relations.referencedArtworksLink.slot('content', referencedText),
 
             !html.isBlank(referencingText) &&
-              slots.referencingLink.slot('content', referencingText),
+              relations.referencingArtworksLink.slot('content', referencingText),
           ]));
     }),
 }
diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js
index 430f651e..0705d93e 100644
--- a/src/content/dependencies/generateCoverCarousel.js
+++ b/src/content/dependencies/generateCoverCarousel.js
@@ -19,7 +19,7 @@ export default {
       });
 
     if (empty(stitched)) {
-      return;
+      return html.blank();
     }
 
     const layout = getCarouselLayoutForNumberOfItems(stitched.length);
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index fa9b3dda..e4dfd905 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -15,23 +15,59 @@ export default {
     links: {validate: v => v.strictArrayOf(v.isHTML)},
     names: {validate: v => v.strictArrayOf(v.isHTML)},
     info: {validate: v => v.strictArrayOf(v.isHTML)},
+    notFromThisGroup: {validate: v => v.strictArrayOf(v.isBoolean)},
+
+    // Differentiating from sparseArrayOf here - this list of classes should
+    // have the same length as the items above, i.e. nulls aren't going to be
+    // filtered out of it, but it is okay to *include* null (standing in for
+    // no classes for this grid item).
+    classes: {
+      validate: v =>
+        v.strictArrayOf(
+          v.optional(
+            v.anyOf(
+              v.isArray,
+              v.isString))),
+    },
 
     lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)},
     actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
   },
 
-  generate(relations, slots, {html, language}) {
-    return (
-      html.tag('div', {class: 'grid-listing'}, [
+  generate: (relations, slots, {html, language}) =>
+    html.tag('div', {class: 'grid-listing'},
+      {[html.onlyIfContent]: true},
+
+      [
         stitchArrays({
+          classes: slots.classes,
           image: slots.images,
           link: slots.links,
           name: slots.names,
           info: slots.info,
-        }).map(({image, link, name, info}, index) =>
+
+          notFromThisGroup:
+            slots.notFromThisGroup ??
+            Array.from(slots.links).fill(null)
+        }).map(({
+            classes,
+            image,
+            link,
+            name,
+            info,
+            notFromThisGroup,
+          }, index) =>
             link.slots({
-              attributes: {class: ['grid-item', 'box']},
+              attributes: [
+                {class: ['grid-item', 'box']},
+
+                (classes
+                  ? {class: classes}
+                  : null),
+              ],
+
               colorContext: 'image-box',
+
               content: [
                 image.slots({
                   thumb: 'medium',
@@ -47,7 +83,15 @@ export default {
                 html.tag('span',
                   {[html.onlyIfContent]: true},
 
-                  language.sanitize(name)),
+                  (notFromThisGroup
+                    ? language.encapsulate('misc.coverGrid.details.notFromThisGroup', capsule =>
+                        language.$(capsule, {
+                          name,
+                          marker:
+                            html.tag('span', {class: 'grid-name-marker'},
+                              language.$(capsule, 'marker')),
+                        }))
+                    : language.sanitize(name))),
 
                 html.tag('span',
                   {[html.onlyIfContent]: true},
@@ -62,6 +106,5 @@ export default {
 
         relations.actionLinks
           .slot('actionLinks', slots.actionLinks),
-      ]));
-  },
+      ]),
 };
diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js
index d9ed036a..a92d15fc 100644
--- a/src/content/dependencies/generateDatetimestampTemplate.js
+++ b/src/content/dependencies/generateDatetimestampTemplate.js
@@ -31,8 +31,10 @@ export default {
           slots.mainContent),
 
       tooltip:
-        slots.tooltip?.slots({
-          attributes: [{class: 'datetimestamp-tooltip'}],
-        }),
+        (html.isBlank(slots.tooltip)
+          ? null
+          : slots.tooltip.slots({
+              attributes: [{class: 'datetimestamp-tooltip'}],
+            })),
     }),
 };
diff --git a/src/content/dependencies/generateExpandableGallerySection.js b/src/content/dependencies/generateExpandableGallerySection.js
new file mode 100644
index 00000000..122ca4b1
--- /dev/null
+++ b/src/content/dependencies/generateExpandableGallerySection.js
@@ -0,0 +1,92 @@
+export default {
+  contentDependencies: ['generateContentHeading'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+  }),
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    contentAboveCut: {
+      type: 'html',
+      mutable: false,
+    },
+
+    contentBelowCut: {
+      type: 'html',
+      mutable: false,
+    },
+
+    caption: {
+      type: 'html',
+      mutable: false,
+    },
+
+    expandCue: {
+      type: 'html',
+      mutable: false,
+    },
+
+    collapseCue: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    html.tag('section', {class: 'expandable-gallery-section'}, [
+      relations.contentHeading.slots({
+        tag: 'h2',
+        title: slots.title,
+      }),
+
+      html.tag('div', {class: 'section-content-above-cut'},
+        {[html.onlyIfContent]: true},
+
+        slots.contentAboveCut),
+
+      html.tag('div', {class: 'section-content-below-cut'},
+        {[html.onlyIfContent]: true},
+
+        !html.isBlank(slots.contentBelowCut) &&
+          {style: 'display: none'},
+
+        slots.contentBelowCut),
+
+      html.tag('div', {class: 'section-expando'},
+        {[html.onlyIfSiblings]: true},
+
+        html.tag('div', {class: 'section-expando-content'},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            html.tag('span', {class: 'section-caption'},
+              slots.caption),
+
+            !html.isBlank(slots.contentBelowCut) &&
+              language.$('misc.coverGrid.expandCollapseCue', {
+                cue:
+                  html.tag('a', {class: 'section-expando-toggle'},
+                    {href: '#'},
+
+                    {[html.joinChildren]: ''},
+                    {[html.noEdgeWhitespace]: true},
+
+                    [
+                      html.tag('span', {class: 'section-expand-cue'},
+                        slots.expandCue),
+
+                      html.tag('span', {class: 'section-collapse-cue'},
+                        {style: 'display: none'},
+                        slots.collapseCue),
+                    ]),
+              }),
+          ])),
+    ]),
+};
diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js
index 8f174b21..84ab549d 100644
--- a/src/content/dependencies/generateFlashActGalleryPage.js
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -1,5 +1,3 @@
-import {stitchArrays} from '#sugar';
-
 import striptags from 'striptags';
 
 export default {
@@ -37,7 +35,7 @@ export default {
 
     coverGridImages:
       act.flashes
-        .map(_flash => relation('image')),
+        .map(flash => relation('image', flash.coverArtwork)),
 
     flashLinks:
       act.flashes
@@ -50,10 +48,6 @@ export default {
 
     flashNames:
       act.flashes.map(flash => flash.name),
-
-    flashCoverPaths:
-      act.flashes.map(flash =>
-        ['media.flashArt', flash.directory, flash.coverArtFileExtension])
   }),
 
   generate: (data, relations, {language}) =>
@@ -71,15 +65,9 @@ export default {
         mainContent: [
           relations.coverGrid.slots({
             links: relations.flashLinks,
+            images: relations.coverGridImages,
             names: data.flashNames,
             lazy: 6,
-
-            images:
-              stitchArrays({
-                image: relations.coverGridImages,
-                path: data.flashCoverPaths,
-              }).map(({image, path}) =>
-                  image.slot('path', path)),
           }),
         ],
 
diff --git a/src/content/dependencies/generateFlashArtworkColumn.js b/src/content/dependencies/generateFlashArtworkColumn.js
new file mode 100644
index 00000000..5987df9e
--- /dev/null
+++ b/src/content/dependencies/generateFlashArtworkColumn.js
@@ -0,0 +1,11 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, flash) => ({
+    coverArtwork:
+      relation('generateCoverArtwork', flash.coverArtwork),
+  }),
+
+  generate: (relations) =>
+    relations.coverArtwork,
+};
diff --git a/src/content/dependencies/generateFlashCoverArtwork.js b/src/content/dependencies/generateFlashCoverArtwork.js
deleted file mode 100644
index 4b0e5242..00000000
--- a/src/content/dependencies/generateFlashCoverArtwork.js
+++ /dev/null
@@ -1,41 +0,0 @@
-export default {
-  contentDependencies: ['generateCoverArtwork', 'image'],
-  extraDependencies: ['html', 'language'],
-
-  relations: (relation) => ({
-    coverArtwork:
-      relation('generateCoverArtwork'),
-
-    image:
-      relation('image'),
-  }),
-
-  data: (flash) => ({
-    path:
-      ['media.flashArt', flash.directory, flash.coverArtFileExtension],
-
-    color:
-      flash.color,
-
-    dimensions:
-      flash.coverArtDimensions,
-  }),
-
-  slots: {
-    mode: {type: 'string'},
-  },
-
-  generate: (data, relations, slots, {language}) =>
-    relations.coverArtwork.slots({
-      mode: slots.mode,
-
-      image:
-        relations.image.slots({
-          path: data.path,
-          color: data.color,
-          alt: language.$('misc.alt.flashArt'),
-        }),
-
-      dimensions: data.dimensions,
-    }),
-};
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index a21bb49e..2788406c 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -53,7 +53,7 @@ export default {
     actCoverGridImages:
       query.flashActs
         .map(act => act.flashes
-          .map(() => relation('image'))),
+          .map(flash => relation('image', flash.coverArtwork))),
   }),
 
   data: (query) => ({
@@ -73,11 +73,6 @@ export default {
       query.flashActs
         .map(act => act.flashes
           .map(flash => flash.name)),
-
-    actCoverGridPaths:
-      query.flashActs
-        .map(act => act.flashes
-          .map(flash => ['media.flashArt', flash.directory, flash.coverArtFileExtension])),
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -116,7 +111,6 @@ export default {
             coverGridImages: relations.actCoverGridImages,
             coverGridLinks: relations.actCoverGridLinks,
             coverGridNames: data.actCoverGridNames,
-            coverGridPaths: data.actCoverGridPaths,
           }).map(({
               colorStyle,
               actLink,
@@ -126,7 +120,6 @@ export default {
               coverGridImages,
               coverGridLinks,
               coverGridNames,
-              coverGridPaths,
             }, index) => [
               html.tag('h2',
                 {id: anchor},
@@ -135,15 +128,9 @@ export default {
 
               coverGrid.slots({
                 links: coverGridLinks,
+                images: coverGridImages,
                 names: coverGridNames,
                 lazy: index === 0 ? 4 : true,
-
-                images:
-                  stitchArrays({
-                    image: coverGridImages,
-                    path: coverGridPaths,
-                  }).map(({image, path}) =>
-                      image.slot('path', path)),
               }),
             ]),
         ],
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 148925fb..ee043bfa 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -3,11 +3,12 @@ import {empty} from '#sugar';
 export default {
   contentDependencies: [
     'generateAdditionalNamesBox',
-    'generateCommentarySection',
+    'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generateContributionList',
     'generateFlashActSidebar',
-    'generateFlashCoverArtwork',
+    'generateFlashArtworkColumn',
     'generateFlashNavAccent',
     'generatePageLayout',
     'generateTrackList',
@@ -47,12 +48,15 @@ export default {
       query.urls
         .map(url => relation('linkExternal', url)),
 
-    cover:
-      relation('generateFlashCoverArtwork', flash),
+    artworkColumn:
+      relation('generateFlashArtworkColumn', flash),
 
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', flash),
+
     flashActLink:
       relation('linkFlashAct', flash.act),
 
@@ -65,11 +69,13 @@ export default {
     contributorContributionList:
       relation('generateContributionList', flash.contributorContribs),
 
-    artistCommentarySection:
-      relation('generateCommentarySection', flash.commentary),
+    artistCommentaryEntries:
+      flash.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
 
-    creditSourcesSection:
-      relation('generateCommentarySection', flash.creditSources),
+    creditSourceEntries:
+      flash.creditingSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
   }),
 
   data: (_query, flash) => ({
@@ -96,7 +102,7 @@ export default {
 
         additionalNames: relations.additionalNamesBox,
 
-        cover: relations.cover,
+        artworkColumnContent: relations.artworkColumn,
 
         mainContent: [
           html.tag('p',
@@ -121,7 +127,7 @@ export default {
             {[html.joinChildren]: html.tag('br')},
 
             language.encapsulate('releaseInfo', capsule => [
-              !html.isBlank(relations.artistCommentarySection) &&
+              !html.isBlank(relations.artistCommentaryEntries) &&
                 language.encapsulate(capsule, 'readCommentary', capsule =>
                   language.$(capsule, {
                     link:
@@ -130,12 +136,12 @@ export default {
                         language.$(capsule, 'link')),
                   })),
 
-              !html.isBlank(relations.creditSourcesSection) &&
-                language.encapsulate(capsule, 'readCreditSources', capsule =>
+              !html.isBlank(relations.creditSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
                   language.$(capsule, {
                     link:
                       html.tag('a',
-                        {href: '#credit-sources'},
+                        {href: '#crediting-sources'},
                         language.$(capsule, 'link')),
                   })),
             ])),
@@ -165,12 +171,25 @@ export default {
             }),
           ]),
 
-          relations.artistCommentarySection,
+          html.tags([
+            relations.contentContentHeading.clone()
+              .slots({
+                attributes: {id: 'artist-commentary'},
+                string: 'misc.artistCommentary',
+              }),
 
-          relations.creditSourcesSection.slots({
-            id: 'credit-sources',
-            title: language.$('misc.creditSources'),
-          }),
+            relations.artistCommentaryEntries,
+          ]),
+
+          html.tags([
+            relations.contentContentHeading.clone()
+              .slots({
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
+              }),
+
+            relations.creditSourceEntries,
+          ]),
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index 79746cd0..dfdad0e8 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -1,14 +1,14 @@
 import {sortChronologically} from '#sort';
-import {empty, stitchArrays} from '#sugar';
 import {filterItemsForCarousel, getTotalDuration} from '#wiki-data';
 
 export default {
   contentDependencies: [
     'generateCoverCarousel',
-    'generateCoverGrid',
+    'generateGroupGalleryPageAlbumsByDateView',
+    'generateGroupGalleryPageAlbumsBySeriesView',
     'generateGroupNavLinks',
     'generateGroupSecondaryNav',
-    'generateGroupSidebar',
+    'generateIntrapageDotSwitcher',
     'generatePageLayout',
     'generateQuickDescription',
     'image',
@@ -21,95 +21,73 @@ export default {
   sprawl: ({wikiInfo}) =>
     ({enableGroupUI: wikiInfo.enableGroupUI}),
 
-  relations(relation, sprawl, group) {
-    const relations = {};
+  query(_sprawl, group) {
+    const query = {};
 
-    const albums =
+    query.allAlbums =
       sortChronologically(group.albums.slice(), {latestFirst: true});
 
-    relations.layout =
-      relation('generatePageLayout');
+    query.allTracks =
+      query.allAlbums.flatMap((album) => album.tracks);
 
-    relations.navLinks =
-      relation('generateGroupNavLinks', group);
+    query.carouselAlbums =
+      filterItemsForCarousel(group.featuredAlbums);
 
-    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));
+    return query;
+  },
 
-      relations.carouselImages =
-        carouselAlbums
-          .map(album => relation('image', album.artTags));
-    }
+  relations: (relation, query, sprawl, group) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    relations.quickDescription =
-      relation('generateQuickDescription', group);
+    navLinks:
+      relation('generateGroupNavLinks', group),
 
-    relations.coverGrid =
-      relation('generateCoverGrid');
+    secondaryNav:
+      (sprawl.enableGroupUI
+        ? relation('generateGroupSecondaryNav', group)
+        : null),
 
-    relations.gridLinks =
-      albums
-        .map(album => relation('linkAlbum', album));
+    coverCarousel:
+      relation('generateCoverCarousel'),
 
-    relations.gridImages =
-      albums.map(album =>
-        (album.hasCoverArt
-          ? relation('image', album.artTags)
-          : relation('image')));
+    carouselLinks:
+      query.carouselAlbums
+        .map(album => relation('linkAlbum', album)),
 
-    return relations;
-  },
+    carouselImages:
+      query.carouselAlbums
+        .map(album => relation('image', album.coverArtworks[0])),
 
-  data(sprawl, group) {
-    const data = {};
+    quickDescription:
+      relation('generateQuickDescription', group),
 
-    data.name = group.name;
-    data.color = group.color;
+    albumViewSwitcher:
+      relation('generateIntrapageDotSwitcher'),
 
-    const albums = sortChronologically(group.albums.slice(), {latestFirst: true});
-    const tracks = albums.flatMap((album) => album.tracks);
+    albumsBySeriesView:
+      relation('generateGroupGalleryPageAlbumsBySeriesView', group),
 
-    data.numAlbums = albums.length;
-    data.numTracks = tracks.length;
-    data.totalDuration = getTotalDuration(tracks, {originalReleasesOnly: true});
+    albumsByDateView:
+      relation('generateGroupGalleryPageAlbumsByDateView', group),
+  }),
 
-    data.gridNames = albums.map(album => album.name);
-    data.gridDurations = albums.map(album => getTotalDuration(album.tracks));
-    data.gridNumTracks = albums.map(album => album.tracks.length);
+  data: (query, _sprawl, group) => ({
+    name:
+      group.name,
 
-    data.gridPaths =
-      albums.map(album =>
-        (album.hasCoverArt
-          ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-          : null));
+    color:
+      group.color,
 
-    const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
+    numAlbums:
+      query.allAlbums.length,
 
-    if (!empty(group.featuredAlbums)) {
-      data.carouselPaths =
-        carouselAlbums.map(album =>
-          (album.hasCoverArt
-            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-            : null));
-    }
+    numTracks:
+      query.allTracks.length,
 
-    return data;
-  },
+    totalDuration:
+      getTotalDuration(query.allTracks, {mainReleasesOnly: true}),
+  }),
 
   generate: (data, relations, {html, language}) =>
     language.encapsulate('groupGalleryPage', pageCapsule =>
@@ -121,16 +99,10 @@ export default {
 
         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)),
-            }),
+          relations.coverCarousel.slots({
+            links: relations.carouselLinks,
+            images: relations.carouselImages,
+          }),
 
           relations.quickDescription,
 
@@ -155,49 +127,78 @@ export default {
                   })),
             })),
 
-          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.coverGrid.noCoverArt', {
-                          album: name,
-                        }),
-                    })),
-              info:
-                stitchArrays({
-                  numTracks: data.gridNumTracks,
-                  duration: data.gridDurations,
-                }).map(({numTracks, duration}) =>
-                    language.$('misc.coverGrid.details.albumLength', {
-                      tracks: language.countTracks(numTracks, {unit: true}),
-                      time: language.formatDuration(duration),
-                    })),
-            }),
+          ([
+            !html.isBlank(relations.albumsBySeriesView),
+            !html.isBlank(relations.albumsByDateView)
+          ]).filter(Boolean).length > 1 &&
+
+            language.encapsulate(pageCapsule, 'albumViewSwitcher', capsule =>
+              html.tag('p', {class: 'gallery-view-switcher'},
+                {class: ['drop', 'shiny']},
+
+                {[html.onlyIfContent]: true},
+                {[html.joinChildren]: html.tag('br')},
+
+                [
+                  language.$(capsule),
+
+                  relations.albumViewSwitcher.slots({
+                    initialOptionIndex: 0,
+
+                    titles: [
+                      !html.isBlank(relations.albumsByDateView) &&
+                        language.$(capsule, 'byDate'),
+
+                      !html.isBlank(relations.albumsBySeriesView) &&
+                        language.$(capsule, 'bySeries'),
+                    ].filter(Boolean),
+
+                    targetIDs: [
+                      !html.isBlank(relations.albumsByDateView) &&
+                        'group-album-gallery-by-date',
+
+                      !html.isBlank(relations.albumsBySeriesView) &&
+                        'group-album-gallery-by-series',
+                    ].filter(Boolean),
+                  }),
+                ])),
+
+          /*
+          data.trackGridLabels.some(value => value !== null) &&
+            html.tag('p', {class: 'gallery-set-switcher'},
+              language.encapsulate(pageCapsule, 'setSwitcher', switcherCapsule =>
+                language.$(switcherCapsule, {
+                  sets:
+                    relations.setSwitcher.slots({
+                      initialOptionIndex: 0,
+
+                      titles:
+                        data.trackGridLabels.map(label =>
+                          label ??
+                          language.$(switcherCapsule, 'unlabeledSet')),
+
+                      targetIDs:
+                        data.trackGridIDs,
+                    }),
+                }))),
+          */
+
+          relations.albumsByDateView,
+
+          relations.albumsBySeriesView.slots({
+            attributes: [
+              !html.isBlank(relations.albumsBySeriesView) &&
+                {style: 'display: none'},
+            ],
+          }),
         ],
 
-        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,
+        secondaryNav: relations.secondaryNav,
       })),
 };
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
new file mode 100644
index 00000000..7d9aa2d2
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
@@ -0,0 +1,66 @@
+import {stitchArrays} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'],
+  extraDependencies: ['language'],
+
+  relations: (relation, albums, _group) => ({
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      albums.map(album =>
+        relation('linkAlbum', album)),
+
+    images:
+      albums.map(album =>
+        (album.hasCoverArt
+          ? relation('image', album.coverArtworks[0])
+          : relation('image')))
+  }),
+
+  data: (albums, group) => ({
+    names:
+      albums.map(album => album.name),
+
+    durations:
+      albums.map(album => getTotalDuration(album.tracks)),
+
+    tracks:
+      albums.map(album => album.tracks.length),
+
+    notFromThisGroup:
+      albums.map(album => !album.groups.includes(group)),
+  }),
+
+  generate: (data, relations, {language}) =>
+    language.encapsulate('misc.coverGrid', capsule =>
+      relations.coverGrid.slots({
+        links: relations.links,
+        names: data.names,
+        notFromThisGroup: data.notFromThisGroup,
+
+        images:
+          stitchArrays({
+            image: relations.images,
+            name: data.names,
+          }).map(({image, name}) =>
+              image.slots({
+                missingSourceContent:
+                  language.$(capsule, 'noCoverArt', {
+                    album: name,
+                  }),
+              })),
+
+        info:
+          stitchArrays({
+            tracks: data.tracks,
+            duration: data.durations,
+          }).map(({tracks, duration}) =>
+              language.$(capsule, 'details.albumLength', {
+                tracks: language.countTracks(tracks, {unit: true}),
+                time: language.formatDuration(duration),
+              })),
+      })),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
new file mode 100644
index 00000000..b7d01eb5
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
@@ -0,0 +1,39 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: ['generateGroupGalleryPageAlbumGrid'],
+  extraDependencies: ['html', 'language'],
+
+  query: (group) => ({
+    albums:
+      sortChronologically(group.albums, {latestFirst: true}),
+  }),
+
+  relations: (relation, query, group) => ({
+    albumGrid:
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albums,
+        group),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('groupGalleryPage.albumsByDate', capsule =>
+      html.tag('div', {id: 'group-album-gallery-by-date'},
+        slots.attributes,
+
+        {[html.onlyIfContent]: true},
+
+        html.tag('section', [
+          html.tag('h2',
+            language.$(capsule, 'title')),
+
+          relations.albumGrid,
+        ]))),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js
new file mode 100644
index 00000000..0337275f
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js
@@ -0,0 +1,26 @@
+export default {
+  contentDependencies: ['generateGroupGalleryPageSeriesSection'],
+  extraDependencies: ['html'],
+
+  relations: (relation, group) => ({
+    seriesSections:
+      group.serieses
+        .map(series =>
+          relation('generateGroupGalleryPageSeriesSection', series)),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('div', {id: 'group-album-gallery-by-series'},
+      slots.attributes,
+
+      {[html.onlyIfContent]: true},
+
+      relations.seriesSections),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPageSeriesSection.js b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
new file mode 100644
index 00000000..2ccead5d
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
@@ -0,0 +1,156 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateExpandableGallerySection',
+    'generateGroupGalleryPageAlbumGrid',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(series) {
+    const query = {};
+
+    // Includes undated albums.
+    const albumsLatestFirst =
+      sortChronologically(series.albums, {latestFirst: true});
+
+    query.albumsAboveCut = albumsLatestFirst.slice(0, 4);
+    query.albumsBelowCut = albumsLatestFirst.slice(4);
+
+    query.allAlbumsDated =
+      series.albums.every(album => album.date);
+
+    query.anyAlbumNotFromThisGroup =
+      series.albums.some(album => !album.groups.includes(series.group));
+
+    query.latestAlbum =
+      albumsLatestFirst
+        .filter(album => album.date)
+        .at(0) ??
+      null;
+
+    query.earliestAlbum =
+      albumsLatestFirst
+        .filter(album => album.date)
+        .at(-1) ??
+      null;
+
+    return query;
+  },
+
+  relations: (relation, query, series) => ({
+    gallerySection:
+      relation('generateExpandableGallerySection'),
+
+    gridAboveCut:
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albumsAboveCut,
+        series.group),
+
+    gridBelowCut:
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albumsBelowCut,
+        series.group),
+  }),
+
+  data: (query, series) => ({
+    name:
+      series.name,
+
+    groupName:
+      series.group.name,
+
+    albums:
+      series.albums.length,
+
+    tracks:
+      series.albums
+        .flatMap(album => album.tracks)
+        .length,
+
+    allAlbumsDated:
+      query.allAlbumsDated,
+
+    anyAlbumNotFromThisGroup:
+      query.anyAlbumNotFromThisGroup,
+
+    earliestAlbumDate:
+      (query.earliestAlbum
+        ? query.earliestAlbum.date
+        : null),
+
+    latestAlbumDate:
+      (query.latestAlbum
+        ? query.latestAlbum.date
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupGalleryPage.albumSection', capsule =>
+      relations.gallerySection.slots({
+        title: data.name,
+
+        contentAboveCut: relations.gridAboveCut,
+        contentBelowCut: relations.gridBelowCut,
+
+        caption:
+          language.encapsulate(capsule, 'caption', captionCapsule =>
+            html.tags([
+              data.anyAlbumNotFromThisGroup &&
+                language.$(captionCapsule, 'seriesAlbumsNotFromGroup', {
+                  marker:
+                    language.$('misc.coverGrid.details.notFromThisGroup.marker'),
+
+                  series:
+                    html.tag('i', data.name),
+
+                  group: data.groupName,
+                }),
+
+              language.encapsulate(captionCapsule, workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.tracks =
+                  html.tag('b',
+                    language.countTracks(data.tracks, {unit: true}));
+
+                workingOptions.albums =
+                  html.tag('b',
+                    language.countAlbums(data.albums, {unit: true}));
+
+                if (data.allAlbumsDated) {
+                  const earliestDate = data.earliestAlbumDate;
+                  const latestDate = data.latestAlbumDate;
+
+                  const earliestYear = earliestDate.getFullYear();
+                  const latestYear = latestDate.getFullYear();
+
+                  if (earliestYear === latestYear) {
+                    if (data.albums === 1) {
+                      workingCapsule += '.withDate';
+                      workingOptions.date =
+                        language.formatDate(earliestDate);
+                    } else {
+                      workingCapsule += '.withYear';
+                      workingOptions.year =
+                        language.formatYear(earliestDate);
+                    }
+                  } else {
+                    workingCapsule += '.withYearRange';
+                    workingOptions.yearRange =
+                      language.formatYearRange(earliestDate, latestDate);
+                  }
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              }),
+            ], {[html.joinChildren]: html.tag('br')})),
+
+        expandCue:
+          language.$(capsule, 'expand'),
+
+        collapseCue:
+          language.$(capsule, 'collapse'),
+      })),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
index 99e7e8ff..4680cb46 100644
--- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -127,7 +127,8 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                html.metatag('chunkwrap', {split: ','},
+                // TODO: This is obviously evil.
+                html.metatag('chunkwrap', {split: /,| (?=and)/},
                   html.resolve(artistCredit)));
           }
 
diff --git a/src/content/dependencies/generateImageOverlay.js b/src/content/dependencies/generateImageOverlay.js
new file mode 100644
index 00000000..cfb78a1b
--- /dev/null
+++ b/src/content/dependencies/generateImageOverlay.js
@@ -0,0 +1,50 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  generate: ({html, language}) =>
+    html.tag('div', {id: 'image-overlay-container'},
+      html.tag('div', {id: 'image-overlay-content-container'}, [
+        html.tag('span', {id: 'image-overlay-image-area'},
+          html.tag('span', {id: 'image-overlay-image-layout'}, [
+            html.tag('img', {id: 'image-overlay-image'}),
+            html.tag('img', {id: 'image-overlay-image-thumb'}),
+          ])),
+
+        html.tag('div', {id: 'image-overlay-action-container'},
+          language.encapsulate('releaseInfo.viewOriginalFile', capsule => [
+            html.tag('div', {id: 'image-overlay-action-content-without-size'},
+              language.$(capsule, {
+                link: html.tag('a', {class: 'image-overlay-view-original'},
+                  language.$(capsule, 'link')),
+              })),
+
+            html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
+              language.$(capsule, 'withSize', {
+                link:
+                  html.tag('a', {class: 'image-overlay-view-original'},
+                    language.$(capsule, 'link')),
+
+                size:
+                  html.tag('span',
+                    {[html.joinChildren]: ''},
+                    [
+                      html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
+                        language.$('count.fileSize.kilobytes', {
+                          kilobytes:
+                            html.tag('span', {class: 'image-overlay-file-size-count'}),
+                        })),
+
+                      html.tag('span', {id: 'image-overlay-file-size-megabytes'},
+                        language.$('count.fileSize.megabytes', {
+                          megabytes:
+                            html.tag('span', {class: 'image-overlay-file-size-count'}),
+                        })),
+                    ]),
+              }),
+
+              html.tag('span', {id: 'image-overlay-file-size-warning'},
+                language.$(capsule, 'sizeWarning')),
+            ]),
+          ])),
+      ])),
+};
diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js
index 3f300676..1d58367d 100644
--- a/src/content/dependencies/generateIntrapageDotSwitcher.js
+++ b/src/content/dependencies/generateIntrapageDotSwitcher.js
@@ -42,6 +42,8 @@ export default {
         }).map(({title, targetID}) =>
             html.tag('a', {href: '#'},
               {'data-target-id': targetID},
+              {[html.onlyIfContent]: true},
+
               language.sanitize(title))),
     }),
 };
diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js
new file mode 100644
index 00000000..0a929429
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateListAllAdditionalFilesChunk'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _album, additionalFiles) => ({
+    chunk:
+      relation('generateListAllAdditionalFilesChunk', additionalFiles),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots, {language}) =>
+    language.encapsulate('listingPage', slots.stringsKey, pageCapsule =>
+      relations.chunk.slots({
+        title:
+          language.$(pageCapsule, 'albumFiles'),
+
+        stringsKey: slots.stringsKey,
+      })),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js
new file mode 100644
index 00000000..a0af1375
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js
@@ -0,0 +1,51 @@
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateListAllAdditionalFilesAlbumChunk',
+    'generateListAllAdditionalFilesTrackChunk',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album, property) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    albumChunk:
+      relation('generateListAllAdditionalFilesAlbumChunk',
+        album,
+        album[property] ?? []),
+
+    trackChunks:
+      album.tracks.map(track =>
+        relation('generateListAllAdditionalFilesTrackChunk',
+          track,
+          track[property] ?? [])),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tags([
+      relations.heading.slots({
+        tag: 'h3',
+        title: relations.albumLink,
+      }),
+
+      html.tag('dl',
+        {[html.onlyIfContent]: true},
+
+        [
+          relations.albumChunk.slot('stringsKey', slots.stringsKey),
+
+          relations.trackChunks.map(trackChunk =>
+            trackChunk.slot('stringsKey', slots.stringsKey)),
+        ]),
+    ]),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
index deb8c4ea..df652efd 100644
--- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -1,90 +1,99 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
+  contentDependencies: ['linkAdditionalFile'],
   extraDependencies: ['html', 'language'],
 
+  relations: (relation, additionalFiles) => ({
+    links:
+      additionalFiles
+        .map(file => file.filenames
+          .map(filename => relation('linkAdditionalFile', file, filename))),
+  }),
+
+  data: (additionalFiles) => ({
+    titles:
+      additionalFiles
+        .map(file => file.title),
+
+    filenames:
+      additionalFiles
+        .map(file => file.filenames),
+  }),
+
   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();
-    }
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('listingPage', slots.stringsKey, pageCapsule =>
+      html.tags([
+        html.tag('dt',
+          {[html.onlyIfSiblings]: true},
+          slots.title),
 
-    return html.tags([
-      html.tag('dt', slots.title),
-      html.tag('dd',
-        html.tag('ul',
-          stitchArrays({
-            additionalFileTitle: slots.additionalFileTitles,
-            additionalFileLinks: slots.additionalFileLinks,
-            additionalFileFiles: slots.additionalFileFiles,
-          }).map(({
-              additionalFileTitle,
-              additionalFileLinks,
-              additionalFileFiles,
-            }) =>
-              language.encapsulate('listingPage', slots.stringsKey, 'file', capsule =>
-                (additionalFileLinks.length === 1
-                  ? html.tag('li',
-                      additionalFileLinks[0].slots({
-                        content:
-                          language.$(capsule, {
-                            title: additionalFileTitle,
-                          }),
-                      }))
+        html.tag('dd',
+          {[html.onlyIfContent]: true},
 
-               : additionalFileLinks.length === 0
-                  ? html.tag('li',
-                      language.$(capsule, 'withNoFiles', {
-                        title: additionalFileTitle,
-                      }))
+          html.tag('ul',
+          {[html.onlyIfContent]: true},
 
-                  : html.tag('li', {class: 'has-details'},
-                      html.tag('details', [
-                        html.tag('summary',
-                          html.tag('span',
-                            language.$(capsule, 'withMultipleFiles', {
-                              title:
-                                html.tag('b', additionalFileTitle),
+            stitchArrays({
+              title: data.titles,
+              links: relations.links,
+              filenames: data.filenames,
+            }).map(({
+                title,
+                links,
+                filenames,
+              }) =>
+                language.encapsulate(pageCapsule, 'file', capsule =>
+                  (links.length === 1
+                    ? html.tag('li',
+                        links[0].slots({
+                          content:
+                            language.$(capsule, {
+                              title: title,
+                            }),
+                        }))
 
-                              files:
-                                language.countAdditionalFiles(
-                                  additionalFileLinks.length,
-                                  {unit: true}),
-                            }))),
+                 : links.length === 0
+                    ? html.tag('li',
+                        language.$(capsule, 'withNoFiles', {
+                          title: title,
+                        }))
 
-                        html.tag('ul',
-                          stitchArrays({
-                            additionalFileLink: additionalFileLinks,
-                            additionalFileFile: additionalFileFiles,
-                          }).map(({additionalFileLink, additionalFileFile}) =>
-                              html.tag('li',
-                                additionalFileLink.slots({
-                                  content:
-                                    language.$(capsule, {
-                                      title: additionalFileFile,
-                                    }),
-                                })))),
-                      ]))))))),
-    ]);
-  },
+                    : html.tag('li', {class: 'has-details'},
+                        html.tag('details', [
+                          html.tag('summary',
+                            html.tag('span',
+                              language.$(capsule, 'withMultipleFiles', {
+                                title:
+                                  html.tag('b', title),
+
+                                files:
+                                  language.countAdditionalFiles(
+                                    links.length,
+                                    {unit: true}),
+                              }))),
+
+                          html.tag('ul',
+                            stitchArrays({
+                              link: links,
+                              filename: filenames,
+                            }).map(({link, filename}) =>
+                                html.tag('li',
+                                  link.slots({
+                                    content:
+                                      language.$(capsule, {
+                                        title: filename,
+                                      }),
+                                  })))),
+                        ]))))))),
+      ])),
 };
diff --git a/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js
new file mode 100644
index 00000000..b2e5addf
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js
@@ -0,0 +1,23 @@
+export default {
+  contentDependencies: ['generateListAllAdditionalFilesChunk', 'linkTrack'],
+  extraDependencies: ['html'],
+
+  relations: (relation, track, additionalFiles) => ({
+    trackLink:
+      relation('linkTrack', track),
+
+    chunk:
+      relation('generateListAllAdditionalFilesChunk', additionalFiles),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots) =>
+    relations.chunk.slots({
+      title: relations.trackLink,
+      stringsKey: slots.stringsKey,
+    }),
+};
+
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js
new file mode 100644
index 00000000..0c91ce0c
--- /dev/null
+++ b/src/content/dependencies/generateLyricsEntry.js
@@ -0,0 +1,91 @@
+export default {
+  contentDependencies: ['linkArtist', 'linkExternal', 'transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    content:
+      relation('transformContent', entry.body),
+
+    artistText:
+      relation('transformContent', entry.artistText),
+
+    artistLinks:
+      entry.artists
+        .filter(artist => artist.name !== 'HSMusic Wiki') // smh
+        .map(artist => relation('linkArtist', artist)),
+
+    sourceLinks:
+      entry.sourceURLs
+        .map(url => relation('linkExternal', url)),
+
+    originDetails:
+      relation('transformContent', entry.originDetails),
+  }),
+
+  data: (entry) => ({
+    isWikiLyrics:
+      entry.isWikiLyrics,
+
+    hasSquareBracketAnnotations:
+      entry.hasSquareBracketAnnotations,
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('misc.lyrics', capsule =>
+      html.tag('div', {class: 'lyrics-entry'},
+        slots.attributes,
+
+        [
+          html.tag('p', {class: 'lyrics-details'},
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            [
+              language.$(capsule, 'source', {
+                [language.onlyIfOptions]: ['source'],
+
+                source:
+                  language.formatUnitList(
+                    relations.sourceLinks.map(link =>
+                      link.slots({
+                        indicateExternal: true,
+                        tab: 'separate',
+                      }))),
+              }),
+
+              data.isWikiLyrics &&
+                language.$(capsule, 'contributors', {
+                  [language.onlyIfOptions]: ['contributors'],
+
+                  contributors:
+                    (html.isBlank(relations.artistText)
+                      ? language.formatUnitList(relations.artistLinks)
+                      : relations.artistText.slot('mode', 'inline')),
+                }),
+
+              // This check is doubled up only for clarity: entries are coded
+              // in data so that `hasSquareBracketAnnotations` is only true
+              // if `isWikiLyrics` is also true.
+              data.isWikiLyrics &&
+              data.hasSquareBracketAnnotations &&
+                language.$(capsule, 'squareBracketAnnotations'),
+            ]),
+
+          html.tag('p', {class: 'origin-details'},
+            {[html.onlyIfContent]: true},
+
+            relations.originDetails.slots({
+              mode: 'inline',
+              absorbPunctuationFollowingExternalLinks: false,
+            })),
+
+          relations.content.slot('mode', 'lyrics'),
+        ])),
+};
diff --git a/src/content/dependencies/generateLyricsSection.js b/src/content/dependencies/generateLyricsSection.js
new file mode 100644
index 00000000..f6b719a9
--- /dev/null
+++ b/src/content/dependencies/generateLyricsSection.js
@@ -0,0 +1,81 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateIntrapageDotSwitcher',
+    'generateLyricsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entries) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    switcher:
+      relation('generateIntrapageDotSwitcher'),
+
+    entries:
+      entries
+        .map(entry => relation('generateLyricsEntry', entry)),
+
+    annotations:
+      entries
+        .map(entry => entry.annotation)
+        .map(annotation => relation('transformContent', annotation)),
+  }),
+
+  data: (entries) => ({
+    ids:
+      Array.from(
+        {length: entries.length},
+        (_, index) => 'lyrics-entry-' + index),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo.lyrics', capsule =>
+      html.tags([
+        relations.heading
+          .slots({
+            attributes: {id: 'lyrics'},
+            title: language.$(capsule),
+          }),
+
+        html.tag('p', {class: 'lyrics-switcher'},
+          {[html.onlyIfContent]: true},
+
+          language.$(capsule, 'switcher', {
+            [language.onlyIfOptions]: ['entries'],
+
+            entries:
+              relations.switcher.slots({
+                initialOptionIndex: 0,
+
+                titles:
+                  relations.annotations.map(annotation =>
+                    annotation.slots({
+                      mode: 'inline',
+                      textOnly: true,
+                    })),
+
+                targetIDs:
+                  data.ids,
+              }),
+          })),
+
+        stitchArrays({
+          entry: relations.entries,
+          id: data.ids,
+        }).map(({entry, id}, index) =>
+            entry.slots({
+              attributes: [
+                {id},
+
+                index >= 1 &&
+                  {style: 'display: none'},
+              ],
+            })),
+      ])),
+};
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 24b9bdca..0326f415 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -1,13 +1,16 @@
 import {openAggregate} from '#aggregate';
-import {empty, repeat} from '#sugar';
+import {atOffset, empty, repeat} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateColorStyleRules',
+    'generateColorStyleTag',
     'generateFooterLocalizationLinks',
+    'generateImageOverlay',
     'generatePageSidebar',
     'generateSearchSidebarBox',
+    'generateStaticURLStyleTag',
     'generateStickyHeadingContainer',
+    'generateWikiWallpaperStyleTag',
     'transformContent',
   ],
 
@@ -57,8 +60,17 @@ export default {
         relation('transformContent', sprawl.footerContent);
     }
 
-    relations.colorStyleRules =
-      relation('generateColorStyleRules');
+    relations.colorStyleTag =
+      relation('generateColorStyleTag');
+
+    relations.staticURLStyleTag =
+      relation('generateStaticURLStyleTag');
+
+    relations.wikiWallpaperStyleTag =
+      relation('generateWikiWallpaperStyleTag');
+
+    relations.imageOverlay =
+      relation('generateImageOverlay');
 
     return relations;
   },
@@ -89,7 +101,7 @@ export default {
       mutable: false,
     },
 
-    cover: {
+    artworkColumnContent: {
       type: 'html',
       mutable: false,
     },
@@ -103,9 +115,9 @@ export default {
 
     color: {validate: v => v.isColor},
 
-    styleRules: {
-      validate: v => v.sparseArrayOf(v.isHTML),
-      default: [],
+    styleTags: {
+      type: 'html',
+      mutable: false,
     },
 
     mainClasses: {
@@ -258,6 +270,29 @@ export default {
         ? data.canonicalBase + pagePathStringFromRoot
         : null);
 
+    const primaryCover = (() => {
+      const apparentFirst = tag => html.smooth(tag).content[0];
+
+      const maybeTemplate =
+        apparentFirst(slots.artworkColumnContent);
+
+      if (!maybeTemplate) return null;
+
+      const maybeTemplateContent =
+        html.resolve(maybeTemplate, {normalize: 'tag'});
+
+      const maybeCoverArtwork =
+        apparentFirst(maybeTemplateContent);
+
+      if (!maybeCoverArtwork) return null;
+
+      if (maybeCoverArtwork.attributes.has('class', 'cover-artwork')) {
+        return maybeTemplate;
+      } else {
+        return null;
+      }
+    })();
+
     const titleContentsHTML =
       (html.isBlank(slots.title)
         ? null
@@ -272,10 +307,16 @@ export default {
       (html.isBlank(slots.title)
         ? null
      : slots.headingMode === 'sticky'
-        ? relations.stickyHeadingContainer.slots({
-            title: titleContentsHTML,
-            cover: slots.cover,
-          })
+        ? [
+            relations.stickyHeadingContainer.slots({
+              title: titleContentsHTML,
+              cover: primaryCover,
+            }),
+
+            relations.stickyHeadingContainer.clone().slots({
+              rootAttributes: {inert: true},
+            }),
+          ]
         : html.tag('h1', titleContentsHTML));
 
     // TODO: There could be neat interactions with the sticky heading here,
@@ -306,9 +347,11 @@ export default {
         [
           titleHTML,
 
-          html.tag('div', {id: 'cover-art-container'},
+          html.tag('div', {id: 'artwork-column'},
             {[html.onlyIfContent]: true},
-            slots.cover),
+            {class: 'isolate-tooltip-z-indexing'},
+
+            slots.artworkColumnContent),
 
           subtitleHTML,
 
@@ -351,7 +394,7 @@ export default {
 
             slots.navLinks
               ?.filter(Boolean)
-              ?.map((cur, i) => {
+              ?.map((cur, i, entries) => {
                 let content;
 
                 if (cur.html) {
@@ -385,20 +428,13 @@ export default {
                   (slots.navLinkStyle === 'hierarchical' &&
                     i === slots.navLinks.length - 1);
 
-                return (
+                const navLink =
                   html.tag('span', {class: 'nav-link'},
                     showAsCurrent &&
                       {class: 'current'},
 
                     [
                       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'},
@@ -409,7 +445,25 @@ export default {
                           [language.onlyIfOptions]: ['links'],
                           links: cur.accent,
                         })),
-                    ]));
+                    ]);
+
+                if (slots.navLinkStyle === 'index') {
+                  return navLink;
+                }
+
+                const prev =
+                  atOffset(entries, i, -1);
+
+                if (
+                  prev &&
+                  prev.releaseRestToWrapTogether !== true &&
+                  (prev.releaseRestToWrapTogether === false ||
+                   prev.auto === 'home')
+                ) {
+                  return navLink;
+                } else {
+                  return html.metatag('blockwrap', navLink);
+                }
               })),
 
           html.tag('div', {class: 'nav-bottom-row'},
@@ -450,14 +504,15 @@ export default {
 
     let showingSidebarLeft;
     let showingSidebarRight;
+    let sidebarsInContentColumn = false;
 
     const leftSidebar = getSidebar('leftSidebar', 'sidebar-left', willShowSearch);
     const rightSidebar = getSidebar('rightSidebar', 'sidebar-right', false);
 
     if (willShowSearch) {
       if (html.isBlank(leftSidebar)) {
-        leftSidebar.setSlot('initiallyHidden', true);
-        showingSidebarLeft = false;
+        sidebarsInContentColumn = true;
+        showingSidebarLeft = true;
       }
 
       leftSidebar.setSlot(
@@ -534,69 +589,33 @@ export default {
               {id: 'additional-files', string: 'additionalFiles'},
               {id: 'commentary', string: 'commentary'},
               {id: 'artist-commentary', string: 'artistCommentary'},
-              {id: 'credit-sources', string: 'creditSources'},
+              {id: 'crediting-sources', string: 'creditingSources'},
+              {id: 'referencing-sources', string: 'referencingSources'},
             ])),
         ]);
 
-    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'},
-          language.encapsulate('releaseInfo.viewOriginalFile', capsule => [
-            html.tag('div', {id: 'image-overlay-action-content-without-size'},
-              language.$(capsule, {
-                link: html.tag('a', {class: 'image-overlay-view-original'},
-                  language.$(capsule, 'link')),
-              })),
+    const slottedStyleTags =
+      html.smush(slots.styleTags);
 
-            html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
-              language.$(capsule, 'withSize', {
-                link:
-                  html.tag('a', {class: 'image-overlay-view-original'},
-                    language.$(capsule, 'link')),
+    const slottedWallpaperStyleTag =
+      slottedStyleTags.content
+        .find(tag => tag.attributes.has('class', 'wallpaper-style'));
 
-                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'}),
-                        })),
+    const fallbackWallpaperStyleTag =
+      (slottedWallpaperStyleTag
+        ? html.blank()
+        : relations.wikiWallpaperStyleTag);
 
-                      html.tag('span', {id: 'image-overlay-file-size-megabytes'},
-                        language.$('count.fileSize.megabytes', {
-                          megabytes:
-                            html.tag('span', {class: 'image-overlay-file-size-count'}),
-                        })),
-                    ]),
-              }),
-
-              html.tag('span', {id: 'image-overlay-file-size-warning'},
-                language.$(capsule, 'sizeWarning')),
-            ]),
-          ])),
-      ]));
-
-    const styleRulesCSS =
-      html.resolve(slots.styleRules, {normalize: 'string'});
-
-    const fallbackBackgroundStyleRule =
-      (styleRulesCSS.match(/body::before[^}]*background-image:/)
-        ? ''
-        : `body::before {\n` +
-          `    background-image: url("${to('media.path', 'bg.jpg')}");\n` +
-          `}`);
+    const usingWallpaperStyleTag =
+      (slottedWallpaperStyleTag
+        ? slottedWallpaperStyleTag
+        : html.resolve(fallbackWallpaperStyleTag, {normalize: 'tag'}));
 
     const numWallpaperParts =
-      html.resolve(slots.styleRules, {normalize: 'string'})
-        .match(/\.wallpaper-part:nth-child/g)
-        ?.length ?? 0;
+      (usingWallpaperStyleTag &&
+       usingWallpaperStyleTag.attributes.has('data-wallpaper-mode', 'parts')
+        ? parseInt(usingWallpaperStyleTag.attributes.get('data-num-wallpaper-parts'))
+        : 0);
 
     const wallpaperPartsHTML =
       html.tag('div', {class: 'wallpaper-parts'},
@@ -738,13 +757,14 @@ export default {
               href: to('staticCSS.path', 'site.css'),
             }),
 
-            html.tag('style', [
-              relations.colorStyleRules
-                .slot('color', slots.color ?? data.wikiColor),
+            relations.colorStyleTag
+              .slot('color', slots.color ?? data.wikiColor),
 
-              fallbackBackgroundStyleRule,
-              slots.styleRules,
-            ]),
+            relations.staticURLStyleTag,
+
+            fallbackWallpaperStyleTag,
+
+            slottedStyleTags,
 
             html.tag('script', {
               src: to('staticLib.path', 'chroma-js/chroma.min.js'),
@@ -773,13 +793,16 @@ export default {
                 showingSidebarRight &&
                   {class: 'showing-sidebar-right'},
 
+                sidebarsInContentColumn &&
+                  {class: 'sidebars-in-content-column'},
+
                 [
                   skippersHTML,
                   layoutHTML,
                 ]),
 
               // infoCardHTML,
-              imageOverlayHTML,
+              relations.imageOverlay,
             ]),
         ])
     ]).toString();
diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js
index 05b1d469..7974c707 100644
--- a/src/content/dependencies/generatePageSidebarConjoinedBox.js
+++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js
@@ -32,11 +32,7 @@ export default {
           .map((content, index, {length}) => [
             content,
             index < length - 1 &&
-              html.tag('hr', {
-                style:
-                  `border-color: var(--primary-color); ` +
-                  `border-style: none none dotted none`,
-              }),
+              html.tag('hr', {class: 'cute'}),
           ]),
     }),
 };
diff --git a/src/content/dependencies/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js
index 3d21b15d..83451eca 100644
--- a/src/content/dependencies/generateReferencedArtworksPage.js
+++ b/src/content/dependencies/generateReferencedArtworksPage.js
@@ -1,67 +1,55 @@
-import {stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: [
+    'generateCoverArtwork',
     'generateCoverGrid',
     'generatePageLayout',
     'image',
-    'linkAlbum',
-    'linkTrack',
+    'linkAnythingMan',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  relations: (relation, referencedArtworks) => ({
+  relations: (relation, artwork) => ({
     layout:
       relation('generatePageLayout'),
 
+    cover:
+      relation('generateCoverArtwork', artwork),
+
     coverGrid:
       relation('generateCoverGrid'),
 
     links:
-      referencedArtworks.map(({thing}) =>
-        (thing.album
-          ? relation('linkTrack', thing)
-          : relation('linkAlbum', thing))),
+      artwork.referencedArtworks.map(({artwork}) =>
+        relation('linkAnythingMan', artwork.thing)),
 
     images:
-      referencedArtworks.map(({thing}) =>
-        relation('image', thing.artTags)),
+      artwork.referencedArtworks.map(({artwork}) =>
+        relation('image', artwork)),
   }),
 
-  data: (referencedArtworks) => ({
+  data: (artwork) => ({
+    color:
+      artwork.thing.color,
+
     count:
-      referencedArtworks.length,
+      artwork.referencedArtworks.length,
 
     names:
-      referencedArtworks
-        .map(({thing}) => thing.name),
-
-    paths:
-      referencedArtworks
-        .map(({thing}) =>
-          (thing.album
-            ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
-            : ['media.albumCover', thing.directory, thing.coverArtFileExtension])),
-
-    dimensions:
-      referencedArtworks
-        .map(({thing}) => thing.coverArtDimensions),
+      artwork.referencedArtworks
+        .map(({artwork}) => artwork.thing.name),
 
     coverArtistNames:
-      referencedArtworks
-        .map(({thing}) =>
-          thing.coverArtistContribs
+      artwork.referencedArtworks
+        .map(({artwork}) =>
+          artwork.artistContribs
             .map(contrib => contrib.artist.name)),
   }),
 
   slots: {
-    color: {validate: v => v.isColor},
-
-    styleRules: {type: 'html', mutable: false},
+    styleTags: {type: 'html', mutable: false},
 
     title: {type: 'html', mutable: false},
-    cover: {type: 'html', mutable: true},
 
     navLinks: {validate: v => v.isArray},
     navBottomRowContent: {type: 'html', mutable: false},
@@ -73,11 +61,13 @@ export default {
         title: slots.title,
         subtitle: language.$(pageCapsule, 'subtitle'),
 
-        color: slots.color,
-        styleRules: slots.styleRules,
+        color: data.color,
+        styleTags: slots.styleTags,
 
-        cover:
-          slots.cover.slot('details', 'artists'),
+        artworkColumnContent:
+          relations.cover.slots({
+            showArtistDetails: true,
+          }),
 
         mainClasses: ['top-index'],
         mainContent: [
@@ -91,19 +81,9 @@ export default {
 
           relations.coverGrid.slots({
             links: relations.links,
+            images: relations.images,
             names: data.names,
 
-            images:
-              stitchArrays({
-                image: relations.images,
-                path: data.paths,
-                dimensions: data.dimensions,
-              }).map(({image, path, dimensions}) =>
-                  image.slots({
-                    path,
-                    dimensions,
-                  })),
-
             info:
               data.coverArtistNames.map(names =>
                 language.$('misc.coverGrid.details.coverArtists', {
diff --git a/src/content/dependencies/generateReferencingArtworksPage.js b/src/content/dependencies/generateReferencingArtworksPage.js
index 2fe2e93d..e97b01f8 100644
--- a/src/content/dependencies/generateReferencingArtworksPage.js
+++ b/src/content/dependencies/generateReferencingArtworksPage.js
@@ -1,67 +1,55 @@
-import {stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: [
+    'generateCoverArtwork',
     'generateCoverGrid',
     'generatePageLayout',
     'image',
-    'linkAlbum',
-    'linkTrack',
+    'linkAnythingMan',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  relations: (relation, referencingArtworks) => ({
+  relations: (relation, artwork) => ({
     layout:
       relation('generatePageLayout'),
 
+    cover:
+      relation('generateCoverArtwork', artwork),
+
     coverGrid:
       relation('generateCoverGrid'),
 
     links:
-      referencingArtworks.map(({thing}) =>
-        (thing.album
-          ? relation('linkTrack', thing)
-          : relation('linkAlbum', thing))),
+      artwork.referencedByArtworks.map(({artwork}) =>
+        relation('linkAnythingMan', artwork.thing)),
 
     images:
-      referencingArtworks.map(({thing}) =>
-        relation('image', thing.artTags)),
+      artwork.referencedByArtworks.map(({artwork}) =>
+        relation('image', artwork)),
   }),
 
-  data: (referencingArtworks) => ({
+  data: (artwork) => ({
+    color:
+      artwork.thing.color,
+
     count:
-      referencingArtworks.length,
+      artwork.referencedByArtworks.length,
 
     names:
-      referencingArtworks
-        .map(({thing}) => thing.name),
-
-    paths:
-      referencingArtworks
-        .map(({thing}) =>
-          (thing.album
-            ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
-            : ['media.albumCover', thing.directory, thing.coverArtFileExtension])),
-
-    dimensions:
-      referencingArtworks
-        .map(({thing}) => thing.coverArtDimensions),
+      artwork.referencedByArtworks
+        .map(({artwork}) => artwork.thing.name),
 
     coverArtistNames:
-      referencingArtworks
-        .map(({thing}) =>
-          thing.coverArtistContribs
+      artwork.referencedByArtworks
+        .map(({artwork}) =>
+          artwork.artistContribs
             .map(contrib => contrib.artist.name)),
   }),
 
   slots: {
-    color: {validate: v => v.isColor},
-
-    styleRules: {type: 'html', mutable: false},
+    styleTags: {type: 'html', mutable: false},
 
     title: {type: 'html', mutable: false},
-    cover: {type: 'html', mutable: true},
 
     navLinks: {validate: v => v.isArray},
     navBottomRowContent: {type: 'html', mutable: false},
@@ -73,11 +61,13 @@ export default {
         title: slots.title,
         subtitle: language.$(pageCapsule, 'subtitle'),
 
-        color: slots.color,
-        styleRules: slots.styleRules,
+        color: data.color,
+        styleTags: slots.styleTags,
 
-        cover:
-          slots.cover.slot('details', 'artists'),
+        artworkColumnContent:
+          relations.cover.slots({
+            showArtistDetails: true,
+          }),
 
         mainClasses: ['top-index'],
         mainContent: [
@@ -91,19 +81,9 @@ export default {
 
           relations.coverGrid.slots({
             links: relations.links,
+            images: relations.images,
             names: data.names,
 
-            images:
-              stitchArrays({
-                image: relations.images,
-                path: data.paths,
-                dimensions: data.dimensions,
-              }).map(({image, path, dimensions}) =>
-                  image.slots({
-                    path,
-                    dimensions,
-                  })),
-
             info:
               data.coverArtistNames.map(names =>
                 language.$('misc.coverGrid.details.coverArtists', {
diff --git a/src/content/dependencies/generateReleaseInfoListenLine.js b/src/content/dependencies/generateReleaseInfoListenLine.js
new file mode 100644
index 00000000..f2a6dd29
--- /dev/null
+++ b/src/content/dependencies/generateReleaseInfoListenLine.js
@@ -0,0 +1,150 @@
+import {isExternalLinkContext} from '#external-links';
+import {empty, stitchArrays, unique} from '#sugar';
+
+function getReleaseContext(urlString, {
+  _artistURLs,
+  albumArtistURLs,
+}) {
+  const composerBandcampDomains =
+    albumArtistURLs
+      .filter(url => url.hostname.endsWith('.bandcamp.com'))
+      .map(url => url.hostname);
+
+  const url = new URL(urlString);
+
+  if (url.hostname === 'homestuck.bandcamp.com') {
+    return 'officialRelease';
+  }
+
+  if (composerBandcampDomains.includes(url.hostname)) {
+    return 'composerRelease';
+  }
+
+  return null;
+}
+
+export default {
+  contentDependencies: ['linkExternal'],
+  extraDependencies: ['html', 'language'],
+
+  query(thing) {
+    const query = {};
+
+    query.album =
+      (thing.album
+        ? thing.album
+        : thing);
+
+    query.artists =
+      thing.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.artistGroups =
+      query.artists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    query.albumArtists =
+      query.album.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.albumArtistGroups =
+      query.albumArtists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    return query;
+  },
+
+  relations: (relation, _query, thing) => ({
+    links:
+      thing.urls.map(url => relation('linkExternal', url)),
+  }),
+
+  data(query, thing) {
+    const data = {};
+
+    data.name = thing.name;
+
+    const artistURLs =
+      unique([
+        ...query.artists.flatMap(artist => artist.urls),
+        ...query.artistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const albumArtistURLs =
+      unique([
+        ...query.albumArtists.flatMap(artist => artist.urls),
+        ...query.albumArtistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const boundGetReleaseContext = urlString =>
+      getReleaseContext(urlString, {
+        artistURLs,
+        albumArtistURLs,
+      });
+
+    let releaseContexts =
+      thing.urls.map(boundGetReleaseContext);
+
+    const albumReleaseContexts =
+      query.album.urls.map(boundGetReleaseContext);
+
+    const presentReleaseContexts =
+      unique(releaseContexts.filter(Boolean));
+
+    const presentAlbumReleaseContexts =
+      unique(albumReleaseContexts.filter(Boolean));
+
+    if (
+      presentReleaseContexts.length <= 1 &&
+      presentAlbumReleaseContexts.length <= 1
+    ) {
+      releaseContexts =
+        thing.urls.map(() => null);
+    }
+
+    data.releaseContexts = releaseContexts;
+
+    return data;
+  },
+
+  slots: {
+    visibleWithoutLinks: {
+      type: 'boolean',
+      default: false,
+    },
+
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('releaseInfo.listenOn', capsule =>
+      (empty(relations.links) && slots.visibleWithoutLinks
+        ? language.$(capsule, 'noLinks', {
+            name:
+              html.tag('i', data.name),
+          })
+
+        : language.$('releaseInfo.listenOn', {
+            [language.onlyIfOptions]: ['links'],
+
+            links:
+              language.formatDisjunctionList(
+                stitchArrays({
+                  link: relations.links,
+                  releaseContext: data.releaseContexts,
+                }).map(({link, releaseContext}) =>
+                    link.slot('context', [
+                      ...
+                      (Array.isArray(slots.context)
+                        ? slots.context
+                        : [slots.context]),
+
+                      releaseContext,
+                    ]))),
+          }))),
+};
diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js
index 188a678f..308a1105 100644
--- a/src/content/dependencies/generateSearchSidebarBox.js
+++ b/src/content/dependencies/generateSearchSidebarBox.js
@@ -57,6 +57,26 @@ export default {
             html.tag('template', {class: 'wiki-search-tag-result-kind-string'},
               language.$(capsule, 'artTag')),
           ]),
+
+          language.encapsulate(capsule, 'resultFilter', capsule => [
+            html.tag('template', {class: 'wiki-search-album-result-filter-string'},
+              language.$(capsule, 'album')),
+
+            html.tag('template', {class: 'wiki-search-artist-result-filter-string'},
+              language.$(capsule, 'artist')),
+
+            html.tag('template', {class: 'wiki-search-flash-result-filter-string'},
+              language.$(capsule, 'flash')),
+
+            html.tag('template', {class: 'wiki-search-group-result-filter-string'},
+              language.$(capsule, 'group')),
+
+            html.tag('template', {class: 'wiki-search-track-result-filter-string'},
+              language.$(capsule, 'track')),
+
+            html.tag('template', {class: 'wiki-search-tag-result-filter-string'},
+              language.$(capsule, 'artTag')),
+          ]),
         ],
       })),
 };
diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js
index 226152c7..931352b4 100644
--- a/src/content/dependencies/generateStaticPage.js
+++ b/src/content/dependencies/generateStaticPage.js
@@ -23,17 +23,19 @@ export default {
         title: data.name,
         headingMode: 'sticky',
 
-        styleRules:
-          (data.stylesheet
-            ? [data.stylesheet]
-            : []),
+        styleTags: [
+          html.tag('style', {class: 'static-page-style'},
+            {[html.onlyIfContent]: true},
+            data.stylesheet),
+        ],
 
         mainClasses: ['long-content'],
         mainContent: [
           relations.content,
 
-          data.script &&
-            html.tag('script', data.script),
+          html.tag('script',
+            {[html.onlyIfContent]: true},
+            data.script),
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateStaticURLStyleTag.js b/src/content/dependencies/generateStaticURLStyleTag.js
new file mode 100644
index 00000000..b927e5d6
--- /dev/null
+++ b/src/content/dependencies/generateStaticURLStyleTag.js
@@ -0,0 +1,23 @@
+export default {
+  contentDependencies: ['generateStyleTag'],
+  extraDependencies: ['to'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+  }),
+
+  generate: (relations, {to}) =>
+    relations.styleTag.slots({
+      attributes: {class: 'static-url-style'},
+
+      rules: [
+        {
+          select: '.image-media-link::after',
+          declare: [
+            `mask-image: url("${to('staticMisc.path', 'image.svg')}");`
+          ],
+        },
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
index 7cfbcf50..ec3062a3 100644
--- a/src/content/dependencies/generateStickyHeadingContainer.js
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -2,6 +2,11 @@ export default {
   extraDependencies: ['html'],
 
   slots: {
+    rootAttributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
     title: {
       type: 'html',
       mutable: false,
@@ -13,27 +18,42 @@ export default {
     },
   },
 
-  generate: (slots, {html}) =>
-    html.tag('div', {class: 'content-sticky-heading-container'},
+  generate: (slots, {html}) => html.tags([
+    html.tag('div', {class: 'content-sticky-heading-root'},
+      slots.rootAttributes,
+
       !html.isBlank(slots.cover) &&
         {class: 'has-cover'},
 
-      [
-        html.tag('div', {class: 'content-sticky-heading-row'}, [
-          html.tag('h1', slots.title),
+      html.tag('div', {class: 'content-sticky-heading-anchor'},
+        html.tag('div', {class: 'content-sticky-heading-container'},
+          !html.isBlank(slots.cover) &&
+            {class: 'has-cover'},
+
+          [
+            html.tag('div', {class: 'content-sticky-heading-row'}, [
+              html.tag('h1', [
+                html.tag('span', {class: 'reference-collapsed-heading'},
+                  {inert: true},
+
+                  slots.title.clone()),
+
+                slots.title,
+              ]),
 
-          html.tag('div', {class: 'content-sticky-heading-cover-container'},
-            {[html.onlyIfContent]: true},
+              html.tag('div', {class: 'content-sticky-heading-cover-container'},
+                {[html.onlyIfContent]: true},
 
-            html.tag('div', {class: 'content-sticky-heading-cover'},
-              {[html.onlyIfContent]: true},
+                html.tag('div', {class: 'content-sticky-heading-cover'},
+                  {[html.onlyIfContent]: true},
 
-              (html.isBlank(slots.cover)
-                ? html.blank()
-                : slots.cover.slot('mode', 'thumbnail')))),
-        ]),
+                  (html.isBlank(slots.cover)
+                    ? html.blank()
+                    : slots.cover.slot('mode', 'thumbnail')))),
+            ]),
 
-        html.tag('div', {class: 'content-sticky-subheading-row'},
-          html.tag('h2', {class: 'content-sticky-subheading'})),
-      ]),
+            html.tag('div', {class: 'content-sticky-subheading-row'},
+              html.tag('h2', {class: 'content-sticky-subheading'})),
+          ]))),
+  ]),
 };
diff --git a/src/content/dependencies/generateStyleTag.js b/src/content/dependencies/generateStyleTag.js
new file mode 100644
index 00000000..5ed09ae5
--- /dev/null
+++ b/src/content/dependencies/generateStyleTag.js
@@ -0,0 +1,48 @@
+import {empty} from '#sugar';
+
+const indent = text =>
+  text
+    .split('\n')
+    .map(line => ' '.repeat(4) + line)
+    .join('\n');
+
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    rules: {
+      validate: v =>
+        v.looseArrayOf(
+          v.validateProperties({
+            select: v.isString,
+            declare: v.looseArrayOf(v.isString),
+          })),
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('style', slots.attributes,
+      {[html.onlyIfContent]: true},
+
+      slots.rules
+        .filter(Boolean)
+
+        .map(rule => ({
+          select: rule.select,
+          declare: rule.declare.filter(Boolean),
+        }))
+
+        .filter(rule => !empty(rule.declare))
+
+        .map(rule =>
+          `${rule.select} {\n` +
+          indent(rule.declare.join('\n')) + '\n' +
+          `}`)
+
+        .join('\n\n')),
+};
diff --git a/src/content/dependencies/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js
new file mode 100644
index 00000000..c7e7f0f8
--- /dev/null
+++ b/src/content/dependencies/generateTrackArtistCommentarySection.js
@@ -0,0 +1,147 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentContentHeading',
+    'generateCommentaryEntry',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    otherSecondaryReleasesWithCommentary:
+      track.otherReleases
+        .filter(track => !track.isMainRelease)
+        .filter(track => !empty(track.commentary)),
+  }),
+
+  relations: (relation, query, track) => ({
+    contentContentHeading:
+      relation('generateContentContentHeading', track),
+
+    mainReleaseTrackLink:
+      (track.isSecondaryRelease
+        ? relation('linkTrack', track.mainReleaseTrack)
+        : null),
+
+    mainReleaseArtistCommentaryEntries:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.commentary
+            .map(entry => relation('generateCommentaryEntry', entry))
+        : null),
+
+    thisReleaseAlbumLink:
+      relation('linkAlbum', track.album),
+
+    artistCommentaryEntries:
+      track.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
+
+    otherReleaseTrackLinks:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => relation('linkTrack', track)),
+  }),
+
+  data: (query, track) => ({
+    name:
+      track.name,
+
+    isSecondaryRelease:
+      track.isSecondaryRelease,
+
+    mainReleaseName:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.name
+        : null),
+
+    mainReleaseAlbumName:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.album.name
+        : null),
+
+    mainReleaseAlbumColor:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.album.color
+        : null),
+
+    otherReleaseAlbumNames:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => track.album.name),
+
+    otherReleaseAlbumColors:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => track.album.color),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.artistCommentary', capsule =>
+      html.tags([
+        relations.contentContentHeading.slots({
+          attributes: {id: 'artist-commentary'},
+          string: 'misc.artistCommentary',
+        }),
+
+        relations.artistCommentaryEntries,
+
+        data.isSecondaryRelease &&
+          html.tag('div', {class: 'inherited-commentary-section'},
+            {[html.onlyIfContent]: true},
+
+            [
+              html.tag('p', {class: ['drop', 'commentary-drop']},
+                {[html.onlyIfSiblings]: true},
+
+                language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => {
+                  const workingOptions = {};
+
+                  workingOptions.album =
+                    relations.mainReleaseTrackLink.slots({
+                      content:
+                        data.mainReleaseAlbumName,
+
+                      color:
+                        data.mainReleaseAlbumColor,
+                    });
+
+                  if (data.name !== data.mainReleaseName) {
+                    workingCapsule += '.namedDifferently';
+                    workingOptions.name =
+                      html.tag('i', data.mainReleaseName);
+                  }
+
+                  return language.$(workingCapsule, workingOptions);
+                })),
+
+              relations.mainReleaseArtistCommentaryEntries,
+            ]),
+
+        html.tag('p', {class: ['drop', 'commentary-drop']},
+          {[html.onlyIfContent]: true},
+
+          language.encapsulate(capsule, 'info.seeSpecificReleases', workingCapsule => {
+            const workingOptions = {};
+
+            workingOptions[language.onlyIfOptions] = ['albums'];
+
+            workingOptions.albums =
+              language.formatUnitList(
+                stitchArrays({
+                  trackLink: relations.otherReleaseTrackLinks,
+                  albumName: data.otherReleaseAlbumNames,
+                  albumColor: data.otherReleaseAlbumColors,
+                }).map(({trackLink, albumName, albumColor}) =>
+                    trackLink.slots({
+                      content: language.sanitize(albumName),
+                      color: albumColor,
+                    })));
+
+            if (!html.isBlank(relations.artistCommentaryEntries)) {
+              workingCapsule += '.withMainCommentary';
+            }
+
+            return language.$(workingCapsule, workingOptions);
+          })),
+      ])),
+};
diff --git a/src/content/dependencies/generateTrackArtworkColumn.js b/src/content/dependencies/generateTrackArtworkColumn.js
new file mode 100644
index 00000000..f06d735b
--- /dev/null
+++ b/src/content/dependencies/generateTrackArtworkColumn.js
@@ -0,0 +1,33 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+  extraDependencies: ['html'],
+
+  relations: (relation, track) => ({
+    albumCover:
+      (!track.hasUniqueCoverArt && track.album.hasCoverArt
+        ? relation('generateCoverArtwork', track.album.coverArtworks[0])
+        : null),
+
+    trackCovers:
+      (track.hasUniqueCoverArt
+        ? track.trackArtworks.map(artwork =>
+            relation('generateCoverArtwork', artwork))
+        : []),
+  }),
+
+  generate: (relations, {html}) =>
+    html.tags([
+      relations.albumCover?.slots({
+        showOriginDetails: true,
+        showArtTagDetails: true,
+        showReferenceDetails: true,
+      }),
+
+      relations.trackCovers.map(cover =>
+        cover.slots({
+          showOriginDetails: true,
+          showArtTagDetails: true,
+          showReferenceDetails: true,
+        })),
+    ]),
+};
diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js
deleted file mode 100644
index 9153e2fc..00000000
--- a/src/content/dependencies/generateTrackCoverArtwork.js
+++ /dev/null
@@ -1,143 +0,0 @@
-export default {
-  contentDependencies: [
-    'generateCoverArtwork',
-    'generateCoverArtworkArtTagDetails',
-    'generateCoverArtworkArtistDetails',
-    'generateCoverArtworkReferenceDetails',
-    'image',
-    'linkAlbum',
-    'linkTrackReferencedArtworks',
-    'linkTrackReferencingArtworks',
-  ],
-
-  extraDependencies: ['html', 'language'],
-
-  query: (track) => ({
-    artTags:
-      (track.hasUniqueCoverArt
-        ? track.artTags
-        : track.album.artTags),
-
-    coverArtistContribs:
-      (track.hasUniqueCoverArt
-        ? track.coverArtistContribs
-        : track.album.coverArtistContribs),
-  }),
-
-  relations: (relation, query, track) => ({
-    coverArtwork:
-      relation('generateCoverArtwork'),
-
-    image:
-      relation('image'),
-
-    artTagDetails:
-      relation('generateCoverArtworkArtTagDetails',
-        query.artTags),
-
-    artistDetails:
-      relation('generateCoverArtworkArtistDetails',
-        query.coverArtistContribs),
-
-    referenceDetails:
-      relation('generateCoverArtworkReferenceDetails',
-        track.referencedArtworks,
-        track.referencedByArtworks),
-
-    referencedArtworksLink:
-      relation('linkTrackReferencedArtworks', track),
-
-    referencingArtworksLink:
-      relation('linkTrackReferencingArtworks', track),
-
-    albumLink:
-      relation('linkAlbum', track.album),
-  }),
-
-  data: (query, 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),
-
-    nonUnique:
-      !track.hasUniqueCoverArt,
-
-    warnings:
-      query.artTags
-        .filter(tag => tag.isContentWarning)
-        .map(tag => tag.name),
-  }),
-
-  slots: {
-    mode: {type: 'string'},
-
-    details: {
-      validate: v => v.is('tags', 'artists'),
-      default: 'tags',
-    },
-
-    showReferenceLinks: {
-      type: 'boolean',
-      default: false,
-    },
-
-    showNonUniqueLine: {
-      type: 'boolean',
-      default: false,
-    },
-  },
-
-  generate: (data, relations, slots, {html, language}) =>
-    relations.coverArtwork.slots({
-      mode: slots.mode,
-
-      image:
-        relations.image.slots({
-          path: data.path,
-          color: data.color,
-          alt: language.$('misc.alt.trackCover'),
-        }),
-
-      dimensions: data.dimensions,
-      warnings: data.warnings,
-
-      details: [
-        slots.details === 'tags' &&
-          relations.artTagDetails,
-
-        slots.details === 'artists'&&
-          relations.artistDetails,
-
-        slots.showReferenceLinks &&
-          relations.referenceDetails.slots({
-            referencedLink:
-              relations.referencedArtworksLink,
-
-            referencingLink:
-              relations.referencingArtworksLink,
-          }),
-
-        slots.showNonUniqueLine &&
-        data.nonUnique &&
-          html.tag('p', {class: 'image-details'},
-            {class: 'non-unique-details'},
-
-            language.$('misc.trackArtFromAlbum', {
-              album:
-                relations.albumLink.slots({
-                  color: false,
-                }),
-            })),
-      ],
-    }),
-};
-
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 4540b79c..6c16ce27 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,16 +1,19 @@
 export default {
   contentDependencies: [
+    'generateAdditionalFilesList',
     'generateAdditionalNamesBox',
-    'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
-    'generateAlbumStyleRules',
-    'generateCommentarySection',
+    'generateAlbumStyleTags',
+    'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generateContributionList',
+    'generateLyricsSection',
     'generatePageLayout',
-    'generateTrackCoverArtwork',
+    'generateTrackArtistCommentarySection',
+    'generateTrackArtworkColumn',
     'generateTrackInfoPageFeaturedByFlashesList',
     'generateTrackInfoPageOtherReleasesList',
     'generateTrackList',
@@ -23,19 +26,21 @@ export default {
     'transformContent',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
+  extraDependencies: ['html', 'language'],
 
-  sprawl: ({wikiInfo}) => ({
-    divideTrackListsByGroups:
-      wikiInfo.divideTrackListsByGroups,
+  query: (track) => ({
+    mainReleaseTrack:
+      (track.isMainRelease
+        ? track
+        : track.mainReleaseTrack),
   }),
 
-  relations: (relation, sprawl, track) => ({
+  relations: (relation, query, track) => ({
     layout:
       relation('generatePageLayout'),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', track.album, track),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
 
     socialEmbed:
       relation('generateTrackSocialEmbed', track),
@@ -55,19 +60,20 @@ export default {
     additionalNamesBox:
       relation('generateAdditionalNamesBox', track.additionalNames),
 
-    cover:
-      (track.hasUniqueCoverArt || track.album.hasCoverArt
-        ? relation('generateTrackCoverArtwork', track)
-        : null),
+    artworkColumn:
+      relation('generateTrackArtworkColumn', track),
 
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', track),
+
     releaseInfo:
       relation('generateTrackReleaseInfo', track),
 
     otherReleasesList:
-        relation('generateTrackInfoPageOtherReleasesList', track),
+      relation('generateTrackInfoPageOtherReleasesList', track),
 
     contributorContributionList:
       relation('generateContributionList', track.contributorContribs),
@@ -80,43 +86,40 @@ export default {
 
     referencedByTracksList:
       relation('generateTrackListDividedByGroups',
-        track.referencedByTracks,
-        sprawl.divideTrackListsByGroups),
+        query.mainReleaseTrack.referencedByTracks),
 
     sampledByTracksList:
       relation('generateTrackListDividedByGroups',
-        track.sampledByTracks,
-        sprawl.divideTrackListsByGroups),
+        query.mainReleaseTrack.sampledByTracks),
 
     flashesThatFeatureList:
       relation('generateTrackInfoPageFeaturedByFlashesList', track),
 
-    lyrics:
-      relation('transformContent', track.lyrics),
+    lyricsSection:
+      relation('generateLyricsSection', track.lyrics),
 
     sheetMusicFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        track.album,
-        track.sheetMusicFiles),
+      relation('generateAdditionalFilesList', track.sheetMusicFiles),
 
     midiProjectFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        track.album,
-        track.midiProjectFiles),
+      relation('generateAdditionalFilesList', track.midiProjectFiles),
 
     additionalFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        track.album,
-        track.additionalFiles),
+      relation('generateAdditionalFilesList', track.additionalFiles),
 
     artistCommentarySection:
-      relation('generateCommentarySection', track.commentary),
+      relation('generateTrackArtistCommentarySection', track),
+
+    creditingSourceEntries:
+      track.creditingSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
 
-    creditSourcesSection:
-      relation('generateCommentarySection', track.creditSources),
+    referencingSourceEntries:
+      track.referencingSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
   }),
 
-  data: (sprawl, track) => ({
+  data: (_query, track) => ({
     name:
       track.name,
 
@@ -137,15 +140,10 @@ export default {
         additionalNames: relations.additionalNamesBox,
 
         color: data.color,
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
-        cover:
-          (relations.cover
-            ? relations.cover.slots({
-                showReferenceLinks: true,
-                showNonUniqueLine: true,
-              })
-            : null),
+        artworkColumnContent:
+          relations.artworkColumn,
 
         mainContent: [
           relations.releaseInfo,
@@ -191,25 +189,26 @@ export default {
                         language.$(capsule, 'link')),
                   })),
 
-              !html.isBlank(relations.creditSourcesSection) &&
-                language.encapsulate(capsule, 'readCreditSources', capsule =>
+              !html.isBlank(relations.creditingSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
                   language.$(capsule, {
                     link:
                       html.tag('a',
-                        {href: '#credit-sources'},
+                        {href: '#crediting-sources'},
                         language.$(capsule, 'link')),
                   })),
-            ])),
 
-          html.tags([
-            relations.contentHeading.clone()
-              .slots({
-                attributes: {id: 'also-released-as'},
-                title: language.$('releaseInfo.alsoReleasedAs'),
-              }),
+              !html.isBlank(relations.referencingSourceEntries) &&
+                language.encapsulate(capsule, 'readReferencingSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#referencing-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
 
-            relations.otherReleasesList,
-          ]),
+          relations.otherReleasesList,
 
           html.tags([
             relations.contentHeading.clone()
@@ -321,17 +320,7 @@ export default {
             relations.flashesThatFeatureList,
           ]),
 
-          html.tags([
-            relations.contentHeading.clone()
-              .slots({
-                attributes: {id: 'lyrics'},
-                title: language.$('releaseInfo.lyrics'),
-              }),
-
-            html.tag('blockquote',
-              {[html.onlyIfContent]: true},
-              relations.lyrics.slot('mode', 'lyrics')),
-          ]),
+          relations.lyricsSection,
 
           html.tags([
             relations.contentHeading.clone()
@@ -365,10 +354,25 @@ export default {
 
           relations.artistCommentarySection,
 
-          relations.creditSourcesSection.slots({
-            id: 'credit-sources',
-            title: language.$('misc.creditSources'),
-          }),
+          html.tags([
+            relations.contentContentHeading.clone()
+              .slots({
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
+              }),
+
+            relations.creditingSourceEntries,
+          ]),
+
+          html.tags([
+            relations.contentContentHeading.clone()
+              .slots({
+                attributes: {id: 'referencing-sources'},
+                string: 'misc.referencingSources',
+              }),
+
+            relations.referencingSourceEntries,
+          ]),
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js
index 5958be9a..61654512 100644
--- a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js
+++ b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js
@@ -14,7 +14,7 @@ export default {
     sortedFeatures:
       (sprawl.enableFlashesAndGames
         ? sortFlashesChronologically(
-            [track, ...track.otherReleases].flatMap(track =>
+            track.allReleases.flatMap(track =>
               track.featuredInFlashes.map(flash => ({
                 flash,
                 track,
@@ -36,6 +36,8 @@ export default {
         .map(({track: directlyFeaturedTrack}) =>
           (directlyFeaturedTrack === track
             ? null
+         : directlyFeaturedTrack.name === track.name
+            ? null
             : relation('linkTrack', directlyFeaturedTrack))),
   }),
 
@@ -52,7 +54,6 @@ export default {
           const options = {flash: flashLink};
 
           if (trackLink) {
-            attributes.add('class', 'rerelease');
             parts.push('asDifferentRelease');
             options.track = trackLink;
           }
diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
index 004bba6d..ebd76577 100644
--- a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
+++ b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
@@ -1,80 +1,42 @@
 import {stitchArrays} from '#sugar';
 
 export default {
-  contentDependencies: [
-    'generateAbsoluteDatetimestamp',
-    'generateColorStyleAttribute',
-    'generateRelativeDatetimestamp',
-    'linkAlbum',
-    'linkTrack',
-  ],
-
+  contentDependencies: ['linkTrack'],
   extraDependencies: ['html', 'language'],
 
   relations: (relation, track) => ({
-    colorStyles:
-      track.otherReleases
-        .map(track => relation('generateColorStyleAttribute', track.color)),
-
     trackLinks:
       track.otherReleases
         .map(track => relation('linkTrack', track)),
+  }),
 
-    albumLinks:
+  data: (track) => ({
+    albumNames:
       track.otherReleases
-        .map(track => relation('linkAlbum', track.album)),
-
-    datetimestamps:
-      track.otherReleases.map(track2 =>
-        (track2.date
-          ? (track.date
-              ? relation('generateRelativeDatetimestamp',
-                  track2.date,
-                  track.date)
-              : relation('generateAbsoluteDatetimestamp',
-                  track2.date))
-          : null)),
+        .map(track => track.album.name),
 
-    items:
-      track.otherReleases.map(track => ({
-        trackLink: relation('linkTrack', track),
-        albumLink: relation('linkAlbum', track.album),
-      })),
+    albumColors:
+      track.otherReleases
+        .map(track => track.album.color),
   }),
 
-  generate: (relations, {html, language}) =>
-    html.tag('ul',
+  generate: (data, relations, {html, language}) =>
+    html.tag('p',
       {[html.onlyIfContent]: true},
 
-      stitchArrays({
-        trackLink: relations.trackLinks,
-        albumLink: relations.albumLinks,
-        datetimestamp: relations.datetimestamps,
-        colorStyle: relations.colorStyles,
-      }).map(({
-          trackLink,
-          albumLink,
-          datetimestamp,
-          colorStyle,
-        }) => {
-          const parts = ['releaseInfo.alsoReleasedAs.item'];
-          const options = {};
-
-          options.track = trackLink.slot('color', false);
-          options.album = albumLink;
-
-          if (datetimestamp) {
-            parts.push('withYear');
-            options.year =
-              datetimestamp.slots({
-                style: 'year',
-                tooltip: true,
-              });
-          }
-
-          return (
-            html.tag('li',
-              colorStyle,
-              language.$(...parts, options)));
-        })),
+      language.$('releaseInfo.alsoReleasedOn', {
+        [language.onlyIfOptions]: ['albums'],
+
+        albums:
+          language.formatConjunctionList(
+            stitchArrays({
+              trackLink: relations.trackLinks,
+              albumName: data.albumNames,
+              albumColor: data.albumColors,
+            }).map(({trackLink, albumName, albumColor}) =>
+                trackLink.slots({
+                  content: language.sanitize(albumName),
+                  color: albumColor,
+                }))),
+      })),
 };
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
index 3cba479e..230868d6 100644
--- a/src/content/dependencies/generateTrackListDividedByGroups.js
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -7,9 +7,16 @@ export default {
     'linkGroup',
   ],
 
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    divideTrackListsByGroups:
+      wikiInfo.divideTrackListsByGroups,
+  }),
+
+  query(sprawl, tracks) {
+    const dividingGroups = sprawl.divideTrackListsByGroups;
 
-  query(tracks, dividingGroups) {
     const groupings = new Map();
     const ungroupedTracks = [];
 
@@ -43,9 +50,9 @@ export default {
     return {groups, groupedTracks, ungroupedTracks};
   },
 
-  relations: (relation, query, tracks, groups) => ({
+  relations: (relation, query, sprawl, tracks) => ({
     flatList:
-      (empty(groups)
+      (empty(sprawl.divideTrackListsByGroups)
         ? relation('generateTrackList', tracks)
         : null),
 
@@ -66,7 +73,7 @@ export default {
         : relation('generateTrackList', query.ungroupedTracks)),
   }),
 
-  data: (query) => ({
+  data: (query, _sprawl, _tracks) => ({
     groupNames:
       query.groups
         .map(group => group.name),
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
index 887b6f03..3c850a18 100644
--- a/src/content/dependencies/generateTrackListItem.js
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -97,7 +97,8 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                html.metatag('chunkwrap', {split: ','},
+                // TODO: This is obviously evil.
+                html.metatag('chunkwrap', {split: /,| (?=and)/},
                   html.resolve(relations.credit)));
           }
 
diff --git a/src/content/dependencies/generateTrackNavLinks.js b/src/content/dependencies/generateTrackNavLinks.js
index e01653f0..6a8b7c64 100644
--- a/src/content/dependencies/generateTrackNavLinks.js
+++ b/src/content/dependencies/generateTrackNavLinks.js
@@ -15,7 +15,7 @@ export default {
       track.album.hasTrackNumbers,
 
     trackNumber:
-      track.album.tracks.indexOf(track) + 1,
+      track.trackNumber,
   }),
 
   slots: {
diff --git a/src/content/dependencies/generateTrackReferencedArtworksPage.js b/src/content/dependencies/generateTrackReferencedArtworksPage.js
index ac81e525..7073409e 100644
--- a/src/content/dependencies/generateTrackReferencedArtworksPage.js
+++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js
@@ -1,9 +1,8 @@
 export default {
   contentDependencies: [
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToTrackLink',
     'generateReferencedArtworksPage',
-    'generateTrackCoverArtwork',
     'generateTrackNavLinks',
   ],
 
@@ -11,27 +10,21 @@ export default {
 
   relations: (relation, track) => ({
     page:
-      relation('generateReferencedArtworksPage', track.referencedArtworks),
+      relation('generateReferencedArtworksPage', track.trackArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', track.album, track),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
 
     navLinks:
       relation('generateTrackNavLinks', track),
 
     backToTrackLink:
       relation('generateBackToTrackLink', track),
-
-    cover:
-      relation('generateTrackCoverArtwork', track),
   }),
 
   data: (track) => ({
     name:
       track.name,
-
-    color:
-      track.color,
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -42,10 +35,7 @@ export default {
             data.name,
         }),
 
-      color: data.color,
-      styleRules: [relations.albumStyleRules],
-
-      cover: relations.cover,
+      styleTags: relations.albumStyleTags,
 
       navLinks:
         html.resolve(
diff --git a/src/content/dependencies/generateTrackReferencingArtworksPage.js b/src/content/dependencies/generateTrackReferencingArtworksPage.js
index 097ee929..a45144c8 100644
--- a/src/content/dependencies/generateTrackReferencingArtworksPage.js
+++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js
@@ -1,9 +1,8 @@
 export default {
   contentDependencies: [
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToTrackLink',
     'generateReferencingArtworksPage',
-    'generateTrackCoverArtwork',
     'generateTrackNavLinks',
   ],
 
@@ -11,27 +10,21 @@ export default {
 
   relations: (relation, track) => ({
     page:
-      relation('generateReferencingArtworksPage', track.referencedByArtworks),
+      relation('generateReferencingArtworksPage', track.trackArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', track.album, track),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
 
     navLinks:
       relation('generateTrackNavLinks', track),
 
     backToTrackLink:
       relation('generateBackToTrackLink', track),
-
-    cover:
-      relation('generateTrackCoverArtwork', track),
   }),
 
   data: (track) => ({
     name:
       track.name,
-
-    color:
-      track.color,
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -42,10 +35,7 @@ export default {
             data.name,
         }),
 
-      color: data.color,
-      styleRules: [relations.albumStyleRules],
-
-      cover: relations.cover,
+      styleTags: relations.albumStyleTags,
 
       navLinks:
         html.resolve(
diff --git a/src/content/dependencies/generateTrackReleaseBox.js b/src/content/dependencies/generateTrackReleaseBox.js
new file mode 100644
index 00000000..ef02e2b9
--- /dev/null
+++ b/src/content/dependencies/generateTrackReleaseBox.js
@@ -0,0 +1,46 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generatePageSidebarBox',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', track.album.color),
+
+    trackLink:
+      relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    albumName:
+      track.album.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumSidebar.releaseBox', boxCapsule =>
+      relations.box.slots({
+        attributes: [
+          {class: 'track-release-sidebar-box'},
+          relations.colorStyle,
+        ],
+
+        content: [
+          html.tag('h1',
+            language.$(boxCapsule, 'title', {
+              album:
+                relations.trackLink.slots({
+                  color: false,
+                  content:
+                    language.sanitize(data.albumName),
+                }),
+            })),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 38b8383f..3298dcc4 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -1,9 +1,7 @@
-import {empty} from '#sugar';
-
 export default {
   contentDependencies: [
     'generateReleaseInfoContributionsLine',
-    'linkExternal',
+    'generateReleaseInfoListenLine',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -11,19 +9,11 @@ export default {
   relations(relation, track) {
     const relations = {};
 
-    relations.artistContributionLinks =
+    relations.artistContributionsLine =
       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));
-    }
+    relations.listenLine =
+      relation('generateReleaseInfoListenLine', track);
 
     return relations;
   },
@@ -37,7 +27,6 @@ export default {
 
     if (
       track.hasUniqueCoverArt &&
-      track.coverArtDate &&
       +track.coverArtDate !== +track.date
     ) {
       data.coverArtDate = track.coverArtDate;
@@ -54,27 +43,17 @@ export default {
           {[html.joinChildren]: html.tag('br')},
 
           [
-            relations.artistContributionLinks.slots({
+            relations.artistContributionsLine.slots({
               stringKey: capsule + '.by',
               featuringStringKey: capsule + '.by.featuring',
               chronologyKind: 'track',
             }),
 
-            relations.coverArtistContributionsLine?.slots({
-              stringKey: capsule + '.coverArtBy',
-              chronologyKind: 'trackArt',
-            }),
-
             language.$(capsule, 'released', {
               [language.onlyIfOptions]: ['date'],
               date: language.formatDate(data.date),
             }),
 
-            language.$(capsule, 'artReleased', {
-              [language.onlyIfOptions]: ['date'],
-              date: language.formatDate(data.coverArtDate),
-            }),
-
             language.$(capsule, 'duration', {
               [language.onlyIfOptions]: ['duration'],
               duration: language.formatDuration(data.duration),
@@ -82,17 +61,9 @@ export default {
           ]),
 
         html.tag('p',
-          language.encapsulate(capsule, 'listenOn', capsule =>
-            (relations.externalLinks
-              ? language.$(capsule, {
-                  links:
-                    language.formatDisjunctionList(
-                      relations.externalLinks
-                        .map(link => link.slot('context', 'track'))),
-                })
-              : language.$(capsule, 'noLinks', {
-                  name:
-                    html.tag('i', data.name),
-                })))),
+          relations.listenLine.slots({
+            visibleWithoutLinks: true,
+            context: ['track'],
+          })),
       ])),
 };
diff --git a/src/content/dependencies/generateTrackSocialEmbed.js b/src/content/dependencies/generateTrackSocialEmbed.js
index 7cb37af2..310816f3 100644
--- a/src/content/dependencies/generateTrackSocialEmbed.js
+++ b/src/content/dependencies/generateTrackSocialEmbed.js
@@ -26,14 +26,12 @@ export default {
     data.trackDirectory = track.directory;
     data.albumDirectory = album.directory;
 
+    data.hasImage = track.hasUniqueCoverArt || album.hasCoverArt;
+
     if (track.hasUniqueCoverArt) {
-      data.imageSource = 'track';
-      data.coverArtFileExtension = track.coverArtFileExtension;
+      data.imagePath = track.trackArtworks[0].path;
     } else if (album.hasCoverArt) {
-      data.imageSource = 'album';
-      data.coverArtFileExtension = album.coverArtFileExtension;
-    } else {
-      data.imageSource = 'none';
+      data.imagePath = album.coverArtworks[0].path;
     }
 
     return data;
@@ -59,10 +57,8 @@ export default {
           absoluteTo('localized.album', data.albumDirectory),
 
         imagePath:
-          (data.imageSource === 'album'
-            ? ['media.albumCover', data.albumDirectory, data.coverArtFileExtension]
-         : data.imageSource === 'track'
-            ? ['media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension]
+          (data.hasImage
+            ? data.imagePath
             : null),
       })),
 };
diff --git a/src/content/dependencies/generateWallpaperStyleTag.js b/src/content/dependencies/generateWallpaperStyleTag.js
new file mode 100644
index 00000000..bf094300
--- /dev/null
+++ b/src/content/dependencies/generateWallpaperStyleTag.js
@@ -0,0 +1,80 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateStyleTag'],
+  extraDependencies: ['html', 'to'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+  }),
+
+  slots: {
+    singleWallpaperPath: {
+      validate: v => v.strictArrayOf(v.isString),
+    },
+
+    singleWallpaperStyle: {
+      validate: v => v.isString,
+    },
+
+    wallpaperPartPaths: {
+      validate: v =>
+        v.strictArrayOf(v.optional(v.strictArrayOf(v.isString))),
+    },
+
+    wallpaperPartStyles: {
+      validate: v =>
+        v.strictArrayOf(v.optional(v.isString)),
+    },
+  },
+
+  generate(relations, slots, {html, to}) {
+    const attributes = html.attributes();
+    const rules = [];
+
+    attributes.add('class', 'wallpaper-style');
+
+    if (empty(slots.wallpaperPartPaths)) {
+      attributes.set('data-wallpaper-mode', 'one');
+
+      rules.push({
+        select: 'body::before',
+        declare: [
+          `background-image: url("${to(...slots.singleWallpaperPath)}");`,
+          slots.singleWallpaperStyle,
+        ],
+      });
+    } else {
+      attributes.set('data-wallpaper-mode', 'parts');
+      attributes.set('data-num-wallpaper-parts', slots.wallpaperPartPaths.length);
+
+      stitchArrays({
+        path: slots.wallpaperPartPaths,
+        style: slots.wallpaperPartStyles,
+      }).forEach(({path, style}, index) => {
+          rules.push({
+            select: `.wallpaper-part:nth-child(${index + 1})`,
+            declare: [
+              path && `background-image: url("${to(...path)}");`,
+              style,
+            ],
+          });
+        });
+
+      rules.push({
+        select: 'body::before',
+        declare: [
+          'display: none;',
+        ],
+      });
+    }
+
+    relations.styleTag.setSlots({
+      attributes,
+      rules,
+    });
+
+    return relations.styleTag;
+  },
+};
diff --git a/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
index 3068d951..b45bfc19 100644
--- a/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
+++ b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
@@ -1,5 +1,3 @@
-import {stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: ['generateCoverCarousel', 'image', 'linkAlbum'],
 
@@ -13,27 +11,12 @@ export default {
 
     images:
       row.albums
-        .map(album => relation('image', album.artTags)),
-  }),
-
-  data: (row) => ({
-    paths:
-      row.albums.map(album =>
-        (album.hasCoverArt
-          ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-          : null)),
+        .map(album => relation('image', album.coverArtworks[0])),
   }),
 
-  generate: (data, relations) =>
+  generate: (relations) =>
     relations.coverCarousel.slots({
-      links:
-        relations.links,
-
-      images:
-        stitchArrays({
-          image: relations.images,
-          path: data.paths,
-        }).map(({image, path}) =>
-            image.slot('path', path)),
+      links: relations.links,
+      images: relations.images,
     }),
 };
diff --git a/src/content/dependencies/generateWikiHomepageAlbumGridRow.js b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
index c1d2c79d..a00136ba 100644
--- a/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
+++ b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
@@ -45,20 +45,17 @@ export default {
 
     images:
       sprawl.albums
-        .map(album => relation('image', album.artTags)),
+        .map(album =>
+          relation('image',
+            (album.hasCoverArt
+              ? album.coverArtworks[0]
+              : null))),
   }),
 
   data: (sprawl, _row) => ({
     names:
       sprawl.albums
         .map(album => album.name),
-
-    paths:
-      sprawl.albums
-        .map(album =>
-          (album.hasCoverArt
-            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-            : null)),
   }),
 
   generate: (data, relations, {language}) =>
@@ -69,11 +66,9 @@ export default {
       images:
         stitchArrays({
           image: relations.images,
-          path: data.paths,
           name: data.names,
-        }).map(({image, path, name}) =>
+        }).map(({image, name}) =>
             image.slots({
-              path,
               missingSourceContent:
                 language.$('misc.coverGrid.noCoverArt', {
                   album: name,
diff --git a/src/content/dependencies/generateWikiWallpaperStyleTag.js b/src/content/dependencies/generateWikiWallpaperStyleTag.js
new file mode 100644
index 00000000..12d27304
--- /dev/null
+++ b/src/content/dependencies/generateWikiWallpaperStyleTag.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateWallpaperStyleTag'],
+  extraDependencies: ['wikiData'],
+
+  sprawl: ({wikiInfo}) => ({wikiInfo}),
+
+  relations: (relation) => ({
+    wallpaperStyleTag:
+      relation('generateWallpaperStyleTag'),
+  }),
+
+  data: ({wikiInfo}) => ({
+    singleWallpaperPath: [
+      'media.path',
+      'bg.' + wikiInfo.wikiWallpaperFileExtension,
+    ],
+
+    singleWallpaperStyle:
+      wikiInfo.wikiWallpaperStyle,
+
+    wallpaperPartPaths:
+      wikiInfo.wikiWallpaperParts.map(part =>
+        (part.asset
+          ? ['media.path', part.asset]
+          : null)),
+
+    wallpaperPartStyles:
+      wikiInfo.wikiWallpaperParts.map(part => part.style),
+  }),
+
+  generate: (data, relations) =>
+    relations.wallpaperStyleTag.slots({
+      singleWallpaperPath: data.singleWallpaperPath,
+      singleWallpaperStyle: data.singleWallpaperStyle,
+      wallpaperPartPaths: data.wallpaperPartPaths,
+      wallpaperPartStyles: data.wallpaperPartStyles,
+    }),
+};
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 6cbcb7dd..bf47b14f 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -16,68 +16,77 @@ export default {
 
   contentDependencies: ['generateColorStyleAttribute'],
 
-  relations: (relation) => ({
+  relations: (relation, _artwork) => ({
     colorStyle:
       relation('generateColorStyleAttribute'),
   }),
 
-  data(artTags) {
-    const data = {};
-
-    if (artTags) {
-      data.contentWarnings =
-        artTags
-          .filter(tag => tag.isContentWarning)
-          .map(tag => tag.name);
-    } else {
-      data.contentWarnings = null;
-    }
-
-    return data;
-  },
+  data: (artwork) => ({
+    path:
+      (artwork
+        ? artwork.path
+        : null),
+
+    warnings:
+      (artwork
+        ? artwork.artTags
+            .filter(artTag => artTag.isContentWarning)
+            .map(artTag => artTag.name)
+        : null),
+
+    dimensions:
+      (artwork
+        ? artwork.dimensions
+        : null),
+  }),
 
   slots: {
-    src: {type: 'string'},
-
-    path: {
-      validate: v => v.validateArrayItems(v.isString),
-    },
-
     thumb: {type: 'string'},
 
+    reveal: {type: 'boolean', default: true},
+    lazy: {type: 'boolean', default: false},
+    square: {type: 'boolean', default: false},
+
     link: {
       validate: v => v.anyOf(v.isBoolean, v.isString),
       default: false,
     },
 
-    color: {
-      validate: v => v.isColor,
-    },
+    color: {validate: v => v.isColor},
 
-    warnings: {
-      validate: v => v.looseArrayOf(v.isString),
+    // Added to the .image-container.
+    attributes: {
+      type: 'attributes',
+      mutable: false,
     },
 
-    reveal: {type: 'boolean', default: true},
-    lazy: {type: 'boolean', default: false},
-
-    square: {type: 'boolean', default: false},
+    // Added to the <img> itself.
+    alt: {type: 'string'},
 
-    dimensions: {
-      validate: v => v.isDimensions,
-    },
+    // Specify 'src' or 'path', or the path will be used from the artwork.
+    // If none of the above is present, the message in missingSourceContent
+    // will be displayed instead.
 
-    alt: {type: 'string'},
+    src: {type: 'string'},
 
-    attributes: {
-      type: 'attributes',
-      mutable: false,
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
     },
 
     missingSourceContent: {
       type: 'html',
       mutable: false,
     },
+
+    // These will also be used from the artwork if not specified as slots.
+
+    warnings: {
+      validate: v => v.looseArrayOf(v.isString),
+    },
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
   },
 
   generate(data, relations, slots, {
@@ -91,15 +100,14 @@ export default {
     missingImagePaths,
     to,
   }) {
-    let originalSrc;
-
-    if (slots.src) {
-      originalSrc = slots.src;
-    } else if (!empty(slots.path)) {
-      originalSrc = to(...slots.path);
-    } else {
-      originalSrc = '';
-    }
+    const originalSrc =
+      (slots.src
+        ? slots.src
+     : slots.path
+        ? to(...slots.path)
+     : data.path
+        ? to(...data.path)
+        : '');
 
     // TODO: This feels janky. It's necessary to deal with static content that
     // includes strings like <img src="media/misc/foo.png">, but processing the
@@ -121,29 +129,27 @@ export default {
       !isMissingImageFile &&
       (typeof slots.link === 'string' || slots.link);
 
-    const contentWarnings =
-      slots.warnings ??
-      data.contentWarnings;
+    const warnings = slots.warnings ?? data.warnings;
+    const dimensions = slots.dimensions ?? data.dimensions;
 
     const willReveal =
       slots.reveal &&
       originalSrc &&
       !isMissingImageFile &&
-      !empty(contentWarnings);
-
-    const willSquare =
-      slots.square;
+      !empty(warnings);
 
     const imgAttributes = html.attributes([
       {class: 'image'},
 
       slots.alt && {alt: slots.alt},
 
-      slots.dimensions?.[0] &&
-        {width: slots.dimensions[0]},
+      dimensions &&
+      dimensions[0] &&
+        {width: dimensions[0]},
 
-      slots.dimensions?.[1] &&
-        {height: slots.dimensions[1]},
+      dimensions &&
+      dimensions[1] &&
+        {height: dimensions[1]},
     ]);
 
     const isPlaceholder =
@@ -169,7 +175,7 @@ export default {
 
         html.tag('span', {class: 'reveal-warnings'},
           language.$('misc.contentWarnings.warnings', {
-            warnings: language.formatUnitList(contentWarnings),
+            warnings: language.formatUnitList(warnings),
           })),
 
         html.tag('br'),
@@ -224,7 +230,6 @@ export default {
 
       const originalDimensions = getDimensionsOfImagePath(mediaSrc);
       const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions);
-      const originalLength = Math.max(originalDimensions[0], originalDimensions[1]);
 
       const fileSize =
         (willLink && mediaSrc
@@ -235,8 +240,7 @@ export default {
         fileSize &&
           {'data-original-size': fileSize},
 
-        originalLength &&
-          {'data-original-length': originalLength},
+        {'data-dimensions': originalDimensions.join('x')},
 
         !empty(availableThumbs) &&
           {'data-thumbs':
@@ -325,14 +329,14 @@ export default {
 
       wrapped =
         html.tag('div', {class: 'image-outer-area'},
-          willSquare &&
+          slots.square &&
             {class: 'square-content'},
 
           wrapped);
 
       wrapped =
         html.tag('div', {class: 'image-container'},
-          willSquare &&
+          slots.square &&
             {class: 'square'},
 
           typeof slots.link === 'string' &&
diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js
index a5009804..25d7324f 100644
--- a/src/content/dependencies/index.js
+++ b/src/content/dependencies/index.js
@@ -11,6 +11,11 @@ import {colors, logWarn} from '#cli';
 import contentFunction, {ContentFunctionSpecError} from '#content-function';
 import {annotateFunction} from '#sugar';
 
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const codeSrcPath = path.resolve(__dirname, '..');
+const codeRootPath = path.resolve(codeSrcPath, '..');
+
 function cachebust(filePath) {
   if (filePath in cachebust.cache) {
     cachebust.cache[filePath] += 1;
@@ -42,7 +47,9 @@ export function watchContentDependencies({
     close,
   });
 
-  const eslint = new ESLint();
+  const eslint = new ESLint({
+    cwd: codeRootPath,
+  });
 
   const metaPath = fileURLToPath(import.meta.url);
   const metaDirname = path.dirname(metaPath);
diff --git a/src/content/dependencies/linkAdditionalFile.js b/src/content/dependencies/linkAdditionalFile.js
new file mode 100644
index 00000000..a8a940b1
--- /dev/null
+++ b/src/content/dependencies/linkAdditionalFile.js
@@ -0,0 +1,29 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  query: (file, filename) => ({
+    index:
+      file.filenames.indexOf(filename),
+  }),
+
+  relations: (relation, _query, _file, _filename) => ({
+    linkTemplate:
+      relation('linkTemplate'),
+  }),
+
+  data: (query, file, filename) => ({
+    filename,
+
+    // Kinda jank, but eh.
+    path:
+      (query.index >= 0
+        ? file.paths.at(query.index)
+        : null),
+  }),
+
+  generate: (data, relations) =>
+    relations.linkTemplate.slots({
+      path: data.path,
+      content: data.filename,
+    }),
+};
diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js
deleted file mode 100644
index 39e7111e..00000000
--- a/src/content/dependencies/linkAlbumAdditionalFile.js
+++ /dev/null
@@ -1,24 +0,0 @@
-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/linkAlbumDynamically.js b/src/content/dependencies/linkAlbumDynamically.js
index 7173b417..45f8c2a9 100644
--- a/src/content/dependencies/linkAlbumDynamically.js
+++ b/src/content/dependencies/linkAlbumDynamically.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: [
     'linkAlbumCommentary',
@@ -23,7 +25,7 @@ export default {
       album.directory,
 
     albumHasCommentary:
-      !!album.commentary,
+      !empty(album.commentary),
   }),
 
   slots: {
diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js
index d4697403..e408c1b2 100644
--- a/src/content/dependencies/linkAnythingMan.js
+++ b/src/content/dependencies/linkAnythingMan.js
@@ -1,6 +1,7 @@
 export default {
   contentDependencies: [
     'linkAlbum',
+    'linkArtwork',
     'linkFlash',
     'linkTrack',
   ],
@@ -13,6 +14,8 @@ export default {
     link:
       (query.referenceType === 'album'
         ? relation('linkAlbum', thing)
+     : query.referenceType === 'artwork'
+        ? relation('linkArtwork', thing)
      : query.referenceType === 'flash'
         ? relation('linkFlash', thing)
      : query.referenceType === 'track'
diff --git a/src/content/dependencies/linkArtTagDynamically.js b/src/content/dependencies/linkArtTagDynamically.js
new file mode 100644
index 00000000..964258e1
--- /dev/null
+++ b/src/content/dependencies/linkArtTagDynamically.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkArtTagGallery', 'linkArtTagInfo'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, artTag) => ({
+    galleryLink: relation('linkArtTagGallery', artTag),
+    infoLink: relation('linkArtTagInfo', artTag),
+  }),
+
+  generate: (relations, {pagePath}) =>
+    (pagePath[0] === 'artTagInfo'
+      ? relations.infoLink
+      : relations.galleryLink),
+};
diff --git a/src/content/dependencies/linkArtTagGallery.js b/src/content/dependencies/linkArtTagGallery.js
new file mode 100644
index 00000000..a92b69c1
--- /dev/null
+++ b/src/content/dependencies/linkArtTagGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artTag) =>
+    ({link: relation('linkThing', 'localized.artTagGallery', artTag)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtTag.js b/src/content/dependencies/linkArtTagInfo.js
index 7ddb7786..409cb3c0 100644
--- a/src/content/dependencies/linkArtTag.js
+++ b/src/content/dependencies/linkArtTagInfo.js
@@ -2,7 +2,7 @@ export default {
   contentDependencies: ['linkThing'],
 
   relations: (relation, artTag) =>
-    ({link: relation('linkThing', 'localized.tag', artTag)}),
+    ({link: relation('linkThing', 'localized.artTagInfo', artTag)}),
 
   generate: (relations) => relations.link,
 };
diff --git a/src/content/dependencies/linkArtwork.js b/src/content/dependencies/linkArtwork.js
new file mode 100644
index 00000000..8cd6f359
--- /dev/null
+++ b/src/content/dependencies/linkArtwork.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['linkAlbum', 'linkTrack'],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Symbol.for('Thing.referenceType')],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbum', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrack', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.link,
+};
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index 073c821e..45c08a08 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -39,6 +39,11 @@ export default {
       default: false,
     },
 
+    disableBrowserTooltip: {
+      type: 'boolean',
+      default: false,
+    },
+
     tab: {
       validate: v => v.is('default', 'separate'),
       default: 'default',
@@ -50,7 +55,7 @@ export default {
     try {
       new URL(data.url);
       urlIsValid = true;
-    } catch (error) {
+    } catch {
       urlIsValid = false;
     }
 
@@ -111,7 +116,9 @@ export default {
       linkAttributes.add('class', 'indicate-external');
 
       let titleText;
-      if (slots.tab === 'separate') {
+      if (slots.disableBrowserTooltip) {
+        titleText = null;
+      } else if (slots.tab === 'separate') {
         if (html.isBlank(slots.content)) {
           titleText =
             language.$('misc.external.opensInNewTab.annotation');
diff --git a/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js
new file mode 100644
index 00000000..ec856631
--- /dev/null
+++ b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js
@@ -0,0 +1,62 @@
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {chunkArtistTrackContributions} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateColorStyleAttribute'],
+  extraDependencies: ['html', 'language'],
+
+  query(track, artist) {
+    const relevantInfoPageChunkingContributions =
+      track.allReleases
+        .flatMap(release => [
+          ...release.artistContribs,
+          ...release.contributorContribs,
+        ])
+        .filter(c => c.artist === artist);
+
+    sortContributionsChronologically(
+      relevantInfoPageChunkingContributions,
+      sortAlbumsTracksChronologically);
+
+    const contributionChunks =
+      chunkArtistTrackContributions(relevantInfoPageChunkingContributions);
+
+    const trackChunks =
+      contributionChunks
+        .map(chunksInAlbum => chunksInAlbum
+          .map(chunksInTrack => chunksInTrack[0].thing));
+
+    const trackChunksForThisAlbum =
+      trackChunks
+        .filter(tracks => tracks[0].album === track.album);
+
+    const containingChunkIndex =
+      trackChunksForThisAlbum
+        .findIndex(tracks => tracks.includes(track));
+
+    return {containingChunkIndex};
+  },
+
+  relations: (relation, _query, track, _artist) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute', track.album.color),
+  }),
+
+  data: (query, track, _artist) => ({
+    albumName:
+      track.album.name,
+
+    albumDirectory:
+      track.album.directory,
+
+    containingChunkIndex:
+      query.containingChunkIndex,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('a',
+      {href: `#tracks-${data.albumDirectory}-${data.containingChunkIndex}`},
+      relations.colorStyle.slot('context', 'primary-only'),
+      language.sanitize(data.albumName)),
+};
diff --git a/src/content/dependencies/linkPathFromMedia.js b/src/content/dependencies/linkPathFromMedia.js
index 34a2b857..d71c69f8 100644
--- a/src/content/dependencies/linkPathFromMedia.js
+++ b/src/content/dependencies/linkPathFromMedia.js
@@ -1,13 +1,64 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: ['linkTemplate'],
 
+  extraDependencies: [
+    'checkIfImagePathHasCachedThumbnails',
+    'getDimensionsOfImagePath',
+    'getSizeOfMediaFile',
+    'getThumbnailsAvailableForDimensions',
+    'html',
+    'to',
+  ],
+
   relations: (relation) =>
     ({link: relation('linkTemplate')}),
 
   data: (path) =>
     ({path}),
 
-  generate: (data, relations) =>
-    relations.link
-      .slot('path', ['media.path', data.path]),
+  generate(data, relations, {
+    checkIfImagePathHasCachedThumbnails,
+    getDimensionsOfImagePath,
+    getSizeOfMediaFile,
+    getThumbnailsAvailableForDimensions,
+    html,
+    to,
+  }) {
+    const attributes = html.attributes();
+
+    if (checkIfImagePathHasCachedThumbnails(data.path)) {
+      const dimensions = getDimensionsOfImagePath(data.path);
+      const availableThumbs = getThumbnailsAvailableForDimensions(dimensions);
+      const fileSize = getSizeOfMediaFile(data.path);
+
+      const embedSrc =
+        to('thumb.path', data.path.replace(/\.(png|jpg)$/, '.tack.jpg'));
+
+      attributes.add([
+        {class: 'image-media-link'},
+
+        {'data-embed-src': embedSrc},
+
+        fileSize &&
+          {'data-original-size': fileSize},
+
+        {'data-dimensions': dimensions.join('x')},
+
+        !empty(availableThumbs) &&
+          {'data-thumbs':
+              availableThumbs
+                .map(([name, size]) => `${name}:${size}`)
+                .join(' ')},
+      ]);
+    }
+
+    relations.link.setSlots({
+      attributes,
+      path: ['media.path', data.path],
+    });
+
+    return relations.link;
+  },
 };
diff --git a/src/content/dependencies/linkReferencedArtworks.js b/src/content/dependencies/linkReferencedArtworks.js
new file mode 100644
index 00000000..c456b808
--- /dev/null
+++ b/src/content/dependencies/linkReferencedArtworks.js
@@ -0,0 +1,24 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'linkAlbumReferencedArtworks',
+    'linkTrackReferencedArtworks',
+  ],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbumReferencedArtworks', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrackReferencedArtworks', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkReferencingArtworks.js b/src/content/dependencies/linkReferencingArtworks.js
new file mode 100644
index 00000000..0cfca4db
--- /dev/null
+++ b/src/content/dependencies/linkReferencingArtworks.js
@@ -0,0 +1,24 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'linkAlbumReferencingArtworks',
+    'linkTrackReferencingArtworks',
+  ],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbumReferencingArtworks', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrackReferencingArtworks', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
index 63cc82e8..4f853dc4 100644
--- a/src/content/dependencies/linkTemplate.js
+++ b/src/content/dependencies/linkTemplate.js
@@ -26,6 +26,11 @@ export default {
       type: 'html',
       mutable: false,
     },
+
+    suffixNormalContent: {
+      type: 'html',
+      mutable: false,
+    },
   },
 
   generate(slots, {
@@ -61,13 +66,22 @@ export default {
       attributes.set('title', slots.tooltip);
     }
 
-    const content =
+    const mainContent =
       (html.isBlank(slots.content)
         ? language.$('misc.missingLinkContent')
-        : striptags(html.resolve(slots.content, {normalize: 'string'}), {
-            disallowedTags: new Set(['a']),
-          }));
+        : striptags(
+            html.resolve(slots.content, {normalize: 'string'}),
+            {disallowedTags: new Set(['a'])}));
+
+    const allContent =
+      (html.isBlank(slots.suffixNormalContent)
+        ? mainContent
+        : html.tags([
+            mainContent,
+            html.tag('span', {class: 'normal-content'},
+              slots.suffixNormalContent),
+          ], {[html.joinChildren]: ''}));
 
-    return html.tag('a', attributes, content);
+    return html.tag('a', attributes, allContent);
   },
 }
diff --git a/src/content/dependencies/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js
index 242cd4cb..bbcf1c34 100644
--- a/src/content/dependencies/linkTrackDynamically.js
+++ b/src/content/dependencies/linkTrackDynamically.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: ['linkTrack'],
   extraDependencies: ['pagePath'],
@@ -14,7 +16,7 @@ export default {
       track.album.directory,
 
     trackHasCommentary:
-      !!track.commentary,
+      !empty(track.commentary),
   }),
 
   generate(data, relations, {pagePath}) {
diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js
index e33ad7b5..8ec69f1d 100644
--- a/src/content/dependencies/listAllAdditionalFilesTemplate.js
+++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js
@@ -1,209 +1,44 @@
 import {sortChronologically} from '#sort';
-import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
     'generateListingPage',
-    'generateListAllAdditionalFilesChunk',
-    'linkAlbum',
-    'linkTrack',
-    'linkAlbumAdditionalFile',
+    'generateListAllAdditionalFilesAlbumSection',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
+  extraDependencies: ['html', 'wikiData'],
 
   sprawl: ({albumData}) => ({albumData}),
 
-  query(sprawl, spec, property) {
-    const albums =
-      sortChronologically(sprawl.albumData.slice());
+  query: (sprawl, spec, property) => ({
+    spec,
+    property,
 
-    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,
-    };
-  },
+    albums:
+      sortChronologically(sprawl.albumData.slice()),
+  }),
 
   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,
+    albumSections:
+      query.albums.map(album =>
+        relation('generateListAllAdditionalFilesAlbumSection',
+          album,
+          query.property)),
   }),
 
   slots: {
     stringsKey: {type: 'string'},
   },
 
-  generate: (data, relations, slots, {html, language}) =>
+  generate: (relations, slots) =>
     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,
-                  })),
-            ]),
-          ]),
+        relations.albumSections.map(section =>
+          section.slot('stringsKey', slots.stringsKey)),
     }),
 };
diff --git a/src/content/dependencies/listArtTagNetwork.js b/src/content/dependencies/listArtTagNetwork.js
index b3a54747..93dd4ce8 100644
--- a/src/content/dependencies/listArtTagNetwork.js
+++ b/src/content/dependencies/listArtTagNetwork.js
@@ -1 +1,366 @@
-export default {generate() {}};
+import {sortAlphabetically} from '#sort';
+import {empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTagInfo'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({artTagData}) {
+    return {artTagData};
+  },
+
+  query(sprawl, spec) {
+    const artTags =
+      sprawl.artTagData.filter(artTag => !artTag.isContentWarning);
+
+    const rootArtTags =
+      artTags
+        .filter(artTag => !empty(artTag.directDescendantArtTags))
+        .filter(artTag =>
+          empty(artTag.directAncestorArtTags) ||
+          artTag.directAncestorArtTags.length >= 2);
+
+    sortAlphabetically(rootArtTags);
+
+    rootArtTags.sort(
+      ({directAncestorArtTags: ancestorsA},
+       {directAncestorArtTags: ancestorsB}) =>
+        ancestorsA.length - ancestorsB.length);
+
+    const getStats = (artTag) => ({
+      directUses:
+        artTag.directlyFeaturedInArtworks.length,
+
+      // Not currently displayed
+      directAndIndirectUses:
+        unique([
+          ...artTag.indirectlyFeaturedInArtworks,
+          ...artTag.directlyFeaturedInArtworks,
+        ]).length,
+
+      totalUses:
+        [
+          ...artTag.directlyFeaturedInArtworks,
+          ...
+            artTag.allDescendantArtTags
+              .flatMap(artTag => artTag.directlyFeaturedInArtworks),
+        ].length,
+
+      descendants:
+        artTag.allDescendantArtTags.length,
+
+      leaves:
+        (empty(artTag.directDescendantArtTags)
+          ? null
+          : artTag.allDescendantArtTags
+              .filter(artTag => empty(artTag.directDescendantArtTags))
+              .length),
+    });
+
+    const recursive = (artTag, depth) => {
+      const descendantNodes =
+        (empty(artTag.directDescendantArtTags)
+          ? null
+       : depth > 0 && artTag.directAncestorArtTags.length >= 2
+          ? null
+          : artTag.directDescendantArtTags
+              .map(artTag => recursive(artTag, depth + 1)));
+
+      descendantNodes?.sort(
+        ({descendantNodes: descendantNodesA},
+         {descendantNodes: descendantNodesB}) =>
+            (descendantNodesA ? 1 : 0)
+          - (descendantNodesB ? 1 : 0));
+
+      const recursiveGetRootAncestor = ancestorArtTag =>
+        (ancestorArtTag.directAncestorArtTags.length === 1
+          ? recursiveGetRootAncestor(ancestorArtTag.directAncestorArtTags[0])
+          : ancestorArtTag);
+
+      const ancestorRootArtTags =
+        (depth === 0 && !empty(artTag.directAncestorArtTags)
+          ? unique(artTag.directAncestorArtTags.map(recursiveGetRootAncestor))
+          : null);
+
+      const stats = getStats(artTag);
+
+      return {
+        artTag,
+        stats,
+        descendantNodes,
+        ancestorRootArtTags,
+      };
+    };
+
+    const uppermostRootTags =
+      artTags
+        .filter(artTag => !empty(artTag.directDescendantArtTags))
+        .filter(artTag => empty(artTag.directAncestorArtTags));
+
+    const orphanArtTags =
+      artTags
+        .filter(artTag => empty(artTag.directDescendantArtTags))
+        .filter(artTag => empty(artTag.directAncestorArtTags));
+
+    return {
+      spec,
+
+      rootNodes:
+        rootArtTags
+          .map(artTag => recursive(artTag, 0)),
+
+      uppermostRootTags,
+      orphanArtTags,
+    };
+  },
+
+  relations(relation, query) {
+    const recursive = queryNode => ({
+      artTagLink:
+        relation('linkArtTagInfo', queryNode.artTag),
+
+      ancestorTagLinks:
+        queryNode.ancestorRootArtTags
+          ?.map(artTag => relation('linkArtTagInfo', artTag))
+          ?? null,
+
+      descendantNodes:
+        queryNode.descendantNodes
+          ?.map(recursive)
+          ?? null,
+    });
+
+    return {
+      page:
+        relation('generateListingPage', query.spec),
+
+      rootNodes:
+        query.rootNodes.map(recursive),
+
+      uppermostRootTagLinks:
+        query.uppermostRootTags
+          .map(artTag => relation('linkArtTagInfo', artTag)),
+
+      orphanArtTagLinks:
+        query.orphanArtTags
+          .map(artTag => relation('linkArtTagInfo', artTag)),
+    };
+  },
+
+  data(query) {
+    const rootArtTags = query.rootNodes.map(({artTag}) => artTag);
+
+    const recursive = queryNode => ({
+      directory:
+        queryNode.artTag.directory,
+
+      directUses:
+        queryNode.stats.directUses,
+
+      totalUses:
+        queryNode.stats.totalUses,
+
+      descendants:
+        queryNode.stats.descendants,
+
+      leaves:
+        queryNode.stats.leaves,
+
+      representsRoot:
+        rootArtTags.includes(queryNode.artTag),
+
+      ancestorTagDirectories:
+        queryNode.ancestorRootArtTags
+          ?.map(artTag => artTag.directory)
+          ?? null,
+
+      descendantNodes:
+        queryNode.descendantNodes
+          ?.map(recursive)
+          ?? null,
+    });
+
+    return {
+      rootNodes:
+        query.rootNodes.map(recursive),
+
+      uppermostRootTagDirectories:
+        query.uppermostRootTags
+          .map(artTag => artTag.directory),
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    const prefix = `listingPage.listArtTags.network`;
+
+    const wrapTagWithJumpTo = (dataNode, relationsNode, depth) =>
+      (depth === 0
+        ? relationsNode.artTagLink
+     : dataNode.representsRoot
+        ? language.$(prefix, 'tag.jumpToRoot', {
+            tag:
+              relationsNode.artTagLink.slots({
+                anchor: true,
+                hash: dataNode.directory,
+              }),
+          })
+        : relationsNode.artTagLink);
+
+    const wrapTagWithStats = (dataNode, relationsNode, depth) => [
+      html.tag('span', {class: 'network-tag'},
+        language.$(prefix, 'tag', {
+          tag:
+            wrapTagWithJumpTo(dataNode, relationsNode, depth),
+        })),
+
+      html.tag('span', {class: 'network-tag'},
+        {class: 'with-stat'},
+        {style: 'display: none'},
+
+        language.$(prefix, 'tag.withStat', {
+          tag:
+            wrapTagWithJumpTo(dataNode, relationsNode, depth),
+
+          stat:
+            html.tag('span', {class: 'network-tag-stat'},
+              language.$(prefix, 'tag.withStat.stat', {
+                stat: [
+                  html.tag('span', {class: 'network-tag-direct-uses-stat'},
+                    dataNode.directUses.toString()),
+
+                  html.tag('span', {class: 'network-tag-total-uses-stat'},
+                    dataNode.totalUses.toString()),
+
+                  html.tag('span', {class: 'network-tag-descendants-stat'},
+                    dataNode.descendants.toString()),
+
+                  html.tag('span', {class: 'network-tag-leaves-stat'},
+                    (dataNode.leaves === null
+                      ? language.$(prefix, 'tag.withStat.notApplicable')
+                      : dataNode.leaves.toString())),
+                ],
+              })),
+        }))
+    ];
+
+    const recursive = (dataNode, relationsNode, depth) => [
+      html.tag('dt',
+        {
+          id: depth === 0 && dataNode.directory,
+          class: depth % 2 === 0 ? 'even' : 'odd',
+        },
+
+        (depth === 0
+          ? (relationsNode.ancestorTagLinks
+              ? language.$(prefix, 'root.withAncestors', {
+                  tag:
+                    wrapTagWithStats(dataNode, relationsNode, depth),
+
+                  ancestors:
+                    language.formatUnitList(
+                      stitchArrays({
+                        link: relationsNode.ancestorTagLinks,
+                        directory: dataNode.ancestorTagDirectories,
+                      }).map(({link, directory}) =>
+                          link.slots({
+                            anchor: true,
+                            hash: directory,
+                          }))),
+                })
+              : language.$(prefix, 'root.jumpToTop', {
+                  tag:
+                    wrapTagWithStats(dataNode, relationsNode, depth),
+
+                  link:
+                    html.tag('a', {href: '#top'},
+                      language.$(prefix, 'root.jumpToTop.link')),
+                }))
+          : wrapTagWithStats(dataNode, relationsNode, depth))),
+
+      dataNode.descendantNodes &&
+      relationsNode.descendantNodes &&
+        html.tag('dd',
+          {class: depth % 2 === 0 ? 'even' : 'odd'},
+          html.tag('dl',
+            stitchArrays({
+              dataNode: dataNode.descendantNodes,
+              relationsNode: relationsNode.descendantNodes,
+            }).map(({dataNode, relationsNode}) =>
+                recursive(dataNode, relationsNode, depth + 1)))),
+    ];
+
+    return relations.page.slots({
+      type: 'custom',
+
+      content: [
+        html.tag('p', {id: 'network-stat-line'},
+          language.$(prefix, 'statLine', {
+            stat: [
+              html.tag('a', {id: 'network-stat-none'},
+                {href: '#'},
+                language.$(prefix, 'statLine.none')),
+
+              html.tag('a', {id: 'network-stat-total-uses'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.totalUses')),
+
+              html.tag('a', {id: 'network-stat-direct-uses'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.directUses')),
+
+              html.tag('a', {id: 'network-stat-descendants'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.descendants')),
+
+              html.tag('a', {id: 'network-stat-leaves'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.leaves')),
+            ],
+          })),
+
+        html.tag('dl', {id: 'network-top-dl'}, [
+          html.tag('dt', {id: 'top'},
+            language.$(prefix, 'jumpToRoot.title')),
+
+          html.tag('dd',
+            html.tag('ul',
+              stitchArrays({
+                link: relations.uppermostRootTagLinks,
+                directory: data.uppermostRootTagDirectories,
+              }).map(({link, directory}) =>
+                  html.tag('li',
+                    language.$(prefix, 'jumpToRoot.item', {
+                      tag:
+                        link.slots({
+                          anchor: true,
+                          hash: directory,
+                        }),
+                    }))))),
+
+          stitchArrays({
+            dataNode: data.rootNodes,
+            relationsNode: relations.rootNodes,
+          }).map(({dataNode, relationsNode}) =>
+              recursive(dataNode, relationsNode, 0)),
+
+          !empty(relations.orphanArtTagLinks) && [
+            html.tag('dt',
+              language.$(prefix, 'orphanArtTags.title')),
+
+            html.tag('dd',
+              html.tag('ul',
+                relations.orphanArtTagLinks.map(orphanArtTagLink =>
+                  html.tag('li',
+                    language.$(prefix, 'orphanArtTags.item', {
+                      tag: orphanArtTagLink,
+                    }))))),
+          ],
+        ]),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/listTagsByName.js b/src/content/dependencies/listArtTagsByName.js
index d7022a55..1df9dfff 100644
--- a/src/content/dependencies/listTagsByName.js
+++ b/src/content/dependencies/listArtTagsByName.js
@@ -1,8 +1,8 @@
 import {sortAlphabetically} from '#sort';
-import {stitchArrays} from '#sugar';
+import {stitchArrays, unique} from '#sugar';
 
 export default {
-  contentDependencies: ['generateListingPage', 'linkArtTag'],
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
   extraDependencies: ['language', 'wikiData'],
 
   sprawl({artTagData}) {
@@ -16,7 +16,7 @@ export default {
       artTags:
         sortAlphabetically(
           artTagData
-            .filter(tag => !tag.isContentWarning)),
+            .filter(artTag => !artTag.isContentWarning)),
     };
   },
 
@@ -26,15 +26,18 @@ export default {
 
       artTagLinks:
         query.artTags
-          .map(tag => relation('linkArtTag', tag)),
+          .map(artTag => relation('linkArtTagGallery', artTag)),
     };
   },
 
   data(query) {
     return {
       counts:
-        query.artTags
-          .map(tag => tag.taggedInThings.length),
+        query.artTags.map(artTag =>
+          unique([
+            ...artTag.indirectlyFeaturedInArtworks,
+            ...artTag.directlyFeaturedInArtworks,
+          ]).length),
     };
   },
 
diff --git a/src/content/dependencies/listArtTagsByUses.js b/src/content/dependencies/listArtTagsByUses.js
new file mode 100644
index 00000000..eca7f1c6
--- /dev/null
+++ b/src/content/dependencies/listArtTagsByUses.js
@@ -0,0 +1,54 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl: ({artTagData}) =>
+    ({artTagData}),
+
+  query({artTagData}, spec) {
+    const artTags =
+      sortAlphabetically(
+        artTagData
+          .filter(artTag => !artTag.isContentWarning));
+
+    const counts =
+      artTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length);
+
+    filterByCount(artTags, counts);
+    sortByCount(artTags, counts, {greatestFirst: true});
+
+    return {spec, artTags, counts};
+  },
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    artTagLinks:
+      query.artTags
+        .map(artTag => relation('linkArtTagGallery', artTag)),
+  }),
+
+  data: (query) =>
+    ({counts: query.counts}),
+
+  generate: (data, relations, {language}) =>
+    relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artTagLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            tag: link,
+            timesUsed: language.countTimesUsed(count, {unit: true}),
+          })),
+    }),
+};
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
index 41944959..99f19764 100644
--- a/src/content/dependencies/listArtistsByContributions.js
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -1,13 +1,6 @@
 import {sortAlphabetically, sortByCount} from '#sort';
-
-import {
-  accumulateSum,
-  empty,
-  filterByCount,
-  filterMultipleArrays,
-  stitchArrays,
-  unique,
-} from '#sugar';
+import {empty, filterByCount, filterMultipleArrays, stitchArrays}
+  from '#sugar';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist'],
@@ -41,37 +34,46 @@ export default {
       query[countsKey] = counts;
     };
 
+    const countContributions = (artist, keys) => {
+      const contribs =
+        keys
+          .flatMap(key => artist[key])
+          .filter(contrib => contrib.countInContributionTotals);
+
+      const things =
+        new Set(contribs.map(contrib => contrib.thing));
+
+      return things.size;
+    };
+
     queryContributionInfo(
       'artistsByTrackContributions',
       'countsByTrackContributions',
       artist =>
-        (unique(
-          ([
-            artist.trackArtistContributions,
-            artist.trackContributorContributions,
-          ]).flat()
-            .map(({thing}) => thing)
-        )).length);
+        countContributions(artist, [
+          'trackArtistContributions',
+          'trackContributorContributions',
+        ]));
 
     queryContributionInfo(
       'artistsByArtworkContributions',
       'countsByArtworkContributions',
       artist =>
-        accumulateSum(
-          [
-            artist.albumCoverArtistContributions,
-            artist.albumWallpaperArtistContributions,
-            artist.albumBannerArtistContributions,
-            artist.trackCoverArtistContributions,
-          ],
-          contribs => contribs.length));
+        countContributions(artist, [
+          'albumCoverArtistContributions',
+          'albumWallpaperArtistContributions',
+          'albumBannerArtistContributions',
+          'trackCoverArtistContributions',
+        ]));
 
     if (sprawl.enableFlashesAndGames) {
       queryContributionInfo(
         'artistsByFlashContributions',
         'countsByFlashContributions',
         artist =>
-          artist.flashContributorContributions.length);
+          countContributions(artist, [
+            'flashContributorContributions',
+          ]));
     }
 
     return query;
diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js
index 0bf9dd2d..17096cfc 100644
--- a/src/content/dependencies/listArtistsByGroup.js
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -37,20 +37,25 @@ export default {
         ([
           (unique(
             ([
-              artist.albumArtistContributions,
-              artist.albumCoverArtistContributions,
-              artist.albumWallpaperArtistContributions,
-              artist.albumBannerArtistContributions,
+              artist.albumArtistContributions
+                .map(contrib => contrib.thing),
+              artist.albumCoverArtistContributions
+                .map(contrib => contrib.thing.thing),
+              artist.albumWallpaperArtistContributions
+                .map(contrib => contrib.thing.thing),
+              artist.albumBannerArtistContributions
+                .map(contrib => contrib.thing.thing),
             ]).flat()
-              .map(({thing}) => thing)
           )).map(album => album.groups),
           (unique(
             ([
-              artist.trackArtistContributions,
-              artist.trackContributorContributions,
-              artist.trackCoverArtistContributions,
+              artist.trackArtistContributions
+                .map(contrib => contrib.thing),
+              artist.trackContributorContributions
+                .map(contrib => contrib.thing),
+              artist.trackCoverArtistContributions
+                .map(contrib => contrib.thing.thing),
             ]).flat()
-              .map(({thing}) => thing)
           )).map(track => track.album.groups),
         ]).flat()
           .map(groups => groups
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 27a2faa3..2a8d1b4c 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -98,13 +98,16 @@ export default {
       ])) {
         // Might combine later with 'track' of the same album and date.
         considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork');
+        // '?? album.date' is kept here because wallpaper and banner may
+        // technically be present for an album w/o cover art, therefore
+        // also no cover art date.
       }
     }
 
     for (const track of tracksLatestFirst) {
       for (const artist of getArtists(track, 'coverArtistContribs')) {
         // No special effect if artist already has 'artwork' for the same album and date.
-        considerDate(artist, track.coverArtDate ?? track.date, track.album, 'artwork');
+        considerDate(artist, track.coverArtDate, track.album, 'artwork');
       }
 
       for (const artist of new Set([
diff --git a/src/content/dependencies/listGroupsByDuration.js b/src/content/dependencies/listGroupsByDuration.js
index da2f26db..c79e1bc4 100644
--- a/src/content/dependencies/listGroupsByDuration.js
+++ b/src/content/dependencies/listGroupsByDuration.js
@@ -16,7 +16,7 @@ export default {
       groups.map(group =>
         getTotalDuration(
           group.albums.flatMap(album => album.tracks),
-          {originalReleasesOnly: true}));
+          {mainReleasesOnly: true}));
 
     filterByCount(groups, durations);
     sortByCount(groups, durations, {greatestFirst: true});
diff --git a/src/content/dependencies/listTagsByUses.js b/src/content/dependencies/listTagsByUses.js
deleted file mode 100644
index 00c700a5..00000000
--- a/src/content/dependencies/listTagsByUses.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import {sortAlphabetically, sortByCount} from '#sort';
-import {filterByCount, stitchArrays} from '#sugar';
-
-export default {
-  contentDependencies: ['generateListingPage', 'linkArtTag'],
-  extraDependencies: ['language', 'wikiData'],
-
-  sprawl({artTagData}) {
-    return {artTagData};
-  },
-
-  query({artTagData}, spec) {
-    const artTags =
-      sortAlphabetically(
-        artTagData
-          .filter(tag => !tag.isContentWarning));
-
-    const counts =
-      artTags
-        .map(tag => tag.taggedInThings.length);
-
-    filterByCount(artTags, counts);
-    sortByCount(artTags, counts, {greatestFirst: true});
-
-    return {spec, artTags, counts};
-  },
-
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
-
-      artTagLinks:
-        query.artTags
-          .map(tag => relation('linkArtTag', tag)),
-    };
-  },
-
-  data(query) {
-    return {
-      counts:
-        query.artTags
-          .map(tag => tag.taggedInThings.length),
-    };
-  },
-
-  generate(data, relations, {language}) {
-    return relations.page.slots({
-      type: 'rows',
-      rows:
-        stitchArrays({
-          link: relations.artTagLinks,
-          count: data.counts,
-        }).map(({link, count}) => ({
-            tag: link,
-            timesUsed: language.countTimesUsed(count, {unit: true}),
-          })),
-    });
-  },
-};
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
index 0a2bfd6c..dcfaeaf0 100644
--- a/src/content/dependencies/listTracksByDate.js
+++ b/src/content/dependencies/listTracksByDate.js
@@ -5,49 +5,54 @@ export default {
   contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
   extraDependencies: ['language', 'wikiData'],
 
-  sprawl({trackData}) {
-    return {trackData};
-  },
+  sprawl: ({trackData}) => ({trackData}),
 
   query({trackData}, spec) {
-    return {
-      spec,
+    const query = {spec};
+
+    query.tracks =
+      sortAlbumsTracksChronologically(
+        trackData.filter(track => track.date));
+
+    query.chunks =
+      chunkByProperties(query.tracks, ['album', 'date']);
 
-      chunks:
-        chunkByProperties(
-          sortAlbumsTracksChronologically(
-            trackData.filter(track => track.date)),
-          ['album', 'date']),
-    };
+    return query;
   },
 
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
 
-      albumLinks:
-        query.chunks
-          .map(({album}) => relation('linkAlbum', album)),
+    albumLinks:
+      query.chunks
+        .map(({album}) => relation('linkAlbum', album)),
 
-      trackLinks:
-        query.chunks
-          .map(({chunk}) => chunk
-            .map(track => relation('linkTrack', track))),
-    };
-  },
+    trackLinks:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(track => relation('linkTrack', track))),
+  }),
 
-  data(query) {
-    return {
-      dates:
-        query.chunks
-          .map(({date}) => date),
+  data: (query) => ({
+    dates:
+      query.chunks
+        .map(({date}) => date),
 
-      rereleases:
-        query.chunks.map(({chunk}) =>
-          chunk.map(track =>
-            track.originalReleaseTrack !== null)),
-    };
-  },
+    rereleases:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(track =>
+            // Check if the index of this track...
+            query.tracks.indexOf(track) >
+            // ...is greater than the *smallest* index
+            // of any of this track's *other* releases.
+            // (It won't be greater than its own index,
+            // so we can use otherReleases here, rather
+            // than allReleases.)
+            Math.min(...
+              track.otherReleases.map(t => query.tracks.indexOf(t))))),
+  }),
 
   generate(data, relations, {language}) {
     return relations.page.slots({
@@ -79,7 +84,7 @@ export default {
         data.rereleases.map(rereleases =>
           rereleases.map(rerelease =>
             (rerelease
-              ? {class: 'rerelease'}
+              ? {class: 'rerelease-line'}
               : null))),
     });
   },
diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js
index a13a76f0..e6ab9d7d 100644
--- a/src/content/dependencies/listTracksWithLyrics.js
+++ b/src/content/dependencies/listTracksWithLyrics.js
@@ -2,7 +2,7 @@ export default {
   contentDependencies: ['listTracksWithExtra'],
 
   relations: (relation, spec) =>
-    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'truthy')}),
+    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'array')}),
 
   generate: (relations) =>
     relations.page,
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 48e20f94..e9a75744 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -1,7 +1,11 @@
+import {basename} from 'node:path';
+
+import {logWarn} from '#cli';
 import {bindFind} from '#find';
-import {replacerSpec, parseInput} from '#replacer';
+import {replacerSpec, parseContentNodes} from '#replacer';
 
 import {Marked} from 'marked';
+import striptags from 'striptags';
 
 const commonMarkedOptions = {
   headerIds: false,
@@ -45,24 +49,44 @@ function getPlaceholder(node, content) {
   return {type: 'text', data: content.slice(node.i, node.iEnd)};
 }
 
+function getArg(node, argKey) {
+  return (
+    node.data.args
+      ?.find(({key}) => key.data === argKey)
+      ?.value ??
+    null);
+}
+
 export default {
   contentDependencies: [
     ...(
       Object.values(replacerSpec)
         .map(description => description.link)
         .filter(Boolean)),
+
     'image',
+    'generateTextWithTooltip',
+    'generateTooltip',
     'linkExternal',
   ],
 
-  extraDependencies: ['html', 'language', 'to', 'wikiData'],
+  extraDependencies: [
+    'html',
+    'language',
+    'niceShowAggregate',
+    'to',
+    'wikiData',
+  ],
 
   sprawl(wikiData, content) {
-    const find = bindFind(wikiData);
+    const find = bindFind(wikiData, {mode: 'quiet'});
 
-    const parsedNodes = parseInput(content ?? '');
+    const {result: parsedNodes, error} =
+      parseContentNodes(content ?? '', {errorMode: 'return'});
 
     return {
+      error,
+
       nodes: parsedNodes
         .map(node => {
           if (node.type !== 'tag') {
@@ -133,6 +157,30 @@ export default {
             return {i: node.i, iEnd: node.iEnd, type: 'internal-link', data};
           }
 
+          if (replacerKey === 'tooltip') {
+            // TODO: Again, no recursive nodes. Sorry!
+            // const enteredLabel = node.data.label && transformNode(node.data.label, opts);
+            const enteredLabel = node.data.label?.data;
+
+            return {
+              i: node.i,
+              iEnd: node.iEnd,
+              type: 'tooltip',
+              data: {
+                tooltip:
+                  replacerValue ?? '(empty tooltip...)',
+
+                label:
+                  enteredLabel ?? '(tooltip without label)',
+
+                link:
+                  (getArg(node, 'link')
+                    ? getArg(node, 'link')[0].data
+                    : null),
+              },
+            };
+          }
+
           // 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.
@@ -152,6 +200,9 @@ export default {
     return {
       content,
 
+      error:
+        sprawl.error,
+
       nodes:
         sprawl.nodes
           .map(node => {
@@ -184,10 +235,18 @@ export default {
               link: relation(name, arg),
               label: node.data.label,
               hash: node.data.hash,
+              name: arg?.name,
+              shortName: arg?.shortName ?? arg?.nameShort,
             }
           : getPlaceholder(node, content));
 
     return {
+      textWithTooltip:
+        relation('generateTextWithTooltip'),
+
+      tooltip:
+        relation('generateTooltip'),
+
       internalLinks:
         nodes
           .filter(({type}) => type === 'internal-link')
@@ -206,11 +265,15 @@ export default {
       externalLinks:
         nodes
           .filter(({type}) => type === 'external-link')
-          .map(node => {
-            const {href} = node.data;
+          .map(({data: {href}}) =>
+            relation('linkExternal', href)),
 
-            return relation('linkExternal', href);
-          }),
+      externalLinksForTooltipNodes:
+        nodes
+          .filter(({type}) => type === 'tooltip')
+          .filter(({data}) => data.link)
+          .map(({data: {link: href}}) =>
+            relation('linkExternal', href)),
 
       images:
         nodes
@@ -241,16 +304,27 @@ export default {
       default: true,
     },
 
+    textOnly: {
+      type: 'boolean',
+      default: false,
+    },
+
     thumb: {
       validate: v => v.is('small', 'medium', 'large'),
       default: 'large',
     },
   },
 
-  generate(data, relations, slots, {html, language, to}) {
+  generate(data, relations, slots, {html, language, niceShowAggregate, to}) {
+    if (data.error) {
+      logWarn`Error in content text.`;
+      niceShowAggregate(data.error);
+    }
+
     let imageIndex = 0;
     let internalLinkIndex = 0;
     let externalLinkIndex = 0;
+    let externalLinkForTooltipNodeIndex = 0;
 
     let offsetTextNode = 0;
 
@@ -258,6 +332,20 @@ export default {
       data.nodes.map((node, index) => {
         const nextNode = data.nodes[index + 1];
 
+        const absorbFollowingPunctuation = template => {
+          if (nextNode?.type !== 'text') {
+            return;
+          }
+
+          const text = nextNode.data;
+          const match = text.match(/^[.,;:?!…]+(?=[^\n]*[a-z])/i);
+          const suffix = match?.[0];
+          if (suffix) {
+            template.setSlot('suffixNormalContent', suffix);
+            offsetTextNode = suffix.length;
+          }
+        };
+
         switch (node.type) {
           case 'text': {
             const text = node.data.slice(offsetTextNode);
@@ -291,9 +379,8 @@ export default {
                   height && {height},
                   style && {style},
 
-                  align === 'center' &&
-                  !link &&
-                    {class: 'align-center'},
+                  align && !link &&
+                    {class: 'align-' + align},
 
                   pixelate &&
                     {class: 'pixelate'});
@@ -304,8 +391,8 @@ export default {
                     {href: link},
                     {target: '_blank'},
 
-                    align === 'center' &&
-                      {class: 'align-center'},
+                    align &&
+                      {class: 'align-' + align},
 
                     {title:
                       language.encapsulate('misc.external.opensInNewTab', capsule =>
@@ -355,8 +442,8 @@ export default {
               inline: false,
               data:
                 html.tag('div', {class: 'content-image-container'},
-                  align === 'center' &&
-                    {class: 'align-center'},
+                  align &&
+                    {class: 'align-' + align},
 
                   image),
             };
@@ -368,32 +455,74 @@ export default {
                 ? to('media.path', node.src.slice('media/'.length))
                 : node.src);
 
-            const {
-              width,
-              height,
-              align,
-              pixelate,
-            } = node;
+            const {width, height, align, inline, pixelate} = node;
 
-            const content =
-              html.tag('div', {class: 'content-video-container'},
-                html.tag('video',
-                  src && {src},
-                  width && {width},
-                  height && {height},
+            const video =
+              html.tag('video',
+                src && {src},
+                width && {width},
+                height && {height},
 
-                  {controls: true},
+                {controls: true},
 
-                  align === 'center' &&
-                    {class: 'align-center'},
+                align && inline &&
+                  {class: 'align-' + align},
+
+                pixelate &&
+                  {class: 'pixelate'});
+
+            const content =
+              (inline
+                ? video
+                : html.tag('div', {class: 'content-video-container'},
+                    align &&
+                      {class: 'align-' + align},
+
+                    video));
 
-                  pixelate &&
-                    {class: 'pixelate'}));
 
             return {
               type: 'processed-video',
-              data:
-                content,
+              data: content,
+            };
+          }
+
+          case 'audio': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {align, inline, nameless} = node;
+
+            const audio =
+              html.tag('audio',
+                src && {src},
+
+                align && inline &&
+                  {class: 'align-' + align},
+
+                {controls: true});
+
+            const content =
+              (inline
+                ? audio
+                : html.tag('div', {class: 'content-audio-container'},
+                    align &&
+                      {class: 'align-' + align},
+
+                    [
+                      !nameless &&
+                        html.tag('a', {class: 'filename'},
+                          src && {href: src},
+                          language.sanitize(basename(node.src))),
+
+                      audio,
+                    ]));
+
+            return {
+              type: 'processed-audio',
+              data: content,
             };
           }
 
@@ -411,7 +540,17 @@ export default {
                 nodeFromRelations.link,
                 {slots: ['content', 'hash']});
 
-            const {label, hash} = nodeFromRelations;
+            const {label, hash, shortName, name} = nodeFromRelations;
+
+            if (slots.textOnly) {
+              if (label) {
+                return {type: 'text', data: label};
+              } else if (slots.preferShortLinkNames) {
+                return {type: 'text', data: shortName ?? name};
+              } else {
+                return {type: 'text', data: name};
+              }
+            }
 
             // These are removed from the typical combined slots({})-style
             // because we don't want to override slots that were already set
@@ -425,7 +564,7 @@ export default {
             try {
               link.getSlotDescription('preferShortName');
               hasPreferShortNameSlot = true;
-            } catch (error) {
+            } catch {
               hasPreferShortNameSlot = false;
             }
 
@@ -438,7 +577,7 @@ export default {
             try {
               link.getSlotDescription('tooltipStyle');
               hasTooltipStyleSlot = true;
-            } catch (error) {
+            } catch {
               hasTooltipStyleSlot = false;
             }
 
@@ -446,6 +585,18 @@ export default {
               link.setSlot('tooltipStyle', 'none');
             }
 
+            let doTheAbsorbyThing = false;
+
+            // TODO: This is just silly.
+            try {
+              const tag = html.resolve(link, {normalize: 'tag'});
+              doTheAbsorbyThing ||= tag.attributes.has('class', 'image-media-link');
+            } catch {}
+
+            if (doTheAbsorbyThing) {
+              absorbFollowingPunctuation(link);
+            }
+
             return {type: 'processed-internal-link', data: link};
           }
 
@@ -453,19 +604,17 @@ export default {
             const {label} = node.data;
             const externalLink = relations.externalLinks[externalLinkIndex++];
 
+            if (slots.textOnly) {
+              return {type: 'text', data: label};
+            }
+
             externalLink.setSlots({
               content: label,
               fromContent: true,
             });
 
-            if (slots.absorbPunctuationFollowingExternalLinks && nextNode?.type === 'text') {
-              const text = nextNode.data;
-              const match = text.match(/^[.,;:?!…]+/);
-              const suffix = match?.[0];
-              if (suffix) {
-                externalLink.setSlot('suffixNormalContent', suffix);
-                offsetTextNode = suffix.length;
-              }
+            if (slots.absorbPunctuationFollowingExternalLinks) {
+              absorbFollowingPunctuation(externalLink);
             }
 
             if (slots.indicateExternalLinks) {
@@ -479,6 +628,52 @@ export default {
             return {type: 'processed-external-link', data: externalLink};
           }
 
+          case 'tooltip': {
+            const {label, link, tooltip: tooltipContent} = node.data;
+
+            const externalLink =
+              (link
+                ? relations.externalLinksForTooltipNodes
+                    .at(externalLinkForTooltipNodeIndex++)
+                : null);
+
+            if (externalLink) {
+              externalLink.setSlots({
+                content: label,
+                fromContent: true,
+              });
+
+              if (slots.indicateExternalLinks) {
+                externalLink.setSlots({
+                  indicateExternal: true,
+                  disableBrowserTooltip: true,
+                  tab: 'separate',
+                  style: 'platform',
+                });
+              }
+            }
+
+            const textWithTooltip = relations.textWithTooltip.clone();
+            const tooltip = relations.tooltip.clone();
+
+            tooltip.setSlots({
+              attributes: {class: 'content-tooltip'},
+              content: tooltipContent, // Not sanitized!
+            });
+
+            textWithTooltip.setSlots({
+              attributes: [
+                {class: 'content-tooltip-guy'},
+                externalLink && {class: 'has-link'},
+              ],
+
+              text: externalLink ?? label,
+              tooltip,
+            });
+
+            return {type: 'processed-tooltip', data: textWithTooltip};
+          }
+
           case 'tag': {
             const {replacerKey, replacerValue} = node.data;
 
@@ -495,12 +690,19 @@ export default {
                 ? valueFn(replacerValue)
                 : replacerValue);
 
-            const contents =
+            const content =
               (htmlFn
                 ? htmlFn(value, {html, language})
                 : value);
 
-            return {type: 'text', data: contents.toString()};
+            const contentText =
+              html.resolve(content, {normalize: 'string'});
+
+            if (slots.textOnly) {
+              return {type: 'text', data: striptags(contentText)};
+            } else {
+              return {type: 'text', data: contentText};
+            }
           }
 
           default:
@@ -584,7 +786,8 @@ export default {
         if (
           (attributes.get('data-type') === 'processed-image' &&
           !attributes.get('data-inline')) ||
-          attributes.get('data-type') === 'processed-video'
+          attributes.get('data-type') === 'processed-video' ||
+          attributes.get('data-type') === 'processed-audio'
         ) {
           tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, '');
           deleteParagraph = true;
@@ -661,25 +864,12 @@ export default {
 
       const markedInput =
         extractNonTextNodes({
-          getTextNodeContents(node, index) {
-            // First, replace line breaks that follow text content with
-            // <br> tags.
-            let content = node.data.replace(/(?!^)\n/gm, '<br>\n');
-
-            // Scrap line breaks that are at the end of a verse.
-            content = content.replace(/<br>$(?=\n\n)/gm, '');
-
-            // If the node started with a line break, and it's not the
-            // very first node, then whatever came before it was inline.
-            // (This is an assumption based on text links being basically
-            // the only tag that shows up in lyrics.) Since this text is
-            // following content that was already inline, restore that
-            // initial line break.
-            if (node.data[0] === '\n' && index !== 0) {
-              content = '<br>' + content;
-            }
-
-            return content;
+          getTextNodeContents(node) {
+            // Just insert <br> before every line break. The resulting
+            // text will appear all in one paragraph - this is expected
+            // for lyrics, and allows for multiple lines of proportional
+            // space between stanzas.
+            return node.data.replace(/\n/g, '<br>\n');
           },
         });
 
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index 4b354ef7..651a61cf 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -14,7 +14,7 @@ export default class CacheableObject {
   static cacheValid = Symbol.for('CacheableObject.cacheValid');
   static updateValue = Symbol.for('CacheableObject.updateValues');
 
-  constructor() {
+  constructor({seal = true} = {}) {
     this[CacheableObject.updateValue] = Object.create(null);
     this[CacheableObject.cachedValue] = Object.create(null);
     this[CacheableObject.cacheValid] = Object.create(null);
@@ -34,10 +34,14 @@ export default class CacheableObject {
         this[property] = null;
       }
     }
+
+    if (seal) {
+      Object.seal(this);
+    }
   }
 
   static finalizeCacheableObjectPrototype() {
-    if (this[CacheableObject.constructorFinalized]) {
+    if (Object.hasOwn(this, CacheableObject.constructorFinalized)) {
       throw new Error(`Constructor ${this.name} already finalized`);
     }
 
@@ -239,13 +243,13 @@ export class CacheableObjectPropertyValueError extends Error {
 
     try {
       inspectOldValue = inspect(oldValue);
-    } catch (error) {
+    } catch {
       inspectOldValue = colors.red(`(couldn't inspect)`);
     }
 
     try {
       inspectNewValue = inspect(newValue);
-    } catch (error) {
+    } catch {
       inspectNewValue = colors.red(`(couldn't inspect)`);
     }
 
diff --git a/src/data/checks.js b/src/data/checks.js
index 58f9daee..3fcb6d3b 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -4,12 +4,11 @@ import {inspect as nodeInspect} from 'node:util';
 import {colors, ENABLE_COLOR} from '#cli';
 
 import CacheableObject from '#cacheable-object';
-import {replacerSpec, parseInput} from '#replacer';
+import {replacerSpec, parseContentNodes} from '#replacer';
 import {compareArrays, cut, cutStart, empty, getNestedProp, iterateMultiline}
   from '#sugar';
 import Thing from '#thing';
 import thingConstructors from '#things';
-import {combineWikiDataArrays, commentaryRegexCaseSensitive} from '#wiki-data';
 
 import {
   annotateErrorWithIndex,
@@ -50,7 +49,7 @@ export function reportDirectoryErrors(wikiData, {
     if (!thingData) continue;
 
     for (const thing of thingData) {
-      if (findSpec.include && !findSpec.include(thing)) {
+      if (findSpec.include && !findSpec.include(thing, thingConstructors)) {
         continue;
       }
 
@@ -185,11 +184,21 @@ export function filterReferenceErrors(wikiData, {
       groups: 'group',
       artTags: '_artTag',
       referencedArtworks: '_artwork',
-      commentary: '_commentary',
+      commentary: '_content',
+      creditingSources: '_content',
+    }],
+
+    ['artTagData', {
+      directDescendantArtTags: 'artTag',
+    }],
+
+    ['artworkData', {
+      referencedArtworks: '_artwork',
     }],
 
     ['flashData', {
-      commentary: '_commentary',
+      commentary: '_content',
+      creditingSources: '_content',
     }],
 
     ['groupCategoryData', {
@@ -216,20 +225,23 @@ export function filterReferenceErrors(wikiData, {
       flashes: 'flash',
     }],
 
-    ['groupData', {
-      serieses: '_serieses',
+    ['seriesData', {
+      albums: 'album',
     }],
 
     ['trackData', {
       artistContribs: '_contrib',
       contributorContribs: '_contrib',
       coverArtistContribs: '_contrib',
-      referencedTracks: '_trackNotRerelease',
-      sampledTracks: '_trackNotRerelease',
+      referencedTracks: '_trackMainReleasesOnly',
+      sampledTracks: '_trackMainReleasesOnly',
       artTags: '_artTag',
       referencedArtworks: '_artwork',
-      originalReleaseTrack: '_trackNotRerelease',
-      commentary: '_commentary',
+      mainReleaseTrack: '_trackMainReleasesOnly',
+      commentary: '_content',
+      creditingSources: '_content',
+      referencingSources: '_content',
+      lyrics: '_content',
     }],
 
     ['wikiInfo', {
@@ -264,12 +276,12 @@ export function filterReferenceErrors(wikiData, {
             let writeProperty = true;
 
             switch (findFnKey) {
-              case '_commentary':
+              case '_content':
                 if (value) {
                   value =
-                    Array.from(value.matchAll(commentaryRegexCaseSensitive))
-                      .map(({groups}) => groups.artistReferences)
-                      .map(text => text.split(',').map(text => text.trim()));
+                    value.map(entry =>
+                      CacheableObject.getUpdateValue(entry, 'artists') ??
+                      []);
                 }
 
                 writeProperty = false;
@@ -283,15 +295,6 @@ export function filterReferenceErrors(wikiData, {
                 // need writing, humm...)
                 writeProperty = false;
                 break;
-
-              case '_serieses':
-                if (value) {
-                  // Doesn't report on which series has the error, but...
-                  value = value.flatMap(series => series.albums);
-                }
-
-                writeProperty = false;
-                break;
             }
 
             if (value === undefined) {
@@ -309,15 +312,12 @@ export function filterReferenceErrors(wikiData, {
               case '_artwork': {
                 const mixed =
                   find.mixed({
-                    album: find.albumWithArtwork,
-                    track: find.trackWithArtwork,
+                    album: find.albumPrimaryArtwork,
+                    track: find.trackPrimaryArtwork,
                   });
 
                 const data =
-                  combineWikiDataArrays([
-                    wikiData.albumData,
-                    wikiData.trackData,
-                  ]);
+                  wikiData.artworkData;
 
                 findFn = ref => mixed(ref.reference, data, {mode: 'error'});
 
@@ -328,7 +328,7 @@ export function filterReferenceErrors(wikiData, {
                 findFn = boundFind.artTag;
                 break;
 
-              case '_commentary':
+              case '_content':
                 findFn = findArtistOrAlias;
                 break;
 
@@ -346,37 +346,32 @@ export function filterReferenceErrors(wikiData, {
                 };
                 break;
 
-              case '_serieses':
-                findFn = boundFind.album;
-                break;
-
               case '_trackArtwork':
                 findFn = ref => boundFind.track(ref.reference);
                 break;
 
-              case '_trackNotRerelease':
+              case '_trackMainReleasesOnly':
                 findFn = trackRef => {
                   const track = boundFind.track(trackRef);
-                  const originalRef = track && CacheableObject.getUpdateValue(track, 'originalReleaseTrack');
+                  const mainRef = track && CacheableObject.getUpdateValue(track, 'mainReleaseTrack');
 
-                  if (originalRef) {
-                    // It's possible for the original to not actually exist, in this case.
-                    // It should still be reported since the 'Originally Released As' field
-                    // was present.
-                    const original = boundFind.track(originalRef, {mode: 'quiet'});
+                  if (mainRef) {
+                    // It's possible for the main release to not actually exist, in this case.
+                    // It should still be reported since the 'Main Release' field was present.
+                    const main = boundFind.track(mainRef, {mode: 'quiet'});
 
                     // Prefer references by name, but only if it's unambiguous.
-                    const originalByName =
-                      (original
-                        ? boundFind.track(original.name, {mode: 'quiet'})
+                    const mainByName =
+                      (main
+                        ? boundFind.track(main.name, {mode: 'quiet'})
                         : null);
 
                     const shouldBeMessage =
-                      (originalByName
-                        ? colors.green(original.name)
-                     : original
-                        ? colors.green('track:' + original.directory)
-                        : colors.green(originalRef));
+                      (mainByName
+                        ? colors.green(main.name)
+                     : main
+                        ? colors.green('track:' + main.directory)
+                        : colors.green(mainRef));
 
                     throw new Error(`Reference ${colors.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`);
                   }
@@ -390,7 +385,7 @@ export function filterReferenceErrors(wikiData, {
                 break;
             }
 
-            const suppress = fn => conditionallySuppressError(error => {
+            const suppress = fn => conditionallySuppressError(_error => {
               // We're not suppressing any errors at the moment.
               // An old suppression is kept below for reference.
 
@@ -461,7 +456,7 @@ export function filterReferenceErrors(wikiData, {
                 }
               }
 
-              if (findFnKey === '_commentary') {
+              if (findFnKey === '_content') {
                 filter(
                   value, {message: errorMessage},
                   decorateErrorWithIndex(refs =>
@@ -562,16 +557,33 @@ export function reportContentTextErrors(wikiData, {
     description: 'description',
   };
 
+  const artworkShape = {
+    source: 'artwork source',
+    originDetails: 'artwork origin details',
+  };
+
   const commentaryShape = {
     body: 'commentary body',
-    artistDisplayText: 'commentary artist display text',
+    artistText: 'commentary artist text',
     annotation: 'commentary annotation',
   };
 
+  const lyricsShape = {
+    body: 'lyrics body',
+    artistText: 'lyrics artist text',
+    annotation: 'lyrics annotation',
+  };
+
   const contentTextSpec = [
     ['albumData', {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
+      creditingSources: commentaryShape,
+      coverArtworks: artworkShape,
+    }],
+
+    ['artTagData', {
+      description: '_content',
     }],
 
     ['artistData', {
@@ -580,6 +592,8 @@ export function reportContentTextErrors(wikiData, {
 
     ['flashData', {
       commentary: commentaryShape,
+      creditingSources: commentaryShape,
+      coverArtwork: artworkShape,
     }],
 
     ['flashActData', {
@@ -609,10 +623,12 @@ export function reportContentTextErrors(wikiData, {
     ['trackData', {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
-      creditSources: commentaryShape,
-      lyrics: '_content',
+      creditingSources: commentaryShape,
+      referencingSources: commentaryShape,
+      lyrics: lyricsShape,
       midiProjectFiles: additionalFileShape,
       sheetMusicFiles: additionalFileShape,
+      trackArtworks: artworkShape,
     }],
 
     ['wikiInfo', {
@@ -625,7 +641,7 @@ export function reportContentTextErrors(wikiData, {
   const findArtistOrAlias = bindFindArtistOrAlias(boundFind);
 
   function* processContent(input) {
-    const nodes = parseInput(input);
+    const nodes = parseContentNodes(input);
 
     for (const node of nodes) {
       const index = node.i;
@@ -682,7 +698,7 @@ export function reportContentTextErrors(wikiData, {
       } else if (node.type === 'external-link') {
         try {
           new URL(node.data.href);
-        } catch (error) {
+        } catch {
           yield {
             index, length,
             message:
@@ -733,8 +749,8 @@ export function reportContentTextErrors(wikiData, {
         for (const thing of things) {
           nest({message: `Content text errors in ${inspect(thing)}`}, ({nest, push}) => {
 
-            for (const [property, shape] of Object.entries(propSpec)) {
-              const value = thing[property];
+            for (let [property, shape] of Object.entries(propSpec)) {
+              let value = thing[property];
 
               if (value === undefined) {
                 push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`));
@@ -753,6 +769,31 @@ export function reportContentTextErrors(wikiData, {
               const topMessage =
                 `Content text errors` + fieldPropertyMessage;
 
+              const checkShapeEntries = (entry, callProcessContentOpts) => {
+                for (const [key, annotation] of Object.entries(shape)) {
+                  const value = entry[key];
+
+                  // TODO: This should be an undefined/null check, like above,
+                  // but it's not, because sometimes the stuff we're checking
+                  // here isn't actually coded as a Thing - so the properties
+                  // might really be undefined instead of null. Terrifying and
+                  // awful. And most of all, citation needed.
+                  if (!value) continue;
+
+                  callProcessContent({
+                    ...callProcessContentOpts,
+
+                    // TODO: `nest` isn't provided by `callProcessContentOpts`
+                    //`but `push` is - this is to match the old code, but
+                    // what's the deal here?
+                    nest,
+
+                    value,
+                    message: `Error in ${colors.green(annotation)}`,
+                  });
+                }
+              };
+
               if (shape === '_content') {
                 callProcessContent({
                   nest,
@@ -760,26 +801,18 @@ export function reportContentTextErrors(wikiData, {
                   value,
                   message: topMessage,
                 });
-              } else {
+              } else if (Array.isArray(value)) {
                 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),
-                      });
-                    }
+                    checkShapeEntries(entry, {
+                      push,
+                      annotateError: error =>
+                        annotateErrorWithIndex(error, index),
+                    });
                   }
                 });
+              } else {
+                checkShapeEntries(value, {push});
               }
             }
           });
@@ -788,3 +821,49 @@ export function reportContentTextErrors(wikiData, {
     }
   });
 }
+
+export function reportOrphanedArtworks(wikiData) {
+  const aggregate =
+    openAggregate({message: `Artwork objects are orphaned`});
+
+  const assess = ({
+    message,
+    filterThing,
+    filterContribs,
+    link,
+  }) => {
+    aggregate.nest({message: `Orphaned ${message}`}, ({push}) => {
+      const ostensibleArtworks =
+        wikiData.artworkData
+          .filter(artwork =>
+            artwork.thing instanceof filterThing &&
+            artwork.artistContribsFromThingProperty === filterContribs);
+
+      const orphanedArtworks =
+        ostensibleArtworks
+          .filter(artwork => !artwork.thing[link].includes(artwork));
+
+      for (const artwork of orphanedArtworks) {
+        push(new Error(`Orphaned: ${inspect(artwork)}`));
+      }
+    });
+  };
+
+  const {Album, Track} = thingConstructors;
+
+  assess({
+    message: `album cover artworks`,
+    filterThing: Album,
+    filterContribs: 'coverArtistContribs',
+    link: 'coverArtworks',
+  });
+
+  assess({
+    message: `track artworks`,
+    filterThing: Track,
+    filterContribs: 'coverArtistContribs',
+    link: 'trackArtworks',
+  });
+
+  aggregate.close();
+}
diff --git a/src/data/composite/control-flow/flipFilter.js b/src/data/composite/control-flow/flipFilter.js
new file mode 100644
index 00000000..995bacad
--- /dev/null
+++ b/src/data/composite/control-flow/flipFilter.js
@@ -0,0 +1,36 @@
+// Flips a filter, so that each true item becomes false, and vice versa.
+// Overwrites the provided dependency.
+//
+// See also:
+//  - withAvailabilityFilter
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `flipFilter`,
+
+  inputs: {
+    filter: input({type: 'array'}),
+  },
+
+  outputs: ({
+    [input.staticDependency('filter')]: filterDependency,
+  }) => [filterDependency ?? '#flippedFilter'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input('filter'),
+        input.staticDependency('filter'),
+      ],
+
+      compute: (continuation, {
+        [input('filter')]: filter,
+        [input.staticDependency('filter')]: filterDependency,
+      }) => continuation({
+        [filterDependency ?? '#flippedFilter']:
+          filter.map(item => !item),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js
index 7e137a14..778dc66b 100644
--- a/src/data/composite/control-flow/index.js
+++ b/src/data/composite/control-flow/index.js
@@ -10,6 +10,7 @@ export {default as exposeDependency} from './exposeDependency.js';
 export {default as exposeDependencyOrContinue} from './exposeDependencyOrContinue.js';
 export {default as exposeUpdateValueOrContinue} from './exposeUpdateValueOrContinue.js';
 export {default as exposeWhetherDependencyAvailable} from './exposeWhetherDependencyAvailable.js';
+export {default as flipFilter} from './flipFilter.js';
 export {default as raiseOutputWithoutDependency} from './raiseOutputWithoutDependency.js';
 export {default as raiseOutputWithoutUpdateValue} from './raiseOutputWithoutUpdateValue.js';
 export {default as withAvailabilityFilter} from './withAvailabilityFilter.js';
diff --git a/src/data/composite/control-flow/withAvailabilityFilter.js b/src/data/composite/control-flow/withAvailabilityFilter.js
index cfea998e..fd93af71 100644
--- a/src/data/composite/control-flow/withAvailabilityFilter.js
+++ b/src/data/composite/control-flow/withAvailabilityFilter.js
@@ -4,6 +4,7 @@
 // Accepts the same mode options as withResultOfAvailabilityCheck.
 //
 // See also:
+//  - flipFilter
 //  - withFilteredList
 //  - withResultOfAvailabilityCheck
 //
diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js
index 46a3dc81..05b59445 100644
--- a/src/data/composite/data/index.js
+++ b/src/data/composite/data/index.js
@@ -20,6 +20,7 @@ export {default as withMappedList} from './withMappedList.js';
 export {default as withSortedList} from './withSortedList.js';
 export {default as withStretchedList} from './withStretchedList.js';
 
+export {default as withLengthOfList} from './withLengthOfList.js';
 export {default as withPropertyFromList} from './withPropertyFromList.js';
 export {default as withPropertiesFromList} from './withPropertiesFromList.js';
 
diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js
index 44c1661d..15ee3373 100644
--- a/src/data/composite/data/withFilteredList.js
+++ b/src/data/composite/data/withFilteredList.js
@@ -2,9 +2,6 @@
 // corresponding items in a list. Items which correspond to a truthy value
 // are kept, and the rest are excluded from the output list.
 //
-// If the flip option is set, only items corresponding with a *falsy* value in
-// the filter are kept.
-//
 // TODO: There should be two outputs - one for the items included according to
 // the filter, and one for the items excluded.
 //
@@ -22,28 +19,19 @@ export default templateCompositeFrom({
   inputs: {
     list: input({type: 'array'}),
     filter: input({type: 'array'}),
-
-    flip: input({
-      type: 'boolean',
-      defaultValue: false,
-    }),
   },
 
   outputs: ['#filteredList'],
 
   steps: () => [
     {
-      dependencies: [input('list'), input('filter'), input('flip')],
+      dependencies: [input('list'), input('filter')],
       compute: (continuation, {
         [input('list')]: list,
         [input('filter')]: filter,
-        [input('flip')]: flip,
       }) => continuation({
         '#filteredList':
-          list.filter((_item, index) =>
-            (flip
-              ? !filter[index]
-              :  filter[index])),
+          list.filter((_item, index) => filter[index]),
       }),
     },
   ],
diff --git a/src/data/composite/data/withLengthOfList.js b/src/data/composite/data/withLengthOfList.js
new file mode 100644
index 00000000..e67aa887
--- /dev/null
+++ b/src/data/composite/data/withLengthOfList.js
@@ -0,0 +1,54 @@
+import {input, templateCompositeFrom} from '#composite';
+
+function getOutputName({
+  [input.staticDependency('list')]: list,
+}) {
+  if (list && list.startsWith('#')) {
+    return `${list}.length`;
+  } else if (list) {
+    return `#${list}.length`;
+  } else {
+    return '#length';
+  }
+}
+
+export default templateCompositeFrom({
+  annotation: `withMappedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: inputs => [getOutputName(inputs)],
+
+  steps: () => [
+    {
+      dependencies: [input.staticDependency('list')],
+      compute: (continuation, inputs) =>
+        continuation({'#output': getOutputName(inputs)}),
+    },
+
+    {
+      dependencies: [input('list')],
+      compute: (continuation, {
+        [input('list')]: list,
+      }) => continuation({
+        ['#value']:
+          (list === null
+            ? null
+            : list.length),
+      }),
+    },
+
+    {
+      dependencies: ['#output', '#value'],
+
+      compute: (continuation, {
+        ['#output']: output,
+        ['#value']: value,
+      }) => continuation({
+        [output]: value,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withNearbyItemFromList.js b/src/data/composite/data/withNearbyItemFromList.js
index 83a8cc21..5e165219 100644
--- a/src/data/composite/data/withNearbyItemFromList.js
+++ b/src/data/composite/data/withNearbyItemFromList.js
@@ -9,6 +9,10 @@
 //  - If the 'valuePastEdge' input is provided, that value will be output
 //    instead of null.
 //
+//  - If the 'filter' input is provided, corresponding items will be skipped,
+//    and only (repeating `offset`) the next included in the filter will be
+//    returned.
+//
 // Both the list and item must be provided.
 //
 // See also:
@@ -16,7 +20,6 @@
 //
 
 import {input, templateCompositeFrom} from '#composite';
-import {atOffset} from '#sugar';
 
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
 
@@ -28,9 +31,12 @@ export default templateCompositeFrom({
   inputs: {
     list: input({acceptsNull: false, type: 'array'}),
     item: input({acceptsNull: false}),
-
     offset: input({type: 'number'}),
+
     wrap: input({type: 'boolean', defaultValue: false}),
+    valuePastEdge: input({defaultValue: null}),
+
+    filter: input({defaultValue: null, type: 'array'}),
   },
 
   outputs: ['#nearbyItem'],
@@ -45,29 +51,55 @@ export default templateCompositeFrom({
       dependency: '#index',
       mode: input.value('index'),
 
-      output: input.value({
-        ['#nearbyItem']:
-          null,
-      }),
+      output: input.value({'#nearbyItem': null}),
     }),
 
     {
       dependencies: [
         input('list'),
         input('offset'),
+
         input('wrap'),
+        input('valuePastEdge'),
+
+        input('filter'),
+
         '#index',
       ],
 
       compute: (continuation, {
         [input('list')]: list,
         [input('offset')]: offset,
+
         [input('wrap')]: wrap,
+        [input('valuePastEdge')]: valuePastEdge,
+
+        [input('filter')]: filter,
+
         ['#index']: index,
-      }) => continuation({
-        ['#nearbyItem']:
-          atOffset(list, index, offset, {wrap}),
-      }),
+      }) => {
+        const startIndex = index;
+
+        do {
+          index += offset;
+
+          if (wrap) {
+            index = index % list.length;
+          } else if (index < 0) {
+            return continuation({'#nearbyItem': valuePastEdge});
+          } else if (index >= list.length) {
+            return continuation({'#nearbyItem': valuePastEdge});
+          }
+
+          if (filter && !filter[index]) {
+            continue;
+          }
+
+          return continuation({'#nearbyItem': list[index]});
+        } while (index !== startIndex);
+
+        return continuation({'#nearbyItem': null});
+      },
     },
   ],
 });
diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js
index 65ebf77b..760095c2 100644
--- a/src/data/composite/data/withPropertyFromList.js
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -5,11 +5,15 @@
 // original list are kept null here. Objects which don't have the specified
 // property are retained in-place as null.
 //
+// If the `internal` input is true, this reads the CacheableObject update value
+// of each object rather than its exposed value.
+//
 // See also:
 //  - withPropertiesFromList
 //  - withPropertyFromObject
 //
 
+import CacheableObject from '#cacheable-object';
 import {input, templateCompositeFrom} from '#composite';
 
 function getOutputName({list, property, prefix}) {
@@ -26,6 +30,7 @@ export default templateCompositeFrom({
     list: input({type: 'array'}),
     property: input({type: 'string'}),
     prefix: input.staticValue({type: 'string', defaultValue: null}),
+    internal: input({type: 'boolean', defaultValue: false}),
   },
 
   outputs: ({
@@ -37,13 +42,26 @@ export default templateCompositeFrom({
 
   steps: () => [
     {
-      dependencies: [input('list'), input('property')],
+      dependencies: [
+        input('list'),
+        input('property'),
+        input('internal'),
+      ],
+
       compute: (continuation, {
         [input('list')]: list,
         [input('property')]: property,
+        [input('internal')]: internal,
       }) => continuation({
         ['#values']:
-          list.map(item => item[property] ?? null),
+          list.map(item =>
+            (item === null
+              ? null
+           : internal
+              ? CacheableObject.getUpdateValue(item, property)
+                  ?? null
+              : item[property]
+                  ?? null)),
       }),
     },
 
diff --git a/src/data/composite/data/withPropertyFromObject.js b/src/data/composite/data/withPropertyFromObject.js
index 4f240506..7b452b99 100644
--- a/src/data/composite/data/withPropertyFromObject.js
+++ b/src/data/composite/data/withPropertyFromObject.js
@@ -13,6 +13,21 @@
 import CacheableObject from '#cacheable-object';
 import {input, templateCompositeFrom} from '#composite';
 
+function getOutputName({
+  [input.staticDependency('object')]: object,
+  [input.staticValue('property')]: property,
+}) {
+  if (object && property) {
+    if (object.startsWith('#')) {
+      return `${object}.${property}`;
+    } else {
+      return `#${object}.${property}`;
+    }
+  } else {
+    return '#value';
+  }
+}
+
 export default templateCompositeFrom({
   annotation: `withPropertyFromObject`,
 
@@ -22,15 +37,7 @@ export default templateCompositeFrom({
     internal: input({type: 'boolean', defaultValue: false}),
   },
 
-  outputs: ({
-    [input.staticDependency('object')]: object,
-    [input.staticValue('property')]: property,
-  }) =>
-    (object && property
-      ? (object.startsWith('#')
-          ? [`${object}.${property}`]
-          : [`#${object}.${property}`])
-      : ['#value']),
+  outputs: inputs => [getOutputName(inputs)],
 
   steps: () => [
     {
@@ -39,17 +46,8 @@ export default templateCompositeFrom({
         input.staticValue('property'),
       ],
 
-      compute: (continuation, {
-        [input.staticDependency('object')]: object,
-        [input.staticValue('property')]: property,
-      }) => continuation({
-        '#output':
-          (object && property
-            ? (object.startsWith('#')
-                ? `${object}.${property}`
-                : `#${object}.${property}`)
-            : '#value'),
-      }),
+      compute: (continuation, inputs) =>
+        continuation({'#output': getOutputName(inputs)}),
     },
 
     {
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
index 8b5098f0..dfc6864f 100644
--- a/src/data/composite/things/album/index.js
+++ b/src/data/composite/things/album/index.js
@@ -1 +1,2 @@
+export {default as withHasCoverArt} from './withHasCoverArt.js';
 export {default as withTracks} from './withTracks.js';
diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/things/album/withHasCoverArt.js
new file mode 100644
index 00000000..fd3f2894
--- /dev/null
+++ b/src/data/composite/things/album/withHasCoverArt.js
@@ -0,0 +1,64 @@
+// TODO: This shouldn't be coded as an Album-specific thing,
+// or even really to do with cover artworks in particular, either.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: 'withHasCoverArt',
+
+  outputs: ['#hasCoverArt'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: 'coverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability'],
+      compute: (continuation, {
+        ['#availability']: availability,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#hasCoverArt']: true,
+            })
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'coverArtworks',
+      mode: input.value('empty'),
+      output: input.value({'#hasCoverArt': false}),
+    }),
+
+    withPropertyFromList({
+      list: 'coverArtworks',
+      property: input.value('artistContribs'),
+      internal: input.value(true),
+    }),
+
+    // Since we're getting the update value for each artwork's artistContribs,
+    // it may not be set at all, and in that case won't be exposing as [].
+    fillMissingListItems({
+      list: '#coverArtworks.artistContribs',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#coverArtworks.artistContribs',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#flattenedList',
+      mode: input.value('empty'),
+    }).outputs({
+      '#availability': '#hasCoverArt',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/art-tag/index.js b/src/data/composite/things/art-tag/index.js
new file mode 100644
index 00000000..bbd38293
--- /dev/null
+++ b/src/data/composite/things/art-tag/index.js
@@ -0,0 +1,2 @@
+export {default as withAllDescendantArtTags} from './withAllDescendantArtTags.js';
+export {default as withAncestorArtTagBaobabTree} from './withAncestorArtTagBaobabTree.js';
diff --git a/src/data/composite/things/art-tag/withAllDescendantArtTags.js b/src/data/composite/things/art-tag/withAllDescendantArtTags.js
new file mode 100644
index 00000000..795f96cd
--- /dev/null
+++ b/src/data/composite/things/art-tag/withAllDescendantArtTags.js
@@ -0,0 +1,44 @@
+// Gets all the art tags which descend from this one - that means its own direct
+// descendants, but also all the direct and indirect desceands of each of those!
+// The results aren't specially sorted, but they won't contain any duplicates
+// (for example if two descendant tags both route deeper to end up including
+// some of the same tags).
+
+import {input, templateCompositeFrom} from '#composite';
+import {unique} from '#sugar';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withAllDescendantArtTags`,
+
+  outputs: ['#allDescendantArtTags'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'directDescendantArtTags',
+      mode: input.value('empty'),
+      output: input.value({'#allDescendantArtTags': []})
+    }),
+
+    withResolvedReferenceList({
+      list: 'directDescendantArtTags',
+      find: soupyFind.input('artTag'),
+    }),
+
+    {
+      dependencies: ['#resolvedReferenceList'],
+      compute: (continuation, {
+        ['#resolvedReferenceList']: directDescendantArtTags,
+      }) => continuation({
+        ['#allDescendantArtTags']:
+          unique([
+            ...directDescendantArtTags,
+            ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags),
+          ]),
+      }),
+    },
+  ],
+})
diff --git a/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js
new file mode 100644
index 00000000..e084a42b
--- /dev/null
+++ b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js
@@ -0,0 +1,46 @@
+// Gets all the art tags which are ancestors of this one as a "baobab tree" -
+// what you'd typically think of as roots are all up in the air! Since this
+// really is backwards from the way that the art tag tree is written in data,
+// chances are pretty good that there will be many of the exact same "leaf"
+// nodes - art tags which don't themselves have any ancestors. In the actual
+// data structure, each node is a Map, with keys for each ancestor and values
+// for each ancestor's own baobab (thus a branching structure, just like normal
+// trees in this regard).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withReverseReferenceList} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withAncestorArtTagBaobabTree`,
+
+  outputs: ['#ancestorArtTagBaobabTree'],
+
+  steps: () => [
+    withReverseReferenceList({
+      reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'),
+    }).outputs({
+      ['#reverseReferenceList']: '#directAncestorArtTags',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#directAncestorArtTags',
+      mode: input.value('empty'),
+      output: input.value({'#ancestorArtTagBaobabTree': new Map()}),
+    }),
+
+    {
+      dependencies: ['#directAncestorArtTags'],
+      compute: (continuation, {
+        ['#directAncestorArtTags']: directAncestorArtTags,
+      }) => continuation({
+        ['#ancestorArtTagBaobabTree']:
+          new Map(
+            directAncestorArtTags
+              .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/artist/artistTotalDuration.js b/src/data/composite/things/artist/artistTotalDuration.js
index a4a33542..b8a205fe 100644
--- a/src/data/composite/things/artist/artistTotalDuration.js
+++ b/src/data/composite/things/artist/artistTotalDuration.js
@@ -48,18 +48,18 @@ export default templateCompositeFrom({
 
     withPropertyFromList({
       list: '#allContributions.thing',
-      property: input.value('isOriginalRelease'),
+      property: input.value('isMainRelease'),
     }),
 
     withFilteredList({
       list: '#allContributions',
-      filter: '#allContributions.thing.isOriginalRelease',
+      filter: '#allContributions.thing.isMainRelease',
     }).outputs({
-      '#filteredList': '#originalContributions',
+      '#filteredList': '#mainReleaseContributions',
     }),
 
     withContributionListSums({
-      list: '#originalContributions',
+      list: '#mainReleaseContributions',
     }),
 
     exposeDependency({
diff --git a/src/data/composite/things/artwork/index.js b/src/data/composite/things/artwork/index.js
new file mode 100644
index 00000000..3693c10f
--- /dev/null
+++ b/src/data/composite/things/artwork/index.js
@@ -0,0 +1,5 @@
+export {default as withAttachedArtwork} from './withAttachedArtwork.js';
+export {default as withContainingArtworkList} from './withContainingArtworkList.js';
+export {default as withContribsFromAttachedArtwork} from './withContribsFromAttachedArtwork.js';
+export {default as withDate} from './withDate.js';
+export {default as withPropertyFromAttachedArtwork} from './withPropertyFromAttachedArtwork.js';
diff --git a/src/data/composite/things/artwork/withAttachedArtwork.js b/src/data/composite/things/artwork/withAttachedArtwork.js
new file mode 100644
index 00000000..d7c0d87b
--- /dev/null
+++ b/src/data/composite/things/artwork/withAttachedArtwork.js
@@ -0,0 +1,43 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {flipFilter, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+import {withNearbyItemFromList, withPropertyFromList} from '#composite/data';
+
+import withContainingArtworkList from './withContainingArtworkList.js';
+
+export default templateCompositeFrom({
+  annotaion: `withContribsFromMainArtwork`,
+
+  outputs: ['#attachedArtwork'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'attachAbove',
+      mode: input.value('falsy'),
+      output: input.value({'#attachedArtwork': null}),
+    }),
+
+    withContainingArtworkList(),
+
+    withPropertyFromList({
+      list: '#containingArtworkList',
+      property: input.value('attachAbove'),
+    }),
+
+    flipFilter({
+      filter: '#containingArtworkList.attachAbove',
+    }).outputs({
+      '#containingArtworkList.attachAbove': '#filterNotAttached',
+    }),
+
+    withNearbyItemFromList({
+      list: '#containingArtworkList',
+      item: input.myself(),
+      offset: input.value(-1),
+      filter: '#filterNotAttached',
+    }).outputs({
+      '#nearbyItem': '#attachedArtwork',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/artwork/withContainingArtworkList.js b/src/data/composite/things/artwork/withContainingArtworkList.js
new file mode 100644
index 00000000..9c928ffd
--- /dev/null
+++ b/src/data/composite/things/artwork/withContainingArtworkList.js
@@ -0,0 +1,46 @@
+// Gets the list of artworks which contains this one, which is functionally
+// equivalent to `this.thing[this.thingProperty]`. If the exposed value is not
+// a list at all (i.e. the property holds a single artwork), this composition
+// outputs null.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withContainingArtworkList`,
+
+  outputs: ['#containingArtworkList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'thing',
+      output: input.value({'#containingArtworkList': null}),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: 'thingProperty',
+      output: input.value({'#containingArtworkList': null}),
+    }),
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: 'thingProperty',
+    }).outputs({
+      '#value': '#containingValue',
+    }),
+
+    {
+      dependencies: ['#containingValue'],
+      compute: (continuation, {
+        ['#containingValue']: containingValue,
+      }) => continuation({
+        ['#containingArtworkList']:
+          (Array.isArray(containingValue)
+            ? containingValue
+            : null),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js
new file mode 100644
index 00000000..e9425c95
--- /dev/null
+++ b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js
@@ -0,0 +1,27 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withRecontextualizedContributionList} from '#composite/wiki-data';
+
+import withPropertyFromAttachedArtwork from './withPropertyFromAttachedArtwork.js';
+
+export default templateCompositeFrom({
+  annotaion: `withContribsFromAttachedArtwork`,
+
+  outputs: ['#attachedArtwork.artistContribs'],
+
+  steps: () => [
+    withPropertyFromAttachedArtwork({
+      property: input.value('artistContribs'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#attachedArtwork.artistContribs',
+      output: input.value({'#attachedArtwork.artistContribs': null}),
+    }),
+
+    withRecontextualizedContributionList({
+      list: '#attachedArtwork.artistContribs',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/artwork/withDate.js b/src/data/composite/things/artwork/withDate.js
new file mode 100644
index 00000000..5e05b814
--- /dev/null
+++ b/src/data/composite/things/artwork/withDate.js
@@ -0,0 +1,41 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withDate`,
+
+  inputs: {
+    from: input({
+      defaultDependency: 'date',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#date'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: date,
+      }) =>
+        (date
+          ? continuation.raiseOutput({'#date': date})
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'dateFromThingProperty',
+      output: input.value({'#date': null}),
+    }),
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: 'dateFromThingProperty',
+    }).outputs({
+      ['#value']: '#date',
+    }),
+  ],
+})
diff --git a/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js b/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js
new file mode 100644
index 00000000..a2f954b9
--- /dev/null
+++ b/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js
@@ -0,0 +1,65 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withAttachedArtwork from './withAttachedArtwork.js';
+
+function getOutputName({
+  [input.staticValue('property')]: property,
+}) {
+  if (property) {
+    return `#attachedArtwork.${property}`;
+  } else {
+    return '#value';
+  }
+}
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromAttachedArtwork`,
+
+  inputs: {
+    property: input({type: 'string'}),
+  },
+
+  outputs: inputs => [getOutputName(inputs)],
+
+  steps: () => [
+    {
+      dependencies: [input.staticValue('property')],
+      compute: (continuation, inputs) =>
+        continuation({'#output': getOutputName(inputs)}),
+    },
+
+    withAttachedArtwork(),
+
+    withResultOfAvailabilityCheck({
+      from: '#attachedArtwork',
+    }),
+
+    {
+      dependencies: ['#availability', '#output'],
+      compute: (continuation, {
+        ['#availability']: availability,
+        ['#output']: output,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutput({[output]: null})),
+    },
+
+    withPropertyFromObject({
+      object: '#attachedArtwork',
+      property: input('property'),
+    }),
+
+    {
+      dependencies: ['#value', '#output'],
+      compute: (continuation, {
+        ['#value']: value,
+        ['#output']: output,
+      }) =>
+        continuation.raiseOutput({[output]: value}),
+    },
+  ],
+});
diff --git a/src/data/composite/things/content/contentArtists.js b/src/data/composite/things/content/contentArtists.js
new file mode 100644
index 00000000..8d5db5a5
--- /dev/null
+++ b/src/data/composite/things/content/contentArtists.js
@@ -0,0 +1,40 @@
+import {input, templateCompositeFrom} from '#composite';
+import {validateReferenceList} from '#validators';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+import withExpressedOrImplicitArtistReferences
+  from './helpers/withExpressedOrImplicitArtistReferences.js';
+
+export default templateCompositeFrom({
+  annotation: `contentArtists`,
+
+  compose: false,
+
+  update: {
+    validate: validateReferenceList('artist'),
+  },
+
+  steps: () => [
+    withExpressedOrImplicitArtistReferences({
+      from: input.updateValue(),
+    }),
+
+    exitWithoutDependency({
+      dependency: '#artistReferences',
+      value: input.value([]),
+    }),
+
+    withResolvedReferenceList({
+      list: '#artistReferences',
+      find: soupyFind.input('artist'),
+    }),
+
+    exposeDependency({
+      dependency: '#resolvedReferenceList',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/content/hasAnnotationPart.js b/src/data/composite/things/content/hasAnnotationPart.js
new file mode 100644
index 00000000..83d175e3
--- /dev/null
+++ b/src/data/composite/things/content/hasAnnotationPart.js
@@ -0,0 +1,25 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency} from '#composite/control-flow';
+
+import withHasAnnotationPart from './withHasAnnotationPart.js';
+
+export default templateCompositeFrom({
+  annotation: `hasAnnotationPart`,
+
+  compose: false,
+
+  inputs: {
+    part: input({type: 'string'}),
+  },
+
+  steps: () => [
+    withHasAnnotationPart({
+      part: input('part'),
+    }),
+
+    exposeDependency({
+      dependency: '#hasAnnotationPart',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js b/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js
new file mode 100644
index 00000000..62799d43
--- /dev/null
+++ b/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js
@@ -0,0 +1,60 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withFilteredList, withMappedList} from '#composite/data';
+import {withContentNodes} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withExpressedOrImplicitArtistReferences`,
+
+  inputs: {
+    from: input({type: 'array', acceptsNull: true}),
+  },
+
+  outputs: ['#artistReferences'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: expressedArtistReferences,
+      }) =>
+        (expressedArtistReferences
+          ? continuation.raiseOutput({'#artistReferences': expressedArtistReferences})
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'artistText',
+      output: input.value({'#artistReferences': null}),
+    }),
+
+    withContentNodes({
+      from: 'artistText',
+    }),
+
+    withMappedList({
+      list: '#contentNodes',
+      map: input.value(node =>
+        node.type === 'tag' &&
+        node.data.replacerKey?.data === 'artist'),
+    }).outputs({
+      '#mappedList': '#artistTagFilter',
+    }),
+
+    withFilteredList({
+      list: '#contentNodes',
+      filter: '#artistTagFilter',
+    }).outputs({
+      '#filteredList': '#artistTags',
+    }),
+
+    withMappedList({
+      list: '#artistTags',
+      map: input.value(node =>
+        node.data.replacerValue[0].data),
+    }).outputs({
+      '#mappedList': '#artistReferences',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/content/index.js b/src/data/composite/things/content/index.js
new file mode 100644
index 00000000..4176337d
--- /dev/null
+++ b/src/data/composite/things/content/index.js
@@ -0,0 +1,7 @@
+export {default as contentArtists} from './contentArtists.js';
+export {default as hasAnnotationPart} from './hasAnnotationPart.js';
+export {default as withAnnotationParts} from './withAnnotationParts.js';
+export {default as withHasAnnotationPart} from './withHasAnnotationPart.js';
+export {default as withSourceText} from './withSourceText.js';
+export {default as withSourceURLs} from './withSourceURLs.js';
+export {default as withWebArchiveDate} from './withWebArchiveDate.js';
diff --git a/src/data/composite/things/content/withAnnotationParts.js b/src/data/composite/things/content/withAnnotationParts.js
new file mode 100644
index 00000000..6311b57a
--- /dev/null
+++ b/src/data/composite/things/content/withAnnotationParts.js
@@ -0,0 +1,93 @@
+import {input, templateCompositeFrom} from '#composite';
+import {transposeArrays} from '#sugar';
+import {is} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromList} from '#composite/data';
+import {splitContentNodesAround, withContentNodes} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withAnnotationParts`,
+
+  inputs: {
+    mode: input({
+      validate: is('strings', 'nodes'),
+    }),
+  },
+
+  outputs: ['#annotationParts'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'annotation',
+      output: input.value({'#annotationParts': []}),
+    }),
+
+    withContentNodes({
+      from: 'annotation',
+    }),
+
+    splitContentNodesAround({
+      nodes: '#contentNodes',
+      around: input.value(/, */g),
+    }),
+
+    {
+      dependencies: ['#contentNodeLists', input('mode')],
+      compute: (continuation, {
+        ['#contentNodeLists']: nodeLists,
+        [input('mode')]: mode,
+      }) =>
+        (mode === 'nodes'
+          ? continuation.raiseOutput({'#annotationParts': nodeLists})
+          : continuation()),
+    },
+
+    {
+      dependencies: ['#contentNodeLists'],
+
+      compute: (continuation, {
+        ['#contentNodeLists']: nodeLists,
+      }) => continuation({
+        ['#firstNodes']:
+          nodeLists.map(list => list.at(0)),
+
+        ['#lastNodes']:
+          nodeLists.map(list => list.at(-1)),
+      }),
+    },
+
+    withPropertyFromList({
+      list: '#firstNodes',
+      property: input.value('i'),
+    }).outputs({
+      '#firstNodes.i': '#startIndices',
+    }),
+
+    withPropertyFromList({
+      list: '#lastNodes',
+      property: input.value('iEnd'),
+    }).outputs({
+      '#lastNodes.iEnd': '#endIndices',
+    }),
+
+    {
+      dependencies: [
+        'annotation',
+        '#startIndices',
+        '#endIndices',
+      ],
+
+      compute: (continuation, {
+        ['annotation']: annotation,
+        ['#startIndices']: startIndices,
+        ['#endIndices']: endIndices,
+      }) => continuation({
+        ['#annotationParts']:
+          transposeArrays([startIndices, endIndices])
+            .map(([start, end]) =>
+              annotation.slice(start, end)),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/content/withHasAnnotationPart.js b/src/data/composite/things/content/withHasAnnotationPart.js
new file mode 100644
index 00000000..4af554f3
--- /dev/null
+++ b/src/data/composite/things/content/withHasAnnotationPart.js
@@ -0,0 +1,43 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withAnnotationParts from './withAnnotationParts.js';
+
+export default templateCompositeFrom({
+  annotation: `withHasAnnotationPart`,
+
+  inputs: {
+    part: input({type: 'string'}),
+  },
+
+  outputs: ['#hasAnnotationPart'],
+
+  steps: () => [
+    withAnnotationParts({
+      mode: input.value('strings'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#annotationParts',
+      output: input.value({'#hasAnnotationPart': false}),
+    }),
+
+    {
+      dependencies: [
+        input('part'),
+        '#annotationParts',
+      ],
+
+      compute: (continuation, {
+        [input('part')]: search,
+        ['#annotationParts']: parts,
+      }) => continuation({
+        ['#hasAnnotationPart']:
+          parts.some(part =>
+            part.toLowerCase() ===
+            search.toLowerCase()),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/content/withSourceText.js b/src/data/composite/things/content/withSourceText.js
new file mode 100644
index 00000000..292306b7
--- /dev/null
+++ b/src/data/composite/things/content/withSourceText.js
@@ -0,0 +1,53 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withAnnotationParts from './withAnnotationParts.js';
+
+export default templateCompositeFrom({
+  annotation: `withSourceText`,
+
+  outputs: ['#sourceText'],
+
+  steps: () => [
+    withAnnotationParts({
+      mode: input.value('nodes'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#annotationParts',
+      output: input.value({'#sourceText': null}),
+    }),
+
+    {
+      dependencies: ['#annotationParts'],
+      compute: (continuation, {
+        ['#annotationParts']: annotationParts,
+      }) => continuation({
+        ['#firstPartWithExternalLink']:
+          annotationParts
+            .find(nodes => nodes
+              .some(node => node.type === 'external-link')) ??
+          null,
+      }),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: '#firstPartWithExternalLink',
+      output: input.value({'#sourceText': null}),
+    }),
+
+    {
+      dependencies: ['annotation', '#firstPartWithExternalLink'],
+      compute: (continuation, {
+        ['annotation']: annotation,
+        ['#firstPartWithExternalLink']: nodes,
+      }) => continuation({
+        ['#sourceText']:
+          annotation.slice(
+            nodes.at(0).i,
+            nodes.at(-1).iEnd),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/content/withSourceURLs.js b/src/data/composite/things/content/withSourceURLs.js
new file mode 100644
index 00000000..f85ff9ea
--- /dev/null
+++ b/src/data/composite/things/content/withSourceURLs.js
@@ -0,0 +1,62 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withFilteredList, withMappedList} from '#composite/data';
+
+import withAnnotationParts from './withAnnotationParts.js';
+
+export default templateCompositeFrom({
+  annotation: `withSourceURLs`,
+
+  outputs: ['#sourceURLs'],
+
+  steps: () => [
+    withAnnotationParts({
+      mode: input.value('nodes'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#annotationParts',
+      output: input.value({'#sourceURLs': []}),
+    }),
+
+    {
+      dependencies: ['#annotationParts'],
+      compute: (continuation, {
+        ['#annotationParts']: annotationParts,
+      }) => continuation({
+        ['#firstPartWithExternalLink']:
+          annotationParts
+            .find(nodes => nodes
+              .some(node => node.type === 'external-link')) ??
+          null,
+      }),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: '#firstPartWithExternalLink',
+      output: input.value({'#sourceURLs': []}),
+    }),
+
+    withMappedList({
+      list: '#firstPartWithExternalLink',
+      map: input.value(node => node.type === 'external-link'),
+    }).outputs({
+      '#mappedList': '#externalLinkFilter',
+    }),
+
+    withFilteredList({
+      list: '#firstPartWithExternalLink',
+      filter: '#externalLinkFilter',
+    }).outputs({
+      '#filteredList': '#externalLinks',
+    }),
+
+    withMappedList({
+      list: '#externalLinks',
+      map: input.value(node => node.data.href),
+    }).outputs({
+      '#mappedList': '#sourceURLs',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/content/withWebArchiveDate.js b/src/data/composite/things/content/withWebArchiveDate.js
new file mode 100644
index 00000000..3aaa4f64
--- /dev/null
+++ b/src/data/composite/things/content/withWebArchiveDate.js
@@ -0,0 +1,41 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withWebArchiveDate`,
+
+  outputs: ['#webArchiveDate'],
+
+  steps: () => [
+    {
+      dependencies: ['annotation'],
+
+      compute: (continuation, {annotation}) =>
+        continuation({
+          ['#dateText']:
+            annotation
+              ?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//)
+              ?.[1] ??
+            null,
+        }),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: '#dateText',
+      output: input.value({['#webArchiveDate']: null}),
+    }),
+
+    {
+      dependencies: ['#dateText'],
+      compute: (continuation, {['#dateText']: dateText}) =>
+        continuation({
+          ['#webArchiveDate']:
+            new Date(
+              dateText.slice(0, 4) + '/' +
+              dateText.slice(4, 6) + '/' +
+              dateText.slice(6, 8)),
+        }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js
index 4a37f2cf..a678c3f5 100644
--- a/src/data/composite/things/contribution/thingPropertyMatches.js
+++ b/src/data/composite/things/contribution/thingPropertyMatches.js
@@ -12,19 +12,31 @@ export default templateCompositeFrom({
   },
 
   steps: () => [
+    {
+      dependencies: ['thing', 'thingProperty'],
+
+      compute: (continuation, {thing, thingProperty}) =>
+        continuation({
+          ['#thingProperty']:
+            (thing.constructor[Symbol.for('Thing.referenceType')] === 'artwork'
+              ? thing.artistContribsFromThingProperty
+              : thingProperty),
+        }),
+    },
+
     exitWithoutDependency({
-      dependency: 'thingProperty',
+      dependency: '#thingProperty',
       value: input.value(false),
     }),
 
     {
       dependencies: [
-        'thingProperty',
+        '#thingProperty',
         input('value'),
       ],
 
       compute: ({
-        ['thingProperty']: thingProperty,
+        ['#thingProperty']: thingProperty,
         [input('value')]: value,
       }) =>
         thingProperty === value,
diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
index 2ee811af..4042e78f 100644
--- a/src/data/composite/things/contribution/thingReferenceTypeMatches.js
+++ b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
@@ -29,10 +29,37 @@ export default templateCompositeFrom({
         input('value'),
       ],
 
-      compute: ({
+      compute: (continuation, {
         ['#thing.constructor']: constructor,
         [input('value')]: value,
       }) =>
+        (constructor[Symbol.for('Thing.referenceType')] === value
+          ? continuation.exit(true)
+       : constructor[Symbol.for('Thing.referenceType')] === 'artwork'
+          ? continuation()
+          : continuation.exit(false)),
+    },
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: input.value('thing'),
+    }),
+
+    withPropertyFromObject({
+      object: '#thing.thing',
+      property: input.value('constructor'),
+    }),
+
+    {
+      dependencies: [
+        '#thing.thing.constructor',
+        input('value'),
+      ],
+
+      compute: ({
+        ['#thing.thing.constructor']: constructor,
+        [input('value')]: value,
+      }) =>
         constructor[Symbol.for('Thing.referenceType')] === value,
     },
   ],
diff --git a/src/data/composite/things/contribution/withContainingReverseContributionList.js b/src/data/composite/things/contribution/withContainingReverseContributionList.js
index 56704c8b..175d6cbb 100644
--- a/src/data/composite/things/contribution/withContainingReverseContributionList.js
+++ b/src/data/composite/things/contribution/withContainingReverseContributionList.js
@@ -1,8 +1,12 @@
-// Get the artist's contribution list containing this property.
+// Get the artist's contribution list containing this property. Although that
+// list literally includes both dated and dateless contributions, here, if the
+// current contribution is dateless, the list is filtered to only include
+// dateless contributions from the same immediately nearby context.
 
 import {input, templateCompositeFrom} from '#composite';
 
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 
 import withContributionArtist from './withContributionArtist.js';
@@ -34,7 +38,43 @@ export default templateCompositeFrom({
       object: '#artist',
       property: input('artistProperty'),
     }).outputs({
-      ['#value']: '#containingReverseContributionList',
+      ['#value']: '#list',
     }),
+
+    withResultOfAvailabilityCheck({
+      from: 'date',
+    }).outputs({
+      ['#availability']: '#hasDate',
+    }),
+
+    {
+      dependencies: ['#hasDate', '#list'],
+      compute: (continuation, {
+        ['#hasDate']: hasDate,
+        ['#list']: list,
+      }) =>
+        (hasDate
+          ? continuation.raiseOutput({
+              ['#containingReverseContributionList']:
+                list.filter(contrib => contrib.date),
+            })
+          : continuation({
+              ['#list']:
+                list.filter(contrib => !contrib.date),
+            })),
+    },
+
+    {
+      dependencies: ['#list', 'thing'],
+      compute: (continuation, {
+        ['#list']: list,
+        ['thing']: thing,
+      }) => continuation({
+        ['#containingReverseContributionList']:
+          (thing.album
+            ? list.filter(contrib => contrib.thing.album === thing.album)
+            : list),
+      }),
+    },
   ],
 });
diff --git a/src/data/composite/things/track-section/index.js b/src/data/composite/things/track-section/index.js
index 3202ed49..f11a2ab5 100644
--- a/src/data/composite/things/track-section/index.js
+++ b/src/data/composite/things/track-section/index.js
@@ -1 +1,3 @@
 export {default as withAlbum} from './withAlbum.js';
+export {default as withContinueCountingFrom} from './withContinueCountingFrom.js';
+export {default as withStartCountingFrom} from './withStartCountingFrom.js';
diff --git a/src/data/composite/things/track-section/withContinueCountingFrom.js b/src/data/composite/things/track-section/withContinueCountingFrom.js
new file mode 100644
index 00000000..0ca52b6c
--- /dev/null
+++ b/src/data/composite/things/track-section/withContinueCountingFrom.js
@@ -0,0 +1,25 @@
+import {templateCompositeFrom} from '#composite';
+
+import withStartCountingFrom from './withStartCountingFrom.js';
+
+export default templateCompositeFrom({
+  annotation: `withContinueCountingFrom`,
+
+  outputs: ['#continueCountingFrom'],
+
+  steps: () => [
+    withStartCountingFrom(),
+
+    {
+      dependencies: ['#startCountingFrom', 'tracks'],
+      compute: (continuation, {
+        ['#startCountingFrom']: startCountingFrom,
+        ['tracks']: tracks,
+      }) => continuation({
+        ['#continueCountingFrom']:
+          startCountingFrom +
+          tracks.length,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track-section/withStartCountingFrom.js b/src/data/composite/things/track-section/withStartCountingFrom.js
new file mode 100644
index 00000000..ef345327
--- /dev/null
+++ b/src/data/composite/things/track-section/withStartCountingFrom.js
@@ -0,0 +1,64 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withNearbyItemFromList, withPropertyFromObject} from '#composite/data';
+
+import withAlbum from './withAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withStartCountingFrom`,
+
+  inputs: {
+    from: input({
+      type: 'number',
+      defaultDependency: 'startCountingFrom',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#startCountingFrom'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: from,
+      }) =>
+        (from === null
+          ? continuation()
+          : continuation.raiseOutput({'#startCountingFrom': from})),
+    },
+
+    withAlbum(),
+
+    raiseOutputWithoutDependency({
+      dependency: '#album',
+      output: input.value({'#startCountingFrom': 1}),
+    }),
+
+    withPropertyFromObject({
+      object: '#album',
+      property: input.value('trackSections'),
+    }),
+
+    withNearbyItemFromList({
+      list: '#album.trackSections',
+      item: input.myself(),
+      offset: input.value(-1),
+    }).outputs({
+      '#nearbyItem': '#previousTrackSection',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#previousTrackSection',
+      output: input.value({'#startCountingFrom': 1}),
+    }),
+
+    withPropertyFromObject({
+      object: '#previousTrackSection',
+      property: input.value('continueCountingFrom'),
+    }).outputs({
+      '#previousTrackSection.continueCountingFrom': '#startCountingFrom',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
index 32c72f78..e789e736 100644
--- a/src/data/composite/things/track/index.js
+++ b/src/data/composite/things/track/index.js
@@ -1,15 +1,17 @@
 export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js';
-export {default as inheritContributionListFromOriginalRelease} from './inheritContributionListFromOriginalRelease.js';
-export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js';
-export {default as withAlbum} from './withAlbum.js';
+export {default as inheritContributionListFromMainRelease} from './inheritContributionListFromMainRelease.js';
+export {default as inheritFromMainRelease} from './inheritFromMainRelease.js';
+export {default as withAllReleases} from './withAllReleases.js';
 export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js';
 export {default as withContainingTrackSection} from './withContainingTrackSection.js';
+export {default as withCoverArtistContribs} from './withCoverArtistContribs.js';
 export {default as withDate} from './withDate.js';
 export {default as withDirectorySuffix} from './withDirectorySuffix.js';
 export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js';
-export {default as withOriginalRelease} from './withOriginalRelease.js';
+export {default as withMainRelease} from './withMainRelease.js';
 export {default as withOtherReleases} from './withOtherReleases.js';
 export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js';
-export {default as withPropertyFromOriginalRelease} from './withPropertyFromOriginalRelease.js';
+export {default as withPropertyFromMainRelease} from './withPropertyFromMainRelease.js';
 export {default as withSuffixDirectoryFromAlbum} from './withSuffixDirectoryFromAlbum.js';
 export {default as withTrackArtDate} from './withTrackArtDate.js';
+export {default as withTrackNumber} from './withTrackNumber.js';
diff --git a/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js b/src/data/composite/things/track/inheritContributionListFromMainRelease.js
index f4ae3ddb..89252feb 100644
--- a/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js
+++ b/src/data/composite/things/track/inheritContributionListFromMainRelease.js
@@ -1,5 +1,5 @@
-// Like inheritFromOriginalRelease, but tuned for contributions.
-// Recontextualized contributions for this track.
+// Like inheritFromMainRelease, but tuned for contributions.
+// Recontextualizes contributions for this track.
 
 import {input, templateCompositeFrom} from '#composite';
 
@@ -9,36 +9,36 @@ import {withRecontextualizedContributionList, withRedatedContributionList}
   from '#composite/wiki-data';
 
 import withDate from './withDate.js';
-import withPropertyFromOriginalRelease
-  from './withPropertyFromOriginalRelease.js';
+import withPropertyFromMainRelease
+  from './withPropertyFromMainRelease.js';
 
 export default templateCompositeFrom({
-  annotation: `inheritContributionListFromOriginalRelease`,
+  annotation: `inheritContributionListFromMainRelease`,
 
   steps: () => [
-    withPropertyFromOriginalRelease({
+    withPropertyFromMainRelease({
       property: input.thisProperty(),
       notFoundValue: input.value([]),
     }),
 
     raiseOutputWithoutDependency({
-      dependency: '#isRerelease',
+      dependency: '#isSecondaryRelease',
       mode: input.value('falsy'),
     }),
 
     withRecontextualizedContributionList({
-      list: '#originalValue',
+      list: '#mainReleaseValue',
     }),
 
     withDate(),
 
     withRedatedContributionList({
-      list: '#originalValue',
+      list: '#mainReleaseValue',
       date: '#date',
     }),
 
     exposeDependency({
-      dependency: '#originalValue',
+      dependency: '#mainReleaseValue',
     }),
   ],
 });
diff --git a/src/data/composite/things/track/inheritFromOriginalRelease.js b/src/data/composite/things/track/inheritFromMainRelease.js
index 38ab06be..b1cbb65e 100644
--- a/src/data/composite/things/track/inheritFromOriginalRelease.js
+++ b/src/data/composite/things/track/inheritFromMainRelease.js
@@ -1,9 +1,9 @@
 // Early exits with the value for the same property as specified on the
-// original release, if this track is a rerelease, and otherwise continues
+// main release, if this track is a secondary release, and otherwise continues
 // without providing any further dependencies.
 //
-// Like withOriginalRelease, this will early exit (with notFoundValue) if the
-// original release is specified by reference and that reference doesn't
+// Like withMainRelease, this will early exit (with notFoundValue) if the
+// main release is specified by reference and that reference doesn't
 // resolve to anything.
 
 import {input, templateCompositeFrom} from '#composite';
@@ -11,11 +11,11 @@ import {input, templateCompositeFrom} from '#composite';
 import {exposeDependency, raiseOutputWithoutDependency}
   from '#composite/control-flow';
 
-import withPropertyFromOriginalRelease
-  from './withPropertyFromOriginalRelease.js';
+import withPropertyFromMainRelease
+  from './withPropertyFromMainRelease.js';
 
 export default templateCompositeFrom({
-  annotation: `inheritFromOriginalRelease`,
+  annotation: `inheritFromMainRelease`,
 
   inputs: {
     notFoundValue: input({
@@ -24,18 +24,18 @@ export default templateCompositeFrom({
   },
 
   steps: () => [
-    withPropertyFromOriginalRelease({
+    withPropertyFromMainRelease({
       property: input.thisProperty(),
       notFoundValue: input('notFoundValue'),
     }),
 
     raiseOutputWithoutDependency({
-      dependency: '#isRerelease',
+      dependency: '#isSecondaryRelease',
       mode: input.value('falsy'),
     }),
 
     exposeDependency({
-      dependency: '#originalValue',
+      dependency: '#mainReleaseValue',
     }),
   ],
 });
diff --git a/src/data/composite/things/track/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js
deleted file mode 100644
index 65a2263d..00000000
--- a/src/data/composite/things/track/trackAdditionalNameList.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// 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/withAlbum.js b/src/data/composite/things/track/withAlbum.js
deleted file mode 100644
index 4c55e1f4..00000000
--- a/src/data/composite/things/track/withAlbum.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// Gets the track's album. This will early exit if albumData is missing.
-// If there's no album whose list of tracks includes this track, the output
-// dependency will be null.
-
-import {templateCompositeFrom} from '#composite';
-
-import {withUniqueReferencingThing} from '#composite/wiki-data';
-import {soupyReverse} from '#composite/wiki-properties';
-
-export default templateCompositeFrom({
-  annotation: `withAlbum`,
-
-  outputs: ['#album'],
-
-  steps: () => [
-    withUniqueReferencingThing({
-      reverse: soupyReverse.input('albumsWhoseTracksInclude'),
-    }).outputs({
-      ['#uniqueReferencingThing']: '#album',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/track/withAllReleases.js b/src/data/composite/things/track/withAllReleases.js
new file mode 100644
index 00000000..891db102
--- /dev/null
+++ b/src/data/composite/things/track/withAllReleases.js
@@ -0,0 +1,46 @@
+// Gets all releases of the current track. All items of the outputs are
+// distinct Track objects; one track is the main release; all else are
+// secondary releases of that main release; and one item, which may be
+// the main release or one of the secondary releases, is the current
+// track. The results are sorted by date, and it is possible that the
+// main release is not actually the earliest/first.
+
+import {input, templateCompositeFrom} from '#composite';
+import {sortByDate} from '#sort';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import withMainRelease from './withMainRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `withAllReleases`,
+
+  outputs: ['#allReleases'],
+
+  steps: () => [
+    withMainRelease({
+      selfIfMain: input.value(true),
+      notFoundValue: input.value([]),
+    }),
+
+    // We don't talk about bruno no no
+    // Yes, this can perform a normal access equivalent to
+    // `this.secondaryReleases` from within a data composition.
+    // Oooooooooooooooooooooooooooooooooooooooooooooooo
+    withPropertyFromObject({
+      object: '#mainRelease',
+      property: input.value('secondaryReleases'),
+    }),
+
+    {
+      dependencies: ['#mainRelease', '#mainRelease.secondaryReleases'],
+      compute: (continuation, {
+        ['#mainRelease']: mainRelease,
+        ['#mainRelease.secondaryReleases']: secondaryReleases,
+      }) => continuation({
+        ['#allReleases']:
+          sortByDate([mainRelease, ...secondaryReleases]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
index 26c5ba97..87edf21e 100644
--- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -9,7 +9,6 @@ import {isBoolean} from '#validators';
 
 import {withPropertyFromObject} from '#composite/data';
 import {withResolvedReference} from '#composite/wiki-data';
-import {soupyFind} from '#composite/wiki-properties';
 
 import {
   exitWithoutDependency,
@@ -17,6 +16,8 @@ import {
   exposeUpdateValueOrContinue,
 } from '#composite/control-flow';
 
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
 export default templateCompositeFrom({
   annotation: `withAlwaysReferenceByDirectory`,
 
@@ -27,18 +28,7 @@ export default templateCompositeFrom({
       validate: input.value(isBoolean),
     }),
 
-    // withAlwaysReferenceByDirectory is sort of a fragile area - we can't
-    // find the track's album the normal way because albums' track lists
-    // recurse back into alwaysReferenceByDirectory!
-    withResolvedReference({
-      ref: 'dataSourceAlbum',
-      find: soupyFind.input('album'),
-    }).outputs({
-      '#resolvedReference': '#album',
-    }),
-
-    withPropertyFromObject({
-      object: '#album',
+    withPropertyFromAlbum({
       property: input.value('alwaysReferenceTracksByDirectory'),
     }),
 
@@ -51,7 +41,7 @@ export default templateCompositeFrom({
 
     // Remaining code is for defaulting to true if this track is a rerelease of
     // another with the same name, so everything further depends on access to
-    // trackData as well as originalReleaseTrack.
+    // trackData as well as mainReleaseTrack.
 
     exitWithoutDependency({
       dependency: 'trackData',
@@ -60,45 +50,46 @@ export default templateCompositeFrom({
     }),
 
     exitWithoutDependency({
-      dependency: 'originalReleaseTrack',
+      dependency: 'mainReleaseTrack',
       value: input.value(false),
     }),
 
-    // It's necessary to use the custom trackOriginalReleasesOnly find function
+    // It's necessary to use the custom trackMainReleasesOnly find function
     // here, so as to avoid recursion issues - the find.track() function depends
     // on accessing each track's alwaysReferenceByDirectory, which means it'll
     // hit *this track* - and thus this step - and end up recursing infinitely.
-    // By definition, find.trackOriginalReleasesOnly excludes tracks which have
-    // an originalReleaseTrack update value set, which means even though it does
+    // By definition, find.trackMainReleasesOnly excludes tracks which have
+    // an mainReleaseTrack update value set, which means even though it does
     // still access each of tracks' `alwaysReferenceByDirectory` property, it
     // won't access that of *this* track - it will never proceed past the
     // `exitWithoutDependency` step directly above, so there's no opportunity
     // for recursion.
     withResolvedReference({
-      ref: 'originalReleaseTrack',
+      ref: 'mainReleaseTrack',
       data: 'trackData',
-      find: input.value(find.trackOriginalReleasesOnly),
+      find: input.value(find.trackMainReleasesOnly),
     }).outputs({
-      '#resolvedReference': '#originalRelease',
+      '#resolvedReference': '#mainRelease',
     }),
 
     exitWithoutDependency({
-      dependency: '#originalRelease',
+      dependency: '#mainRelease',
       value: input.value(false),
     }),
 
     withPropertyFromObject({
-      object: '#originalRelease',
+      object: '#mainRelease',
       property: input.value('name'),
     }),
 
     {
-      dependencies: ['name', '#originalRelease.name'],
+      dependencies: ['name', '#mainRelease.name'],
       compute: (continuation, {
         name,
-        ['#originalRelease.name']: originalName,
+        ['#mainRelease.name']: mainReleaseName,
       }) => continuation({
-        ['#alwaysReferenceByDirectory']: name === originalName,
+        ['#alwaysReferenceByDirectory']:
+          name === mainReleaseName,
       }),
     },
   ],
diff --git a/src/data/composite/things/track/withCoverArtistContribs.js b/src/data/composite/things/track/withCoverArtistContribs.js
new file mode 100644
index 00000000..9057cfeb
--- /dev/null
+++ b/src/data/composite/things/track/withCoverArtistContribs.js
@@ -0,0 +1,73 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList} from '#validators';
+
+import {exposeDependencyOrContinue} from '#composite/control-flow';
+
+import {
+  withRecontextualizedContributionList,
+  withRedatedContributionList,
+  withResolvedContribs,
+} from '#composite/wiki-data';
+
+import exitWithoutUniqueCoverArt from './exitWithoutUniqueCoverArt.js';
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+import withTrackArtDate from './withTrackArtDate.js';
+
+export default templateCompositeFrom({
+  annotation: `withCoverArtistContribs`,
+
+  inputs: {
+    from: input({
+      defaultDependency: 'coverArtistContribs',
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#coverArtistContribs'],
+
+  steps: () => [
+    exitWithoutUniqueCoverArt({
+      value: input.value([]),
+    }),
+
+    withTrackArtDate(),
+
+    withResolvedContribs({
+      from: input('from'),
+      thingProperty: input.value('coverArtistContribs'),
+      artistProperty: input.value('trackCoverArtistContributions'),
+      date: '#trackArtDate',
+    }).outputs({
+      '#resolvedContribs': '#coverArtistContribs',
+    }),
+
+    exposeDependencyOrContinue({
+      dependency: '#coverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    withPropertyFromAlbum({
+      property: input.value('trackCoverArtistContribs'),
+    }),
+
+    withRecontextualizedContributionList({
+      list: '#album.trackCoverArtistContribs',
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
+
+    withRedatedContributionList({
+      list: '#album.trackCoverArtistContribs',
+      date: '#trackArtDate',
+    }),
+
+    {
+      dependencies: ['#album.trackCoverArtistContribs'],
+      compute: (continuation, {
+        ['#album.trackCoverArtistContribs']: coverArtistContribs,
+      }) => continuation({
+        ['#coverArtistContribs']: coverArtistContribs,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js
index f7e65f25..85d3b92a 100644
--- a/src/data/composite/things/track/withHasUniqueCoverArt.js
+++ b/src/data/composite/things/track/withHasUniqueCoverArt.js
@@ -5,11 +5,18 @@
 // or a placeholder. (This property is named hasUniqueCoverArt instead of
 // the usual hasCoverArt to emphasize that it does not inherit from the
 // album.)
+//
+// withHasUniqueCoverArt is based only around the presence of *specified*
+// cover artist contributions, not whether the references to artists on those
+// contributions actually resolve to anything. It completely evades interacting
+// with find/replace.
 
 import {input, templateCompositeFrom} from '#composite';
-import {empty} from '#sugar';
 
-import {withResolvedContribs} from '#composite/wiki-data';
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/data';
 
 import withPropertyFromAlbum from './withPropertyFromAlbum.js';
 
@@ -29,36 +36,73 @@ export default templateCompositeFrom({
           : continuation()),
     },
 
-    withResolvedContribs({
+    withResultOfAvailabilityCheck({
       from: 'coverArtistContribs',
-      date: input.value(null),
+      mode: input.value('empty'),
     }),
 
     {
-      dependencies: ['#resolvedContribs'],
+      dependencies: ['#availability'],
       compute: (continuation, {
-        ['#resolvedContribs']: contribsFromTrack,
+        ['#availability']: availability,
       }) =>
-        (empty(contribsFromTrack)
-          ? continuation()
-          : continuation.raiseOutput({
+        (availability
+          ? continuation.raiseOutput({
               ['#hasUniqueCoverArt']: true,
-            })),
+            })
+          : continuation()),
     },
 
     withPropertyFromAlbum({
       property: input.value('trackCoverArtistContribs'),
+      internal: input.value(true),
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#album.trackCoverArtistContribs',
+      mode: input.value('empty'),
     }),
 
     {
-      dependencies: ['#album.trackCoverArtistContribs'],
+      dependencies: ['#availability'],
       compute: (continuation, {
-        ['#album.trackCoverArtistContribs']: contribsFromAlbum,
+        ['#availability']: availability,
       }) =>
-        continuation.raiseOutput({
-          ['#hasUniqueCoverArt']:
-            !empty(contribsFromAlbum),
-        }),
+        (availability
+          ? continuation.raiseOutput({
+              ['#hasUniqueCoverArt']: true,
+            })
+          : continuation()),
     },
+
+    raiseOutputWithoutDependency({
+      dependency: 'trackArtworks',
+      mode: input.value('empty'),
+      output: input.value({'#hasUniqueCoverArt': false}),
+    }),
+
+    withPropertyFromList({
+      list: 'trackArtworks',
+      property: input.value('artistContribs'),
+      internal: input.value(true),
+    }),
+
+    // Since we're getting the update value for each artwork's artistContribs,
+    // it may not be set at all, and in that case won't be exposing as [].
+    fillMissingListItems({
+      list: '#trackArtworks.artistContribs',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#trackArtworks.artistContribs',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#flattenedList',
+      mode: input.value('empty'),
+    }).outputs({
+      '#availability': '#hasUniqueCoverArt',
+    }),
   ],
 });
diff --git a/src/data/composite/things/track/withOriginalRelease.js b/src/data/composite/things/track/withMainRelease.js
index 7aefc64a..3a91edae 100644
--- a/src/data/composite/things/track/withOriginalRelease.js
+++ b/src/data/composite/things/track/withMainRelease.js
@@ -1,7 +1,7 @@
-// Just includes the original release of this track as a dependency.
-// If this track isn't a rerelease, then it'll provide null, unless the
-// {selfIfOriginal} option is set, in which case it'll provide this track
-// itself. This will early exit (with notFoundValue) if the original release
+// Just includes the main release of this track as a dependency.
+// If this track isn't a secondary release, then it'll provide null, unless
+// the {selfIfMain} option is set, in which case it'll provide this track
+// itself. This will early exit (with notFoundValue) if the main release
 // is specified by reference and that reference doesn't resolve to anything.
 
 import {input, templateCompositeFrom} from '#composite';
@@ -12,42 +12,42 @@ import {withResolvedReference} from '#composite/wiki-data';
 import {soupyFind} from '#composite/wiki-properties';
 
 export default templateCompositeFrom({
-  annotation: `withOriginalRelease`,
+  annotation: `withMainRelease`,
 
   inputs: {
-    selfIfOriginal: input({type: 'boolean', defaultValue: false}),
+    selfIfMain: input({type: 'boolean', defaultValue: false}),
     notFoundValue: input({defaultValue: null}),
   },
 
-  outputs: ['#originalRelease'],
+  outputs: ['#mainRelease'],
 
   steps: () => [
     withResultOfAvailabilityCheck({
-      from: 'originalReleaseTrack',
+      from: 'mainReleaseTrack',
     }),
 
     {
       dependencies: [
         input.myself(),
-        input('selfIfOriginal'),
+        input('selfIfMain'),
         '#availability',
       ],
 
       compute: (continuation, {
         [input.myself()]: track,
-        [input('selfIfOriginal')]: selfIfOriginal,
+        [input('selfIfMain')]: selfIfMain,
         '#availability': availability,
       }) =>
         (availability
           ? continuation()
           : continuation.raiseOutput({
-              ['#originalRelease']:
-                (selfIfOriginal ? track : null),
+              ['#mainRelease']:
+                (selfIfMain ? track : null),
             })),
     },
 
     withResolvedReference({
-      ref: 'originalReleaseTrack',
+      ref: 'mainReleaseTrack',
       find: soupyFind.input('track'),
     }),
 
@@ -63,7 +63,7 @@ export default templateCompositeFrom({
         ['#resolvedReference']: resolvedReference,
       }) =>
         continuation({
-          ['#originalRelease']: resolvedReference,
+          ['#mainRelease']: resolvedReference,
         }),
     },
   ],
diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js
index f8c1c3f0..bb3e8983 100644
--- a/src/data/composite/things/track/withOtherReleases.js
+++ b/src/data/composite/things/track/withOtherReleases.js
@@ -1,8 +1,9 @@
-import {input, templateCompositeFrom} from '#composite';
+// Gets all releases of the current track *except* this track itself;
+// in other words, all other releases of the current track.
 
-import {exitWithoutDependency} from '#composite/control-flow';
+import {input, templateCompositeFrom} from '#composite';
 
-import withOriginalRelease from './withOriginalRelease.js';
+import withAllReleases from './withAllReleases.js';
 
 export default templateCompositeFrom({
   annotation: `withOtherReleases`,
@@ -10,31 +11,16 @@ export default templateCompositeFrom({
   outputs: ['#otherReleases'],
 
   steps: () => [
-    exitWithoutDependency({
-      dependency: 'trackData',
-      mode: input.value('empty'),
-    }),
-
-    withOriginalRelease({
-      selfIfOriginal: input.value(true),
-      notFoundValue: input.value([]),
-    }),
+    withAllReleases(),
 
     {
-      dependencies: [input.myself(), '#originalRelease', 'trackData'],
+      dependencies: [input.myself(), '#allReleases'],
       compute: (continuation, {
         [input.myself()]: thisTrack,
-        ['#originalRelease']: originalRelease,
-        trackData,
+        ['#allReleases']: allReleases,
       }) => continuation({
         ['#otherReleases']:
-          (originalRelease === thisTrack
-            ? []
-            : [originalRelease])
-            .concat(trackData.filter(track =>
-              track !== originalRelease &&
-              track !== thisTrack &&
-              track.originalReleaseTrack === originalRelease)),
+          allReleases.filter(track => track !== thisTrack),
       }),
     },
   ],
diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js
index e9c5b56e..a203c2e7 100644
--- a/src/data/composite/things/track/withPropertyFromAlbum.js
+++ b/src/data/composite/things/track/withPropertyFromAlbum.js
@@ -5,13 +5,12 @@ import {input, templateCompositeFrom} from '#composite';
 
 import {withPropertyFromObject} from '#composite/data';
 
-import withAlbum from './withAlbum.js';
-
 export default templateCompositeFrom({
   annotation: `withPropertyFromAlbum`,
 
   inputs: {
     property: input.staticValue({type: 'string'}),
+    internal: input({type: 'boolean', defaultValue: false}),
   },
 
   outputs: ({
@@ -19,11 +18,21 @@ export default templateCompositeFrom({
   }) => ['#album.' + property],
 
   steps: () => [
-    withAlbum(),
+    // XXX: This is a ridiculous hack considering `defaultValue` above.
+    // If we were certain what was up, we'd just get around to fixing it LOL
+    {
+      dependencies: [input('internal')],
+      compute: (continuation, {
+        [input('internal')]: internal,
+      }) => continuation({
+        ['#internal']: internal ?? false,
+      }),
+    },
 
     withPropertyFromObject({
-      object: '#album',
+      object: 'album',
       property: input('property'),
+      internal: '#internal',
     }),
 
     {
diff --git a/src/data/composite/things/track/withPropertyFromOriginalRelease.js b/src/data/composite/things/track/withPropertyFromMainRelease.js
index fd37f6de..393a4c63 100644
--- a/src/data/composite/things/track/withPropertyFromOriginalRelease.js
+++ b/src/data/composite/things/track/withPropertyFromMainRelease.js
@@ -1,8 +1,8 @@
-// Provides a value inherited from the original release, if applicable, and a
-// flag indicating if this track is a rerelase or not.
+// Provides a value inherited from the main release, if applicable, and a
+// flag indicating if this track is a secondary release or not.
 //
-// Like withOriginalRelease, this will early exit (with notFoundValue) if the
-// original release is specified by reference and that reference doesn't
+// Like withMainRelease, this will early exit (with notFoundValue) if the
+// main release is specified by reference and that reference doesn't
 // resolve to anything.
 
 import {input, templateCompositeFrom} from '#composite';
@@ -10,10 +10,10 @@ import {input, templateCompositeFrom} from '#composite';
 import {withResultOfAvailabilityCheck} from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 
-import withOriginalRelease from './withOriginalRelease.js';
+import withMainRelease from './withMainRelease.js';
 
 export default templateCompositeFrom({
-  annotation: `inheritFromOriginalRelease`,
+  annotation: `inheritFromMainRelease`,
 
   inputs: {
     property: input({type: 'string'}),
@@ -26,18 +26,18 @@ export default templateCompositeFrom({
   outputs: ({
     [input.staticValue('property')]: property,
   }) =>
-    ['#isRerelease'].concat(
+    ['#isSecondaryRelease'].concat(
       (property
-        ? ['#original.' + property]
-        : ['#originalValue'])),
+        ? ['#mainRelease.' + property]
+        : ['#mainReleaseValue'])),
 
   steps: () => [
-    withOriginalRelease({
+    withMainRelease({
       notFoundValue: input('notFoundValue'),
     }),
 
     withResultOfAvailabilityCheck({
-      from: '#originalRelease',
+      from: '#mainRelease',
     }),
 
     {
@@ -54,14 +54,14 @@ export default templateCompositeFrom({
           ? continuation()
           : continuation.raiseOutput(
               Object.assign(
-                {'#isRerelease': false},
+                {'#isSecondaryRelease': false},
                 (property
-                  ? {['#original.' + property]: null}
-                  : {'#originalValue': null})))),
+                  ? {['#mainRelease.' + property]: null}
+                  : {'#mainReleaseValue': null})))),
     },
 
     withPropertyFromObject({
-      object: '#originalRelease',
+      object: '#mainRelease',
       property: input('property'),
     }),
 
@@ -77,10 +77,10 @@ export default templateCompositeFrom({
       }) =>
         continuation.raiseOutput(
           Object.assign(
-            {'#isRerelease': true},
+            {'#isSecondaryRelease': true},
             (property
-              ? {['#original.' + property]: value}
-              : {'#originalValue': value}))),
+              ? {['#mainRelease.' + property]: value}
+              : {'#mainReleaseValue': value}))),
     },
   ],
 });
diff --git a/src/data/composite/things/track/withTrackArtDate.js b/src/data/composite/things/track/withTrackArtDate.js
index e2c4d8bc..9b7b61c7 100644
--- a/src/data/composite/things/track/withTrackArtDate.js
+++ b/src/data/composite/things/track/withTrackArtDate.js
@@ -1,11 +1,3 @@
-// Gets the date of cover art release. This represents only the track's own
-// unique cover artwork, if any.
-//
-// If the 'fallback' option is false (the default), this will only output
-// the track's own coverArtDate or its album's trackArtDate. If 'fallback'
-// is set, and neither of these is available, it'll output the track's own
-// date instead.
-
 import {input, templateCompositeFrom} from '#composite';
 import {isDate} from '#validators';
 
@@ -24,11 +16,6 @@ export default templateCompositeFrom({
       defaultDependency: 'coverArtDate',
       acceptsNull: true,
     }),
-
-    fallback: input({
-      type: 'boolean',
-      defaultValue: false,
-    }),
   },
 
   outputs: ['#trackArtDate'],
@@ -57,20 +44,13 @@ export default templateCompositeFrom({
     }),
 
     {
-      dependencies: [
-        '#album.trackArtDate',
-        input('fallback'),
-      ],
-
+      dependencies: ['#album.trackArtDate'],
       compute: (continuation, {
         ['#album.trackArtDate']: albumTrackArtDate,
-        [input('fallback')]: fallback,
       }) =>
         (albumTrackArtDate
           ? continuation.raiseOutput({'#trackArtDate': albumTrackArtDate})
-       : fallback
-          ? continuation()
-          : continuation.raiseOutput({'#trackArtDate': null})),
+          : continuation()),
     },
 
     withDate().outputs({
diff --git a/src/data/composite/things/track/withTrackNumber.js b/src/data/composite/things/track/withTrackNumber.js
new file mode 100644
index 00000000..61428e8c
--- /dev/null
+++ b/src/data/composite/things/track/withTrackNumber.js
@@ -0,0 +1,50 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withIndexInList, withPropertiesFromObject} from '#composite/data';
+
+import withContainingTrackSection from './withContainingTrackSection.js';
+
+export default templateCompositeFrom({
+  annotation: `withTrackNumber`,
+
+  outputs: ['#trackNumber'],
+
+  steps: () => [
+    withContainingTrackSection(),
+
+    // Zero is the fallback, not one, but in most albums the first track
+    // (and its intended output by this composition) will be one.
+    raiseOutputWithoutDependency({
+      dependency: '#trackSection',
+      output: input.value({'#trackNumber': 0}),
+    }),
+
+    withPropertiesFromObject({
+      object: '#trackSection',
+      properties: input.value(['tracks', 'startCountingFrom']),
+    }),
+
+    withIndexInList({
+      list: '#trackSection.tracks',
+      item: input.myself(),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#index',
+      output: input.value({'#trackNumber': 0}),
+    }),
+
+    {
+      dependencies: ['#trackSection.startCountingFrom', '#index'],
+      compute: (continuation, {
+        ['#trackSection.startCountingFrom']: startCountingFrom,
+        ['#index']: index,
+      }) => continuation({
+        ['#trackNumber']:
+          startCountingFrom +
+          index,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index be83e4c9..38afc2ac 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -11,18 +11,19 @@ export {default as inputNotFoundMode} from './inputNotFoundMode.js';
 export {default as inputSoupyFind} from './inputSoupyFind.js';
 export {default as inputSoupyReverse} from './inputSoupyReverse.js';
 export {default as inputWikiData} from './inputWikiData.js';
+export {default as splitContentNodesAround} from './splitContentNodesAround.js';
 export {default as withClonedThings} from './withClonedThings.js';
+export {default as withConstitutedArtwork} from './withConstitutedArtwork.js';
+export {default as withContentNodes} from './withContentNodes.js';
 export {default as withContributionListSums} from './withContributionListSums.js';
 export {default as withCoverArtDate} from './withCoverArtDate.js';
 export {default as withDirectory} from './withDirectory.js';
-export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
 export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js';
 export {default as withRedatedContributionList} from './withRedatedContributionList.js';
 export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js';
 export {default as withResolvedContribs} from './withResolvedContribs.js';
 export {default as withResolvedReference} from './withResolvedReference.js';
 export {default as withResolvedReferenceList} from './withResolvedReferenceList.js';
-export {default as withResolvedSeriesList} from './withResolvedSeriesList.js';
 export {default as withReverseReferenceList} from './withReverseReferenceList.js';
 export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js';
 export {default as withUniqueReferencingThing} from './withUniqueReferencingThing.js';
diff --git a/src/data/composite/wiki-data/splitContentNodesAround.js b/src/data/composite/wiki-data/splitContentNodesAround.js
new file mode 100644
index 00000000..6648d8e1
--- /dev/null
+++ b/src/data/composite/wiki-data/splitContentNodesAround.js
@@ -0,0 +1,87 @@
+import {input, templateCompositeFrom} from '#composite';
+import {splitContentNodesAround} from '#replacer';
+import {anyOf, isFunction, validateInstanceOf} from '#validators';
+
+import {withFilteredList, withMappedList, withUnflattenedList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `splitContentNodesAround`,
+
+  inputs: {
+    nodes: input({type: 'array'}),
+
+    around: input({
+      validate:
+        anyOf(isFunction, validateInstanceOf(RegExp)),
+    }),
+  },
+
+  outputs: ['#contentNodeLists'],
+
+  steps: () => [
+    {
+      dependencies: [input('nodes'), input('around')],
+
+      compute: (continuation, {
+        [input('nodes')]: nodes,
+        [input('around')]: splitter,
+      }) => continuation({
+        ['#nodes']:
+          Array.from(splitContentNodesAround(nodes, splitter)),
+      }),
+    },
+
+    withMappedList({
+      list: '#nodes',
+      map: input.value(node => node.type === 'separator'),
+    }).outputs({
+      '#mappedList': '#separatorFilter',
+    }),
+
+    withMappedList({
+      list: '#separatorFilter',
+      filter: '#separatorFilter',
+      map: input.value((_node, index) => index),
+    }),
+
+    withFilteredList({
+      list: '#mappedList',
+      filter: '#separatorFilter',
+    }).outputs({
+      '#filteredList': '#separatorIndices',
+    }),
+
+    {
+      dependencies: ['#nodes', '#separatorFilter'],
+
+      compute: (continuation, {
+        ['#nodes']: nodes,
+        ['#separatorFilter']: separatorFilter,
+      }) => continuation({
+        ['#nodes']:
+          nodes.map((node, index) =>
+            (separatorFilter[index]
+              ? null
+              : node)),
+      }),
+    },
+
+    {
+      dependencies: ['#separatorIndices'],
+      compute: (continuation, {
+        ['#separatorIndices']: separatorIndices,
+      }) => continuation({
+        ['#unflattenIndices']:
+          [0, ...separatorIndices],
+      }),
+    },
+
+    withUnflattenedList({
+      list: '#nodes',
+      indices: '#unflattenIndices',
+    }).outputs({
+      '#unflattenedList': '#contentNodeLists',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js
new file mode 100644
index 00000000..28d719e2
--- /dev/null
+++ b/src/data/composite/wiki-data/withConstitutedArtwork.js
@@ -0,0 +1,60 @@
+import {input, templateCompositeFrom} from '#composite';
+import thingConstructors from '#things';
+
+export default templateCompositeFrom({
+  annotation: `withConstitutedArtwork`,
+
+  inputs: {
+    thingProperty: input({type: 'string', acceptsNull: true}),
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  outputs: ['#constitutedArtwork'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input.myself(),
+        input('thingProperty'),
+        input('dimensionsFromThingProperty'),
+        input('fileExtensionFromThingProperty'),
+        input('dateFromThingProperty'),
+        input('artistContribsFromThingProperty'),
+        input('artistContribsArtistProperty'),
+        input('artTagsFromThingProperty'),
+        input('referencedArtworksFromThingProperty'),
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input('thingProperty')]: thingProperty,
+        [input('dimensionsFromThingProperty')]: dimensionsFromThingProperty,
+        [input('fileExtensionFromThingProperty')]: fileExtensionFromThingProperty,
+        [input('dateFromThingProperty')]: dateFromThingProperty,
+        [input('artistContribsFromThingProperty')]: artistContribsFromThingProperty,
+        [input('artistContribsArtistProperty')]: artistContribsArtistProperty,
+        [input('artTagsFromThingProperty')]: artTagsFromThingProperty,
+        [input('referencedArtworksFromThingProperty')]: referencedArtworksFromThingProperty,
+      }) => continuation({
+        ['#constitutedArtwork']:
+          Object.assign(new thingConstructors.Artwork, {
+            thing: myself,
+            thingProperty,
+            dimensionsFromThingProperty,
+            fileExtensionFromThingProperty,
+            artistContribsFromThingProperty,
+            artistContribsArtistProperty,
+            artTagsFromThingProperty,
+            dateFromThingProperty,
+            referencedArtworksFromThingProperty,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withContentNodes.js b/src/data/composite/wiki-data/withContentNodes.js
new file mode 100644
index 00000000..d014d43b
--- /dev/null
+++ b/src/data/composite/wiki-data/withContentNodes.js
@@ -0,0 +1,25 @@
+import {input, templateCompositeFrom} from '#composite';
+import {parseContentNodes} from '#replacer';
+
+export default templateCompositeFrom({
+  annotation: `withContentNodes`,
+
+  inputs: {
+    from: input({type: 'string', acceptsNull: false}),
+  },
+
+  outputs: ['#contentNodes'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+
+      compute: (continuation, {
+        [input('from')]: string,
+      }) => continuation({
+        ['#contentNodes']:
+          parseContentNodes(string),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/wiki-data/withCoverArtDate.js
index 0c644c77..a114d5ff 100644
--- a/src/data/composite/wiki-data/withCoverArtDate.js
+++ b/src/data/composite/wiki-data/withCoverArtDate.js
@@ -1,7 +1,3 @@
-// Gets the current thing's coverArtDate, or, if the 'fallback' option is set,
-// the thing's date. This is always null if the thing doesn't actually have
-// any coverArtistContribs.
-
 import {input, templateCompositeFrom} from '#composite';
 import {isDate} from '#validators';
 
@@ -18,11 +14,6 @@ export default templateCompositeFrom({
       defaultDependency: 'coverArtDate',
       acceptsNull: true,
     }),
-
-    fallback: input({
-      type: 'boolean',
-      defaultValue: false,
-    }),
   },
 
   outputs: ['#coverArtDate'],
@@ -50,21 +41,11 @@ export default templateCompositeFrom({
     },
 
     {
-      dependencies: [input('fallback')],
-      compute: (continuation, {
-        [input('fallback')]: fallback,
-      }) =>
-        (fallback
-          ? continuation()
-          : continuation.raiseOutput({'#coverArtDate': null})),
-    },
-
-    {
       dependencies: ['date'],
       compute: (continuation, {date}) =>
         (date
-          ? continuation.raiseOutput({'#coverArtDate': date})
-          : continuation.raiseOutput({'#coverArtDate': null})),
+          ? continuation({'#coverArtDate': date})
+          : continuation({'#coverArtDate': null})),
     },
   ],
 });
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
deleted file mode 100644
index 9bf4278c..00000000
--- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js
+++ /dev/null
@@ -1,260 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {stitchArrays} from '#sugar';
-import {isCommentary} from '#validators';
-import {commentaryRegexCaseSensitive} from '#wiki-data';
-
-import {
-  fillMissingListItems,
-  withFlattenedList,
-  withPropertiesFromList,
-  withUnflattenedList,
-} from '#composite/data';
-
-import inputSoupyFind from './inputSoupyFind.js';
-import 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',
-        'secondDate',
-        'dateKind',
-        'accessDate',
-        'accessKind',
-      ]),
-    }),
-
-    // The artistReferences group will always have a value, since it's required
-    // for the line to match in the first place.
-
-    {
-      dependencies: ['#entries.artistReferences'],
-      compute: (continuation, {
-        ['#entries.artistReferences']: artistReferenceTexts,
-      }) => continuation({
-        ['#entries.artistReferences']:
-          artistReferenceTexts
-            .map(text => text.split(',').map(ref => ref.trim())),
-      }),
-    },
-
-    withFlattenedList({
-      list: '#entries.artistReferences',
-    }),
-
-    withResolvedReferenceList({
-      list: '#flattenedList',
-      find: inputSoupyFind.input('artist'),
-      notFoundMode: input.value('null'),
-    }),
-
-    withUnflattenedList({
-      list: '#resolvedReferenceList',
-    }).outputs({
-      '#unflattenedList': '#entries.artists',
-    }),
-
-    fillMissingListItems({
-      list: '#entries.artistDisplayText',
-      fill: input.value(null),
-    }),
-
-    fillMissingListItems({
-      list: '#entries.annotation',
-      fill: input.value(null),
-    }),
-
-    {
-      dependencies: ['#entries.annotation'],
-      compute: (continuation, {
-        ['#entries.annotation']: annotation,
-      }) => continuation({
-        ['#entries.webArchiveDate']:
-          annotation
-            .map(text => text?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//))
-            .map(match => match?.[1])
-            .map(dateText =>
-              (dateText
-                ? dateText.slice(0, 4) + '/' +
-                  dateText.slice(4, 6) + '/' +
-                  dateText.slice(6, 8)
-                : null)),
-      }),
-    },
-
-    {
-      dependencies: ['#entries.date'],
-      compute: (continuation, {
-        ['#entries.date']: date,
-      }) => continuation({
-        ['#entries.date']:
-          date
-            .map(date => date ? new Date(date) : null),
-      }),
-    },
-
-    {
-      dependencies: ['#entries.secondDate'],
-      compute: (continuation, {
-        ['#entries.secondDate']: secondDate,
-      }) => continuation({
-        ['#entries.secondDate']:
-          secondDate
-            .map(date => date ? new Date(date) : null),
-      }),
-    },
-
-    fillMissingListItems({
-      list: '#entries.dateKind',
-      fill: input.value(null),
-    }),
-
-    {
-      dependencies: ['#entries.accessDate', '#entries.webArchiveDate'],
-      compute: (continuation, {
-        ['#entries.accessDate']: accessDate,
-        ['#entries.webArchiveDate']: webArchiveDate,
-      }) => continuation({
-        ['#entries.accessDate']:
-          stitchArrays({accessDate, webArchiveDate})
-            .map(({accessDate, webArchiveDate}) =>
-              accessDate ??
-              webArchiveDate ??
-              null)
-            .map(date => date ? new Date(date) : date),
-      }),
-    },
-
-    {
-      dependencies: ['#entries.accessKind', '#entries.webArchiveDate'],
-      compute: (continuation, {
-        ['#entries.accessKind']: accessKind,
-        ['#entries.webArchiveDate']: webArchiveDate,
-      }) => continuation({
-        ['#entries.accessKind']:
-          stitchArrays({accessKind, webArchiveDate})
-            .map(({accessKind, webArchiveDate}) =>
-              accessKind ??
-              (webArchiveDate && 'captured') ??
-              null),
-      }),
-    },
-
-    {
-      dependencies: [
-        '#entries.artists',
-        '#entries.artistDisplayText',
-        '#entries.annotation',
-        '#entries.date',
-        '#entries.secondDate',
-        '#entries.dateKind',
-        '#entries.accessDate',
-        '#entries.accessKind',
-        '#entries.body',
-      ],
-
-      compute: (continuation, {
-        ['#entries.artists']: artists,
-        ['#entries.artistDisplayText']: artistDisplayText,
-        ['#entries.annotation']: annotation,
-        ['#entries.date']: date,
-        ['#entries.secondDate']: secondDate,
-        ['#entries.dateKind']: dateKind,
-        ['#entries.accessDate']: accessDate,
-        ['#entries.accessKind']: accessKind,
-        ['#entries.body']: body,
-      }) => continuation({
-        ['#parsedCommentaryEntries']:
-          stitchArrays({
-            artists,
-            artistDisplayText,
-            annotation,
-            date,
-            secondDate,
-            dateKind,
-            accessDate,
-            accessKind,
-            body,
-          }),
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
index c9a7c058..9cc52f29 100644
--- a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
+++ b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
@@ -1,6 +1,6 @@
 import {input, templateCompositeFrom} from '#composite';
 import {stitchArrays} from '#sugar';
-import {isDate, isObject, validateArrayItems} from '#validators';
+import {isObject, validateArrayItems} from '#validators';
 
 import {withPropertyFromList} from '#composite/data';
 
@@ -22,11 +22,6 @@ export default templateCompositeFrom({
       acceptsNull: true,
     }),
 
-    date: input({
-      validate: isDate,
-      acceptsNull: true,
-    }),
-
     reference: input({type: 'string', defaultValue: 'reference'}),
     annotation: input({type: 'string', defaultValue: 'annotation'}),
     thing: input({type: 'string', defaultValue: 'thing'}),
@@ -91,17 +86,6 @@ export default templateCompositeFrom({
       }),
     },
 
-    {
-      dependencies: ['#matches', input('date')],
-      compute: (continuation, {
-        ['#matches']: matches,
-        [input('date')]: date,
-      }) => continuation({
-        ['#matches']:
-          matches.map(match => ({...match, date})),
-      }),
-    },
-
     withAvailabilityFilter({
       from: '#resolvedReferenceList',
     }),
diff --git a/src/data/composite/wiki-data/withResolvedSeriesList.js b/src/data/composite/wiki-data/withResolvedSeriesList.js
deleted file mode 100644
index deaab466..00000000
--- a/src/data/composite/wiki-data/withResolvedSeriesList.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {stitchArrays} from '#sugar';
-import {isSeriesList, validateThing} from '#validators';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-
-import {
-  fillMissingListItems,
-  withFlattenedList,
-  withUnflattenedList,
-  withPropertiesFromList,
-} from '#composite/data';
-
-import inputSoupyFind from './inputSoupyFind.js';
-import withResolvedReferenceList from './withResolvedReferenceList.js';
-
-export default templateCompositeFrom({
-  annotation: `withResolvedSeriesList`,
-
-  inputs: {
-    group: input({
-      validate: validateThing({referenceType: 'group'}),
-    }),
-
-    list: input({
-      validate: isSeriesList,
-      acceptsNull: true,
-    }),
-  },
-
-  outputs: ['#resolvedSeriesList'],
-
-  steps: () => [
-    raiseOutputWithoutDependency({
-      dependency: input('list'),
-      mode: input.value('empty'),
-      output: input.value({
-        ['#resolvedSeriesList']: [],
-      }),
-    }),
-
-    withPropertiesFromList({
-      list: input('list'),
-      prefix: input.value('#serieses'),
-      properties: input.value([
-        'name',
-        'description',
-        'albums',
-
-        'showAlbumArtists',
-      ]),
-    }),
-
-    fillMissingListItems({
-      list: '#serieses.albums',
-      fill: input.value([]),
-    }),
-
-    withFlattenedList({
-      list: '#serieses.albums',
-    }),
-
-    withResolvedReferenceList({
-      list: '#flattenedList',
-      find: inputSoupyFind.input('album'),
-      notFoundMode: input.value('null'),
-    }),
-
-    withUnflattenedList({
-      list: '#resolvedReferenceList',
-    }).outputs({
-      '#unflattenedList': '#serieses.albums',
-    }),
-
-    fillMissingListItems({
-      list: '#serieses.description',
-      fill: input.value(null),
-    }),
-
-    fillMissingListItems({
-      list: '#serieses.showAlbumArtists',
-      fill: input.value(null),
-    }),
-
-    {
-      dependencies: [
-        '#serieses.name',
-        '#serieses.description',
-        '#serieses.albums',
-
-        '#serieses.showAlbumArtists',
-      ],
-
-      compute: (continuation, {
-        ['#serieses.name']: name,
-        ['#serieses.description']: description,
-        ['#serieses.albums']: albums,
-
-        ['#serieses.showAlbumArtists']: showAlbumArtists,
-      }) => continuation({
-        ['#seriesProperties']:
-          stitchArrays({
-            name,
-            description,
-            albums,
-
-            showAlbumArtists,
-          }).map(properties => ({
-              ...properties,
-              group: input
-            }))
-      }),
-    },
-
-    {
-      dependencies: ['#seriesProperties', input('group')],
-      compute: (continuation, {
-        ['#seriesProperties']: seriesProperties,
-        [input('group')]: group,
-      }) => continuation({
-        ['#resolvedSeriesList']:
-          seriesProperties
-            .map(properties => ({
-              ...properties,
-              group,
-            })),
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/wiki-properties/additionalFiles.js b/src/data/composite/wiki-properties/additionalFiles.js
deleted file mode 100644
index 6760527a..00000000
--- a/src/data/composite/wiki-properties/additionalFiles.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// 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
deleted file mode 100644
index c5971d4a..00000000
--- a/src/data/composite/wiki-properties/additionalNameList.js
+++ /dev/null
@@ -1,14 +0,0 @@
-// A list of additional names! These can be used for a variety of purposes,
-// e.g. providing extra searchable titles, localizations, romanizations or
-// original titles, and so on. Each item has a name and, optionally, a
-// descriptive annotation.
-
-import {isAdditionalNameList} from '#validators';
-
-export default function() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isAdditionalNameList},
-    expose: {transform: value => value ?? []},
-  };
-}
diff --git a/src/data/composite/wiki-properties/annotatedReferenceList.js b/src/data/composite/wiki-properties/annotatedReferenceList.js
index bb6875f1..8e6c96a1 100644
--- a/src/data/composite/wiki-properties/annotatedReferenceList.js
+++ b/src/data/composite/wiki-properties/annotatedReferenceList.js
@@ -2,7 +2,6 @@ import {input, templateCompositeFrom} from '#composite';
 
 import {
   isContentString,
-  isDate,
   optional,
   validateArrayItems,
   validateProperties,
@@ -27,11 +26,6 @@ export default templateCompositeFrom({
     data: inputWikiData({allowMixedTypes: true}),
     find: inputSoupyFind(),
 
-    date: input({
-      validate: isDate,
-      acceptsNull: true,
-    }),
-
     reference: input.staticValue({type: 'string', defaultValue: 'reference'}),
     annotation: input.staticValue({type: 'string', defaultValue: 'annotation'}),
     thing: input.staticValue({type: 'string', defaultValue: 'thing'}),
@@ -57,8 +51,6 @@ export default templateCompositeFrom({
     withResolvedAnnotatedReferenceList({
       list: input.updateValue(),
 
-      date: input('date'),
-
       reference: input('reference'),
       annotation: input('annotation'),
       thing: input('thing'),
diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js
deleted file mode 100644
index cd6b7ac4..00000000
--- a/src/data/composite/wiki-properties/commentary.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// 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
index c5c14769..54d3e1a5 100644
--- a/src/data/composite/wiki-properties/commentatorArtists.js
+++ b/src/data/composite/wiki-properties/commentatorArtists.js
@@ -7,7 +7,6 @@ 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`,
@@ -21,19 +20,13 @@ export default templateCompositeFrom({
       value: input.value([]),
     }),
 
-    withParsedCommentaryEntries({
-      from: 'commentary',
-    }),
-
     withPropertyFromList({
-      list: '#parsedCommentaryEntries',
+      list: 'commentary',
       property: input.value('artists'),
-    }).outputs({
-      '#parsedCommentaryEntries.artists': '#artistLists',
     }),
 
     withFlattenedList({
-      list: '#artistLists',
+      list: '#commentary.artists',
     }).outputs({
       '#flattenedList': '#artists',
     }),
diff --git a/src/data/composite/wiki-properties/constitutibleArtwork.js b/src/data/composite/wiki-properties/constitutibleArtwork.js
new file mode 100644
index 00000000..48f4211a
--- /dev/null
+++ b/src/data/composite/wiki-properties/constitutibleArtwork.js
@@ -0,0 +1,70 @@
+// This composition does not actually inspect the values of any properties
+// specified, so it's not responsible for determining whether a constituted
+// artwork should exist at all.
+
+import {input, templateCompositeFrom} from '#composite';
+import {withEntries} from '#sugar';
+import Thing from '#thing';
+import {validateThing} from '#validators';
+
+import {exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {withConstitutedArtwork} from '#composite/wiki-data';
+
+const template = templateCompositeFrom({
+  annotation: `constitutibleArtwork`,
+
+  compose: false,
+
+  inputs: {
+    thingProperty: input({type: 'string', acceptsNull: true}),
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      validate: input.value(
+        validateThing({
+          referenceType: 'artwork',
+        })),
+    }),
+
+    withConstitutedArtwork({
+      thingProperty: input('thingProperty'),
+      dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
+      fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
+      dateFromThingProperty: input('dateFromThingProperty'),
+      artistContribsFromThingProperty: input('artistContribsFromThingProperty'),
+      artistContribsArtistProperty: input('artistContribsArtistProperty'),
+      artTagsFromThingProperty: input('artTagsFromThingProperty'),
+      referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'),
+    }),
+
+    exposeDependency({
+      dependency: '#constitutedArtwork',
+    }),
+  ],
+});
+
+template.fromYAMLFieldSpec = function(field) {
+  const {[Thing.yamlDocumentSpec]: documentSpec} = this;
+
+  const {provide} = documentSpec.fields[field].transform;
+
+  const inputs =
+    withEntries(provide, entries =>
+      entries.map(([property, value]) => [
+        property,
+        input.value(value),
+      ]));
+
+  return template(inputs);
+};
+
+export default template;
diff --git a/src/data/composite/wiki-properties/constitutibleArtworkList.js b/src/data/composite/wiki-properties/constitutibleArtworkList.js
new file mode 100644
index 00000000..dad3a957
--- /dev/null
+++ b/src/data/composite/wiki-properties/constitutibleArtworkList.js
@@ -0,0 +1,72 @@
+// This composition does not actually inspect the values of any properties
+// specified, so it's not responsible for determining whether a constituted
+// artwork should exist at all.
+
+import {input, templateCompositeFrom} from '#composite';
+import {withEntries} from '#sugar';
+import Thing from '#thing';
+import {validateWikiData} from '#validators';
+
+import {exposeUpdateValueOrContinue} from '#composite/control-flow';
+import {withConstitutedArtwork} from '#composite/wiki-data';
+
+const template = templateCompositeFrom({
+  annotation: `constitutibleArtworkList`,
+
+  compose: false,
+
+  inputs: {
+    thingProperty: input({type: 'string', acceptsNull: true}),
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      validate: input.value(
+        validateWikiData({
+          referenceType: 'artwork',
+        })),
+    }),
+
+    withConstitutedArtwork({
+      thingProperty: input('thingProperty'),
+      dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
+      fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
+      dateFromThingProperty: input('dateFromThingProperty'),
+      artistContribsFromThingProperty: input('artistContribsFromThingProperty'),
+      artistContribsArtistProperty: input('artistContribsArtistProperty'),
+      artTagsFromThingProperty: input('artTagsFromThingProperty'),
+      referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'),
+    }),
+
+    {
+      dependencies: ['#constitutedArtwork'],
+      compute: ({
+        ['#constitutedArtwork']: constitutedArtwork,
+      }) => [constitutedArtwork],
+    },
+  ],
+});
+
+template.fromYAMLFieldSpec = function(field) {
+  const {[Thing.yamlDocumentSpec]: documentSpec} = this;
+
+  const {provide} = documentSpec.fields[field].transform;
+
+  const inputs =
+    withEntries(provide, entries =>
+      entries.map(([property, value]) => [
+        property,
+        input.value(value),
+      ]));
+
+  return template(inputs);
+};
+
+export default template;
diff --git a/src/data/composite/wiki-properties/directory.js b/src/data/composite/wiki-properties/directory.js
index 9ca2a204..1756a8e5 100644
--- a/src/data/composite/wiki-properties/directory.js
+++ b/src/data/composite/wiki-properties/directory.js
@@ -18,6 +18,7 @@ export default templateCompositeFrom({
     name: input({
       validate: isName,
       defaultDependency: 'name',
+      acceptsNull: true,
     }),
 
     suffix: input({
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index 4aaaeb72..e8f109d3 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -3,12 +3,11 @@
 // Entries here may depend on entries in #composite/control-flow,
 // #composite/data, and #composite/wiki-data.
 
-export {default as additionalFiles} from './additionalFiles.js';
-export {default as additionalNameList} from './additionalNameList.js';
 export {default as annotatedReferenceList} from './annotatedReferenceList.js';
 export {default as color} from './color.js';
-export {default as commentary} from './commentary.js';
 export {default as commentatorArtists} from './commentatorArtists.js';
+export {default as constitutibleArtwork} from './constitutibleArtwork.js';
+export {default as constitutibleArtworkList} from './constitutibleArtworkList.js';
 export {default as contentString} from './contentString.js';
 export {default as contribsPresent} from './contribsPresent.js';
 export {default as contributionList} from './contributionList.js';
@@ -22,7 +21,6 @@ export {default as name} from './name.js';
 export {default as referenceList} from './referenceList.js';
 export {default as referencedArtworkList} from './referencedArtworkList.js';
 export {default as reverseReferenceList} from './reverseReferenceList.js';
-export {default as seriesList} from './seriesList.js';
 export {default as simpleDate} from './simpleDate.js';
 export {default as simpleString} from './simpleString.js';
 export {default as singleReference} from './singleReference.js';
diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js
index 819b2f43..4f243493 100644
--- a/src/data/composite/wiki-properties/referencedArtworkList.js
+++ b/src/data/composite/wiki-properties/referencedArtworkList.js
@@ -1,7 +1,5 @@
 import {input, templateCompositeFrom} from '#composite';
 import find from '#find';
-import {isDate} from '#validators';
-import {combineWikiDataArrays} from '#wiki-data';
 
 import annotatedReferenceList from './annotatedReferenceList.js';
 
@@ -10,47 +8,24 @@ export default templateCompositeFrom({
 
   compose: false,
 
-  inputs: {
-    date: input({
-      validate: isDate,
-      acceptsNull: true,
-    }),
-  },
-
   steps: () => [
     {
-      dependencies: [
-        'albumData',
-        'trackData',
-      ],
-
-      compute: (continuation, {
-        albumData,
-        trackData,
-      }) => continuation({
-        ['#data']:
-          combineWikiDataArrays([
-            albumData,
-            trackData,
-          ]),
-      }),
-    },
-
-    {
       compute: (continuation) => continuation({
         ['#find']:
           find.mixed({
-            track: find.trackWithArtwork,
-            album: find.albumWithArtwork,
+            track: find.trackPrimaryArtwork,
+            album: find.albumPrimaryArtwork,
           }),
       }),
     },
 
     annotatedReferenceList({
       referenceType: input.value(['album', 'track']),
-      data: '#data',
+
+      data: 'artworkData',
       find: '#find',
-      date: input('date'),
+
+      thing: input.value('artwork'),
     }),
   ],
 });
diff --git a/src/data/composite/wiki-properties/seriesList.js b/src/data/composite/wiki-properties/seriesList.js
deleted file mode 100644
index 2a101b45..00000000
--- a/src/data/composite/wiki-properties/seriesList.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {isSeriesList, validateThing} from '#validators';
-
-import {exposeDependency} from '#composite/control-flow';
-import {withResolvedSeriesList} from '#composite/wiki-data';
-
-export default templateCompositeFrom({
-  annotation: `seriesList`,
-
-  compose: false,
-
-  inputs: {
-    group: input({
-      validate: validateThing({referenceType: 'group'}),
-    }),
-  },
-
-  steps: () => [
-    withResolvedSeriesList({
-      group: input('group'),
-
-      list: input.updateValue({
-        validate: isSeriesList,
-      }),
-    }),
-
-    exposeDependency({
-      dependency: '#resolvedSeriesList',
-    }),
-  ],
-});
diff --git a/src/data/composite/wiki-properties/soupyReverse.js b/src/data/composite/wiki-properties/soupyReverse.js
index 269ccd6f..784a66b4 100644
--- a/src/data/composite/wiki-properties/soupyReverse.js
+++ b/src/data/composite/wiki-properties/soupyReverse.js
@@ -19,4 +19,19 @@ soupyReverse.contributionsBy =
     referenced: contrib => [contrib.artist],
   });
 
+soupyReverse.artworkContributionsBy =
+  (bindTo, artworkProperty, {single = false} = {}) => ({
+    bindTo,
+
+    referencing: thing =>
+      (single
+        ? (thing[artworkProperty]
+            ? thing[artworkProperty].artistContribs
+            : [])
+        : thing[artworkProperty]
+            .flatMap(artwork => artwork.artistContribs)),
+
+    referenced: contrib => [contrib.artist],
+  });
+
 export default soupyReverse;
diff --git a/src/data/thing.js b/src/data/thing.js
index c51c5fe5..f719224d 100644
--- a/src/data/thing.js
+++ b/src/data/thing.js
@@ -21,6 +21,14 @@ export default class Thing extends CacheableObject {
   static yamlDocumentSpec = Symbol.for('Thing.yamlDocumentSpec');
   static getYamlLoadingSpec = Symbol.for('Thing.getYamlLoadingSpec');
 
+  static yamlSourceFilename = Symbol.for('Thing.yamlSourceFilename');
+  static yamlSourceDocument = Symbol.for('Thing.yamlSourceDocument');
+  static yamlSourceDocumentPlacement = Symbol.for('Thing.yamlSourceDocumentPlacement');
+
+  [Symbol.for('Thing.yamlSourceFilename')] = null;
+  [Symbol.for('Thing.yamlSourceDocument')] = null;
+  [Symbol.for('Thing.yamlSourceDocumentPlacement')] = null;
+
   static isThingConstructor = Symbol.for('Thing.isThingConstructor');
   static isThing = Symbol.for('Thing.isThing');
 
@@ -29,11 +37,13 @@ export default class Thing extends CacheableObject {
   static [Symbol.for('Thing.isThingConstructor')] = NaN;
 
   constructor() {
-    super();
+    super({seal: false});
 
     // To detect:
     // Object.hasOwn(object, Symbol.for('Thing.isThing'))
     this[Symbol.for('Thing.isThing')] = NaN;
+
+    Object.seal(this);
   }
 
   static [Symbol.for('Thing.selectAll')] = _wikiData => [];
@@ -50,7 +60,7 @@ export default class Thing extends CacheableObject {
       if (this.name) {
         name = colors.green(`"${this.name}"`);
       }
-    } catch (error) {
+    } catch {
       name = colors.yellow(`couldn't get name`);
     }
 
@@ -59,7 +69,7 @@ export default class Thing extends CacheableObject {
       if (this.directory) {
         reference = colors.blue(Thing.getReference(this));
       }
-    } catch (error) {
+    } catch {
       reference = colors.yellow(`couldn't get reference`);
     }
 
diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js
new file mode 100644
index 00000000..398d0af5
--- /dev/null
+++ b/src/data/things/additional-file.js
@@ -0,0 +1,47 @@
+import {input} from '#composite';
+import Thing from '#thing';
+import {isString, validateArrayItems} from '#validators';
+
+import {contentString, simpleString, thing} from '#composite/wiki-properties';
+
+import {exposeConstant, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+
+export class AdditionalFile extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: thing(),
+
+    title: simpleString(),
+
+    description: contentString(),
+
+    filenames: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(validateArrayItems(isString)),
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Title': {property: 'title'},
+      'Description': {property: 'description'},
+      'Files': {property: 'filenames'},
+    },
+  };
+
+  get paths() {
+    if (!this.thing) return null;
+    if (!this.thing.getOwnAdditionalFilePath) return null;
+
+    return (
+      this.filenames.map(filename =>
+        this.thing.getOwnAdditionalFilePath(this, filename)));
+  }
+}
diff --git a/src/data/things/additional-name.js b/src/data/things/additional-name.js
new file mode 100644
index 00000000..4c23f291
--- /dev/null
+++ b/src/data/things/additional-name.js
@@ -0,0 +1,21 @@
+import Thing from '#thing';
+
+import {contentString, thing} from '#composite/wiki-properties';
+
+export class AdditionalName extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: thing(),
+
+    name: contentString(),
+    annotation: contentString(),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+      'Annotation': {property: 'annotation'},
+    },
+  };
+}
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 6bf683c5..5132b962 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -3,19 +3,23 @@ export const DATA_ALBUM_DIRECTORY = 'album';
 import * as path from 'node:path';
 import {inspect} from 'node:util';
 
+import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
 import {input} from '#composite';
 import {traverse} from '#node-utils';
 import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
-import {accumulateSum, empty} from '#sugar';
+import {empty} from '#sugar';
 import Thing from '#thing';
-import {isColor, isDate, isDirectory} from '#validators';
+import {isColor, isDate, isDirectory, isNumber} from '#validators';
 
 import {
   parseAdditionalFiles,
   parseAdditionalNames,
   parseAnnotatedReferences,
+  parseArtwork,
+  parseCommentary,
   parseContributors,
+  parseCreditingSources,
   parseDate,
   parseDimensions,
   parseWallpaperParts,
@@ -29,11 +33,10 @@ import {exitWithoutContribs, withDirectory, withCoverArtDate}
   from '#composite/wiki-data';
 
 import {
-  additionalFiles,
-  additionalNameList,
-  commentary,
   color,
   commentatorArtists,
+  constitutibleArtwork,
+  constitutibleArtworkList,
   contentString,
   contribsPresent,
   contributionList,
@@ -44,7 +47,6 @@ import {
   name,
   referencedArtworkList,
   referenceList,
-  reverseReferenceList,
   simpleDate,
   simpleString,
   soupyFind,
@@ -56,16 +58,21 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withTracks} from '#composite/things/album';
-import {withAlbum} from '#composite/things/track-section';
+import {withHasCoverArt, withTracks} from '#composite/things/album';
+import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
+  from '#composite/things/track-section';
 
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalFile,
+    AdditionalName,
     ArtTag,
+    Artwork,
+    CommentaryEntry,
+    CreditingSourcesEntry,
     Group,
-    Track,
     TrackSection,
     WikiInfo,
   }) => ({
@@ -90,10 +97,14 @@ export class Album extends Thing {
     alwaysReferenceTracksByDirectory: flag(false),
     suffixTrackDirectories: flag(false),
 
+    countTracksInArtistTotals: flag(true),
+
     color: color(),
     urls: urls(),
 
-    additionalNames: additionalNameList(),
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
 
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
@@ -103,16 +114,10 @@ export class Album extends Thing {
     dateAddedToWiki: simpleDate(),
 
     coverArtDate: [
-      // ~~TODO: Why does this fall back, but Track.coverArtDate doesn't?~~
-      // TODO: OK so it's because tracks don't *store* dates just like that.
-      // Really instead of fallback being a flag, it should be a date value,
-      // if this option is worth existing at all.
       withCoverArtDate({
         from: input.updateValue({
           validate: isDate,
         }),
-
-        fallback: input.value(true),
       }),
 
       exposeDependency({dependency: '#coverArtDate'}),
@@ -141,7 +146,11 @@ export class Album extends Thing {
     ],
 
     wallpaperParts: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
+      exitWithoutContribs({
+        contribs: 'wallpaperArtistContribs',
+        value: input.value([]),
+      }),
+
       wallpaperParts(),
     ],
 
@@ -162,13 +171,56 @@ export class Album extends Thing {
       dimensions(),
     ],
 
+    wallpaperArtwork: [
+      exitWithoutDependency({
+        dependency: 'wallpaperArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Wallpaper Artwork'),
+    ],
+
+    bannerArtwork: [
+      exitWithoutDependency({
+        dependency: 'bannerArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
+    ],
+
+    coverArtworks: [
+      withHasCoverArt(),
+
+      exitWithoutDependency({
+        dependency: '#hasCoverArt',
+        mode: input.value('falsy'),
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+    ],
+
     hasTrackNumbers: flag(true),
     isListedOnHomepage: flag(true),
     isListedInGalleries: flag(true),
 
-    commentary: commentary(),
-    creditSources: commentary(),
-    additionalFiles: additionalFiles(),
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
+
+    creditingSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
+
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
     trackSections: thingList({
       class: input.value(TrackSection),
@@ -180,9 +232,7 @@ export class Album extends Thing {
     }),
 
     coverArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
-      }),
+      withCoverArtDate(),
 
       contributionList({
         date: '#coverArtDate',
@@ -201,9 +251,7 @@ export class Album extends Thing {
     }),
 
     wallpaperArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
-      }),
+      withCoverArtDate(),
 
       contributionList({
         date: '#coverArtDate',
@@ -212,9 +260,7 @@ export class Album extends Thing {
     ],
 
     bannerArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
-      }),
+      withCoverArtDate(),
 
       contributionList({
         date: '#coverArtDate',
@@ -245,20 +291,7 @@ export class Album extends Thing {
         value: input.value([]),
       }),
 
-      {
-        dependencies: ['coverArtDate', 'date'],
-        compute: (continuation, {
-          coverArtDate,
-          date,
-        }) => continuation({
-          ['#date']:
-            coverArtDate ?? date,
-        }),
-      },
-
-      referencedArtworkList({
-        date: '#date',
-      }),
+      referencedArtworkList(),
     ],
 
     // Update only
@@ -267,13 +300,8 @@ export class Album extends Thing {
     reverse: soupyReverse(),
 
     // used for referencedArtworkList (mixedFind)
-    albumData: wikiData({
-      class: input.value(Album),
-    }),
-
-    // used for referencedArtworkList (mixedFind)
-    trackData: wikiData({
-      class: input.value(Track),
+    artworkData: wikiData({
+      class: input.value(Artwork),
     }),
 
     // used for withMatchingContributionPresets (indirectly by Contribution)
@@ -285,7 +313,11 @@ export class Album extends Thing {
 
     commentatorArtists: commentatorArtists(),
 
-    hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}),
+    hasCoverArt: [
+      withHasCoverArt(),
+      exposeDependency({dependency: '#hasCoverArt'}),
+    ],
+
     hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
     hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
 
@@ -293,17 +325,6 @@ export class Album extends Thing {
       withTracks(),
       exposeDependency({dependency: '#tracks'}),
     ],
-
-    referencedByArtworks: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
-
-      reverseReferenceList({
-        reverse: soupyReverse.input('artworksWhichReference'),
-      }),
-    ],
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -379,6 +400,31 @@ export class Album extends Thing {
           ? [] 
           : [album.name]),
     },
+
+    albumPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'album',
+        'album-referencing-artworks',
+        'album-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork, {Artwork, Album}) =>
+        artwork instanceof Artwork &&
+        artwork.thing instanceof Album &&
+        artwork === artwork.thing.coverArtworks[0],
+
+      getMatchableNames: ({thing: album}) =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+
+      getMatchableDirectories: ({thing: album}) =>
+        [album.directory],
+    },
   };
 
   static [Thing.reverseSpecs] = {
@@ -414,13 +460,13 @@ export class Album extends Thing {
       soupyReverse.contributionsBy('albumData', 'artistContribs'),
 
     albumCoverArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'coverArtistContribs'),
+      soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'),
 
     albumWallpaperArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'wallpaperArtistContribs'),
+      soupyReverse.artworkContributionsBy('albumData', 'wallpaperArtwork', {single: true}),
 
     albumBannerArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'bannerArtistContribs'),
+      soupyReverse.artworkContributionsBy('albumData', 'bannerArtwork', {single: true}),
 
     albumsWithCommentaryBy: {
       bindTo: 'albumData',
@@ -458,6 +504,8 @@ export class Album extends Thing {
         transform: String,
       },
 
+      'Count Tracks In Artist Totals': {property: 'countInArtistTotals'},
+
       'Date': {
         property: 'date',
         transform: parseDate,
@@ -470,6 +518,49 @@ export class Album extends Thing {
       'Listed on Homepage': {property: 'isListedOnHomepage'},
       'Listed in Galleries': {property: 'isListedInGalleries'},
 
+      'Cover Artwork': {
+        property: 'coverArtworks',
+        transform:
+          parseArtwork({
+            thingProperty: 'coverArtworks',
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'albumCoverArtistContributions',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+          }),
+      },
+
+      'Banner Artwork': {
+        property: 'bannerArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'bannerArtwork',
+            dimensionsFromThingProperty: 'bannerDimensions',
+            fileExtensionFromThingProperty: 'bannerFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'bannerArtistContribs',
+            artistContribsArtistProperty: 'albumBannerArtistContributions',
+          }),
+      },
+
+      'Wallpaper Artwork': {
+        property: 'wallpaperArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'wallpaperArtwork',
+            dimensionsFromThingProperty: null,
+            fileExtensionFromThingProperty: 'wallpaperFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'wallpaperArtistContribs',
+            artistContribsArtistProperty: 'albumWallpaperArtistContributions',
+          }),
+      },
+
       'Cover Art Date': {
         property: 'coverArtDate',
         transform: parseDate,
@@ -503,9 +594,10 @@ export class Album extends Thing {
         transform: parseContributors,
       },
 
-      'Wallpaper Style': {property: 'wallpaperStyle'},
       'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
 
+      'Wallpaper Style': {property: 'wallpaperStyle'},
+
       'Wallpaper Parts': {
         property: 'wallpaperParts',
         transform: parseWallpaperParts,
@@ -524,8 +616,15 @@ export class Album extends Thing {
         transform: parseDimensions,
       },
 
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
+      },
 
       'Additional Files': {
         property: 'additionalFiles',
@@ -597,6 +696,12 @@ export class Album extends Thing {
       const trackSectionData = [];
       const trackData = [];
 
+      const artworkData = [];
+      const commentaryData = [];
+      const creditingSourceData = [];
+      const referencingSourceData = [];
+      const lyricsData = [];
+
       for (const {header: album, entries} of results) {
         const trackSections = [];
 
@@ -608,8 +713,6 @@ export class Album extends Thing {
           isDefaultTrackSection: true,
         });
 
-        const albumRef = Thing.getReference(album);
-
         const closeCurrentTrackSection = () => {
           if (
             currentTrackSection.isDefaultTrackSection &&
@@ -636,17 +739,53 @@ export class Album extends Thing {
           currentTrackSectionTracks.push(entry);
           trackData.push(entry);
 
-          entry.dataSourceAlbum = albumRef;
+          // Set the track's album before accessing its list of artworks.
+          // The existence of its artwork objects may depend on access to
+          // its album's 'Default Track Cover Artists'.
+          entry.album = album;
+
+          artworkData.push(...entry.trackArtworks);
+          commentaryData.push(...entry.commentary);
+          creditingSourceData.push(...entry.creditingSources);
+          referencingSourceData.push(...entry.referencingSources);
+
+          // TODO: As exposed, Track.lyrics tries to inherit from the main
+          // release, which is impossible before the data's been linked.
+          // We just use the update value here. But it's icky!
+          lyricsData.push(...CacheableObject.getUpdateValue(entry, 'lyrics') ?? []);
         }
 
         closeCurrentTrackSection();
 
         albumData.push(album);
 
+        artworkData.push(...album.coverArtworks);
+
+        if (album.bannerArtwork) {
+          artworkData.push(album.bannerArtwork);
+        }
+
+        if (album.wallpaperArtwork) {
+          artworkData.push(album.wallpaperArtwork);
+        }
+
+        commentaryData.push(...album.commentary);
+        creditingSourceData.push(...album.creditingSources);
+
         album.trackSections = trackSections;
       }
 
-      return {albumData, trackSectionData, trackData};
+      return {
+        albumData,
+        trackSectionData,
+        trackData,
+
+        artworkData,
+        commentaryData,
+        creditingSourceData,
+        referencingSourceData,
+        lyricsData,
+      };
     },
 
     sort({albumData, trackData}) {
@@ -654,13 +793,65 @@ export class Album extends Thing {
       sortAlbumsTracksChronologically(trackData);
     },
   });
+
+  getOwnAdditionalFilePath(_file, filename) {
+    return [
+      'media.albumAdditionalFile',
+      this.directory,
+      filename,
+    ];
+  }
+
+  getOwnArtworkPath(artwork) {
+    if (artwork === this.bannerArtwork) {
+      return [
+        'media.albumBanner',
+        this.directory,
+        artwork.fileExtension,
+      ];
+    }
+
+    if (artwork === this.wallpaperArtwork) {
+      if (!empty(this.wallpaperParts)) {
+        return null;
+      }
+
+      return [
+        'media.albumWallpaper',
+        this.directory,
+        artwork.fileExtension,
+      ];
+    }
+
+    // TODO: using trackCover here is obviously, badly wrong
+    // but we ought to refactor banners and wallpapers similarly
+    // (i.e. depend on those intrinsic artwork paths rather than
+    // accessing media.{albumBanner,albumWallpaper} from content
+    // or other code directly)
+    return [
+      'media.trackCover',
+      this.directory,
+
+      (artwork.unqualifiedDirectory
+        ? 'cover-' + artwork.unqualifiedDirectory
+        : 'cover'),
+
+      artwork.fileExtension,
+    ];
+  }
+
+  // As of writing, albums don't even have a `duration` property...
+  // so this function will never be called... but the message stands...
+  countOwnContributionInDurationTotals(_contrib) {
+    return false;
+  }
 }
 
 export class TrackSection extends Thing {
   static [Thing.friendlyName] = `Track Section`;
   static [Thing.referenceType] = `track-section`;
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({Track}) => ({
     // Update & expose
 
     name: name('Unnamed Track Section'),
@@ -682,6 +873,14 @@ export class TrackSection extends Thing {
       exposeDependency({dependency: '#album.color'}),
     ],
 
+    startCountingFrom: [
+      withStartCountingFrom({
+        from: input.updateValue({validate: isNumber}),
+      }),
+
+      exposeDependency({dependency: '#startCountingFrom'}),
+    ],
+
     dateOriginallyReleased: simpleDate(),
 
     isDefaultTrackSection: flag(false),
@@ -731,42 +930,10 @@ export class TrackSection extends Thing {
       },
     ],
 
-    startIndex: [
-      withAlbum(),
+    continueCountingFrom: [
+      withContinueCountingFrom(),
 
-      withPropertyFromObject({
-        object: '#album',
-        property: input.value('trackSections'),
-      }),
-
-      {
-        dependencies: ['#album.trackSections', input.myself()],
-        compute: (continuation, {
-          ['#album.trackSections']: trackSections,
-          [input.myself()]: myself,
-        }) => continuation({
-          ['#index']:
-            trackSections.indexOf(myself),
-        }),
-      },
-
-      exitWithoutDependency({
-        dependency: '#index',
-        mode: input.value('index'),
-        value: input.value(0),
-      }),
-
-      {
-        dependencies: ['#album.trackSections', '#index'],
-        compute: ({
-          ['#album.trackSections']: trackSections,
-          ['#index']: index,
-        }) =>
-          accumulateSum(
-            trackSections
-              .slice(0, index)
-              .map(section => section.tracks.length)),
-      },
+      exposeDependency({dependency: '#continueCountingFrom'}),
     ],
   });
 
@@ -797,6 +964,7 @@ export class TrackSection extends Thing {
     fields: {
       'Section': {property: 'name'},
       'Color': {property: 'color'},
+      'Start Counting From': {property: 'startCountingFrom'},
 
       'Date Originally Released': {
         property: 'dateOriginallyReleased',
@@ -812,38 +980,38 @@ export class TrackSection extends Thing {
 
     parts.push(Thing.prototype[inspect.custom].apply(this));
 
-    if (depth >= 0) {
+    if (depth >= 0) showAlbum: {
       let album = null;
       try {
         album = this.album;
-      } catch {}
+      } catch {
+        break showAlbum;
+      }
 
       let first = null;
       try {
-        first = this.startIndex;
+        first = this.tracks.at(0).trackNumber;
       } catch {}
 
-      let length = null;
+      let last = null;
       try {
-        length = this.tracks.length;
+        last = this.tracks.at(-1).trackNumber;
       } catch {}
 
-      if (album) {
-        const albumName = album.name;
-        const albumIndex = album.trackSections.indexOf(this);
+      const albumName = album.name;
+      const albumIndex = album.trackSections.indexOf(this);
 
-        const num =
-          (albumIndex === -1
-            ? 'indeterminate position'
-            : `#${albumIndex + 1}`);
+      const num =
+        (albumIndex === -1
+          ? 'indeterminate position'
+          : `#${albumIndex + 1}`);
 
-        const range =
-          (albumIndex >= 0 && first !== null && length !== null
-            ? `: ${first + 1}-${first + length + 1}`
-            : '');
+      const range =
+        (albumIndex >= 0 && first !== null && last !== null
+          ? `: ${first}-${last}`
+          : '');
 
-        parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
-      }
+      parts.push(` (${colors.yellow(num + range)} in ${colors.green(`"${albumName}"`)})`);
     }
 
     return parts.join('');
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index 9842c887..518f616b 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,32 +1,45 @@
 export const ART_TAG_DATA_FILE = 'tags.yaml';
 
 import {input} from '#composite';
-import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort';
+import {sortAlphabetically} from '#sort';
 import Thing from '#thing';
+import {unique} from '#sugar';
 import {isName} from '#validators';
+import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml';
 
-import {exposeUpdateValueOrContinue} from '#composite/control-flow';
+import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
 
 import {
+  annotatedReferenceList,
   color,
+  contentString,
   directory,
   flag,
+  referenceList,
+  reverseReferenceList,
   name,
+  soupyFind,
   soupyReverse,
-  wikiData,
+  thingList,
+  urls,
 } from '#composite/wiki-properties';
 
+import {withAllDescendantArtTags, withAncestorArtTagBaobabTree}
+  from '#composite/things/art-tag';
+
 export class ArtTag extends Thing {
   static [Thing.referenceType] = 'tag';
   static [Thing.friendlyName] = `Art Tag`;
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({
     // Update & expose
 
     name: name('Unnamed Art Tag'),
     directory: directory(),
     color: color(),
     isContentWarning: flag(false),
+    extraReadingURLs: urls(),
 
     nameShort: [
       exposeUpdateValueOrContinue({
@@ -40,26 +53,74 @@ export class ArtTag extends Thing {
       },
     ],
 
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
+
+    description: contentString(),
+
+    directDescendantArtTags: referenceList({
+      class: input.value(ArtTag),
+      find: soupyFind.input('artTag'),
+    }),
+
+    relatedArtTags: annotatedReferenceList({
+      class: input.value(ArtTag),
+      find: soupyFind.input('artTag'),
+
+      reference: input.value('artTag'),
+      thing: input.value('artTag'),
+    }),
+
     // Update only
 
+    find: soupyFind(),
     reverse: soupyReverse(),
 
     // Expose only
 
-    taggedInThings: {
-      flags: {expose: true},
-
-      expose: {
-        dependencies: ['this', 'reverse'],
-        compute: ({this: artTag, reverse}) =>
-          sortAlbumsTracksChronologically(
-            [
-              ...reverse.albumsWhoseArtworksFeature(artTag),
-              ...reverse.tracksWhoseArtworksFeature(artTag),
-            ],
-            {getDate: thing => thing.coverArtDate ?? thing.date}),
+    descriptionShort: [
+      exitWithoutDependency({
+        dependency: 'description',
+        mode: input.value('falsy'),
+      }),
+
+      {
+        dependencies: ['description'],
+        compute: ({description}) =>
+          description.split('<hr class="split">')[0],
       },
-    },
+    ],
+
+    directlyFeaturedInArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichFeature'),
+    }),
+
+    indirectlyFeaturedInArtworks: [
+      withAllDescendantArtTags(),
+
+      {
+        dependencies: ['#allDescendantArtTags'],
+        compute: ({'#allDescendantArtTags': allDescendantArtTags}) =>
+          unique(
+            allDescendantArtTags
+              .flatMap(artTag => artTag.directlyFeaturedInArtworks)),
+      },
+    ],
+
+    allDescendantArtTags: [
+      withAllDescendantArtTags(),
+      exposeDependency({dependency: '#allDescendantArtTags'}),
+    ],
+
+    directAncestorArtTags: reverseReferenceList({
+      reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'),
+    }),
+
+    ancestorArtTagBaobabTree: [
+      withAncestorArtTagBaobabTree(),
+      exposeDependency({dependency: '#ancestorArtTagBaobabTree'}),
+    ],
   });
 
   static [Thing.findSpecs] = {
@@ -67,10 +128,19 @@ export class ArtTag extends Thing {
       referenceTypes: ['tag'],
       bindTo: 'artTagData',
 
-      getMatchableNames: tag =>
-        (tag.isContentWarning
-          ? [`cw: ${tag.name}`]
-          : [tag.name]),
+      getMatchableNames: artTag =>
+        (artTag.isContentWarning
+          ? [`cw: ${artTag.name}`]
+          : [artTag.name]),
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    artTagsWhichDirectlyAncestor: {
+      bindTo: 'artTagData',
+
+      referencing: artTag => [artTag],
+      referenced: artTag => artTag.directDescendantArtTags,
     },
   };
 
@@ -79,9 +149,27 @@ export class ArtTag extends Thing {
       'Tag': {property: 'name'},
       'Short Name': {property: 'nameShort'},
       'Directory': {property: 'directory'},
+      'Description': {property: 'description'},
+      'Extra Reading URLs': {property: 'extraReadingURLs'},
+
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
 
       'Color': {property: 'color'},
       'Is CW': {property: 'isContentWarning'},
+
+      'Direct Descendant Tags': {property: 'directDescendantArtTags'},
+
+      'Related Tags': {
+        property: 'relatedArtTags',
+        transform: entries =>
+          parseAnnotatedReferences(entries, {
+            referenceField: 'Tag',
+            referenceProperty: 'artTag',
+          }),
+      },
     },
   };
 
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 7ed99a8e..5b67051c 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -10,8 +10,12 @@ import {stitchArrays} from '#sugar';
 import Thing from '#thing';
 import {isName, validateArrayItems} from '#validators';
 import {getKebabCase} from '#wiki-data';
+import {parseArtwork} from '#yaml';
+
+import {exitWithoutDependency} from '#composite/control-flow';
 
 import {
+  constitutibleArtwork,
   contentString,
   directory,
   fileExtension,
@@ -22,7 +26,6 @@ import {
   soupyFind,
   soupyReverse,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 import {artistTotalDuration} from '#composite/things/artist';
@@ -31,7 +34,7 @@ export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
   static [Thing.wikiDataArray] = 'artistData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     name: name('Unnamed Artist'),
@@ -43,6 +46,16 @@ export class Artist extends Thing {
     hasAvatar: flag(false),
     avatarFileExtension: fileExtension('jpg'),
 
+    avatarArtwork: [
+      exitWithoutDependency({
+        dependency: 'hasAvatar',
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Avatar Artwork'),
+    ],
+
     aliasNames: {
       flags: {update: true, expose: true},
       update: {validate: validateArrayItems(isName)},
@@ -193,6 +206,17 @@ export class Artist extends Thing {
       'URLs': {property: 'urls'},
       'Context Notes': {property: 'contextNotes'},
 
+      // note: doesn't really work as an independent field yet
+      'Avatar Artwork': {
+        property: 'avatarArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'avatarArtwork',
+            fileExtensionFromThingProperty: 'avatarFileExtension',
+          }),
+      },
+
       'Has Avatar': {property: 'hasAvatar'},
       'Avatar File Extension': {property: 'avatarFileExtension'},
 
@@ -238,7 +262,12 @@ export class Artist extends Thing {
 
       const artistData = [...artists, ...artistAliases];
 
-      return {artistData};
+      const artworkData =
+        artistData
+          .filter(artist => artist.hasAvatar)
+          .map(artist => artist.avatarArtwork);
+
+      return {artistData, artworkData};
     },
 
     sort({artistData}) {
@@ -257,7 +286,7 @@ export class Artist extends Thing {
       let aliasedArtist;
       try {
         aliasedArtist = this.aliasedArtist.name;
-      } catch (_error) {
+      } catch {
         aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist');
       }
 
@@ -266,4 +295,12 @@ export class Artist extends Thing {
 
     return parts.join('');
   }
+
+  getOwnArtworkPath(artwork) {
+    return [
+      'media.artistAvatar',
+      this.directory,
+      artwork.fileExtension,
+    ];
+  }
 }
diff --git a/src/data/things/artwork.js b/src/data/things/artwork.js
new file mode 100644
index 00000000..57c293ca
--- /dev/null
+++ b/src/data/things/artwork.js
@@ -0,0 +1,510 @@
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {input} from '#composite';
+import find from '#find';
+import Thing from '#thing';
+
+import {
+  isContentString,
+  isContributionList,
+  isDate,
+  isDimensions,
+  isFileExtension,
+  optional,
+  validateArrayItems,
+  validateProperties,
+  validateReference,
+  validateReferenceList,
+} from '#validators';
+
+import {
+  parseAnnotatedReferences,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+} from '#yaml';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  withRecontextualizedContributionList,
+  withResolvedAnnotatedReferenceList,
+  withResolvedContribs,
+  withResolvedReferenceList,
+} from '#composite/wiki-data';
+
+import {
+  contentString,
+  directory,
+  flag,
+  reverseReferenceList,
+  simpleString,
+  soupyFind,
+  soupyReverse,
+  thing,
+  wikiData,
+} from '#composite/wiki-properties';
+
+import {
+  withAttachedArtwork,
+  withContainingArtworkList,
+  withContribsFromAttachedArtwork,
+  withPropertyFromAttachedArtwork,
+  withDate,
+} from '#composite/things/artwork';
+
+export class Artwork extends Thing {
+  static [Thing.referenceType] = 'artwork';
+
+  static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({
+    // Update & expose
+
+    unqualifiedDirectory: directory({
+      name: input.value(null),
+    }),
+
+    thing: thing(),
+    thingProperty: simpleString(),
+
+    label: simpleString(),
+    source: contentString(),
+    originDetails: contentString(),
+
+    dateFromThingProperty: simpleString(),
+
+    date: [
+      withDate({
+        from: input.updateValue({validate: isDate}),
+      }),
+
+      exposeDependency({dependency: '#date'}),
+    ],
+
+    fileExtensionFromThingProperty: simpleString(),
+
+    fileExtension: [
+      {
+        compute: (continuation) => continuation({
+          ['#default']: 'jpg',
+        }),
+      },
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isFileExtension),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'thing',
+        value: '#default',
+      }),
+
+      exitWithoutDependency({
+        dependency: 'fileExtensionFromThingProperty',
+        value: '#default',
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'fileExtensionFromThingProperty',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#value',
+      }),
+
+      exposeDependency({
+        dependency: '#default',
+      }),
+    ],
+
+    dimensionsFromThingProperty: simpleString(),
+
+    dimensions: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDimensions),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'dimensionsFromThingProperty',
+        value: input.value(null),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'dimensionsFromThingProperty',
+      }).outputs({
+        ['#value']: '#dimensionsFromThing',
+      }),
+
+      exitWithoutDependency({
+        dependency: 'dimensionsFromThingProperty',
+        value: input.value(null),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#dimensionsFromThing',
+      }),
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
+
+    attachAbove: flag(false),
+
+    artistContribsFromThingProperty: simpleString(),
+    artistContribsArtistProperty: simpleString(),
+
+    artistContribs: [
+      withDate(),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        date: '#date',
+        thingProperty: input.thisProperty(),
+        artistProperty: 'artistContribsArtistProperty',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#resolvedContribs',
+        mode: input.value('empty'),
+      }),
+
+      withContribsFromAttachedArtwork(),
+
+      exposeDependencyOrContinue({
+        dependency: '#attachedArtwork.artistContribs',
+      }),
+
+      exitWithoutDependency({
+        dependency: 'artistContribsFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'artistContribsFromThingProperty',
+      }).outputs({
+        ['#value']: '#artistContribs',
+      }),
+
+      withRecontextualizedContributionList({
+        list: '#artistContribs',
+      }),
+
+      exposeDependency({
+        dependency: '#artistContribs',
+      }),
+    ],
+
+    artTagsFromThingProperty: simpleString(),
+
+    artTags: [
+      withResolvedReferenceList({
+        list: input.updateValue({
+          validate:
+            validateReferenceList(ArtTag[Thing.referenceType]),
+        }),
+
+        find: soupyFind.input('artTag'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#resolvedReferenceList',
+        mode: input.value('empty'),
+      }),
+
+      withPropertyFromAttachedArtwork({
+        property: input.value('artTags'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#attachedArtwork.artTags',
+      }),
+
+      exitWithoutDependency({
+        dependency: 'artTagsFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'artTagsFromThingProperty',
+      }).outputs({
+        ['#value']: '#artTags',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#artTags',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+
+    referencedArtworksFromThingProperty: simpleString(),
+
+    referencedArtworks: [
+      {
+        compute: (continuation) => continuation({
+          ['#find']:
+            find.mixed({
+              track: find.trackPrimaryArtwork,
+              album: find.albumPrimaryArtwork,
+            }),
+        }),
+      },
+
+      withResolvedAnnotatedReferenceList({
+        list: input.updateValue({
+          validate:
+            // TODO: It's annoying to hardcode this when it's really the
+            // same behavior as through annotatedReferenceList and through
+            // referenceListUpdateDescription, the latter of which isn't
+            // available outside of #composite/wiki-data internals.
+            validateArrayItems(
+              validateProperties({
+                reference: validateReference(['album', 'track']),
+                annotation: optional(isContentString),
+              })),
+        }),
+
+        data: 'artworkData',
+        find: '#find',
+
+        thing: input.value('artwork'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#resolvedAnnotatedReferenceList',
+        mode: input.value('empty'),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'referencedArtworksFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'referencedArtworksFromThingProperty',
+      }).outputs({
+        ['#value']: '#referencedArtworks',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#referencedArtworks',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for referencedArtworks (mixedFind)
+    artworkData: wikiData({
+      class: input.value(Artwork),
+    }),
+
+    // Expose only
+
+    referencedByArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichReference'),
+    }),
+
+    isMainArtwork: [
+      withContainingArtworkList(),
+
+      exitWithoutDependency({
+        dependency: '#containingArtworkList',
+        value: input.value(null),
+      }),
+
+      {
+        dependencies: [input.myself(), '#containingArtworkList'],
+        compute: ({
+          [input.myself()]: myself,
+          ['#containingArtworkList']: list,
+        }) =>
+          list[0] === myself,
+      },
+    ],
+
+    mainArtwork: [
+      withContainingArtworkList(),
+
+      exitWithoutDependency({
+        dependency: '#containingArtworkList',
+        value: input.value(null),
+      }),
+
+      {
+        dependencies: ['#containingArtworkList'],
+        compute: ({'#containingArtworkList': list}) =>
+          list[0],
+      },
+    ],
+
+    attachedArtwork: [
+      withAttachedArtwork(),
+
+      exposeDependency({
+        dependency: '#attachedArtwork',
+      }),
+    ],
+
+    attachingArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichAttach'),
+    }),
+
+    groups: [
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('groups'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#thing.groups',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Directory': {property: 'unqualifiedDirectory'},
+      'File Extension': {property: 'fileExtension'},
+
+      'Dimensions': {
+        property: 'dimensions',
+        transform: parseDimensions,
+      },
+
+      'Label': {property: 'label'},
+      'Source': {property: 'source'},
+      'Origin Details': {property: 'originDetails'},
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
+
+      'Attach Above': {property: 'attachAbove'},
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Tags': {property: 'artTags'},
+
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    artworksWhichReference: {
+      bindTo: 'artworkData',
+
+      referencing: referencingArtwork =>
+        referencingArtwork.referencedArtworks
+          .map(({artwork: referencedArtwork, ...referenceDetails}) => ({
+            referencingArtwork,
+            referencedArtwork,
+            referenceDetails,
+          })),
+
+      referenced: ({referencedArtwork}) => [referencedArtwork],
+
+      tidy: ({referencingArtwork, referenceDetails}) => ({
+        artwork: referencingArtwork,
+        ...referenceDetails,
+      }),
+
+      date: ({artwork}) => artwork.date,
+    },
+
+    artworksWhichAttach: {
+      bindTo: 'artworkData',
+
+      referencing: referencingArtwork =>
+        (referencingArtwork.attachAbove
+          ? [referencingArtwork]
+          : []),
+
+      referenced: referencingArtwork =>
+        [referencingArtwork.attachedArtwork],
+    },
+
+    artworksWhichFeature: {
+      bindTo: 'artworkData',
+
+      referencing: artwork => [artwork],
+      referenced: artwork => artwork.artTags,
+    },
+  };
+
+  get path() {
+    if (!this.thing) return null;
+    if (!this.thing.getOwnArtworkPath) return null;
+
+    return this.thing.getOwnArtworkPath(this);
+  }
+
+  countOwnContributionInContributionTotals(contrib) {
+    if (this.attachAbove) {
+      return false;
+    }
+
+    if (contrib.annotation?.startsWith('edits for wiki')) {
+      return false;
+    }
+
+    return true;
+  }
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (this.thing) {
+      if (depth >= 0) {
+        const newOptions = {
+          ...options,
+          depth:
+            (options.depth === null
+              ? null
+              : options.depth - 1),
+        };
+
+        parts.push(` for ${inspect(this.thing, newOptions)}`);
+      } else {
+        parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`);
+      }
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/content.js b/src/data/things/content.js
new file mode 100644
index 00000000..ca41ccaa
--- /dev/null
+++ b/src/data/things/content.js
@@ -0,0 +1,205 @@
+import {input} from '#composite';
+import Thing from '#thing';
+import {is, isDate} from '#validators';
+import {parseDate} from '#yaml';
+
+import {contentString, simpleDate, soupyFind, thing}
+  from '#composite/wiki-properties';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+  withResultOfAvailabilityCheck,
+} from '#composite/control-flow';
+
+import {
+  contentArtists,
+  hasAnnotationPart,
+  withAnnotationParts,
+  withHasAnnotationPart,
+  withSourceText,
+  withSourceURLs,
+  withWebArchiveDate,
+} from '#composite/things/content';
+
+export class ContentEntry extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: thing(),
+
+    artists: contentArtists(),
+
+    artistText: contentString(),
+
+    annotation: contentString(),
+
+    dateKind: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate: is(...[
+          'sometime',
+          'throughout',
+          'around',
+        ]),
+      },
+    },
+
+    accessKind: [
+      exitWithoutDependency({
+        dependency: 'accessDate',
+      }),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(
+          is(...[
+            'captured',
+            'accessed',
+          ])),
+      }),
+
+      withWebArchiveDate(),
+
+      withResultOfAvailabilityCheck({
+        from: '#webArchiveDate',
+      }),
+
+      {
+        dependencies: ['#availability'],
+        compute: (continuation, {['#availability']: availability}) =>
+          (availability
+            ? continuation.exit('captured')
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value('accessed'),
+      }),
+    ],
+
+    date: simpleDate(),
+
+    secondDate: simpleDate(),
+
+    accessDate: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      withWebArchiveDate(),
+
+      exposeDependencyOrContinue({
+        dependency: '#webArchiveDate',
+      }),
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
+
+    body: contentString(),
+
+    // Update only
+
+    find: soupyFind(),
+
+    // Expose only
+
+    annotationParts: [
+      withAnnotationParts({
+        mode: input.value('strings'),
+      }),
+
+      exposeDependency({dependency: '#annotationParts'}),
+    ],
+
+    sourceText: [
+      withSourceText(),
+      exposeDependency({dependency: '#sourceText'}),
+    ],
+
+    sourceURLs: [
+      withSourceURLs(),
+      exposeDependency({dependency: '#sourceURLs'}),
+    ],
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Artists': {property: 'artists'},
+      'Artist Text': {property: 'artistText'},
+
+      'Annotation': {property: 'annotation'},
+
+      'Date Kind': {property: 'dateKind'},
+      'Access Kind': {property: 'accessKind'},
+
+      'Date': {property: 'date', transform: parseDate},
+      'Second Date': {property: 'secondDate', transform: parseDate},
+      'Access Date': {property: 'accessDate', transform: parseDate},
+
+      'Body': {property: 'body'},
+    },
+  };
+}
+
+export class CommentaryEntry extends ContentEntry {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isWikiEditorCommentary: hasAnnotationPart({
+      part: input.value('wiki editor'),
+    }),
+  });
+}
+
+export class LyricsEntry extends ContentEntry {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    originDetails: contentString(),
+
+    // Expose only
+
+    isWikiLyrics: hasAnnotationPart({
+      part: input.value('wiki lyrics'),
+    }),
+
+    hasSquareBracketAnnotations: [
+      withHasAnnotationPart({
+        part: input.value('wiki lyrics'),
+      }),
+
+      exitWithoutDependency({
+        dependency: '#hasAnnotationPart',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'body',
+        value: input.value(false),
+      }),
+
+      {
+        dependencies: ['body'],
+        compute: ({body}) =>
+          /\[.*\]/m.test(body),
+      },
+    ],
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, {
+    fields: {
+      'Origin Details': {property: 'originDetails'},
+    },
+  });
+}
+
+export class CreditingSourcesEntry extends ContentEntry {}
+
+export class ReferencingSourcesEntry extends ContentEntry {}
diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js
index c92fafb4..90e8eb79 100644
--- a/src/data/things/contribution.js
+++ b/src/data/things/contribution.js
@@ -5,12 +5,20 @@ import {colors} from '#cli';
 import {input} from '#composite';
 import {empty} from '#sugar';
 import Thing from '#thing';
-import {isStringNonEmpty, isThing, validateReference} from '#validators';
+import {isBoolean, isStringNonEmpty, isThing, validateReference}
+  from '#validators';
 
-import {exitWithoutDependency, exposeDependency} from '#composite/control-flow';
 import {flag, simpleDate, soupyFind} from '#composite/wiki-properties';
 
 import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
   withFilteredList,
   withNearbyItemFromList,
   withPropertyFromList,
@@ -70,7 +78,26 @@ export class Contribution extends Thing {
         property: input.thisProperty(),
       }),
 
-      flag(true),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInContributionTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInContributionTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
 
     countInDurationTotals: [
@@ -78,7 +105,37 @@ export class Contribution extends Thing {
         property: input.thisProperty(),
       }),
 
-      flag(true),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('duration'),
+      }),
+
+      exitWithoutDependency({
+        dependency: '#thing.duration',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInDurationTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInDurationTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
 
     // Update only
@@ -238,6 +295,21 @@ export class Contribution extends Thing {
         dependency: '#nearbyItem',
       }),
     ],
+
+    groups: [
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('groups'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#thing.groups',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
   });
 
   [inspect.custom](depth, options, inspect) {
@@ -259,7 +331,7 @@ export class Contribution extends Thing {
       let artist;
       try {
         artist = this.artist;
-      } catch (_error) {
+      } catch {
         // Computing artist might crash for any reason - don't distract from
         // other errors as a result of inspecting this contribution.
       }
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index fe1d17ff..160221f0 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -6,8 +6,16 @@ import {sortFlashesChronologically} from '#sort';
 import Thing from '#thing';
 import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
   from '#validators';
-import {parseAdditionalNames, parseContributors, parseDate, parseDimensions}
-  from '#yaml';
+
+import {
+  parseArtwork,
+  parseAdditionalNames,
+  parseCommentary,
+  parseContributors,
+  parseCreditingSources,
+  parseDate,
+  parseDimensions,
+} from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
 
@@ -19,10 +27,9 @@ import {
 } from '#composite/control-flow';
 
 import {
-  additionalNameList,
   color,
-  commentary,
   commentatorArtists,
+  constitutibleArtwork,
   contentString,
   contributionList,
   dimensions,
@@ -34,8 +41,8 @@ import {
   soupyFind,
   soupyReverse,
   thing,
+  thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 import {withFlashAct} from '#composite/things/flash';
@@ -45,8 +52,10 @@ export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalName,
+    CommentaryEntry,
+    CreditingSourcesEntry,
     Track,
-    FlashAct,
     WikiInfo,
   }) => ({
     // Update & expose
@@ -100,6 +109,10 @@ export class Flash extends Thing {
 
     coverArtDimensions: dimensions(),
 
+    coverArtwork:
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+
     contributorContribs: contributionList({
       date: 'date',
       artistProperty: input.value('flashContributorContributions'),
@@ -112,10 +125,17 @@ export class Flash extends Thing {
 
     urls: urls(),
 
-    additionalNames: additionalNameList(),
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
+
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
 
-    commentary: commentary(),
-    creditSources: commentary(),
+    creditingSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
 
     // Update only
 
@@ -205,6 +225,17 @@ export class Flash extends Thing {
         transform: parseAdditionalNames,
       },
 
+      'Cover Artwork': {
+        property: 'coverArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'coverArtwork',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dimensionsFromThingProperty: 'coverArtDimensions',
+          }),
+      },
+
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
       'Cover Art Dimensions': {
@@ -219,12 +250,27 @@ export class Flash extends Thing {
         transform: parseContributors,
       },
 
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
+      },
 
       'Review Points': {ignore: true},
     },
   };
+
+  getOwnArtworkPath(artwork) {
+    return [
+      'media.flashArt',
+      this.directory,
+      artwork.fileExtension,
+    ];
+  }
 }
 
 export class FlashAct extends Thing {
@@ -411,7 +457,19 @@ export class FlashSide extends Thing {
       const flashActData = results.filter(x => x instanceof FlashAct);
       const flashSideData = results.filter(x => x instanceof FlashSide);
 
-      return {flashData, flashActData, flashSideData};
+      const artworkData = flashData.map(flash => flash.coverArtwork);
+      const commentaryData = flashData.flatMap(flash => flash.commentary);
+      const creditingSourceData = flashData.flatMap(flash => flash.creditingSources);
+
+      return {
+        flashData,
+        flashActData,
+        flashSideData,
+
+        artworkData,
+        commentaryData,
+        creditingSourceData,
+      };
     },
 
     sort({flashData}) {
diff --git a/src/data/things/group.js b/src/data/things/group.js
index ed3c59bb..0262a3a5 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -1,7 +1,11 @@
 export const GROUP_DATA_FILE = 'groups.yaml';
 
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
 import {input} from '#composite';
 import Thing from '#thing';
+import {is} from '#validators';
 import {parseAnnotatedReferences, parseSerieses} from '#yaml';
 
 import {
@@ -11,16 +15,16 @@ import {
   directory,
   name,
   referenceList,
-  seriesList,
   soupyFind,
+  thing,
+  thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 export class Group extends Thing {
   static [Thing.referenceType] = 'group';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({
+  static [Thing.getPropertyDescriptors] = ({Album, Artist, Series}) => ({
     // Update & expose
 
     name: name('Unnamed Group'),
@@ -34,8 +38,6 @@ export class Group extends Thing {
       class: input.value(Artist),
       find: soupyFind.input('artist'),
 
-      date: input.value(null),
-
       reference: input.value('artist'),
       thing: input.value('artist'),
     }),
@@ -45,8 +47,8 @@ export class Group extends Thing {
       find: soupyFind.input('album'),
     }),
 
-    serieses: seriesList({
-      group: input.myself(),
+    serieses: thingList({
+      class: input.value(Series),
     }),
 
     // Update only
@@ -194,8 +196,9 @@ export class Group extends Thing {
 
       const groupData = results.filter(x => x instanceof Group);
       const groupCategoryData = results.filter(x => x instanceof GroupCategory);
+      const seriesData = groupData.flatMap(group => group.serieses);
 
-      return {groupData, groupCategoryData};
+      return {groupData, groupCategoryData, seriesData};
     },
 
     // Groups aren't sorted at all, always preserving the order in the data
@@ -242,3 +245,73 @@ export class GroupCategory extends Thing {
     },
   };
 }
+
+export class Series extends Thing {
+  static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({
+    // Update & expose
+
+    name: name('Unnamed Series'),
+
+    showAlbumArtists: {
+      flags: {update: true, expose: true},
+      update: {
+        validate:
+          is('all', 'differing', 'none'),
+      },
+    },
+
+    description: contentString(),
+
+    group: thing({
+      class: input.value(Group),
+    }),
+
+    albums: referenceList({
+      class: input.value(Album),
+      find: soupyFind.input('album'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+
+      'Description': {property: 'description'},
+
+      'Show Album Artists': {property: 'showAlbumArtists'},
+
+      'Albums': {property: 'albums'},
+    },
+  };
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (depth >= 0) showGroup: {
+      let group = null;
+      try {
+        group = this.group;
+      } catch {
+        break showGroup;
+      }
+
+      const groupName = group.name;
+      const groupIndex = group.serieses.indexOf(this);
+
+      const num =
+        (groupIndex === -1
+          ? 'indeterminate position'
+          : `#${groupIndex + 1}`);
+
+      parts.push(` (${colors.yellow(num)} in ${colors.green(`"${groupName}"`)})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index 812c0b30..3a11c287 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -41,6 +41,7 @@ export class HomepageLayout extends Thing {
     navbarLinks: {
       flags: {update: true, expose: true},
       update: {validate: validateArrayItems(isStringNonEmpty)},
+      expose: {transform: value => value ?? []},
     },
 
     sections: thingList({
@@ -62,7 +63,6 @@ export class HomepageLayout extends Thing {
     thingConstructors: {
       HomepageLayout,
       HomepageLayoutSection,
-      HomepageLayoutAlbumsRow,
     },
   }) => ({
     title: `Process homepage layout file`,
@@ -249,7 +249,7 @@ export class HomepageLayoutActionsRow extends HomepageLayoutRow {
 export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
   static [Thing.friendlyName] = `Homepage Album Carousel Row`;
 
-  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
+  static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({
     ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
 
     // Update & expose
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 9f033c23..11307b50 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -6,31 +6,42 @@ import CacheableObject from '#cacheable-object';
 import {logError} from '#cli';
 import {compositeFrom} from '#composite';
 import * as serialize from '#serialize';
+import {withEntries} from '#sugar';
 import Thing from '#thing';
 
+import * as additionalFileClasses from './additional-file.js';
+import * as additionalNameClasses from './additional-name.js';
 import * as albumClasses from './album.js';
 import * as artTagClasses from './art-tag.js';
 import * as artistClasses from './artist.js';
+import * as artworkClasses from './artwork.js';
+import * as contentClasses from './content.js';
 import * as contributionClasses from './contribution.js';
 import * as flashClasses from './flash.js';
 import * as groupClasses from './group.js';
 import * as homepageLayoutClasses from './homepage-layout.js';
 import * as languageClasses from './language.js';
 import * as newsEntryClasses from './news-entry.js';
+import * as sortingRuleClasses from './sorting-rule.js';
 import * as staticPageClasses from './static-page.js';
 import * as trackClasses from './track.js';
 import * as wikiInfoClasses from './wiki-info.js';
 
 const allClassLists = {
+  'additional-file.js': additionalFileClasses,
+  'additional-name.js': additionalNameClasses,
   'album.js': albumClasses,
   'art-tag.js': artTagClasses,
   'artist.js': artistClasses,
+  'artwork.js': artworkClasses,
+  'content.js': contentClasses,
   'contribution.js': contributionClasses,
   'flash.js': flashClasses,
   'group.js': groupClasses,
   'homepage-layout.js': homepageLayoutClasses,
   'language.js': languageClasses,
   'news-entry.js': newsEntryClasses,
+  'sorting-rule.js': sortingRuleClasses,
   'static-page.js': staticPageClasses,
   'track.js': trackClasses,
   'wiki-info.js': wikiInfoClasses,
@@ -79,13 +90,25 @@ function errorDuplicateClassNames() {
 }
 
 function flattenClassLists() {
+  let allClassesUnsorted = Object.create(null);
+
   for (const classes of Object.values(allClassLists)) {
     for (const [name, constructor] of Object.entries(classes)) {
       if (typeof constructor !== 'function') continue;
       if (!(constructor.prototype instanceof Thing)) continue;
-      allClasses[name] = constructor;
+      allClassesUnsorted[name] = constructor;
     }
   }
+
+  // Sort subclasses after their superclasses.
+  Object.assign(allClasses,
+    withEntries(allClassesUnsorted, entries =>
+      entries.sort(({[1]: A}, {[1]: B}) =>
+        (A.prototype instanceof B
+          ? +1
+       : B.prototype instanceof A
+          ? -1
+          :  0))));
 }
 
 function descriptorAggregateHelper({
@@ -184,6 +207,10 @@ function finalizeCacheableObjectPrototypes() {
     op(constructor) {
       constructor.finalizeCacheableObjectPrototype();
     },
+
+    showFailedClasses(failedClasses) {
+      logError`Failed to finalize cacheable object prototypes for classes: ${failedClasses.join(', ')}`;
+    },
   });
 }
 
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 800c4471..e3689643 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -135,6 +135,7 @@ export class Language extends Thing {
     },
 
     intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}),
+    intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}),
     intl_number: this.#intlHelper(Intl.NumberFormat),
     intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}),
     intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}),
@@ -488,22 +489,44 @@ export class Language extends Thing {
     // or both are undefined, that's just blank content.
     const hasStart = startDate !== null && startDate !== undefined;
     const hasEnd = endDate !== null && endDate !== undefined;
-    if (!hasStart || !hasEnd) {
-      if (startDate === endDate) {
-        return html.blank();
-      } else if (hasStart) {
-        throw new Error(`Expected both start and end of date range, got only start`);
-      } else if (hasEnd) {
-        throw new Error(`Expected both start and end of date range, got only end`);
-      } else {
-        throw new Error(`Got mismatched ${startDate}/${endDate} for start and end`);
-      }
+    if (!hasStart && !hasEnd) {
+      return html.blank();
+    } else if (hasStart && !hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only start`);
+    } else if (!hasStart && hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only end`);
     }
 
     this.assertIntlAvailable('intl_date');
     return this.intl_date.formatRange(startDate, endDate);
   }
 
+  formatYear(date) {
+    if (date === null || date === undefined) {
+      return html.blank();
+    }
+
+    this.assertIntlAvailable('intl_dateYear');
+    return this.intl_dateYear.format(date);
+  }
+
+  formatYearRange(startDate, endDate) {
+    // formatYearRange expects both values to be present, but if both are null
+    // or both are undefined, that's just blank content.
+    const hasStart = startDate !== null && startDate !== undefined;
+    const hasEnd = endDate !== null && endDate !== undefined;
+    if (!hasStart && !hasEnd) {
+      return html.blank();
+    } else if (hasStart && !hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only start`);
+    } else if (!hasStart && hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only end`);
+    }
+
+    this.assertIntlAvailable('intl_dateYear');
+    return this.intl_dateYear.formatRange(startDate, endDate);
+  }
+
   formatDateDuration({
     years: numYears = 0,
     months: numMonths = 0,
@@ -842,6 +865,18 @@ export class Language extends Thing {
     }
   }
 
+  typicallyLowerCase(string) {
+    // Utter nonsense implementation, so this only works on strings,
+    // not actual HTML content, and may rudely disrespect *intentful*
+    // capitalization of whatever goes into it.
+
+    if (typeof string !== 'string') return string;
+    if (string.length <= 1) return string;
+    if (/^\S+?[A-Z]/.test(string)) return string;
+
+    return string[0].toLowerCase() + string.slice(1);
+  }
+
   // Utility function to quickly provide a useful string key
   // (generally a prefix) to stuff nested beneath it.
   encapsulate(...args) {
@@ -896,6 +931,7 @@ const countHelper = (stringKey, optionName = stringKey) =>
 Object.assign(Language.prototype, {
   countAdditionalFiles: countHelper('additionalFiles', 'files'),
   countAlbums: countHelper('albums'),
+  countArtTags: countHelper('artTags', 'tags'),
   countArtworks: countHelper('artworks'),
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
@@ -903,6 +939,7 @@ Object.assign(Language.prototype, {
   countDays: countHelper('days'),
   countFlashes: countHelper('flashes'),
   countMonths: countHelper('months'),
+  countTimesFeatured: countHelper('timesFeatured'),
   countTimesReferenced: countHelper('timesReferenced'),
   countTimesUsed: countHelper('timesUsed'),
   countTracks: countHelper('tracks'),
diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js
new file mode 100644
index 00000000..ccc4ad89
--- /dev/null
+++ b/src/data/things/sorting-rule.js
@@ -0,0 +1,385 @@
+export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml';
+
+import {readFile, writeFile} from 'node:fs/promises';
+import * as path from 'node:path';
+
+import {chunkByProperties, compareArrays, unique} from '#sugar';
+import Thing from '#thing';
+import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators';
+
+import {
+  compareCaseLessSensitive,
+  sortByDate,
+  sortByDirectory,
+  sortByName,
+} from '#sort';
+
+import {
+  documentModes,
+  flattenThingLayoutToDocumentOrder,
+  getThingLayoutForFilename,
+  reorderDocumentsInYAMLSourceText,
+} from '#yaml';
+
+import {flag} from '#composite/wiki-properties';
+
+function isSelectFollowingEntry(value) {
+  isObject(value);
+
+  const {length} = Object.keys(value);
+  if (length !== 1) {
+    throw new Error(`Expected object with 1 key, got ${length}`);
+  }
+
+  return true;
+}
+
+export class SortingRule extends Thing {
+  static [Thing.friendlyName] = `Sorting Rule`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    active: flag(true),
+
+    message: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Message': {property: 'message'},
+      'Active': {property: 'active'},
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {allInOne},
+    thingConstructors: {DocumentSortingRule},
+  }) => ({
+    title: `Process sorting rules file`,
+    file: SORTING_RULE_DATA_FILE,
+
+    documentMode: allInOne,
+    documentThing: document =>
+      (document['Sort Documents']
+        ? DocumentSortingRule
+        : null),
+
+    save: (results) => ({sortingRules: results}),
+  });
+
+  check(opts) {
+    return this.constructor.check(this, opts);
+  }
+
+  apply(opts) {
+    return this.constructor.apply(this, opts);
+  }
+
+  static check(rule, opts) {
+    const result = this.apply(rule, {...opts, dry: true});
+    if (!result) return true;
+    if (!result.changed) return true;
+    return false;
+  }
+
+  static async apply(_rule, _opts) {
+    throw new Error(`Not implemented`);
+  }
+
+  static async* applyAll(_rules, _opts) {
+    throw new Error(`Not implemented`);
+  }
+
+  static async* go({dataPath, wikiData, dry}) {
+    const rules = wikiData.sortingRules;
+    const constructors = unique(rules.map(rule => rule.constructor));
+
+    for (const constructor of constructors) {
+      yield* constructor.applyAll(
+        rules
+          .filter(rule => rule.active)
+          .filter(rule => rule.constructor === constructor),
+        {dataPath, wikiData, dry});
+    }
+  }
+}
+
+export class ThingSortingRule extends SortingRule {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    properties: {
+      flags: {update: true, expose: true},
+      update: {
+        validate: strictArrayOf(isStringNonEmpty),
+      },
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(SortingRule, {
+    fields: {
+      'By Properties': {property: 'properties'},
+    },
+  });
+
+  sort(sortable) {
+    if (this.properties) {
+      for (const property of this.properties.slice().reverse()) {
+        const get = thing => thing[property];
+        const lc = property.toLowerCase();
+
+        if (lc.endsWith('date')) {
+          sortByDate(sortable, {getDate: get});
+          continue;
+        }
+
+        if (lc.endsWith('directory')) {
+          sortByDirectory(sortable, {getDirectory: get});
+          continue;
+        }
+
+        if (lc.endsWith('name')) {
+          sortByName(sortable, {getName: get});
+          continue;
+        }
+
+        const values = sortable.map(get);
+
+        if (values.every(v => typeof v === 'string')) {
+          sortable.sort((a, b) =>
+            compareCaseLessSensitive(get(a), get(b)));
+          continue;
+        }
+
+        if (values.every(v => typeof v === 'number')) {
+          sortable.sort((a, b) => get(a) - get(b));
+          continue;
+        }
+
+        sortable.sort((a, b) =>
+          (get(a).toString() < get(b).toString()
+            ? -1
+         : get(a).toString() > get(b).toString()
+            ? +1
+            :  0));
+      }
+    }
+
+    return sortable;
+  }
+}
+
+export class DocumentSortingRule extends ThingSortingRule {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    // TODO: glob :plead:
+    filename: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    message: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+
+      expose: {
+        dependencies: ['filename'],
+        transform: (value, {filename}) =>
+          value ??
+          `Sort ${filename}`,
+      },
+    },
+
+    selectDocumentsFollowing: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate:
+          anyOf(
+            isSelectFollowingEntry,
+            strictArrayOf(isSelectFollowingEntry)),
+      },
+
+      compute: {
+        transform: value =>
+          (Array.isArray(value)
+            ? value
+            : [value]),
+      },
+    },
+
+    selectDocumentsUnder: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ThingSortingRule, {
+    fields: {
+      'Sort Documents': {property: 'filename'},
+      'Select Documents Following': {property: 'selectDocumentsFollowing'},
+      'Select Documents Under': {property: 'selectDocumentsUnder'},
+    },
+
+    invalidFieldCombinations: [
+      {message: `Specify only one of these`, fields: [
+        'Select Documents Following',
+        'Select Documents Under',
+      ]},
+    ],
+  });
+
+  static async apply(rule, {wikiData, dataPath, dry}) {
+    const oldLayout = getThingLayoutForFilename(rule.filename, wikiData);
+    if (!oldLayout) return null;
+
+    const newLayout = rule.#processLayout(oldLayout);
+
+    const oldOrder = flattenThingLayoutToDocumentOrder(oldLayout);
+    const newOrder = flattenThingLayoutToDocumentOrder(newLayout);
+    const changed = compareArrays(oldOrder, newOrder);
+
+    if (dry) return {changed};
+
+    const realPath =
+      path.join(
+        dataPath,
+        rule.filename.split(path.posix.sep).join(path.sep));
+
+    const oldSourceText = await readFile(realPath, 'utf8');
+    const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder);
+
+    await writeFile(realPath, newSourceText);
+
+    return {changed};
+  }
+
+  static async* applyAll(rules, {wikiData, dataPath, dry}) {
+    rules =
+      rules
+        .slice()
+        .sort((a, b) => a.filename.localeCompare(b.filename, 'en'));
+
+    for (const {chunk, filename} of chunkByProperties(rules, ['filename'])) {
+      const initialLayout = getThingLayoutForFilename(filename, wikiData);
+      if (!initialLayout) continue;
+
+      let currLayout = initialLayout;
+      let prevLayout = initialLayout;
+      let anyChanged = false;
+
+      for (const rule of chunk) {
+        currLayout = rule.#processLayout(currLayout);
+
+        const prevOrder = flattenThingLayoutToDocumentOrder(prevLayout);
+        const currOrder = flattenThingLayoutToDocumentOrder(currLayout);
+
+        if (compareArrays(currOrder, prevOrder)) {
+          yield {rule, changed: false};
+        } else {
+          anyChanged = true;
+          yield {rule, changed: true};
+        }
+
+        prevLayout = currLayout;
+      }
+
+      if (!anyChanged) continue;
+      if (dry) continue;
+
+      const newLayout = currLayout;
+      const newOrder = flattenThingLayoutToDocumentOrder(newLayout);
+
+      const realPath =
+        path.join(
+          dataPath,
+          filename.split(path.posix.sep).join(path.sep));
+
+      const oldSourceText = await readFile(realPath, 'utf8');
+      const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder);
+
+      await writeFile(realPath, newSourceText);
+    }
+  }
+
+  #processLayout(layout) {
+    const fresh = {...layout};
+
+    let sortable = null;
+    switch (fresh.documentMode) {
+      case documentModes.headerAndEntries:
+        sortable = fresh.entryThings =
+          fresh.entryThings.slice();
+        break;
+
+      case documentModes.allInOne:
+        sortable = fresh.things =
+          fresh.things.slice();
+        break;
+
+      default:
+        throw new Error(`Invalid document type for sorting`);
+    }
+
+    if (this.selectDocumentsFollowing) {
+      for (const entry of this.selectDocumentsFollowing) {
+        const [field, value] = Object.entries(entry)[0];
+
+        const after =
+          sortable.findIndex(thing =>
+            thing[Thing.yamlSourceDocument][field] === value);
+
+        const different =
+          after +
+          sortable
+            .slice(after)
+            .findIndex(thing =>
+              Object.hasOwn(thing[Thing.yamlSourceDocument], field) &&
+              thing[Thing.yamlSourceDocument][field] !== value);
+
+        const before =
+          (different === -1
+            ? sortable.length
+            : different);
+
+        const subsortable =
+          sortable.slice(after + 1, before);
+
+        this.sort(subsortable);
+
+        sortable.splice(after + 1, before - after - 1, ...subsortable);
+      }
+    } else if (this.selectDocumentsUnder) {
+      const field = this.selectDocumentsUnder;
+
+      const indices =
+        Array.from(sortable.entries())
+          .filter(([_index, thing]) =>
+            Object.hasOwn(thing[Thing.yamlSourceDocument], field))
+          .map(([index, _thing]) => index);
+
+      for (const [indicesIndex, after] of indices.entries()) {
+        const before =
+          (indicesIndex === indices.length - 1
+            ? sortable.length
+            : indices[indicesIndex + 1]);
+
+        const subsortable =
+          sortable.slice(after + 1, before);
+
+        this.sort(subsortable);
+
+        sortable.splice(after + 1, before - after - 1, ...subsortable);
+      }
+    } else {
+      this.sort(sortable);
+    }
+
+    return fresh;
+  }
+}
diff --git a/src/data/things/track.js b/src/data/things/track.js
index af2206f0..8b9420c7 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -11,10 +11,15 @@ import {
   parseAdditionalFiles,
   parseAdditionalNames,
   parseAnnotatedReferences,
+  parseArtwork,
+  parseCommentary,
   parseContributors,
+  parseCreditingSources,
+  parseReferencingSources,
   parseDate,
   parseDimensions,
   parseDuration,
+  parseLyrics,
 } from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
@@ -34,11 +39,8 @@ import {
 } from '#composite/wiki-data';
 
 import {
-  additionalFiles,
-  additionalNameList,
-  commentary,
   commentatorArtists,
-  contentString,
+  constitutibleArtworkList,
   contributionList,
   dimensions,
   directory,
@@ -54,35 +56,43 @@ import {
   soupyFind,
   soupyReverse,
   thing,
+  thingList,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
 
 import {
   exitWithoutUniqueCoverArt,
-  inheritContributionListFromOriginalRelease,
-  inheritFromOriginalRelease,
-  withAlbum,
+  inheritContributionListFromMainRelease,
+  inheritFromMainRelease,
+  withAllReleases,
   withAlwaysReferenceByDirectory,
   withContainingTrackSection,
+  withCoverArtistContribs,
   withDate,
   withDirectorySuffix,
   withHasUniqueCoverArt,
-  withOriginalRelease,
+  withMainRelease,
   withOtherReleases,
   withPropertyFromAlbum,
   withSuffixDirectoryFromAlbum,
   withTrackArtDate,
+  withTrackNumber,
 } from '#composite/things/track';
 
 export class Track extends Thing {
   static [Thing.referenceType] = 'track';
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalFile,
+    AdditionalName,
     Album,
     ArtTag,
-    Flash,
-    TrackSection,
+    Artwork,
+    CommentaryEntry,
+    CreditingSourcesEntry,
+    LyricsEntry,
+    ReferencingSourcesEntry,
     WikiInfo,
   }) => ({
     // Update & expose
@@ -119,7 +129,13 @@ export class Track extends Thing {
       })
     ],
 
-    additionalNames: additionalNameList(),
+    album: thing({
+      class: input.value(Album),
+    }),
+
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
 
     bandcampTrackIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
@@ -154,6 +170,18 @@ export class Track extends Thing {
       exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
     ],
 
+    countInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('countTracksInArtistTotals'),
+      }),
+
+      exposeDependency({dependency: '#album.countTracksInArtistTotals'}),
+    ],
+
     // 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
@@ -195,6 +223,8 @@ export class Track extends Thing {
     coverArtDimensions: [
       exitWithoutUniqueCoverArt(),
 
+      exposeUpdateValueOrContinue(),
+
       withPropertyFromAlbum({
         property: input.value('trackDimensions'),
       }),
@@ -204,33 +234,48 @@ export class Track extends Thing {
       dimensions(),
     ],
 
-    commentary: commentary(),
-    creditSources: commentary(),
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
+
+    creditingSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
+
+    referencingSources: thingList({
+      class: input.value(ReferencingSourcesEntry),
+    }),
 
     lyrics: [
-      inheritFromOriginalRelease(),
-      contentString(),
+      // TODO: Inherited lyrics are literally the same objects, so of course
+      // their .thing properties aren't going to point back to this one, and
+      // certainly couldn't be recontextualized...
+      inheritFromMainRelease(),
+
+      thingList({
+        class: input.value(LyricsEntry),
+      }),
     ],
 
-    additionalFiles: additionalFiles(),
-    sheetMusicFiles: additionalFiles(),
-    midiProjectFiles: additionalFiles(),
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
-    originalReleaseTrack: singleReference({
-      class: input.value(Track),
-      find: soupyFind.input('track'),
+    sheetMusicFiles: thingList({
+      class: input.value(AdditionalFile),
     }),
 
-    // 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: soupyFind.input('album'),
+    midiProjectFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    mainReleaseTrack: singleReference({
+      class: input.value(Track),
+      find: soupyFind.input('track'),
     }),
 
     artistContribs: [
-      inheritContributionListFromOriginalRelease(),
+      inheritContributionListFromMainRelease(),
 
       withDate(),
 
@@ -266,7 +311,7 @@ export class Track extends Thing {
     ],
 
     contributorContribs: [
-      inheritContributionListFromOriginalRelease(),
+      inheritContributionListFromMainRelease(),
 
       withDate(),
 
@@ -276,51 +321,18 @@ export class Track extends Thing {
       }),
     ],
 
-    // 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([]),
-      }),
-
-      withTrackArtDate({
-        fallback: input.value(true),
-      }),
-
-      withResolvedContribs({
-        from: input.updateValue({validate: isContributionList}),
-        thingProperty: input.thisProperty(),
-        artistProperty: input.value('trackCoverArtistContributions'),
-        date: '#trackArtDate',
-      }).outputs({
-        '#resolvedContribs': '#coverArtistContribs',
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#coverArtistContribs',
-        mode: input.value('empty'),
-      }),
-
-      withPropertyFromAlbum({
-        property: input.value('trackCoverArtistContribs'),
-      }),
-
-      withRecontextualizedContributionList({
-        list: '#album.trackCoverArtistContribs',
-        artistProperty: input.value('trackCoverArtistContributions'),
-      }),
-
-      withRedatedContributionList({
-        list: '#album.trackCoverArtistContribs',
-        date: '#trackArtDate',
+      withCoverArtistContribs({
+        from: input.updateValue({
+          validate: isContributionList,
+        }),
       }),
 
-      exposeDependency({dependency: '#album.trackCoverArtistContribs'}),
+      exposeDependency({dependency: '#coverArtistContribs'}),
     ],
 
     referencedTracks: [
-      inheritFromOriginalRelease({
+      inheritFromMainRelease({
         notFoundValue: input.value([]),
       }),
 
@@ -331,7 +343,7 @@ export class Track extends Thing {
     ],
 
     sampledTracks: [
-      inheritFromOriginalRelease({
+      inheritFromMainRelease({
         notFoundValue: input.value([]),
       }),
 
@@ -341,6 +353,15 @@ export class Track extends Thing {
       }),
     ],
 
+    trackArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Track Artwork'),
+    ],
+
     artTags: [
       exitWithoutUniqueCoverArt({
         value: input.value([]),
@@ -357,13 +378,7 @@ export class Track extends Thing {
         value: input.value([]),
       }),
 
-      withTrackArtDate({
-        fallback: input.value(true),
-      }),
-
-      referencedArtworkList({
-        date: '#trackArtDate',
-      }),
+      referencedArtworkList(),
     ],
 
     // Update only
@@ -372,11 +387,11 @@ export class Track extends Thing {
     reverse: soupyReverse(),
 
     // used for referencedArtworkList (mixedFind)
-    albumData: wikiData({
-      class: input.value(Album),
+    artworkData: wikiData({
+      class: input.value(Artwork),
     }),
 
-    // used for referencedArtworkList (mixedFind)
+    // used for withAlwaysReferenceByDirectory (for some reason)
     trackData: wikiData({
       class: input.value(Track),
     }),
@@ -390,43 +405,64 @@ export class Track extends Thing {
 
     commentatorArtists: commentatorArtists(),
 
-    album: [
-      withAlbum(),
-      exposeDependency({dependency: '#album'}),
-    ],
-
     date: [
       withDate(),
       exposeDependency({dependency: '#date'}),
     ],
 
+    trackNumber: [
+      withTrackNumber(),
+      exposeDependency({dependency: '#trackNumber'}),
+    ],
+
     hasUniqueCoverArt: [
       withHasUniqueCoverArt(),
       exposeDependency({dependency: '#hasUniqueCoverArt'}),
     ],
 
-    isOriginalRelease: [
-      withOriginalRelease(),
+    isMainRelease: [
+      withMainRelease(),
 
       exposeWhetherDependencyAvailable({
-        dependency: '#originalRelease',
+        dependency: '#mainRelease',
         negate: input.value(true),
       }),
     ],
 
-    isRerelease: [
-      withOriginalRelease(),
+    isSecondaryRelease: [
+      withMainRelease(),
 
       exposeWhetherDependencyAvailable({
-        dependency: '#originalRelease',
+        dependency: '#mainRelease',
       }),
     ],
 
+    // Only has any value for main releases, because secondary releases
+    // are never secondary to *another* secondary release.
+    secondaryReleases: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichAreSecondaryReleasesOf'),
+    }),
+
+    allReleases: [
+      withAllReleases(),
+      exposeDependency({dependency: '#allReleases'}),
+    ],
+
     otherReleases: [
       withOtherReleases(),
       exposeDependency({dependency: '#otherReleases'}),
     ],
 
+    groups: [
+      withPropertyFromAlbum({
+        property: input.value('groups'),
+      }),
+
+      exposeDependency({
+        dependency: '#album.groups',
+      }),
+    ],
+
     referencedByTracks: reverseReferenceList({
       reverse: soupyReverse.input('tracksWhichReference'),
     }),
@@ -438,16 +474,6 @@ export class Track extends Thing {
     featuredInFlashes: reverseReferenceList({
       reverse: soupyReverse.input('flashesWhichFeature'),
     }),
-
-    referencedByArtworks: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
-
-      reverseReferenceList({
-        reverse: soupyReverse.input('artworksWhichReference'),
-      }),
-    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -471,6 +497,8 @@ export class Track extends Thing {
         transform: String,
       },
 
+      'Count In Artist Totals': {property: 'countInArtistTotals'},
+
       'Duration': {
         property: 'duration',
         transform: parseDuration,
@@ -506,9 +534,25 @@ export class Track extends Thing {
 
       'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
 
-      'Lyrics': {property: 'lyrics'},
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
+      'Lyrics': {
+        property: 'lyrics',
+        transform: parseLyrics,
+      },
+
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
+      },
+
+      'Referencing Sources': {
+        property: 'referencingSources',
+        transform: parseReferencingSources,
+      },
 
       'Additional Files': {
         property: 'additionalFiles',
@@ -525,7 +569,7 @@ export class Track extends Thing {
         transform: parseAdditionalFiles,
       },
 
-      'Originally Released As': {property: 'originalReleaseTrack'},
+      'Main Release': {property: 'mainReleaseTrack'},
       'Referenced Tracks': {property: 'referencedTracks'},
       'Sampled Tracks': {property: 'sampledTracks'},
 
@@ -552,34 +596,54 @@ export class Track extends Thing {
         transform: parseContributors,
       },
 
+      'Track Artwork': {
+        property: 'trackArtworks',
+        transform:
+          parseArtwork({
+            thingProperty: 'trackArtworks',
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'trackCoverArtistContributions',
+          }),
+      },
+
       'Art Tags': {property: 'artTags'},
 
       'Review Points': {ignore: true},
     },
 
     invalidFieldCombinations: [
-      {message: `Rereleases inherit references from the original`, fields: [
-        'Originally Released As',
+      {message: `Secondary releases never count in artist totals`, fields: [
+        'Main Release',
+        'Count In Artist Totals',
+      ]},
+
+      {message: `Secondary releases inherit references from the main one`, fields: [
+        'Main Release',
         'Referenced Tracks',
       ]},
 
-      {message: `Rereleases inherit samples from the original`, fields: [
-        'Originally Released As',
+      {message: `Secondary releases inherit samples from the main one`, fields: [
+        'Main Release',
         'Sampled Tracks',
       ]},
 
-      {message: `Rereleases inherit artists from the original`, fields: [
-        'Originally Released As',
+      {message: `Secondary releases inherit artists from the main one`, fields: [
+        'Main Release',
         'Artists',
       ]},
 
-      {message: `Rereleases inherit contributors from the original`, fields: [
-        'Originally Released As',
+      {message: `Secondary releases inherit contributors from the main one`, fields: [
+        'Main Release',
         'Contributors',
       ]},
 
-      {message: `Rereleases inherit lyrics from the original`, fields: [
-        'Originally Released As',
+      {message: `Secondary releases inherit lyrics from the main one`, fields: [
+        'Main Release',
         'Lyrics',
       ]},
 
@@ -609,12 +673,12 @@ export class Track extends Thing {
           : [track.name]),
     },
 
-    trackOriginalReleasesOnly: {
+    trackMainReleasesOnly: {
       referenceTypes: ['track'],
       bindTo: 'trackData',
 
       include: track =>
-        !CacheableObject.getUpdateValue(track, 'originalReleaseTrack'),
+        !CacheableObject.getUpdateValue(track, 'mainReleaseTrack'),
 
       // It's still necessary to check alwaysReferenceByDirectory here, since
       // it may be set manually (with `Always Reference By Directory: true`),
@@ -643,20 +707,45 @@ export class Track extends Thing {
           ? []
           : [track.name]),
     },
+
+    trackPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'track',
+        'track-referencing-artworks',
+        'track-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork, {Artwork, Track}) =>
+        artwork instanceof Artwork &&
+        artwork.thing instanceof Track &&
+        artwork === artwork.thing.trackArtworks[0],
+
+      getMatchableNames: ({thing: track}) =>
+        (track.alwaysReferenceByDirectory
+          ? []
+          : [track.name]),
+
+      getMatchableDirectories: ({thing: track}) =>
+        [track.directory],
+    },
   };
 
   static [Thing.reverseSpecs] = {
     tracksWhichReference: {
       bindTo: 'trackData',
 
-      referencing: track => track.isOriginalRelease ? [track] : [],
+      referencing: track => track.isMainRelease ? [track] : [],
       referenced: track => track.referencedTracks,
     },
 
     tracksWhichSample: {
       bindTo: 'trackData',
 
-      referencing: track => track.isOriginalRelease ? [track] : [],
+      referencing: track => track.isMainRelease ? [track] : [],
       referenced: track => track.sampledTracks,
     },
 
@@ -674,7 +763,7 @@ export class Track extends Thing {
       soupyReverse.contributionsBy('trackData', 'contributorContribs'),
 
     trackCoverArtistContributionsBy:
-      soupyReverse.contributionsBy('trackData', 'coverArtistContribs'),
+      soupyReverse.artworkContributionsBy('trackData', 'trackArtworks'),
 
     tracksWithCommentaryBy: {
       bindTo: 'trackData',
@@ -682,32 +771,80 @@ export class Track extends Thing {
       referencing: track => [track],
       referenced: track => track.commentatorArtists,
     },
+
+    tracksWhichAreSecondaryReleasesOf: {
+      bindTo: 'trackData',
+
+      referencing: track => track.isSecondaryRelease ? [track] : [],
+      referenced: track => [track.mainReleaseTrack],
+    },
   };
 
   // Track YAML loading is handled in album.js.
   static [Thing.getYamlLoadingSpec] = null;
 
+  getOwnAdditionalFilePath(_file, filename) {
+    if (!this.album) return null;
+
+    return [
+      'media.albumAdditionalFile',
+      this.album.directory,
+      filename,
+    ];
+  }
+
+  getOwnArtworkPath(artwork) {
+    if (!this.album) return null;
+
+    return [
+      'media.trackCover',
+      this.album.directory,
+
+      (artwork.unqualifiedDirectory
+        ? this.directory + '-' + artwork.unqualifiedDirectory
+        : this.directory),
+
+      artwork.fileExtension,
+    ];
+  }
+
+  countOwnContributionInContributionTotals(_contrib) {
+    if (!this.countInArtistTotals) {
+      return false;
+    }
+
+    if (this.isSecondaryRelease) {
+      return false;
+    }
+
+    return true;
+  }
+
+  countOwnContributionInDurationTotals(_contrib) {
+    if (!this.countInArtistTotals) {
+      return false;
+    }
+
+    if (this.isSecondaryRelease) {
+      return false;
+    }
+
+    return true;
+  }
+
   [inspect.custom](depth) {
     const parts = [];
 
     parts.push(Thing.prototype[inspect.custom].apply(this));
 
-    if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) {
-      parts.unshift(`${colors.yellow('[rerelease]')} `);
+    if (CacheableObject.getUpdateValue(this, 'mainReleaseTrack')) {
+      parts.unshift(`${colors.yellow('[secrelease]')} `);
     }
 
     let album;
 
     if (depth >= 0) {
-      try {
-        album = this.album;
-      } catch (_error) {
-        // Computing album might crash for any reason, which we don't want to
-        // distract from another error we might be trying to work out at the
-        // moment (for which debugging might involve inspecting this track!).
-      }
-
-      album ??= this.dataSourceAlbum;
+      album = this.album;
     }
 
     if (album) {
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 590598be..f97f9027 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -2,7 +2,7 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml';
 
 import {input} from '#composite';
 import Thing from '#thing';
-import {parseContributionPresets} from '#yaml';
+import {parseContributionPresets, parseWallpaperParts} from '#yaml';
 
 import {
   isBoolean,
@@ -14,8 +14,17 @@ import {
 } from '#validators';
 
 import {exitWithoutDependency} from '#composite/control-flow';
-import {contentString, flag, name, referenceList, soupyFind}
-  from '#composite/wiki-properties';
+
+import {
+  contentString,
+  fileExtension,
+  flag,
+  name,
+  referenceList,
+  simpleString,
+  soupyFind,
+  wallpaperParts,
+} from '#composite/wiki-properties';
 
 export class WikiInfo extends Thing {
   static [Thing.friendlyName] = `Wiki Info`;
@@ -68,6 +77,10 @@ export class WikiInfo extends Thing {
       },
     },
 
+    wikiWallpaperFileExtension: fileExtension('jpg'),
+    wikiWallpaperStyle: simpleString(),
+    wikiWallpaperParts: wallpaperParts(),
+
     divideTrackListsByGroups: referenceList({
       class: input.value(Group),
       find: soupyFind.input('group'),
@@ -112,18 +125,34 @@ export class WikiInfo extends Thing {
     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'},
+
+      'Wiki Wallpaper File Extension': {property: 'wikiWallpaperFileExtension'},
+
+      'Wiki Wallpaper Style': {property: 'wikiWallpaperStyle'},
+
+      'Wiki Wallpaper Parts': {
+        property: 'wikiWallpaperParts',
+        transform: parseWallpaperParts,
+      },
+
       'Enable Flashes & Games': {property: 'enableFlashesAndGames'},
       'Enable Listings': {property: 'enableListings'},
       'Enable News': {property: 'enableNews'},
       'Enable Art Tag UI': {property: 'enableArtTagUI'},
       'Enable Group UI': {property: 'enableGroupUI'},
 
+      'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'},
+
       'Contribution Presets': {
         property: 'contributionPresets',
         transform: parseContributionPresets,
diff --git a/src/data/yaml.js b/src/data/yaml.js
index ee65eb7f..9a0295b8 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -8,11 +8,14 @@ import {inspect as nodeInspect} from 'node:util';
 import yaml from 'js-yaml';
 
 import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
+import {parseContentNodes, splitContentNodesAround} from '#replacer';
 import {sortByName} from '#sort';
 import Thing from '#thing';
 import thingConstructors from '#things';
+import {matchContentEntries, multipleLyricsDetectionRegex} from '#wiki-data';
 
 import {
+  aggregateThrows,
   annotateErrorWithFile,
   decorateErrorWithIndex,
   decorateErrorWithAnnotation,
@@ -33,6 +36,7 @@ import {
   getNestedProp,
   stitchArrays,
   typeAppearance,
+  unique,
   withEntries,
 } from '#sugar';
 
@@ -87,6 +91,10 @@ function makeProcessDocument(thingConstructor, {
   // A or B.
   //
   invalidFieldCombinations = [],
+
+  // Bouncing function used to process subdocuments: this is a function which
+  // in turn calls the appropriate *result of* makeProcessDocument.
+  processDocument: bouncer,
 }) {
   if (!thingConstructor) {
     throw new Error(`Missing Thing class`);
@@ -96,6 +104,10 @@ function makeProcessDocument(thingConstructor, {
     throw new Error(`Expected fields to be provided`);
   }
 
+  if (!bouncer) {
+    throw new Error(`Missing processDocument bouncer`);
+  }
+
   const knownFields = Object.keys(fieldSpecs);
 
   const ignoredFields =
@@ -143,9 +155,12 @@ function makeProcessDocument(thingConstructor, {
         : `document`);
 
     const aggregate = openAggregate({
+      ...aggregateThrows(ProcessDocumentError),
       message: `Errors processing ${constructorPart}` + namePart,
     });
 
+    const thing = Reflect.construct(thingConstructor, []);
+
     const documentEntries = Object.entries(document)
       .filter(([field]) => !ignoredFields.includes(field));
 
@@ -193,13 +208,50 @@ function makeProcessDocument(thingConstructor, {
 
     const fieldValues = {};
 
+    const subdocSymbol = Symbol('subdoc');
+    const subdocLayouts = {};
+
+    const isSubdocToken = value =>
+      typeof value === 'object' &&
+      value !== null &&
+      Object.hasOwn(value, subdocSymbol);
+
+    const transformUtilities = {
+      ...thingConstructors,
+
+      subdoc(documentType, data, {
+        bindInto = null,
+        provide = null,
+      } = {}) {
+        if (!documentType)
+          throw new Error(`Expected document type, got ${typeAppearance(documentType)}`);
+        if (!data)
+          throw new Error(`Expected data, got ${typeAppearance(data)}`);
+        if (typeof data !== 'object' || data === null)
+          throw new Error(`Expected data to be an object, got ${typeAppearance(data)}`);
+        if (typeof bindInto !== 'string' && bindInto !== null)
+          throw new Error(`Expected bindInto to be a string, got ${typeAppearance(bindInto)}`);
+        if (typeof provide !== 'object' && provide !== null)
+          throw new Error(`Expected provide to be an object, got ${typeAppearance(provide)}`);
+
+        return {
+          [subdocSymbol]: {
+            documentType,
+            data,
+            bindInto,
+            provide,
+          },
+        };
+      },
+    };
+
     for (const [field, documentValue] of documentEntries) {
       if (skippedFields.has(field)) continue;
 
       // This variable would like to certify itself as "not into capitalism".
       let propertyValue =
         (fieldSpecs[field].transform
-          ? fieldSpecs[field].transform(documentValue)
+          ? fieldSpecs[field].transform(documentValue, transformUtilities)
           : documentValue);
 
       // Completely blank items in a YAML list are read as null.
@@ -222,10 +274,99 @@ function makeProcessDocument(thingConstructor, {
         }
       }
 
+      if (isSubdocToken(propertyValue)) {
+        subdocLayouts[field] = propertyValue[subdocSymbol];
+        continue;
+      }
+
+      if (Array.isArray(propertyValue) && propertyValue.every(isSubdocToken)) {
+        subdocLayouts[field] =
+          propertyValue
+            .map(token => token[subdocSymbol]);
+        continue;
+      }
+
       fieldValues[field] = propertyValue;
     }
 
-    const thing = Reflect.construct(thingConstructor, []);
+    const subdocErrors = [];
+
+    const followSubdocSetup = setup => {
+      let error = null;
+
+      let subthing;
+      try {
+        const result = bouncer(setup.data, setup.documentType);
+        subthing = result.thing;
+        result.aggregate.close();
+      } catch (caughtError) {
+        error = caughtError;
+      }
+
+      if (subthing) {
+        if (setup.bindInto) {
+          subthing[setup.bindInto] = thing;
+        }
+
+        if (setup.provide) {
+          Object.assign(subthing, setup.provide);
+        }
+      }
+
+      return {error, subthing};
+    };
+
+    for (const [field, layout] of Object.entries(subdocLayouts)) {
+      if (Array.isArray(layout)) {
+        const subthings = [];
+        let anySucceeded = false;
+        let anyFailed = false;
+
+        for (const [index, setup] of layout.entries()) {
+          const {subthing, error} = followSubdocSetup(setup);
+          if (error) {
+            subdocErrors.push(new SubdocError(
+              {field, index},
+              setup,
+              {cause: error}));
+          }
+
+          if (subthing) {
+            subthings.push(subthing);
+            anySucceeded = true;
+          } else {
+            anyFailed = true;
+          }
+        }
+
+        if (anySucceeded) {
+          fieldValues[field] = subthings;
+        } else if (anyFailed) {
+          skippedFields.add(field);
+        }
+      } else {
+        const setup = layout;
+        const {subthing, error} = followSubdocSetup(setup);
+
+        if (error) {
+          subdocErrors.push(new SubdocError(
+            {field},
+            setup,
+            {cause: error}));
+        }
+
+        if (subthing) {
+          fieldValues[field] = subthing;
+        } else {
+          skippedFields.add(field);
+        }
+      }
+    }
+
+    if (!empty(subdocErrors)) {
+      aggregate.push(new SubdocAggregateError(
+        subdocErrors, thingConstructor));
+    }
 
     const fieldValueErrors = [];
 
@@ -259,6 +400,8 @@ function makeProcessDocument(thingConstructor, {
   });
 }
 
+export class ProcessDocumentError extends AggregateError {}
+
 export class UnknownFieldsError extends Error {
   constructor(fields) {
     super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`);
@@ -346,12 +489,46 @@ export class SkippedFieldsSummaryError extends Error {
         : `${entries.length} fields`);
 
     super(
-      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) +
+      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:`)) + '\n' +
       lines.join('\n') + '\n' +
       colors.bright(colors.yellow(`See above errors for details.`)));
   }
 }
 
+export class SubdocError extends Error {
+  constructor({field, index = null}, setup, options) {
+    const fieldText =
+      (index === null
+        ? colors.green(`"${field}"`)
+        : colors.yellow(`#${index + 1}`) + ' in ' +
+          colors.green(`"${field}"`));
+
+    const constructorText =
+      setup.documentType.name;
+
+    if (options.cause instanceof ProcessDocumentError) {
+      options.cause[Symbol.for('hsmusic.aggregate.translucent')] = true;
+    }
+
+    super(
+      `Errors processing ${constructorText} for ${fieldText} field`,
+      options);
+  }
+}
+
+export class SubdocAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+  constructor(errors, thingConstructor) {
+    const constructorText =
+      colors.green(thingConstructor.name);
+
+    super(
+      errors,
+      `Errors processing subdocuments for ${constructorText}`);
+  }
+}
+
 export function parseDate(date) {
   return new Date(date);
 }
@@ -434,49 +611,39 @@ export function parseContributors(entries) {
   });
 }
 
-export function parseAdditionalFiles(entries) {
+export function parseAdditionalFiles(entries, {subdoc, AdditionalFile}) {
   return parseArrayEntries(entries, item => {
     if (typeof item !== 'object') return item;
 
-    return {
-      title: item['Title'],
-      description: item['Description'] ?? null,
-      files: item['Files'],
-    };
+    return subdoc(AdditionalFile, item, {bindInto: 'thing'});
   });
 }
 
-export function parseAdditionalNames(entries) {
+export function parseAdditionalNames(entries, {subdoc, AdditionalName}) {
   return parseArrayEntries(entries, item => {
-    if (typeof item === 'object' && typeof item['Name'] === 'string')
-      return {
-        name: item['Name'],
-        annotation: item['Annotation'] ?? null,
-      };
+    if (typeof item === 'object') {
+      return subdoc(AdditionalName, item, {bindInto: 'thing'});
+    }
 
     if (typeof item !== 'string') return item;
 
     const match = item.match(extractAccentRegex);
     if (!match) return item;
 
-    return {
-      name: match.groups.main,
-      annotation: match.groups.accent ?? null,
+    const document = {
+      ['Name']: match.groups.main,
+      ['Annotation']: match.groups.accent ?? null,
     };
+
+    return subdoc(AdditionalName, document, {bindInto: 'thing'});
   });
 }
 
-export function parseSerieses(entries) {
+export function parseSerieses(entries, {subdoc, Series}) {
   return parseArrayEntries(entries, item => {
     if (typeof item !== 'object') return item;
 
-    return {
-      name: item['Name'],
-      description: item['Description'] ?? null,
-      albums: item['Albums'] ?? null,
-
-      showAlbumArtists: item['Show Album Artists'] ?? null,
-    };
+    return subdoc(Series, item, {bindInto: 'group'});
   });
 }
 
@@ -614,6 +781,172 @@ export function parseAnnotatedReferences(entries, {
   });
 }
 
+export function parseArtwork({
+  single = false,
+  thingProperty = null,
+  dimensionsFromThingProperty = null,
+  fileExtensionFromThingProperty = null,
+  dateFromThingProperty = null,
+  artistContribsFromThingProperty = null,
+  artistContribsArtistProperty = null,
+  artTagsFromThingProperty = null,
+  referencedArtworksFromThingProperty = null,
+}) {
+  const provide = {
+    thingProperty,
+    dimensionsFromThingProperty,
+    fileExtensionFromThingProperty,
+    dateFromThingProperty,
+    artistContribsFromThingProperty,
+    artistContribsArtistProperty,
+    artTagsFromThingProperty,
+    referencedArtworksFromThingProperty,
+  };
+
+  const parseSingleEntry = (entry, {subdoc, Artwork}) =>
+    subdoc(Artwork, entry, {bindInto: 'thing', provide});
+
+  const transform = (value, ...args) =>
+    (Array.isArray(value)
+      ? value.map(entry => parseSingleEntry(entry, ...args))
+   : single
+      ? parseSingleEntry(value, ...args)
+      : [parseSingleEntry(value, ...args)]);
+
+  transform.provide = provide;
+
+  return transform;
+}
+
+export function parseContentEntriesFromSourceText(thingClass, sourceText, {subdoc}) {
+  function map(matchEntry) {
+    let artistText = null, artistReferences = null;
+
+    const artistTextNodes =
+      Array.from(
+        splitContentNodesAround(
+          parseContentNodes(matchEntry.artistText),
+          /\|/g));
+
+    const separatorIndices =
+      artistTextNodes
+        .filter(node => node.type === 'separator')
+        .map(node => artistTextNodes.indexOf(node));
+
+    if (empty(separatorIndices)) {
+      if (artistTextNodes.length === 1 && artistTextNodes[0].type === 'text') {
+        artistReferences = matchEntry.artistText;
+      } else {
+        artistText = matchEntry.artistText;
+      }
+    } else {
+      const firstSeparatorIndex =
+        separatorIndices.at(0);
+
+      const secondSeparatorIndex =
+        separatorIndices.at(1) ??
+        artistTextNodes.length;
+
+      artistReferences =
+        matchEntry.artistText.slice(
+          artistTextNodes.at(0).i,
+          artistTextNodes.at(firstSeparatorIndex - 1).iEnd);
+
+      artistText =
+        matchEntry.artistText.slice(
+          artistTextNodes.at(firstSeparatorIndex).iEnd,
+          artistTextNodes.at(secondSeparatorIndex - 1).iEnd);
+    }
+
+    if (artistReferences) {
+      artistReferences =
+        artistReferences
+          .split(',')
+          .map(ref => ref.trim());
+    }
+
+    return {
+      'Artists':
+        artistReferences,
+
+      'Artist Text':
+        artistText,
+
+      'Annotation':
+        matchEntry.annotation,
+
+      'Date':
+        matchEntry.date,
+
+      'Second Date':
+        matchEntry.secondDate,
+
+      'Date Kind':
+        matchEntry.dateKind,
+
+      'Access Date':
+        matchEntry.accessDate,
+
+      'Access Kind':
+        matchEntry.accessKind,
+
+      'Body':
+        matchEntry.body,
+    };
+  }
+
+  const documents =
+    matchContentEntries(sourceText)
+      .map(matchEntry =>
+        withEntries(
+          map(matchEntry),
+          entries => entries
+            .filter(([key, value]) =>
+              value !== undefined &&
+              value !== null)));
+
+  const subdocs =
+    documents.map(document =>
+      subdoc(thingClass, document, {bindInto: 'thing'}));
+
+  return subdocs;
+}
+
+export function parseContentEntries(thingClass, value, {subdoc}) {
+  if (typeof value === 'string') {
+    return parseContentEntriesFromSourceText(thingClass, value, {subdoc});
+  } else if (Array.isArray(value)) {
+    return value.map(doc => subdoc(thingClass, doc, {bindInto: 'thing'}));
+  } else {
+    return value;
+  }
+}
+
+export function parseCommentary(value, {subdoc, CommentaryEntry}) {
+  return parseContentEntries(CommentaryEntry, value, {subdoc});
+}
+
+export function parseCreditingSources(value, {subdoc, CreditingSourcesEntry}) {
+  return parseContentEntries(CreditingSourcesEntry, value, {subdoc});
+}
+
+export function parseReferencingSources(value, {subdoc, ReferencingSourcesEntry}) {
+  return parseContentEntries(ReferencingSourcesEntry, value, {subdoc});
+}
+
+export function parseLyrics(value, {subdoc, LyricsEntry}) {
+  if (
+    typeof value === 'string' &&
+    !multipleLyricsDetectionRegex.test(value)
+  ) {
+    const document = {'Body': value};
+
+    return [subdoc(LyricsEntry, document, {bindInto: 'thing'})];
+  }
+
+  return parseContentEntries(LyricsEntry, value, {subdoc});
+}
+
 // documentModes: Symbols indicating sets of behavior for loading and processing
 // data files.
 export const documentModes = {
@@ -689,16 +1022,23 @@ export const documentModes = {
 export function getAllDataSteps() {
   try {
     thingConstructors;
-  } catch (error) {
+  } catch {
     throw new Error(`Thing constructors aren't ready yet, can't get all data steps`);
   }
 
   const steps = [];
 
+  const seenLoadingFns = new Set();
+
   for (const thingConstructor of Object.values(thingConstructors)) {
     const getSpecFn = thingConstructor[Thing.getYamlLoadingSpec];
     if (!getSpecFn) continue;
 
+    // Subclasses can expose literally the same static properties
+    // by inheritence. We don't want to double-count those!
+    if (seenLoadingFns.has(getSpecFn)) continue;
+    seenLoadingFns.add(getSpecFn);
+
     steps.push(getSpecFn({
       documentModes,
       thingConstructors,
@@ -891,7 +1231,7 @@ export function processThingsFromDataStep(documents, dataStep) {
         throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
       }
 
-      fn = makeProcessDocument(thingClass, spec);
+      fn = makeProcessDocument(thingClass, {...spec, processDocument});
       submap.set(thingClass, fn);
     }
 
@@ -906,15 +1246,23 @@ export function processThingsFromDataStep(documents, dataStep) {
       const aggregate = openAggregate({message: `Errors processing documents`});
 
       documents.forEach(
-        decorateErrorWithIndex(document => {
+        decorateErrorWithIndex((document, index) => {
           const {thing, aggregate: subAggregate} =
             processDocument(document, dataStep.documentThing);
 
+          thing[Thing.yamlSourceDocument] = document;
+          thing[Thing.yamlSourceDocumentPlacement] =
+            [documentModes.allInOne, index];
+
           result.push(thing);
           aggregate.call(subAggregate.close);
         }));
 
-      return {aggregate, result};
+      return {
+        aggregate,
+        result,
+        things: result,
+      };
     }
 
     case documentModes.oneDocumentTotal: {
@@ -924,7 +1272,15 @@ export function processThingsFromDataStep(documents, dataStep) {
       const {thing, aggregate} =
         processDocument(documents[0], dataStep.documentThing);
 
-      return {aggregate, result: thing};
+      thing[Thing.yamlSourceDocument] = documents[0];
+      thing[Thing.yamlSourceDocumentPlacement] =
+        [documentModes.oneDocumentTotal];
+
+      return {
+        aggregate,
+        result: thing,
+        things: [thing],
+      };
     }
 
     case documentModes.headerAndEntries: {
@@ -939,6 +1295,10 @@ export function processThingsFromDataStep(documents, dataStep) {
       const {thing: headerThing, aggregate: headerAggregate} =
         processDocument(headerDocument, dataStep.headerDocumentThing);
 
+      headerThing[Thing.yamlSourceDocument] = headerDocument;
+      headerThing[Thing.yamlSourceDocumentPlacement] =
+        [documentModes.headerAndEntries, 'header'];
+
       try {
         headerAggregate.close();
       } catch (caughtError) {
@@ -952,6 +1312,10 @@ export function processThingsFromDataStep(documents, dataStep) {
         const {thing: entryThing, aggregate: entryAggregate} =
           processDocument(entryDocument, dataStep.entryDocumentThing);
 
+        entryThing[Thing.yamlSourceDocument] = entryDocument;
+        entryThing[Thing.yamlSourceDocumentPlacement] =
+          [documentModes.headerAndEntries, 'entry', index];
+
         entryThings.push(entryThing);
 
         try {
@@ -968,6 +1332,7 @@ export function processThingsFromDataStep(documents, dataStep) {
           header: headerThing,
           entries: entryThings,
         },
+        things: [headerThing, ...entryThings],
       };
     }
 
@@ -981,7 +1346,15 @@ export function processThingsFromDataStep(documents, dataStep) {
       const {thing, aggregate} =
         processDocument(documents[0], dataStep.documentThing);
 
-      return {aggregate, result: thing};
+      thing[Thing.yamlSourceDocument] = documents[0];
+      thing[Thing.yamlSourceDocumentPlacement] =
+        [documentModes.onePerFile];
+
+      return {
+        aggregate,
+        result: thing,
+        things: [thing],
+      };
     }
 
     default:
@@ -1081,9 +1454,16 @@ export async function processThingsFromDataSteps(documentLists, fileLists, dataS
           file: files,
           documents: documentLists,
         }).map(({file, documents}) => {
-            const {result, aggregate} =
+            const {result, aggregate, things} =
               processThingsFromDataStep(documents, dataStep);
 
+            for (const thing of things) {
+              thing[Thing.yamlSourceFilename] =
+                path.relative(dataPath, file)
+                  .split(path.sep)
+                  .join(path.posix.sep);
+            }
+
             const close = decorateErrorWithFileFromDataPath(aggregate.close, {dataPath});
             aggregate.close = () => close({file});
 
@@ -1232,8 +1612,7 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
     // link if the 'find' or 'reverse' properties will be implicitly linked
 
     ['albumData', [
-      'albumData',
-      'trackData',
+      'artworkData',
       'wikiInfo',
     ]],
 
@@ -1241,6 +1620,12 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
 
     ['artistData', [/* find, reverse */]],
 
+    ['artworkData', ['artworkData']],
+
+    ['commentaryData', [/* find */]],
+
+    ['creditingSourceData', [/* find */]],
+
     ['flashData', [
       'wikiInfo',
     ]],
@@ -1255,8 +1640,14 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
 
     ['homepageLayout.sections.rows', [/* find */]],
 
+    ['lyricsData', [/* find */]],
+
+    ['referencingSourceData', [/* find */]],
+
+    ['seriesData', [/* find */]],
+
     ['trackData', [
-      'albumData',
+      'artworkData',
       'trackData',
       'wikiInfo',
     ]],
@@ -1398,3 +1789,199 @@ export async function quickLoadAllFromYAML(dataPath, {
 
   return wikiData;
 }
+
+export function cruddilyGetAllThings(wikiData) {
+  const allThings = [];
+
+  for (const v of Object.values(wikiData)) {
+    if (Array.isArray(v)) {
+      allThings.push(...v);
+    } else {
+      allThings.push(v);
+    }
+  }
+
+  return allThings;
+}
+
+export function getThingLayoutForFilename(filename, wikiData) {
+  const things =
+    cruddilyGetAllThings(wikiData)
+      .filter(thing =>
+        thing[Thing.yamlSourceFilename] === filename);
+
+  if (empty(things)) {
+    return null;
+  }
+
+  const allDocumentModes =
+    unique(things.map(thing =>
+      thing[Thing.yamlSourceDocumentPlacement][0]));
+
+  if (allDocumentModes.length > 1) {
+    throw new Error(`More than one document mode for documents from ${filename}`);
+  }
+
+  const documentMode = allDocumentModes[0];
+
+  switch (documentMode) {
+    case documentModes.allInOne: {
+      return {
+        documentMode,
+        things:
+          things.sort((a, b) =>
+            a[Thing.yamlSourceDocumentPlacement][1] -
+            b[Thing.yamlSourceDocumentPlacement][1]),
+      };
+    }
+
+    case documentModes.oneDocumentTotal:
+    case documentModes.onePerFile: {
+      if (things.length > 1) {
+        throw new Error(`More than one document for ${filename}`);
+      }
+
+      return {
+        documentMode,
+        thing: things[0],
+      };
+    }
+
+    case documentModes.headerAndEntries: {
+      const headerThings =
+        things.filter(thing =>
+          thing[Thing.yamlSourceDocumentPlacement][1] === 'header');
+
+      if (headerThings.length > 1) {
+        throw new Error(`More than one header document for ${filename}`);
+      }
+
+      return {
+        documentMode,
+        headerThing: headerThings[0] ?? null,
+        entryThings:
+          things
+            .filter(thing =>
+              thing[Thing.yamlSourceDocumentPlacement][1] === 'entry')
+            .sort((a, b) =>
+              a[Thing.yamlSourceDocumentPlacement][2] -
+              b[Thing.yamlSourceDocumentPlacement][2]),
+      };
+    }
+
+    default: {
+      return {documentMode};
+    }
+  }
+}
+
+export function flattenThingLayoutToDocumentOrder(layout) {
+  switch (layout.documentMode) {
+    case documentModes.oneDocumentTotal:
+    case documentModes.onePerFile: {
+      if (layout.thing) {
+        return [0];
+      } else {
+        return [];
+      }
+    }
+
+    case documentModes.allInOne: {
+      const indices =
+        layout.things
+          .map(thing => thing[Thing.yamlSourceDocumentPlacement][1]);
+
+      return indices;
+    }
+
+    case documentModes.headerAndEntries: {
+      const entryIndices =
+        layout.entryThings
+          .map(thing => thing[Thing.yamlSourceDocumentPlacement][2])
+          .map(index => index + 1);
+
+      if (layout.headerThing) {
+        return [0, ...entryIndices];
+      } else {
+        return entryIndices;
+      }
+    }
+
+    default: {
+      throw new Error(`Unknown document mode`);
+    }
+  }
+}
+
+export function* splitDocumentsInYAMLSourceText(sourceText) {
+  // Not multiline!
+  const dividerRegex = /(?:\r\n|\n|^)-{3,}(?:\r\n|\n|$)/g;
+
+  let previousDivider = '';
+
+  while (true) {
+    const {lastIndex} = dividerRegex;
+    const match = dividerRegex.exec(sourceText);
+    if (match) {
+      const nextDivider = match[0];
+
+      yield {
+        previousDivider,
+        nextDivider,
+        text: sourceText.slice(lastIndex, match.index),
+      };
+
+      previousDivider = nextDivider;
+    } else {
+      const nextDivider = '';
+      const lineBreak = previousDivider.match(/\r?\n/)?.[0] ?? '';
+
+      yield {
+        previousDivider,
+        nextDivider,
+        text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, lineBreak),
+      };
+
+      return;
+    }
+  }
+}
+
+export function recombineDocumentsIntoYAMLSourceText(documents) {
+  const dividers =
+    unique(
+      documents
+        .flatMap(d => [d.previousDivider, d.nextDivider])
+        .filter(Boolean));
+
+  const divider = dividers[0];
+
+  if (dividers.length > 1) {
+    // TODO: Accommodate mixed dividers as best as we can lol
+    logWarn`Found multiple dividers in this file, using only ${divider}`;
+  }
+
+  let sourceText = '';
+
+  for (const document of documents) {
+    if (sourceText) {
+      sourceText += divider;
+    }
+
+    sourceText += document.text;
+  }
+
+  return sourceText;
+}
+
+export function reorderDocumentsInYAMLSourceText(sourceText, order) {
+  const sourceDocuments =
+    Array.from(splitDocumentsInYAMLSourceText(sourceText));
+
+  const sortedDocuments =
+    Array.from(
+      order,
+      sourceIndex => sourceDocuments[sourceIndex]);
+
+  return recombineDocumentsIntoYAMLSourceText(sortedDocuments);
+}
diff --git a/src/external-links.js b/src/external-links.js
index 1055a391..ab1555bd 100644
--- a/src/external-links.js
+++ b/src/external-links.js
@@ -4,11 +4,9 @@ import {
   anyOf,
   is,
   isBoolean,
-  isObject,
   isStringNonEmpty,
   looseArrayOf,
   optional,
-  validateAllPropertyValues,
   validateArrayItems,
   validateInstanceOf,
   validateProperties,
@@ -32,6 +30,9 @@ export const externalLinkContexts = [
   'generic',
   'group',
   'track',
+
+  'composerRelease',
+  'officialRelease',
 ];
 
 export const isExternalLinkContext =
@@ -257,6 +258,30 @@ export const externalLinkSpec = [
   },
 
   {
+    match: {
+      domain: '.bandcamp.com',
+      context: 'composerRelease',
+    },
+
+    platform: 'bandcamp.composerRelease',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'bandcamp',
+  },
+
+  {
+    match: {
+      domain: '.bandcamp.com',
+      context: 'officialRelease',
+    },
+
+    platform: 'bandcamp.officialRelease',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'bandcamp',
+  },
+
+  {
     match: {domain: '.bandcamp.com'},
 
     platform: 'bandcamp',
diff --git a/src/find-reverse.js b/src/find-reverse.js
index f31d3c45..c99a4a71 100644
--- a/src/find-reverse.js
+++ b/src/find-reverse.js
@@ -11,16 +11,23 @@ export function getAllSpecs({
 }) {
   try {
     thingConstructors;
-  } catch (error) {
+  } catch {
     throw new Error(`Thing constructors aren't ready yet, can't get all ${word} specs`);
   }
 
   const specs = {...hardcodedSpecs};
 
+  const seenSpecs = new Set();
+
   for (const thingConstructor of Object.values(thingConstructors)) {
     const thingSpecs = thingConstructor[constructorKey];
     if (!thingSpecs) continue;
 
+    // Subclasses can expose literally the same static properties
+    // by inheritence. We don't want to double-count those!
+    if (seenSpecs.has(thingSpecs)) continue;
+    seenSpecs.add(thingSpecs);
+
     for (const [key, spec] of Object.entries(thingSpecs)) {
       specs[key] =
         postprocessSpec(spec, {
@@ -45,7 +52,7 @@ export function findSpec(key, {
 
   try {
     thingConstructors;
-  } catch (error) {
+  } catch {
     throw new Error(`Thing constructors aren't ready yet, can't check if "${word}.${key}" available`);
   }
 
diff --git a/src/find.js b/src/find.js
index e590bc4f..8f2170d4 100644
--- a/src/find.js
+++ b/src/find.js
@@ -42,7 +42,7 @@ export function processAvailableMatchesByName(data, {
   multipleNameMatches = Object.create(null),
 }) {
   for (const thing of data) {
-    if (!include(thing)) continue;
+    if (!include(thing, thingConstructors)) continue;
 
     for (const name of getMatchableNames(thing)) {
       if (typeof name !== 'string') {
@@ -56,11 +56,14 @@ export function processAvailableMatchesByName(data, {
         if (normalizedName in multipleNameMatches) {
           multipleNameMatches[normalizedName].push(thing);
         } else {
-          multipleNameMatches[normalizedName] = [results[normalizedName], thing];
+          multipleNameMatches[normalizedName] = [
+            results[normalizedName].thing,
+            thing,
+          ];
           results[normalizedName] = null;
         }
       } else {
-        results[normalizedName] = thing;
+        results[normalizedName] = {thing, name};
       }
     }
   }
@@ -79,7 +82,7 @@ export function processAvailableMatchesByDirectory(data, {
   results = Object.create(null),
 }) {
   for (const thing of data) {
-    if (!include(thing)) continue;
+    if (!include(thing, thingConstructors)) continue;
 
     for (const directory of getMatchableDirectories(thing)) {
       if (typeof directory !== 'string') {
@@ -87,7 +90,7 @@ export function processAvailableMatchesByDirectory(data, {
         continue;
       }
 
-      results[directory] = thing;
+      results[directory] = {thing, directory};
     }
   }
 
@@ -117,13 +120,50 @@ function oopsMultipleNameMatches(mode, {
     `Returning null for this reference.`);
 }
 
+function oopsNameCapitalizationMismatch(mode, {
+  matchingName,
+  matchedName,
+}) {
+  if (matchingName.length === matchedName.length) {
+    let a = '', b = '';
+    for (let i = 0; i < matchingName.length; i++) {
+      if (
+        matchingName[i] === matchedName[i] ||
+        matchingName[i].toLowerCase() !== matchingName[i].toLowerCase()
+      ) {
+        a += matchingName[i];
+        b += matchedName[i];
+      } else {
+        a += colors.bright(colors.red(matchingName[i]));
+        b += colors.bright(colors.green(matchedName[i]));
+      }
+    }
+
+    matchingName = a;
+    matchedName = b;
+  }
+
+  return warnOrThrow(mode,
+    `Provided capitalization differs from the matched name. Please resolve:\n` +
+    `- provided: ${matchingName}\n` +
+    `- should be: ${matchedName}\n` +
+    `Returning null for this reference.`);
+}
+
 export function prepareMatchByName(mode, {byName, multipleNameMatches}) {
   return (name) => {
     const normalizedName = name.toLowerCase();
     const match = byName[normalizedName];
 
     if (match) {
-      return match;
+      if (name === match.name) {
+        return match.thing;
+      } else {
+        return oopsNameCapitalizationMismatch(mode, {
+          matchingName: name,
+          matchedName: match.name,
+        });
+      }
     } else if (multipleNameMatches[normalizedName]) {
       return oopsMultipleNameMatches(mode, {
         name,
@@ -154,7 +194,13 @@ export function prepareMatchByDirectory(mode, {referenceTypes, byDirectory}) {
       });
     }
 
-    return byDirectory[directory];
+    const match = byDirectory[directory];
+
+    if (match) {
+      return match.thing;
+    } else {
+      return null;
+    }
   };
 }
 
@@ -266,9 +312,9 @@ export function postprocessFindSpec(spec, {thingConstructor}) {
   if (spec[Symbol.for('Thing.findThisThingOnly')] !== false) {
     if (spec.include) {
       const oldInclude = spec.include;
-      newSpec.include = thing =>
+      newSpec.include = (thing, ...args) =>
         thing instanceof thingConstructor &&
-        oldInclude(thing);
+        oldInclude(thing, ...args);
     } else {
       newSpec.include = thing =>
         thing instanceof thingConstructor;
@@ -345,7 +391,13 @@ function findMixedHelper(config) {
           });
         }
 
-        return byDirectory[referenceType][directory];
+        const match = byDirectory[referenceType][directory];
+
+        if (match) {
+          return match.thing;
+        } else {
+          return null;
+        }
       },
 
       matchByName:
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 3ccd8ce2..40505189 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -162,7 +162,6 @@ import {
 
 import dimensionsOf from 'image-size';
 
-import CacheableObject from '#cacheable-object';
 import {stringifyCache} from '#cli';
 import {commandExists, isMain, promisifyProcess, traverse} from '#node-utils';
 import {sortByName} from '#sort';
@@ -447,7 +446,7 @@ async function getImageMagickVersion(binary) {
 
   try {
     await promisifyProcess(proc, false);
-  } catch (error) {
+  } catch {
     return null;
   }
 
@@ -556,42 +555,56 @@ async function determineThumbtacksNeededForFile({
   return mismatchedWithinRightSize;
 }
 
-async function generateImageThumbnail(imagePath, thumbtack, {
+// Write all requested thumbtacks for a source image in one pass
+// This saves a lot of disk reads which are probably the main bottleneck
+function prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks) {
+  const args = [filePathInMedia, '-strip'];
+
+  const basename =
+    path.basename(filePathInMedia, path.extname(filePathInMedia));
+
+  // do larger sizes first
+  thumbtacks.sort((a, b) => thumbnailSpec[b].size - thumbnailSpec[a].size);
+
+  for (const tack of thumbtacks) {
+    const {size, quality} = thumbnailSpec[tack];
+    const filename = `${basename}.${tack}.jpg`;
+    const filePathInCache = path.join(dirnameInCache, filename);
+    args.push(
+      '(', '+clone',
+      '-resize', `${size}x${size}>`,
+      '-interlace', 'Plane',
+      '-quality', `${quality}%`,
+      '-write', filePathInCache,
+      '+delete', ')',
+    );
+  }
+
+  // throw away the (already written) image stream
+  args.push('null:');
+
+  return args;
+}
+
+async function generateImageThumbnails(imagePath, thumbtacks, {
   mediaPath,
   mediaCachePath,
   spawnConvert,
 }) {
+  if (empty(thumbtacks)) return;
+
   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);
+  const convertArgs =
+    prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks);
+
+  await promisifyProcess(spawnConvert(convertArgs), false);
 }
 
 export async function determineMediaCachePath({
@@ -628,7 +641,7 @@ export async function determineMediaCachePath({
   try {
     const files = await readdir(mediaPath);
     mediaIncludesThumbnailCache = files.includes(CACHE_FILE);
-  } catch (error) {
+  } catch {
     mediaIncludesThumbnailCache = false;
   }
 
@@ -861,7 +874,7 @@ export async function migrateThumbsIntoDedicatedCacheDirectory({
         path.join(mediaPath, CACHE_FILE),
         path.join(mediaCachePath, CACHE_FILE));
       logInfo`Moved thumbnail cache file.`;
-    } catch (error) {
+    } catch {
       logWarn`Failed to move cache file. (${CACHE_FILE})`;
       logWarn`Check its permissions, or try copying/pasting.`;
     }
@@ -1100,33 +1113,23 @@ export default async function genThumbs({
   const writeMessageFn = () =>
     `Writing image thumbnails. [failed: ${numFailed}]`;
 
-  const generateCallImageIndices =
-    imageThumbtacksNeeded
-      .flatMap(({length}, index) =>
-        Array.from({length}, () => index));
-
-  const generateCallImagePaths =
-    generateCallImageIndices
-      .map(index => imagePaths[index]);
-
-  const generateCallThumbtacks =
-    imageThumbtacksNeeded.flat();
-
   const generateCallFns =
     stitchArrays({
-      imagePath: generateCallImagePaths,
-      thumbtack: generateCallThumbtacks,
-    }).map(({imagePath, thumbtack}) => () =>
-        generateImageThumbnail(imagePath, thumbtack, {
+      imagePath: imagePaths,
+      thumbtacks: imageThumbtacksNeeded,
+    }).map(({imagePath, thumbtacks}) => () =>
+        generateImageThumbnails(imagePath, thumbtacks, {
           mediaPath,
           mediaCachePath,
           spawnConvert,
         }).catch(error => {
             numFailed++;
-            return ({error});
+            return {error};
           }));
 
-  logInfo`Generating ${generateCallFns.length} thumbnails for ${imagePaths.length} media files.`;
+  const totalThumbs = imageThumbtacksNeeded.reduce((sum, tacks) => sum + tacks.length, 0);
+
+  logInfo`Generating ${totalThumbs} thumbnails for ${imagePaths.length} media files.`;
   if (generateCallFns.length > 500) {
     logInfo`Go get a latte - this could take a while!`;
   }
@@ -1135,37 +1138,30 @@ export default async function genThumbs({
     await progressPromiseAll(writeMessageFn,
       queue(generateCallFns, magickThreads));
 
-  let successfulIndices;
+  let successfulPaths;
 
   {
-    const erroredIndices = generateCallImageIndices.slice();
-    const erroredPaths = generateCallImagePaths.slice();
-    const erroredThumbtacks = generateCallThumbtacks.slice();
+    const erroredPaths = imagePaths.slice();
     const errors = generateCallResults.map(result => result?.error);
 
     const {removed} =
       filterMultipleArrays(
-        erroredIndices,
         erroredPaths,
-        erroredThumbtacks,
         errors,
-        (_index, _imagePath, _thumbtack, error) => error);
+        (_imagePath, error) => error);
 
-    successfulIndices = new Set(removed[0]);
-
-    const chunks =
-      chunkMultipleArrays(erroredPaths, erroredThumbtacks, errors,
-        (imagePath, lastImagePath) => imagePath !== lastImagePath);
+    ([successfulPaths] = removed);
 
     // 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}`;
-      }
-    }
+    stitchArrays({
+      imagePath: erroredPaths,
+      error: errors,
+    }).forEach(({imagePath, error}) => {
+        logError`Failed to generate thumbnails for ${imagePath}:`;
+        logError`- ${error}`;
+      });
 
     if (empty(errors)) {
       logInfo`All needed thumbnails generated successfully - nice!`;
@@ -1179,8 +1175,8 @@ export default async function genThumbs({
     imagePaths,
     imageThumbtacksNeeded,
     imageDimensions,
-    (_imagePath, _thumbtacksNeeded, _dimensions, index) =>
-      successfulIndices.has(index));
+    (imagePath, _thumbtacksNeeded, _dimensions) =>
+      successfulPaths.includes(imagePath));
 
   for (const {
     imagePath,
@@ -1242,41 +1238,20 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) {
   const fromRoot = urls.from('media.root');
 
   const paths = [
+    wikiData.artworkData
+      .filter(artwork => artwork.path)
+      .map(artwork => fromRoot.to(...artwork.path)),
+
     wikiData.albumData
-      .map(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')) &&
-        empty(album.wallpaperParts) && [
-          fromRoot.to('media.albumWallpaper', album.directory, album.wallpaperFileExtension),
-        ],
-
-        !empty(CacheableObject.getUpdateValue(album, 'wallpaperArtistContribs')) &&
-        !empty(album.wallpaperParts) &&
-          album.wallpaperParts.flatMap(part => [
-            part.asset &&
-              fromRoot.to('media.albumWallpaperPart', album.directory, part.asset),
-          ]),
-      ])
-      .flat(2)
-      .filter(Boolean),
-
-    wikiData.artistData
-      .filter(artist => artist.hasAvatar)
-      .map(artist => fromRoot.to('media.artistAvatar', artist.directory, artist.avatarFileExtension)),
-
-    wikiData.flashData
-      .map(flash => fromRoot.to('media.flashArt', flash.directory, flash.coverArtFileExtension)),
-
-    wikiData.trackData
-      .filter(track => track.hasUniqueCoverArt)
-      .map(track => fromRoot.to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension)),
+      .flatMap(album => album.wallpaperParts
+        .filter(part => part.asset)
+        .map(part =>
+          fromRoot.to('media.albumWallpaperPart', album.directory, part.asset))),
+
+    wikiData.wikiInfo.wikiWallpaperParts
+      .filter(part => part.asset)
+      .map(part =>
+        fromRoot.to('media.path', part.asset)),
   ].flat();
 
   sortByName(paths, {getName: path => path});
diff --git a/src/html.js b/src/html.js
index 0fe424df..dd1d1960 100644
--- a/src/html.js
+++ b/src/html.js
@@ -53,6 +53,17 @@ export const attributeSpec = {
   },
 };
 
+let disabledSlotValidation = false;
+let disabledTagTracing = false;
+
+export function disableSlotValidation() {
+  disabledSlotValidation = true;
+}
+
+export function disableTagTracing() {
+  disabledTagTracing = true;
+}
+
 // Pass to tag() as an attributes key to make tag() return a 8lank tag if the
 // provided content is empty. Useful for when you'll only 8e showing an element
 // according to the presence of content that would 8elong there.
@@ -223,7 +234,11 @@ export function isBlank(content) {
     // could include content. These need to be checked too.
     // Check each of the templates one at a time.
     for (const template of result) {
-      const content = template.content;
+      // Resolve the content all the way down to a tag -
+      // if it's a template that returns another template,
+      // that won't do, because we need to detect if its
+      // final content is a tag marked onlyIfSiblings.
+      const content = normalize(template);
 
       if (content instanceof Tag && content.onlyIfSiblings) {
         continue;
@@ -352,8 +367,10 @@ export class Tag {
     this.attributes = attributes;
     this.content = content;
 
-    this.#traceError = new Error();
-  }
+    if (!disabledTagTracing) {
+      this.#traceError = new Error();
+    }
+}
 
   clone() {
     return Reflect.construct(this.constructor, [
@@ -512,6 +529,10 @@ export class Tag {
     }
   }
 
+  #getAttributeRaw(attribute) {
+    return this.attributes.get(attribute);
+  }
+
   set onlyIfContent(value) {
     this.#setAttributeFlag(onlyIfContent, value);
   }
@@ -579,7 +600,7 @@ export class Tag {
 
     try {
       this.content = this.content;
-    } catch (error) {
+    } catch {
       this.#setAttributeFlag(imaginarySibling, false);
     }
   }
@@ -662,7 +683,7 @@ export class Tag {
 
     const chunkwrapSplitter =
       (this.chunkwrap
-        ? this.#getAttributeString('split')
+        ? this.#getAttributeRaw('split')
         : null);
 
     let seenChunkwrapSplitter =
@@ -702,17 +723,19 @@ export class Tag {
             `of ${inspect(this, {compact: true})}`,
             {cause: caughtError});
 
-        error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
-        error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
+        if (this.#traceError && !disabledTagTracing) {
+          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.unhelpfulTraceLines`)] = [
+            /content-function\.js/,
+            /util\/html\.js/,
+          ];
 
-        error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
-          /content\/dependencies\/(.*\.js:.*(?=\)))/,
-        ];
+          error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
+            /content\/dependencies\/(.*\.js:.*(?=\)))/,
+          ];
+        }
 
         throw error;
       }
@@ -727,7 +750,7 @@ export class Tag {
 
       const chunkwrapChunks =
         (typeof nonTemplateItem === 'string' && chunkwrapSplitter
-          ? itemContent.split(chunkwrapSplitter)
+          ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter))
           : null);
 
       const itemIncludesChunkwrapSplit =
@@ -773,7 +796,7 @@ export class Tag {
 
       appendItemContent: {
         if (itemIncludesChunkwrapSplit) {
-          for (const [index, chunk] of chunkwrapChunks.entries()) {
+          for (const [index, {chunk, following}] of chunkwrapChunks.entries()) {
             if (index === 0) {
               // The first chunk isn't actually a chunk all on its own, it's
               // text that should be appended to the previous chunk. We will
@@ -781,12 +804,27 @@ export class Tag {
               // the next chunk.
               content += chunk;
             } else {
-              const whitespace = chunk.match(/^\s+/) ?? '';
-              content += chunkwrapSplitter;
+              const followingWhitespace = following.match(/\s+$/) ?? '';
+              const chunkWhitespace = chunk.match(/^\s+/) ?? '';
+
+              if (followingWhitespace) {
+                content += following.slice(0, -followingWhitespace.length);
+              } else {
+                content += following;
+              }
+
               content += '</span>';
-              content += whitespace;
+
+              content += followingWhitespace;
+              content += chunkWhitespace;
+
               content += '<span class="chunkwrap">';
-              content += chunk.slice(whitespace.length);
+
+              if (chunkWhitespace) {
+                content += chunk.slice(chunkWhitespace.length);
+              } else {
+                content += chunk;
+              }
             }
           }
 
@@ -1009,6 +1047,49 @@ export class Tag {
   }
 }
 
+export function* getChunkwrapChunks(content, splitter) {
+  const splitString =
+    (typeof splitter === 'string'
+      ? splitter
+      : null);
+
+  if (splitString) {
+    let following = '';
+    for (const chunk of content.split(splitString)) {
+      yield {chunk, following};
+      following = splitString;
+    }
+
+    return;
+  }
+
+  const splitRegExp =
+    (splitter instanceof RegExp
+      ? new RegExp(
+          splitter.source,
+          (splitter.flags.includes('g')
+            ? splitter.flags
+            : splitter.flags + 'g'))
+      : null);
+
+  if (splitRegExp) {
+    let following = '';
+    let prevIndex = 0;
+    for (const match of content.matchAll(splitRegExp)) {
+      const chunk = content.slice(prevIndex, match.index);
+      yield {chunk, following};
+
+      following = match[0];
+      prevIndex = match.index + match[0].length;
+    }
+
+    const chunk = content.slice(prevIndex);
+    yield {chunk, following};
+
+    return;
+  }
+}
+
 export function attributes(attributes) {
   return new Attributes(attributes);
 }
@@ -1069,6 +1150,34 @@ export class Attributes {
   }
 
   add(...args) {
+    // Very common case: add({class: 'foo', id: 'bar'})¡
+    // The argument is a plain object (no Template, no Attributes,
+    // no blessAttributes symbol). We can skip the expensive
+    // isAttributesAdditionSinglet() validation and flatten/array handling.
+    if (
+      args.length === 1 &&
+      args[0] &&
+      typeof args[0] === 'object' &&
+      !Array.isArray(args[0]) &&
+      !(args[0] instanceof Attributes) &&
+      !(args[0] instanceof Template) &&
+      !Object.hasOwn(args[0], blessAttributes)
+    ) {
+      const obj = args[0];
+
+      // Preserve existing merge semantics by funnelling each key through
+      // the internal #addOneAttribute helper (handles class/style union,
+      // unique merging, etc.) but avoid *per-object* validation overhead.
+      for (const key of Reflect.ownKeys(obj)) {
+        this.#addOneAttribute(key, obj[key]);
+      }
+
+      // Match the original return style (list of results) so callers that
+      // inspect the return continue to work.
+      return obj;
+    }
+
+    // Fall back to the original slow-but-thorough implementation
     switch (args.length) {
       case 1:
         isAttributesAdditionSinglet(args[0]);
@@ -1080,10 +1189,11 @@ export class Attributes {
 
       default:
         throw new Error(
-          `Expected array or object, or attribute and value`);
+          'Expected array or object, or attribute and value');
     }
   }
 
+
   with(...args) {
     const clone = this.clone();
     clone.add(...args);
@@ -1254,6 +1364,9 @@ export class Attributes {
           return value.some(Boolean);
         } else if (value === null) {
           return false;
+        } else if (value instanceof RegExp) {
+          // Oooooooo.
+          return true;
         } else {
           // Other objects are an error.
           break;
@@ -1285,13 +1398,16 @@ export class Attributes {
       case 'number':
         return value.toString();
 
-      // If it's a kept object, it's an array.
       case 'object': {
-        const joiner =
-          (descriptor?.arraylike && descriptor?.join)
-            ?? ' ';
+        if (Array.isArray(value)) {
+          const joiner =
+            (descriptor?.arraylike && descriptor?.join)
+              ?? ' ';
 
-        return value.filter(Boolean).join(joiner);
+          return value.filter(Boolean).join(joiner);
+        } else {
+          return value;
+        }
       }
 
       default:
@@ -1671,6 +1787,10 @@ export class Template {
   }
 
   static validateSlotValueAgainstDescription(value, description) {
+    if (disabledSlotValidation) {
+      return true;
+    }
+
     if (value === undefined) {
       throw new TypeError(`Specify value as null or don't specify at all`);
     }
@@ -1963,6 +2083,8 @@ export const isAttributeValue =
   anyOf(
     isString, isNumber, isBoolean, isArray,
     isTag, isTemplate,
+    // Evil. Ooooo
+    validateInstanceOf(RegExp),
     validateArrayItems(item => isAttributeValue(item)));
 
 export const isAttributesAdditionPair = pair => {
diff --git a/src/listing-spec.js b/src/listing-spec.js
index 749f009a..142c5976 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -196,15 +196,22 @@ listingSpec.push({
 
 listingSpec.push({
   directory: 'tags/by-name',
-  stringsKey: 'listTags.byName',
-  contentFunction: 'listTagsByName',
+  stringsKey: 'listArtTags.byName',
+  contentFunction: 'listArtTagsByName',
   featureFlag: 'enableArtTagUI',
 });
 
 listingSpec.push({
   directory: 'tags/by-uses',
-  stringsKey: 'listTags.byUses',
-  contentFunction: 'listTagsByUses',
+  stringsKey: 'listArtTags.byUses',
+  contentFunction: 'listArtTagsByUses',
+  featureFlag: 'enableArtTagUI',
+});
+
+listingSpec.push({
+  directory: 'tags/network',
+  stringsKey: 'listArtTags.network',
+  contentFunction: 'listArtTagNetwork',
   featureFlag: 'enableArtTagUI',
 });
 
diff --git a/src/page/album.js b/src/page/album.js
index 8c08b960..696e2854 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -43,7 +43,8 @@ export function pathsForTarget(album) {
       path: ['albumReferencedArtworks', album.directory],
 
       condition: () =>
-        !empty(album.referencedArtworks),
+        album.hasCoverArt &&
+        !empty(album.coverArtworks[0].referencedArtworks),
 
       contentFunction: {
         name: 'generateAlbumReferencedArtworksPage',
@@ -56,7 +57,8 @@ export function pathsForTarget(album) {
       path: ['albumReferencingArtworks', album.directory],
 
       condition: () =>
-        !empty(album.referencedByArtworks),
+        album.hasCoverArt &&
+        !empty(album.coverArtworks[0].referencedByArtworks),
 
       contentFunction: {
         name: 'generateAlbumReferencingArtworksPage',
diff --git a/src/page/tag.js b/src/page/art-tag.js
index 8942aea9..5b61229d 100644
--- a/src/page/tag.js
+++ b/src/page/art-tag.js
@@ -1,6 +1,6 @@
 // Art tag page specification.
 
-export const description = `per-artwork-tag gallery pages`;
+export const description = `per-art-tag info & gallery pages`;
 
 export function condition({wikiData}) {
   return wikiData.wikiInfo.enableArtTagUI;
@@ -14,7 +14,17 @@ export function pathsForTarget(tag) {
   return [
     {
       type: 'page',
-      path: ['tag', tag.directory],
+      path: ['artTagInfo', tag.directory],
+
+      contentFunction: {
+        name: 'generateArtTagInfoPage',
+        args: [tag],
+      },
+    },
+
+    {
+      type: 'page',
+      path: ['artTagGallery', tag.directory],
 
       contentFunction: {
         name: 'generateArtTagGalleryPage',
diff --git a/src/page/index.js b/src/page/index.js
index 21d93c8f..ae480136 100644
--- a/src/page/index.js
+++ b/src/page/index.js
@@ -1,6 +1,7 @@
 export * as album from './album.js';
 export * as artist from './artist.js';
 export * as artistAlias from './artist-alias.js';
+export * as artTag from './art-tag.js';
 export * as flash from './flash.js';
 export * as flashAct from './flash-act.js';
 export * as group from './group.js';
@@ -8,5 +9,4 @@ export * as homepage from './homepage.js';
 export * as listing from './listing.js';
 export * as news from './news.js';
 export * as static from './static.js';
-export * as tag from './tag.js';
 export * as track from './track.js';
diff --git a/src/page/track.js b/src/page/track.js
index 301af991..95647334 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -25,7 +25,8 @@ export function pathsForTarget(track) {
       path: ['trackReferencedArtworks', track.directory],
 
       condition: () =>
-        !empty(track.referencedArtworks),
+        track.hasUniqueCoverArt &&
+        !empty(track.trackArtworks[0].referencedArtworks),
 
       contentFunction: {
         name: 'generateTrackReferencedArtworksPage',
@@ -38,7 +39,8 @@ export function pathsForTarget(track) {
       path: ['trackReferencingArtworks', track.directory],
 
       condition: () =>
-        !empty(track.referencedByArtworks),
+        track.hasUniqueCoverArt &&
+        !empty(track.trackArtworks[0].referencedByArtworks),
 
       contentFunction: {
         name: 'generateTrackReferencingArtworksPage',
diff --git a/src/replacer.js b/src/replacer.js
index 5378db1d..779ee78d 100644
--- a/src/replacer.js
+++ b/src/replacer.js
@@ -8,7 +8,8 @@
 import * as marked from 'marked';
 
 import * as html from '#html';
-import {escapeRegex, typeAppearance} from '#sugar';
+import {empty, escapeRegex, typeAppearance} from '#sugar';
+import {matchMarkdownLinks} from '#wiki-data';
 
 export const replacerSpec = {
   'album': {
@@ -152,7 +153,12 @@ export const replacerSpec = {
 
   'tag': {
     find: 'artTag',
-    link: 'linkArtTag',
+    link: 'linkArtTagDynamically',
+  },
+
+  'tag-info': {
+    find: 'artTag',
+    link: 'linkArtTagInfo',
   },
 
   'track': {
@@ -169,6 +175,11 @@ export const replacerSpec = {
     find: 'trackWithArtwork',
     link: 'linkTrackReferencingArtworks',
   },
+
+  'tooltip': {
+    value: (ref) => ref,
+    link: null,
+  }
 };
 
 // Syntax literals.
@@ -453,7 +464,7 @@ export function squashBackslashes(text) {
   // a set of characters where the backslash carries meaning
   // into later formatting (i.e. markdown). Note that we do
   // NOT compress double backslashes into single backslashes.
-  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>-])/g, '$1');
+  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>.-])/g, '$1');
 }
 
 export function restoreRawHTMLTags(text) {
@@ -513,23 +524,30 @@ export function postprocessComments(inputNodes) {
   return outputNodes;
 }
 
-export function postprocessImages(inputNodes) {
+function postprocessHTMLTags(inputNodes, tagName, callback) {
   const outputNodes = [];
-
-  let atStartOfLine = true;
+  const errors = [];
 
   const lastNode = inputNodes.at(-1);
 
+  const regexp =
+    new RegExp(
+      `<${tagName} (.*?)>` +
+      (html.selfClosingTags.includes(tagName)
+        ? ''
+        : `(?:</${tagName}>)?`),
+      'g');
+
+  let atStartOfLine = true;
+
   for (const node of inputNodes) {
     if (node.type === 'tag') {
       atStartOfLine = false;
     }
 
     if (node.type === 'text') {
-      const imageRegexp = /<img (.*?)>/g;
-
       let match = null, parseFrom = 0;
-      while (match = imageRegexp.exec(node.data)) {
+      while (match = regexp.exec(node.data)) {
         const previousText = node.data.slice(parseFrom, match.index);
 
         outputNodes.push({
@@ -541,23 +559,19 @@ export function postprocessImages(inputNodes) {
 
         parseFrom = match.index + match[0].length;
 
-        const imageNode = {type: 'image'};
-        const attributes = html.parseAttributes(match[1]);
-
-        imageNode.src = attributes.get('src');
-
         if (previousText.endsWith('\n')) {
           atStartOfLine = true;
         } else if (previousText.length) {
           atStartOfLine = false;
         }
 
-        imageNode.inline = (() => {
-          // Images can force themselves to be rendered inline using a custom
-          // attribute - this style just works better for certain embeds,
-          // usually jokes or small images.
-          if (attributes.get('inline')) return true;
+        const attributes =
+          html.parseAttributes(match[1]);
 
+        const remainingTextInNode =
+          node.data.slice(parseFrom);
+
+        const inline = (() => {
           // If we've already determined we're in the middle of a line,
           // we're inline. (Of course!)
           if (!atStartOfLine) {
@@ -566,42 +580,33 @@ export function postprocessImages(inputNodes) {
 
           // If there's more text to go in this text node, and what's
           // remaining doesn't start with a line break, we're inline.
-          if (
-            parseFrom !== node.data.length &&
-            node.data[parseFrom] !== '\n'
-          ) {
+          if (remainingTextInNode && remainingTextInNode[0] !== '\n') {
             return true;
           }
 
           // If we're at the end of this text node, but this text node
           // isn't the last node overall, we're inline.
-          if (
-            parseFrom === node.data.length &&
-            node !== lastNode
-          ) {
+          if (!remainingTextInNode && node !== lastNode) {
             return true;
           }
 
-          // If no other condition matches, this image is on its own line.
+          // If no other condition matches, this tag is on its own line.
           return false;
         })();
 
-        if (attributes.get('link')) imageNode.link = attributes.get('link');
-        if (attributes.get('style')) imageNode.style = attributes.get('style');
-        if (attributes.get('width')) imageNode.width = parseInt(attributes.get('width'));
-        if (attributes.get('height')) imageNode.height = parseInt(attributes.get('height'));
-        if (attributes.get('align')) imageNode.align = attributes.get('align');
-        if (attributes.get('pixelate')) imageNode.pixelate = true;
-
-        if (attributes.get('warning')) {
-          imageNode.warnings =
-            attributes.get('warning').split(', ');
+        try {
+          outputNodes.push(
+            callback(attributes, {
+              inline,
+            }));
+        } catch (caughtError) {
+          errors.push(new Error(
+            `Failed to process ${match[0]}`,
+            {cause: caughtError}));
         }
 
-        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
+        // No longer at the start of a line after the tag - there will at
+        // least be text with only '\n' before the next of this tag that's
         // on its own line.
         atStartOfLine = false;
       }
@@ -621,57 +626,85 @@ export function postprocessImages(inputNodes) {
     outputNodes.push(node);
   }
 
+  if (!empty(errors)) {
+    throw new AggregateError(
+      errors,
+    `Errors postprocessing <${tagName}> tags`);
+  }
+
   return outputNodes;
 }
 
-export function postprocessVideos(inputNodes) {
-  const outputNodes = [];
+function complainAboutMediaSrc(src) {
+  if (!src) {
+    throw new Error(`Missing "src" attribute`);
+  }
 
-  for (const node of inputNodes) {
-    if (node.type !== 'text') {
-      outputNodes.push(node);
-      continue;
-    }
+  if (src.startsWith('/media/')) {
+    throw new Error(`Start "src" with "media/", not "/media/"`);
+  }
+}
 
-    const videoRegexp = /<video (.*?)>(<\/video>)?/g;
+export function postprocessImages(inputNodes) {
+  return postprocessHTMLTags(inputNodes, 'img',
+    (attributes, {inline}) => {
+      const node = {type: 'image'};
 
-    let match = null, parseFrom = 0;
-    while (match = videoRegexp.exec(node.data)) {
-      const previousText = node.data.slice(parseFrom, match.index);
+      node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
 
-      outputNodes.push({
-        type: 'text',
-        data: previousText,
-        i: node.i + parseFrom,
-        iEnd: node.i + parseFrom + match.index,
-      });
+      node.inline = attributes.get('inline') ?? inline;
 
-      parseFrom = match.index + match[0].length;
+      if (attributes.get('link')) node.link = attributes.get('link');
+      if (attributes.get('style')) node.style = attributes.get('style');
+      if (attributes.get('width')) node.width = parseInt(attributes.get('width'));
+      if (attributes.get('height')) node.height = parseInt(attributes.get('height'));
+      if (attributes.get('align')) node.align = attributes.get('align');
+      if (attributes.get('pixelate')) node.pixelate = true;
 
-      const videoNode = {type: 'video'};
-      const attributes = html.parseAttributes(match[1]);
+      if (attributes.get('warning')) {
+        node.warnings =
+          attributes.get('warning').split(', ');
+      }
 
-      videoNode.src = attributes.get('src');
+      return node;
+    });
+}
 
-      if (attributes.get('width')) videoNode.width = parseInt(attributes.get('width'));
-      if (attributes.get('height')) videoNode.height = parseInt(attributes.get('height'));
-      if (attributes.get('align')) videoNode.align = attributes.get('align');
-      if (attributes.get('pixelate')) videoNode.pixelate = true;
+export function postprocessVideos(inputNodes) {
+  return postprocessHTMLTags(inputNodes, 'video',
+    (attributes, {inline}) => {
+      const node = {type: 'video'};
 
-      outputNodes.push(videoNode);
-    }
+      node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
 
-    if (parseFrom !== node.data.length) {
-      outputNodes.push({
-        type: 'text',
-        data: node.data.slice(parseFrom),
-        i: node.i + parseFrom,
-        iEnd: node.iEnd,
-      });
-    }
-  }
+      node.inline = attributes.get('inline') ?? inline;
 
-  return outputNodes;
+      if (attributes.get('width')) node.width = parseInt(attributes.get('width'));
+      if (attributes.get('height')) node.height = parseInt(attributes.get('height'));
+      if (attributes.get('align')) node.align = attributes.get('align');
+      if (attributes.get('pixelate')) node.pixelate = true;
+
+      return node;
+    });
+}
+
+export function postprocessAudios(inputNodes) {
+  return postprocessHTMLTags(inputNodes, 'audio',
+    (attributes, {inline}) => {
+      const node = {type: 'audio'};
+
+      node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
+
+      node.inline = attributes.get('inline') ?? inline;
+
+      if (attributes.get('align')) node.align = attributes.get('align');
+      if (attributes.get('nameless')) node.nameless = true;
+
+      return node;
+    });
 }
 
 export function postprocessHeadings(inputNodes) {
@@ -769,109 +802,234 @@ export function postprocessExternalLinks(inputNodes) {
       continue;
     }
 
-    const plausibleLinkRegexp = /\[.*?\)/g;
-
-    let textContent = '';
+    let textNode = {
+      i: node.i,
+      iEnd: null,
+      type: 'text',
+      data: '',
+    };
 
-    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 = '';
-        }
+    let parseFrom = 0;
+    for (const match of matchMarkdownLinks(node.data, {marked})) {
+      const {label, href, index, length} = match;
 
-        const offset = plausibleMatch.index + definiteMatch.index;
-        const length = definiteMatch[0].length;
+      textNode.data += node.data.slice(parseFrom, index);
 
-        outputNodes.push({
-          i: node.i + offset,
-          iEnd: node.i + offset + length,
-          type: 'external-link',
-          data: {label, href},
-        });
+      // Split the containing text node into two - the second of these will
+      // be filled in and pushed by the next match, or after iterating over
+      // all matches.
+      if (textNode.data) {
+        textNode.iEnd = textNode.i + textNode.data.length;
+        outputNodes.push(textNode);
 
-        parseFrom = offset + length;
-      } else {
-        parseFrom = plausibleMatch.index;
+        textNode = {
+          i: node.i + index + length,
+          iEnd: null,
+          type: 'text',
+          data: '',
+        };
       }
+
+      outputNodes.push({
+        i: node.i + index,
+        iEnd: node.i + index + length,
+        type: 'external-link',
+        data: {label, href},
+      });
+
+      parseFrom = index + length;
     }
 
     if (parseFrom !== node.data.length) {
-      textContent += node.data.slice(parseFrom);
+      textNode.data += node.data.slice(parseFrom);
+      textNode.iEnd = node.iEnd;
     }
 
-    if (textContent.length) {
-      outputNodes.push({type: 'text', data: textContent});
+    if (textNode.data) {
+      outputNodes.push(textNode);
     }
   }
 
   return outputNodes;
 }
 
-export function parseInput(input) {
+export function parseContentNodes(input, {
+  errorMode = 'throw',
+} = {}) {
   if (typeof input !== 'string') {
     throw new TypeError(`Expected input to be string, got ${typeAppearance(input)}`);
   }
 
-  try {
-    let output = parseNodes(input, 0);
-    output = postprocessComments(output);
-    output = postprocessImages(output);
-    output = postprocessVideos(output);
-    output = postprocessHeadings(output);
-    output = postprocessSummaries(output);
-    output = postprocessExternalLinks(output);
-    return output;
-  } catch (errorNode) {
-    if (errorNode.type !== 'error') {
-      throw errorNode;
+  let result = null, error = null;
+
+  process: {
+    try {
+      result = parseNodes(input, 0);
+    } catch (caughtError) {
+      if (caughtError.type === 'error') {
+        const {i, data: {message}} = caughtError;
+
+        let lineStart = input.slice(0, i).lastIndexOf('\n');
+        if (lineStart >= 0) {
+          lineStart += 1;
+        } else {
+          lineStart = 0;
+        }
+
+        let lineEnd = input.slice(i).indexOf('\n');
+        if (lineEnd >= 0) {
+          lineEnd += i;
+        } else {
+          lineEnd = input.length;
+        }
+
+        const line = input.slice(lineStart, lineEnd);
+
+        const cursor = i - lineStart;
+
+        error =
+          new SyntaxError(
+            `Parse error (at pos ${i}): ${message}\n` +
+            line + `\n` +
+            '-'.repeat(cursor) + '^');
+      } else {
+        error = caughtError;
+      }
+
+      // A parse error means there's no output to continue with at all,
+      // so stop here.
+      break process;
     }
 
-    const {
-      i,
-      data: {message},
-    } = errorNode;
+    const postprocessErrors = [];
+
+    for (const postprocess of [
+      postprocessComments,
+      postprocessImages,
+      postprocessVideos,
+      postprocessAudios,
+      postprocessHeadings,
+      postprocessSummaries,
+      postprocessExternalLinks,
+    ]) {
+      try {
+        result = postprocess(result);
+      } catch (caughtError) {
+        const error =
+          new Error(
+            `Error in step ${`"${postprocess.name}"`}`,
+            {cause: caughtError});
+
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+        postprocessErrors.push(error);
+      }
+    }
 
-    let lineStart = input.slice(0, i).lastIndexOf('\n');
-    if (lineStart >= 0) {
-      lineStart += 1;
-    } else {
-      lineStart = 0;
+    if (!empty(postprocessErrors)) {
+      error =
+        new AggregateError(
+          postprocessErrors,
+        `Errors postprocessing content text`);
+
+      error[Symbol.for('hsmusic.aggregate.translucent')] = 'single';
     }
+  }
 
-    let lineEnd = input.slice(i).indexOf('\n');
-    if (lineEnd >= 0) {
-      lineEnd += i;
+  if (errorMode === 'throw') {
+    if (error) {
+      throw error;
     } else {
-      lineEnd = input.length;
+      return result;
     }
+  } else if (errorMode === 'return') {
+    if (!result) {
+      result = [{
+        i: 0,
+        iEnd: input.length,
+        type: 'text',
+        data: input,
+      }];
+    }
+
+    return {error, result};
+  } else {
+    throw new Error(`Unknown errorMode ${errorMode}`);
+  }
+}
 
-    const line = input.slice(lineStart, lineEnd);
+export function* splitContentNodesAround(nodes, splitter) {
+  if (splitter instanceof RegExp) {
+    const regex = splitter;
 
-    const cursor = i - lineStart;
+    splitter = function*(text) {
+      for (const match of text.matchAll(regex)) {
+        yield {
+          index: match.index,
+          length: match[0].length,
+        };
+      }
+    };
+  }
 
-    throw new SyntaxError([
-      `Parse error (at pos ${i}): ${message}`,
-      line,
-      '-'.repeat(cursor) + '^',
-    ].join('\n'));
+  if (typeof splitter === 'string') {
+    throw new TypeError(`Expected generator or regular expression`);
+  }
+
+  function* splitTextNode(node) {
+    let textNode = {
+      i: node.i,
+      iEnd: null,
+      type: 'text',
+      data: '',
+    };
+
+    let parseFrom = 0;
+    for (const match of splitter(node.data)) {
+      const {index, length} = match;
+
+      textNode.data += node.data.slice(parseFrom, index);
+
+      if (textNode.data) {
+        textNode.iEnd = textNode.i + textNode.data.length;
+        yield textNode;
+      }
+
+      yield {
+        i: node.i + index,
+        iEnd: node.i + index + length,
+        type: 'separator',
+        data: {
+          text: node.data.slice(index, index + length),
+          match,
+        },
+      };
+
+      textNode = {
+        i: node.i + index + length,
+        iEnd: null,
+        type: 'text',
+        data: '',
+      };
+
+      parseFrom = index + length;
+    }
+
+    if (parseFrom !== node.data.length) {
+      textNode.data += node.data.slice(parseFrom);
+      textNode.iEnd = node.iEnd;
+    }
+
+    if (textNode.data) {
+      yield textNode;
+    }
+  }
+
+  for (const node of nodes) {
+    if (node.type === 'text') {
+      yield* splitTextNode(node);
+    } else {
+      yield node;
+    }
   }
 }
diff --git a/src/reverse.js b/src/reverse.js
index 9ad5c8a7..b4b225f0 100644
--- a/src/reverse.js
+++ b/src/reverse.js
@@ -83,7 +83,9 @@ function reverseHelper(spec) {
     for (const referencedThing of allReferencedThings) {
       if (cacheRecord.has(referencedThing)) {
         const referencingThings = cacheRecord.get(referencedThing);
-        sortByDate(referencingThings);
+        sortByDate(referencingThings, {
+          getDate: spec.date ?? (thing => thing.date),
+        });
       }
     }
 
@@ -100,28 +102,7 @@ function reverseHelper(spec) {
   };
 }
 
-const hardcodedReverseSpecs = {
-  // Artworks aren't Thing objects.
-  // This spec operates on albums and tracks alike!
-  artworksWhichReference: {
-    bindTo: 'wikiData',
-
-    referencing: ({albumData, trackData}) =>
-      [...albumData, ...trackData]
-        .flatMap(referencingThing =>
-          referencingThing.referencedArtworks
-            .map(({thing: referencedThing, ...referenceDetails}) => ({
-              referencingThing,
-              referencedThing,
-              referenceDetails,
-            }))),
-
-    referenced: ({referencedThing}) => [referencedThing],
-
-    tidy: ({referencingThing, referenceDetails}) =>
-      ({thing: referencingThing, ...referenceDetails}),
-  },
-};
+const hardcodedReverseSpecs = {};
 
 const findReverseHelperConfig = {
   word: `reverse`,
diff --git a/src/static/css/site.css b/src/static/css/site.css
index e38b5e0d..a9ed90c6 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -61,7 +61,7 @@ body::before, .wallpaper-part {
 
 #page-container {
   max-width: 1100px;
-  margin: 0 auto 40px;
+  margin: 0 auto 38px;
   padding: 15px 0;
 }
 
@@ -76,10 +76,25 @@ body::before, .wallpaper-part {
   height: unset;
 }
 
+@property --banner-shine {
+  syntax: '<percentage>';
+  initial-value: 0%;
+  inherits: false;
+}
+
 #banner {
   margin: 10px 0;
   width: 100%;
   position: relative;
+
+  --banner-shine: 4%;
+  -webkit-box-reflect: below -12px linear-gradient(transparent, color-mix(in srgb, transparent, var(--banner-shine) white));
+  transition: --banner-shine 0.8s;
+}
+
+#banner:hover {
+  --banner-shine: 35%;
+  transition-delay: 0.3s;
 }
 
 #banner::after {
@@ -161,10 +176,9 @@ body::before, .wallpaper-part {
 }
 
 .sidebar-column {
-  flex: 1 1 20%;
+  flex: 1 1 35%;
   min-width: 150px;
   max-width: 250px;
-  flex-basis: 250px;
   align-self: flex-start;
 }
 
@@ -179,6 +193,16 @@ body::before, .wallpaper-part {
   display: none;
 }
 
+.sidebar-column.always-content-column {
+  /* duplicated in thin & medium media query */
+  position: static !important;
+  max-width: unset !important;
+  flex-basis: unset !important;
+  margin-right: 0 !important;
+  margin-left: 0 !important;
+  width: 100%;
+}
+
 .sidebar-multiple {
   display: flex;
   flex-direction: column;
@@ -252,7 +276,11 @@ body::before, .wallpaper-part {
 #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);
+  border-bottom: 2px solid #fff1;
+  box-shadow:
+    0 0 40px #0008,
+    0 2px 15px -3px #2221,
+    0 2px 6px 2px #1113;
 }
 
 #skippers > * {
@@ -342,6 +370,11 @@ body::before, .wallpaper-part {
   margin: 0;
 }
 
+.sidebar h2:first-child {
+  margin-top: 0.5em;
+  margin-bottom: 0.5em;
+}
+
 .sidebar h3 {
   font-size: 1.1em;
   font-style: oblique;
@@ -393,6 +426,42 @@ body::before, .wallpaper-part {
   padding-left: 10px;
 }
 
+.sidebar details.has-tree-list[open] summary {
+  font-weight: 800;
+}
+
+.sidebar dl.tree-list {
+  margin-top: 0.25em;
+  line-height: 1.25em;
+  padding-left: 15px;
+}
+
+.sidebar dl.tree-list dt {
+  display: list-item;
+  list-style-type: disc;
+  padding-left: 0;
+  margin-left: 20px;
+}
+
+.sidebar dl.tree-list dl {
+  padding-left: 15px;
+}
+
+.sidebar dl.tree-list dd {
+  margin-left: 0;
+}
+
+.sidebar dl.tree-list dt.current a {
+  font-weight: 800;
+  border-bottom: 1px solid;
+}
+
+.sidebar .times-used {
+  opacity: 0.7;
+  font-size: 0.9em;
+  cursor: default;
+}
+
 .sidebar li.current {
   font-weight: 800;
 }
@@ -507,6 +576,40 @@ summary.underline-white > span:hover a:not(:hover) {
   margin-top: 0 !important;
 }
 
+.track-release-sidebar-box {
+  --content-padding: 3px;
+}
+
+.track-release-sidebar-box h1 {
+  margin: 0;
+  font-weight: normal;
+  font-size: 0.9em;
+  font-style: oblique;
+}
+
+.track-release-sidebar-box + .track-release-sidebar-box,
+.track-release-sidebar-box + .track-list-sidebar-box,
+.track-list-sidebar-box + .track-release-sidebar-box {
+  margin-top: 5px !important;
+  border-top-left-radius: 0;
+  border-top-right-radius: 0;
+}
+
+.track-release-sidebar-box:has(+ .track-list-sidebar-box),
+.track-list-sidebar-box:has(+ .track-release-sidebar-box) {
+  border-bottom-right-radius: 0;
+  border-bottom-left-radius: 0;
+}
+
+.track-list-sidebar-box summary {
+  padding-left: 20px !important;
+  text-indent: -15px !important;
+}
+
+.track-list-sidebar-box .track-section-range {
+  white-space: nowrap;
+}
+
 .wiki-search-sidebar-box {
   padding: 1px 0 0 0;
 
@@ -658,6 +761,96 @@ summary.underline-white > span:hover a:not(:hover) {
   cursor: default;
 }
 
+.wiki-search-filter-container {
+  padding: 4px;
+}
+
+.wiki-search-filter-link {
+  display: inline-block;
+  margin: 2px;
+  padding: 2px 4px;
+  border: 2px solid transparent;
+  border-radius: 4px;
+}
+
+.wiki-search-filter-link:where(.active.shown) {
+  animation:
+    0.15s ease   0.00s forwards normal    show-filter,
+    0.60s linear 0.15s infinite alternate blink-filter;
+}
+
+.wiki-search-filter-link:where(.active:not(.shown)) {
+  animation:
+    0.00s linear 0.00s forwards normal    show-filter,
+    0.60s linear 0.00s infinite alternate blink-filter;
+}
+
+.wiki-search-filter-link:where(:not(.active).hidden) {
+  /* We can't just reverse the show-filter animation,
+   * because that won't actually start it over again.
+   */
+  animation:
+    0.15s ease   0.00s forwards reverse   show-filter-the-sequel;
+}
+
+.wiki-search-filter-link.active-from-query {
+  background: var(--primary-color);
+  border-color: var(--primary-color);
+  color: #000a;
+  animation: none;
+}
+
+.wiki-search-filter-link.active-from-query::after {
+  content: "I";
+  color: black;
+  font-family: monospace;
+  font-weight: 800;
+  font-size: 1.2em;
+  margin-left: 0.5ch;
+  vertical-align: middle;
+  animation: 1s steps(2, jump-none) 0.6s infinite blink-caret;
+}
+
+@keyframes show-filter {
+  from {
+    background: transparent;
+    border-color: transparent;
+    color: var(--primary-color);
+  }
+
+  to {
+    background: var(--primary-color);
+    border-color: var(--primary-color);
+    color: black;
+  }
+}
+
+/* Exactly the same as show-filter above. */
+@keyframes show-filter-the-sequel {
+  from {
+    background: transparent;
+    border-color: transparent;
+    color: var(--primary-color);
+  }
+
+  to {
+    background: var(--primary-color);
+    border-color: var(--primary-color);
+    color: black;
+  }
+}
+
+@keyframes blink-filter {
+  to {
+    background: color-mix(in srgb, var(--primary-color) 90%, transparent);
+  }
+}
+
+@keyframes blink-caret {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+
 .wiki-search-result {
   position: relative;
   display: flex;
@@ -821,6 +1014,10 @@ a:not([href]):hover {
   text-decoration: none;
 }
 
+a .normal-content {
+  color: white;
+}
+
 .external-link:not(.from-content) {
   white-space: nowrap;
 }
@@ -834,8 +1031,26 @@ a:not([href]):hover {
   color: white;
 }
 
-.external-link .normal-content {
-  color: white;
+.image-media-link::after {
+  content: '';
+  display: inline-block;
+  width: 22px;
+  height: 1em;
+
+  background-color: var(--primary-color);
+
+  /* mask-image is set in content JavaScript,
+   * because we can't identify the correct nor
+   * absolute path to the file from CSS.
+   */
+
+  mask-repeat: no-repeat;
+  mask-position: calc(100% - 2px);
+  vertical-align: text-bottom;
+}
+
+.image-media-link:hover::after {
+  background-color: white;
 }
 
 .nav-link {
@@ -856,29 +1071,46 @@ a:not([href]):hover {
   font-weight: 800;
 }
 
-.nav-links-hierarchical .nav-link:not(:first-child)::before {
+.nav-links-hierarchical .nav-link + .nav-link::before,
+.nav-links-hierarchical .nav-link + .blockwrap .nav-link::before {
   content: "\0020/\0020";
 }
 
-.series-nav-link {
+.series-nav-links {
   display: inline-block;
 }
 
-.series-nav-link:not(:first-child)::before {
+.series-nav-links:not(:first-child)::before {
   content: "\00a0»\00a0";
   font-weight: normal;
 }
 
-.series-nav-link:not(:last-child)::after {
+.series-nav-links:not(:last-child)::after {
   content: ",\00a0";
 }
 
-.series-nav-link + .series-nav-link::before {
+.series-nav-links + .series-nav-links::before {
   content: "";
 }
 
+.dot-switcher > span:not(:first-child) {
+  display: inline-block;
+  white-space: nowrap;
+}
+
+/* Yeah, all this stuff only applies to elements of the dot switcher
+ * besides the first, which will necessarily have a bullet point at left.
+ */
+.dot-switcher *:where(.dot-switcher > span:not(:first-child) > *) {
+  display: inline-block;
+  white-space: wrap;
+  text-align: left;
+  vertical-align: top;
+}
+
 .dot-switcher > span:not(:first-child)::before {
   content: "\0020\00b7\0020";
+  white-space: pre;
   font-weight: 800;
 }
 
@@ -905,7 +1137,7 @@ a:not([href]):hover {
   display: block;
 }
 
-#secondary-nav.album-secondary-nav.with-previous-next {
+#secondary-nav.album-secondary-nav {
   display: flex;
   justify-content: space-around;
   padding-left: 7.5% !important;
@@ -923,7 +1155,8 @@ a:not([href]):hover {
   margin-right: 5px;
 }
 
-#secondary-nav.album-secondary-nav .dot-switcher {
+#secondary-nav.album-secondary-nav .group-nav-links .dot-switcher,
+#secondary-nav.album-secondary-nav .series-nav-links .dot-switcher {
   white-space: nowrap;
 }
 
@@ -956,7 +1189,9 @@ a:not([href]):hover {
 .text-with-tooltip.datetimestamp .text-with-tooltip-interaction-cue,
 .text-with-tooltip.missing-duration .text-with-tooltip-interaction-cue,
 .text-with-tooltip.commentary-date .text-with-tooltip-interaction-cue,
-.text-with-tooltip.wiki-edits .text-with-tooltip-interaction-cue {
+.text-with-tooltip.wiki-edits .text-with-tooltip-interaction-cue,
+.text-with-tooltip.rerelease .text-with-tooltip-interaction-cue,
+.text-with-tooltip.first-release .text-with-tooltip-interaction-cue {
   cursor: default;
 }
 
@@ -973,7 +1208,17 @@ a:not([href]):hover {
   text-decoration: none !important;
 }
 
+.text-with-tooltip.wiki-edits > .hoverable {
+  white-space: nowrap;
+}
+
+.isolate-tooltip-z-indexing > * {
+  position: relative;
+  z-index: -1;
+}
+
 .tooltip {
+  font-size: 1rem;
   position: absolute;
   z-index: 3;
   left: -10px;
@@ -981,7 +1226,12 @@ a:not([href]):hover {
   display: none;
 }
 
-li:not(:first-child:last-child) .tooltip,
+.cover-artwork .tooltip,
+#sidebar .tooltip {
+  font-size: 0.9rem;
+}
+
+li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)),
 .offset-tooltips > :not(:first-child:last-child) .tooltip {
   left: 14px;
 }
@@ -1013,7 +1263,10 @@ li:not(:first-child:last-child) .tooltip,
 
 .datetimestamp-tooltip,
 .missing-duration-tooltip,
-.commentary-date-tooltip {
+.commentary-date-tooltip,
+.rerelease-tooltip,
+.first-release-tooltip,
+.content-tooltip {
   padding: 3px 4px 2px 2px;
   left: -10px;
 }
@@ -1021,18 +1274,23 @@ li:not(:first-child:last-child) .tooltip,
 .thing-name-tooltip,
 .wiki-edits-tooltip {
   padding: 3px 4px 2px 2px;
-  left: -6px !important;
+  left: -6px;
 }
 
-.wiki-edits-tooltip {
+.thing-name-tooltip .tooltip-content,
+.wiki-edits-tooltip .tooltip-content {
   font-size: 0.85em;
 }
 
-/* Terrifying?
- * https://stackoverflow.com/a/64424759/4633828
- */
-.thing-name-tooltip { margin-right: -120px; }
-.wiki-edits-tooltip { margin-right: -200px; }
+.thing-name-tooltip .tooltip-content {
+  width: max-content;
+  max-width: 120px;
+}
+
+.wiki-edits-tooltip .tooltip-content {
+  width: max-content;
+  max-width: 200px;
+}
 
 .contribution-tooltip .tooltip-content {
   padding: 6px 2px 2px 2px;
@@ -1108,11 +1366,16 @@ li:not(:first-child:last-child) .tooltip,
   font-size: 0.85em;
 }
 
-.contribution-tooltip .tooltip-divider {
+.contribution-tooltip .tooltip-divider,
+.tooltip-content hr.cute {
   grid-column-start: icon-start;
   grid-column-end: platform-end;
-
   border-top: 1px dotted var(--primary-color);
+}
+
+/* Don't mind me... */
+.tooltip-content .tooltip-divider,
+.tooltip-content hr.cute {
   margin-top: 3px;
   margin-bottom: 4px;
 }
@@ -1178,6 +1441,37 @@ li:not(:first-child:last-child) .tooltip,
   padding: 3px 4.5px;
 }
 
+.rerelease-tooltip .tooltip-content,
+.first-release-tooltip .tooltip-content {
+  padding: 3px 4.5px;
+  width: 260px;
+  font-size: 0.9em;
+}
+
+.content-tooltip-guy .hoverable a {
+  text-decoration-color: transparent;
+  text-decoration-style: dotted;
+}
+
+.content-tooltip-guy {
+  display: inline-block;
+}
+
+.content-tooltip-guy.has-link .text-with-tooltip-interaction-cue {
+  text-decoration-color: var(--primary-color);
+}
+
+.content-tooltip .tooltip-content {
+  padding: 3px 4.5px;
+  width: 240px;
+}
+
+.cover-artwork .content-tooltip {
+  font-size: 0.85rem;
+  padding: 2px 3px;
+  width: 220px;
+}
+
 .external-icon {
   display: inline-block;
   padding: 0 3px;
@@ -1194,8 +1488,8 @@ li:not(:first-child:last-child) .tooltip,
   fill: var(--primary-color);
 }
 
-.rerelease,
-.other-group-accent {
+.other-group-accent,
+.rerelease-line {
   opacity: 0.7;
   font-style: oblique;
 }
@@ -1228,6 +1522,38 @@ s.spoiler::-moz-selection {
   background: white;
 }
 
+span.path, code.filename {
+  font-size: 0.95em;
+  font-family: "courier new", monospace;
+  font-weight: 800;
+  background: #ccc3;
+
+  padding: 0.05em 0.5ch;
+  border: 1px solid #ccce;
+  border-radius: 2px;
+  line-height: 1.4;
+}
+
+.image-details code.filename {
+  margin-left: -0.4ch;
+  opacity: 0.8;
+}
+
+.image-details code.filename:hover {
+  opacity: 1;
+  cursor: text;
+}
+
+span.path i {
+  display: inline-block;
+  font-style: normal;
+}
+
+span.path i::before {
+  content: "\0020/\0020";
+  color: #ccc;
+}
+
 progress {
   accent-color: var(--primary-color);
 }
@@ -1249,12 +1575,16 @@ p .current {
   font-weight: 800;
 }
 
-#cover-art-container {
+hr.cute,
+#content hr.cute,
+.sidebar hr.cute {
+  border-color: var(--primary-color);
+  border-style: none none dotted none;
+}
+
+.cover-artwork {
   font-size: 0.8em;
   border: 2px solid var(--primary-color);
-  box-shadow:
-    0 2px 14px -6px var(--primary-color),
-    0 0 12px 12px #00000080;
 
   border-radius: 0 0 4px 4px;
   background: var(--bg-black-color);
@@ -1263,37 +1593,64 @@ p .current {
           backdrop-filter: blur(3px);
 }
 
-#cover-art-container:has(.image-details),
-#cover-art-container.has-image-details {
+.cover-artwork:has(.image-details),
+.cover-artwork.has-image-details {
   border-radius: 0 0 6px 6px;
 }
 
-#cover-art-container:not(:has(.image-details)),
-#cover-art-container:not(.has-image-details) {
+.cover-artwork:not(:has(.image-details)),
+.cover-artwork:not(.has-image-details) {
   /* Hacky: `overflow: hidden` hides tag tooltips, so it can't be applied
    * if we've got tags/details visible. But it's okay, because we only
    * need to apply it if it *doesn't* - that's when the rounded border
-   * of #cover-art-container needs to cut off its child image-container
+   * of the .cover-artwork needs to cut off its child .image-container
    * (which has a background that otherwise causes sharp corners).
    */
   overflow: hidden;
 }
 
-#cover-art-container .image-container {
-  /* Border is handled on the cover-art-container. */
+#artwork-column .cover-artwork {
+  box-shadow:
+    0 2px 14px -6px var(--primary-color),
+    0 0 12px 12px #00000080;
+}
+
+#artwork-column .cover-artwork:not(:first-child),
+#artwork-column .cover-artwork-joiner {
+  margin-left: 30px;
+  margin-right: 5px;
+}
+
+#artwork-column .cover-artwork:first-child + .cover-artwork-joiner,
+#artwork-column .cover-artwork.attached-artwork-is-main-artwork,
+#artwork-column .cover-artwork.attached-artwork-is-main-artwork + .cover-artwork-joiner {
+  margin-left: 17.5px;
+  margin-right: 17.5px;
+}
+
+.cover-artwork:where(#artwork-column .cover-artwork:not(:first-child)) {
+  margin-top: 20px;
+}
+
+#artwork-column .cover-artwork:last-child:not(:first-child) {
+  margin-bottom: 25px;
+}
+
+.cover-artwork .image-container {
+  /* Border is handled on the .cover-artwork. */
   border: none;
-  border-radius: 0;
+  border-radius: 0 !important;
 }
 
-#cover-art-container .image-details {
+.cover-artwork .image-details {
   border-top-color: var(--deep-color);
 }
 
-#cover-art-container .image-details + .image-details {
+.cover-artwork .image-details + .image-details {
   border-top-color: var(--primary-color);
 }
 
-#cover-art-container .image {
+.cover-artwork .image {
   display: block;
   width: 100%;
   height: 100%;
@@ -1336,6 +1693,10 @@ p .current {
   margin-bottom: 2px;
 }
 
+ul.image-details.art-tag-details {
+  padding-bottom: 0;
+}
+
 ul.image-details.art-tag-details li {
   display: inline-block;
 }
@@ -1344,35 +1705,64 @@ ul.image-details.art-tag-details li:not(:last-child)::after {
   content: " \00b7 ";
 }
 
-.image-details.non-unique-details {
-  font-style: oblique;
-}
-
 p.image-details.illustrator-details {
   text-align: center;
   font-style: oblique;
 }
 
-#artist-commentary.first-entry-is-dated {
-  clear: right;
+p.image-details.origin-details {
+  margin-bottom: 2px;
 }
 
-.commentary-entry-heading {
-  display: flex;
-  flex-direction: row;
+p.image-details.origin-details .origin-details {
+  display: block;
+  margin-top: 0.25em;
+}
 
-  margin-left: 15px;
-  padding-left: 5px;
-  max-width: 625px;
-  padding-bottom: 0.2em;
+.cover-artwork-joiner {
+  z-index: -2;
+}
 
-  border-bottom: 1px solid var(--dim-color);
+.cover-artwork-joiner::after {
+  content: "";
+  display: block;
+  width: 0;
+  height: 15px;
+  margin-left: auto;
+  margin-right: auto;
+  border-right: 3px solid var(--primary-color);
 }
 
-.commentary-entry-heading-text {
-  flex-grow: 1;
-  padding-left: 1.25ch;
+.cover-artwork-joiner + .cover-artwork {
+  margin-top: 0 !important;
+}
+
+.album-art-info {
+  font-size: 0.8em;
+  border: 2px solid var(--deep-color);
+
+  margin: 10px min(15px, 1vw) 15px;
+
+  background: var(--bg-black-color);
+  padding: 6px;
+  border-radius: 5px;
+
+  -webkit-backdrop-filter: blur(3px);
+          backdrop-filter: blur(3px);
+}
+
+.album-art-info p {
+  margin: 0;
+}
+
+.commentary-entry-heading {
+  margin-left: 15px;
+  padding-left: calc(5px + 1.25ch);
   text-indent: -1.25ch;
+  margin-right: min(calc(8vw - 35px), 45px);
+  padding-bottom: 0.2em;
+
+  border-bottom: 1px solid var(--dim-color);
 }
 
 .commentary-entry-accent {
@@ -1380,13 +1770,12 @@ p.image-details.illustrator-details {
 }
 
 .commentary-entry-heading .commentary-date {
-  flex-shrink: 0;
-
-  margin-left: 0.75ch;
-  align-self: flex-end;
+  display: inline-block;
+  text-indent: 0;
+}
 
-  padding-left: 0.5ch;
-  padding-right: 0.25ch;
+.commentary-entry-heading.dated .commentary-entry-heading-text {
+  margin-right: 0.75ch;
 }
 
 .commentary-entry-heading .hoverable {
@@ -1401,6 +1790,15 @@ p.image-details.illustrator-details {
   color: var(--primary-color);
 }
 
+.inherited-commentary-section {
+  clear: right;
+  margin-top: 1em;
+  margin-right: min(4vw, 60px);
+  border: 2px solid var(--deep-color);
+  border-radius: 4px;
+  background: #ffffff07;
+}
+
 .commentary-art {
   float: right;
   width: 30%;
@@ -1415,6 +1813,33 @@ p.image-details.illustrator-details {
   box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.25) !important;
 }
 
+.lyrics-switcher {
+  padding-left: 20px;
+}
+
+.lyrics-switcher > span:not(:first-child)::before {
+  content: "\0020\00b7\0020";
+  font-weight: 800;
+}
+
+.lyrics-entry {
+  padding-left: 40px;
+}
+
+.lyrics-entry .lyrics-details,
+.lyrics-entry .origin-details {
+  font-size: 0.9em;
+  font-style: oblique;
+}
+
+.lyrics-entry .lyrics-details {
+  margin-bottom: 0;
+}
+
+.lyrics-entry .origin-details {
+  margin-top: 0.25em;
+}
+
 .js-hide,
 .js-show-once-data,
 .js-hide-once-data {
@@ -1422,24 +1847,32 @@ p.image-details.illustrator-details {
 }
 
 .content-image-container,
-.content-video-container {
+.content-video-container,
+.content-audio-container {
   margin-top: 1em;
   margin-bottom: 1em;
 }
 
-.content-image-container.align-center,
-.content-video-container.align-center {
+.content-image-container.align-center {
   text-align: center;
   margin-top: 1.5em;
   margin-bottom: 1.5em;
 }
 
-a.align-center, img.align-center {
+.content-image-container.align-full {
+  width: 100%;
+}
+
+a.align-center, img.align-center, audio.align-center, video.align-center {
   display: block;
   margin-left: auto;
   margin-right: auto;
 }
 
+a.align-full, img.align-full, video.align-full {
+  width: 100%;
+}
+
 center {
   margin-top: 1em;
   margin-bottom: 1em;
@@ -1496,11 +1929,6 @@ h1 {
   font-size: 2em;
 }
 
-html[data-url-key="localized.home"] #content h1 {
-  text-align: center;
-  font-size: 2.5em;
-}
-
 #content.flash-index h2 {
   text-align: center;
   font-size: 2.5em;
@@ -1555,6 +1983,54 @@ ul.quick-info li:not(:last-child)::after {
   margin-top: 25px;
 }
 
+.gallery-set-switcher {
+  text-align: center;
+}
+
+.gallery-view-switcher {
+  margin-left: auto;
+  margin-right: auto;
+  text-align: center;
+  line-height: 1.4;
+}
+
+#content.top-index section {
+  margin-bottom: 1.5em;
+}
+
+.expandable-gallery-section .section-expando {
+  margin-top: 1em;
+  margin-bottom: 2em;
+
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+}
+
+.expandable-gallery-section .section-expando-content {
+  text-align: center;
+  line-height: 1.5;
+}
+
+.expandable-gallery-section .section-expando-toggle {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+.expandable-gallery-section.expanded .section-content-below-cut {
+  animation: expand-gallery-section 0.8s forwards;
+}
+
+@keyframes expand-gallery-section {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
+}
+
 .quick-description:not(.has-external-links-only) {
   --clamped-padding-ratio: max(var(--responsive-padding-ratio), 0.06);
   margin-left: auto;
@@ -1585,6 +2061,7 @@ ul.quick-info li:not(:last-child)::after {
 
 .quick-description > blockquote {
   margin-left: 0 !important;
+  margin-right: 0 !important;
 }
 
 .quick-description .description-content.long hr ~ p {
@@ -1632,7 +2109,6 @@ ul.quick-info li:not(:last-child)::after {
 
 li .by {
   font-style: oblique;
-  max-width: 600px;
 }
 
 li .by a {
@@ -1648,8 +2124,8 @@ p code {
 
 #content blockquote {
   margin-left: 40px;
-  max-width: 600px;
-  margin-right: 0;
+  margin-right: min(8vw, 75px);
+  width: auto;
 }
 
 #content blockquote blockquote {
@@ -1696,7 +2172,6 @@ main.long-content > h1 {
 
 dl dt {
   padding-left: 40px;
-  max-width: 600px;
 }
 
 dl dt {
@@ -1706,6 +2181,10 @@ dl dt {
   margin-bottom: 2px;
 }
 
+dl dt[id]:not(.content-heading) {
+  --custom-scroll-offset: calc(2.5em - 2px);
+}
+
 dl dd {
   margin-bottom: 1em;
 }
@@ -1721,6 +2200,13 @@ ul > li.has-details {
   margin-left: -17px;
 }
 
+li .origin-details {
+  display: block;
+  margin-left: 2ch;
+  font-size: 0.9em;
+  font-style: oblique;
+}
+
 .album-group-list dt,
 .group-series-list dt {
   font-style: oblique;
@@ -1732,6 +2218,15 @@ ul > li.has-details {
   margin-left: 0;
 }
 
+.album-group-list li {
+  padding-left: 1.5ch;
+  text-indent: -1.5ch;
+}
+
+.album-group-list li > * {
+  text-indent: 0;
+}
+
 .album-group-list blockquote {
   max-width: 540px;
   margin-bottom: 9px;
@@ -1762,31 +2257,54 @@ ul > li.has-details {
 
 #content hr {
   border: 1px inset #808080;
-  width: 100%;
+}
+
+#content hr.split {
+  color: #808080;
 }
 
 #content hr.split::before {
   content: "(split)";
-  color: #808080;
 }
 
-#content hr.split {
+#content hr.main-separator {
+  color: var(--dim-color);
+  clear: none;
+  margin-top: -0.25em;
+  margin-bottom: 1.75em;
+}
+
+#content hr.main-separator::before {
+  content: "♦";
+  font-size: 1.2em;
+}
+
+#content hr.split,
+#content hr.main-separator {
   position: relative;
   overflow: hidden;
   border: none;
 }
 
-#content hr.split::after {
+#content hr.split::after,
+#content hr.main-separator::after {
   display: inline-block;
   content: "";
-  border: 1px inset #808080;
-  width: 100%;
+  width: calc(100% - min(calc(8vw - 35px), 45px));
   position: absolute;
   top: 50%;
-  margin-top: -2px;
   margin-left: 10px;
 }
 
+#content hr.split::after {
+  border: 1px inset currentColor;
+  margin-top: -2px;
+}
+
+#content hr.main-separator::after {
+  border-bottom: 1px solid currentColor;
+}
+
 li > ul {
   margin-top: 5px;
 }
@@ -1901,19 +2419,9 @@ h1 a[href="#additional-names-box"]:hover {
   --custom-scroll-offset: calc(0.5em - 2px);
 
   margin: 1em 0 1em -10px;
-  padding: 15px 20px 10px 20px;
-  width: max-content;
   max-width: min(60vw, 600px);
 
-  border: 1px dotted var(--primary-color);
-  border-radius: 6px;
-
-  background:
-    linear-gradient(var(--bg-color), var(--bg-color)),
-    linear-gradient(#000000bb, #000000bb),
-    var(--primary-color);
-
-  box-shadow: 0 -2px 6px -1px var(--dim-color) inset;
+  padding: 15px 20px 10px 20px;
 
   display: none;
 }
@@ -1950,6 +2458,203 @@ h1 a[href="#additional-names-box"]:hover {
   vertical-align: text-bottom;
 }
 
+#content.top-index #additional-names-box {
+  margin-left: auto;
+  margin-right: auto;
+  margin-bottom: 2em;
+}
+
+#content.top-index #additional-names-box {
+  text-align: center;
+  margin-bottom: 0.75em;
+}
+
+/* Specific pages - homepage */
+
+html[data-url-key="localized.home"] #content h1 {
+  text-align: center;
+  font-size: 2.5em;
+}
+
+html[data-language-code="preview-en"][data-url-key="localized.home"] #content h1::after {
+  font-family: cursive;
+  display: block;
+  content: "(Preview Build)";
+  font-size: 0.8em;
+}
+
+/* Specific pages - art tag gallery */
+
+html[data-url-key="localized.artTagGallery"] #descends-from-line {
+  margin-bottom: 0.25em;
+}
+
+html[data-url-key="localized.artTagGallery"] #descendants-line {
+  margin-top: 0.25em;
+}
+
+html[data-url-key="localized.artTagGallery"] #descends-from-line a,
+html[data-url-key="localized.artTagGallery"] #descendants-line a {
+  display: inline-block;
+}
+
+
+html[data-url-key="localized.artTagGallery"] #featured-direct-line,
+html[data-url-key="localized.artTagGallery"] #featured-indirect-line,
+html[data-url-key="localized.artTagGallery"] #showing-direct-line,
+html[data-url-key="localized.artTagGallery"] #showing-indirect-line {
+  display: none;
+}
+
+html[data-url-key="localized.artTagGallery"] #showing-all-line a,
+html[data-url-key="localized.artTagGallery"] #showing-direct-line a,
+html[data-url-key="localized.artTagGallery"] #showing-indirect-line a {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+/* Specific pages - "Art Tag Network" listing */
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd) {
+  margin-left: 20px;
+  margin-bottom: 0;
+  padding-left: 10px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd):not(:last-child) {
+  padding-bottom: 20px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-stat-line {
+  padding-left: 10px;
+  margin-left: 20px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-stat-line a {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] .network-tag-stat {
+  display: inline-block;
+  text-align: right;
+  min-width: 5ch;
+  margin-right: 2px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-top-dl > dt:has(.network-tag.with-stat:not([style*="display: none"])) {
+  padding-left: 20px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dt + dt:has(+ dd) {
+  padding-top: 20px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dt:has(+ dd) .network-tag-stat {
+  text-align: center;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt {
+  padding-left: 10px;
+  margin-left: 20px;
+  margin-bottom: 0;
+  padding-bottom: 2px;
+  max-width: unset;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd).even,
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:not(#network-top-dl > dt).even {
+  border-left: 1px solid #eaeaea;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd).odd,
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:not(#network-top-dl > dt).odd {
+  border-left: 1px solid #7b7b7b;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd,
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt {
+  position: relative;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:last-child:not(#network-top-dl > dd).odd::after,
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:last-child:not(#network-top-dl > dt).odd::after {
+  content: "";
+  display: block;
+  width: 7px;
+  height: 7px;
+  background: #7b7b7b;
+  position: absolute;
+  bottom: -4px;
+  left: -4px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:last-child:not(#network-top-dl > dd).even::after,
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:last-child:not(#network-top-dl > dt).even::after {
+  content: "";
+  display: block;
+  width: 6px;
+  height: 6px;
+  background: #eaeaea;
+  position: absolute;
+  bottom: -3px;
+  left: -3px;
+  border-bottom-right-radius: 6px;
+  border-top-left-radius: 3px;
+}
+
+/* "Drops" */
+
+.drop {
+  padding: 15px 20px;
+  width: max-content;
+  max-width: min(60vw, 600px);
+
+  border: 1px dotted var(--primary-color);
+  border-radius: 6px;
+
+  background:
+    linear-gradient(var(--bg-color), var(--bg-color)),
+    linear-gradient(#000000bb, #000000bb),
+    var(--primary-color);
+
+  --drop-shadow: 0 -2px 6px -1px var(--dim-color) inset;
+  box-shadow: var(--drop-shadow);
+}
+
+.drop.shiny {
+  cursor: default;
+}
+
+@supports (box-shadow: 1px 1px 1px color-mix(in srgb, blue, 40% red)) {
+  @property --drop-shine {
+    syntax: '<percentage>';
+    initial-value: 0%;
+    inherits: false;
+  }
+
+  .drop.shiny {
+    cursor: default;
+    transition: --drop-shine 0.2s;
+  }
+
+  .drop.shiny:hover {
+    --drop-shine: 100%;
+
+    box-shadow:
+      var(--drop-shadow),
+      0 2px 4px -0.5px color-mix(in srgb, var(--primary-color), calc(100% - var(--drop-shine)) transparent);
+  }
+}
+
+.commentary-drop {
+  margin-top: 25px;
+  margin-bottom: 15px;
+  margin-left: 20px;
+  padding: 10px 20px;
+  max-width: min(60vw, 300px);
+}
+
 /* Images */
 
 .image-container {
@@ -1968,15 +2673,46 @@ h1 a[href="#additional-names-box"]:hover {
   color: white;
 }
 
-/* Videos (in content) get a lite version of image-container. */
-.content-video-container {
-  width: min-content;
+/* Videos and audios (in content) get a lite version of image-container. */
+.content-video-container,
+.content-audio-container {
+  width: fit-content;
+  max-width: 100%;
   background-color: var(--dark-color);
   border: 2px solid var(--primary-color);
   border-radius: 2.5px 2.5px 3px 3px;
   padding: 5px;
 }
 
+.content-video-container video,
+.content-audio-container audio {
+  display: block;
+  max-width: 100%;
+}
+
+.content-video-container.align-center,
+.content-audio-container.align-center {
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.content-video-container.align-full,
+.content-audio-container.align-full {
+  width: 100%;
+}
+
+.content-audio-container .filename {
+  color: white;
+  font-family: monospace;
+  display: block;
+  font-size: 0.9em;
+  padding-left: 1ch;
+  padding-right: 1ch;
+  padding-bottom: 0.25em;
+  margin-bottom: 0.5em;
+  border-bottom: 1px solid #fff4;
+}
+
 .image-text-area {
   position: absolute;
   top: 0;
@@ -2031,6 +2767,12 @@ img {
   object-fit: cover;
 }
 
+p > img {
+  max-width: 100%;
+  object-fit: contain;
+  height: auto;
+}
+
 .image-inner-area::after {
   content: "";
   display: block;
@@ -2261,6 +3003,10 @@ video.pixelate, .pixelate video {
   max-width: 200px;
 }
 
+.grid-name-marker {
+  color: white;
+}
+
 .grid-actions {
   display: flex;
   flex-direction: row;
@@ -2619,14 +3365,32 @@ h3.content-heading {
   );
 }
 
+.content-sticky-heading-root {
+  width: calc(100% + 2 * var(--content-padding));
+  margin: calc(-1 * var(--content-padding));
+  margin-bottom: 0;
+}
+
+.content-sticky-heading-anchor,
 .content-sticky-heading-container {
+  width: 100%;
+}
+
+.content-sticky-heading-root:not([inert]) {
   position: sticky;
   top: 0;
+}
 
-  margin: calc(-1 * var(--content-padding));
-  margin-bottom: calc(0.5 * var(--content-padding));
+.content-sticky-heading-anchor:not(:where(.content-sticky-heading-root[inert]) *) {
+  position: relative;
+}
 
-  transform: translateY(-5px);
+.content-sticky-heading-container:not(:where(.content-sticky-heading-root[inert]) *) {
+  position: absolute;
+}
+
+.content-sticky-heading-root[inert] {
+  visibility: hidden;
 }
 
 main.long-content .content-sticky-heading-container {
@@ -2666,9 +3430,60 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   grid-template-columns: 1fr min(40%, 400px);
 }
 
+.content-sticky-heading-container.cover-visible .content-sticky-heading-row {
+  grid-template-columns: 1fr min(40%, 90px);
+}
+
+.content-sticky-heading-root.has-cover {
+  padding-right: min(40%, 400px);
+}
+
 .content-sticky-heading-row h1 {
+  position: relative;
   margin: 0;
   padding-right: 20px;
+  line-height: 1.4;
+}
+
+.content-sticky-heading-row h1 .reference-collapsed-heading {
+  position: absolute;
+  white-space: nowrap;
+  visibility: hidden;
+}
+
+.content-sticky-heading-container.collapse h1 {
+  white-space: nowrap;
+  overflow-wrap: normal;
+
+  animation: collapse-sticky-heading 0.35s forwards;
+  text-overflow: ellipsis;
+  overflow-x: hidden;
+}
+
+@keyframes collapse-sticky-heading {
+  from {
+    height: var(--uncollapsed-heading-height);
+  }
+
+  99.9% {
+    height: var(--collapsed-heading-height);
+  }
+
+  to {
+    height: auto;
+  }
+}
+
+.content-sticky-heading-container h1 a {
+  transition: text-decoration-color 0.35s;
+}
+
+.content-sticky-heading-container h1 a:not([href]) {
+  color: inherit;
+  cursor: text;
+  text-decoration: underline;
+  text-decoration-style: dotted;
+  text-decoration-color: transparent;
 }
 
 .content-sticky-heading-cover-container {
@@ -2696,7 +3511,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   transition: transform 0.35s, opacity 0.30s;
 }
 
-.content-sticky-heading-cover .image-container {
+.content-sticky-heading-cover .cover-artwork {
   border-width: 1px;
   border-radius: 1.25px;
   box-shadow: none;
@@ -2890,31 +3705,34 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
           backdrop-filter: blur(3px);
 }
 
-#image-overlay-image-container {
+#image-overlay-image-area {
   display: block;
-  position: relative;
   overflow: hidden;
   width: 80vmin;
-  height: 80vmin;
   margin-left: auto;
   margin-right: auto;
 }
 
+#image-overlay-image-layout {
+  display: block;
+  position: relative;
+  margin: 4px 3px;
+  background: rgba(0, 0, 0, 0.65);
+}
+
 #image-overlay-image,
 #image-overlay-image-thumb {
-  display: inline-block;
-  object-fit: contain;
+  display: block;
   width: 100%;
-  height: 100%;
-  background: rgba(0, 0, 0, 0.65);
+  height: auto;
 }
 
 #image-overlay-image {
   position: absolute;
-  top: 3px;
-  left: 3px;
-  width: calc(100% - 6px);
-  height: calc(100% - 4px);
+}
+
+#image-overlay-container.no-thumb #image-overlay-image {
+  position: static;
 }
 
 #image-overlay-image-thumb {
@@ -2928,7 +3746,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   transition: opacity 0.25s;
 }
 
-#image-overlay-image-container::after {
+#image-overlay-image-area::after {
   content: "";
   display: block;
   position: absolute;
@@ -2941,18 +3759,18 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   transition: 0.25s;
 }
 
-#image-overlay-container.loaded #image-overlay-image-container::after {
+#image-overlay-container.loaded #image-overlay-image-area::after {
   width: 100%;
   background: white;
   opacity: 0;
 }
 
-#image-overlay-container.errored #image-overlay-image-container::after {
+#image-overlay-container.errored #image-overlay-image-area::after {
   width: 100%;
   background: red;
 }
 
-#image-overlay-container:not(.visible) #image-overlay-image-container::after {
+#image-overlay-container:not(.visible) #image-overlay-image-area::after {
   width: 0 !important;
 }
 
@@ -2980,21 +3798,11 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   font-size: 0.9em;
 }
 
-/* important easter egg mode */
-
-html[data-language-code="preview-en"][data-url-key="localized.home"] #content
-  h1::after {
-  font-family: cursive;
-  display: block;
-  content: "(Preview Build)";
-  font-size: 0.8em;
-}
-
 /* Layout - Wide (most computers) */
 
-@media (min-width: 900px) {
-  #page-container.showing-sidebar-left #secondary-nav:not(.always-visible),
-  #page-container.showing-sidebar-right #secondary-nav:not(.always-visible) {
+@media (min-width: 850px) {
+  #page-container.showing-sidebar-left:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible),
+  #page-container.showing-sidebar-right:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible) {
     display: none;
   }
 }
@@ -3006,7 +3814,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
  * if so desired.
  */
 
-@media (min-width: 600px) and (max-width: 899.98px) {
+@media (min-width: 600px) and (max-width: 849.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.
@@ -3020,7 +3828,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
 /* Layout - Wide or Medium */
 
 @media (min-width: 600px) {
-  .content-sticky-heading-container {
+  .content-sticky-heading-root {
     /* Safari doesn't always play nicely with position: sticky,
      * this seems to fix images sometimes displaying above the
      * position: absolute subheading (h2) child
@@ -3034,10 +3842,11 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
   /* Cover art floats to the right. It's positioned in HTML beneath the
    * heading, so pull it up a little to "float" on top.
    */
-  #cover-art-container {
+  #artwork-column {
     float: right;
     width: 40%;
-    max-width: 400px;
+    min-width: 220px;
+    max-width: 280px;
     margin: -60px 0 10px 20px;
 
     position: relative;
@@ -3047,18 +3856,18 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
   /* ...Except on top-indexes, where cover art is displayed prominently
    * between the heading and subheading.
    */
-  #content.top-index #cover-art-container {
+  #content.top-index #artwork-column {
     float: none;
     margin: 2em auto 2.5em auto;
     max-width: 375px;
   }
 
-  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+10)) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+7)) {
     flex-basis: 23%;
     margin: 15px;
   }
 
-  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+10) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+7) {
     flex-basis: 18%;
     margin: 10px;
   }
@@ -3066,13 +3875,15 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
 
 /* Layout - Medium or Thin */
 
-@media (max-width: 899.98px) {
+@media (max-width: 849.98px) {
   .sidebar.collapsible,
   .sidebar-box-joiner.collapsible,
   .sidebar-column.all-boxes-collapsible {
     display: none;
   }
 
+  /* Duplicated for "sidebars in content column" */
+
   .layout-columns {
     flex-direction: column;
   }
@@ -3094,13 +3905,51 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     display: none;
   }
 
+  .wiki-search-sidebar-box {
+    max-height: max(245px, 60vh, calc(100vh - 205px));
+  }
+
+  /* End duplicated for "sidebars in content column" */
+
   .grid-listing > .grid-item {
     flex-basis: 40%;
   }
+}
 
-  .wiki-search-sidebar-box {
-    max-height: max(245px, 60vh, calc(100vh - 205px));
-  }
+/* Layout - "sidebars in content column"
+ * This is the same code as immediately above, for medium and
+ * thin layouts, but can be opted into by the page itself
+ * instead of through a media query.
+ */
+
+#page-container.sidebars-in-content-column
+.layout-columns {
+  flex-direction: column;
+}
+
+#page-container.sidebars-in-content-column
+.layout-columns > *:not(:last-child) {
+  margin-bottom: 10px;
+}
+
+#page-container.sidebars-in-content-column
+.sidebar-column {
+  position: static !important;
+  max-width: unset !important;
+  flex-basis: unset !important;
+  margin-right: 0 !important;
+  margin-left: 0 !important;
+  width: 100%;
+}
+
+#page-container.sidebars-in-content-column
+.sidebar .news-entry:not(.first-news-entry) {
+  display: none;
+}
+
+#page-container.sidebars-in-content-column
+.wiki-search-sidebar-box {
+  max-height: max(245px, 60vh, calc(100vh - 205px));
 }
 
 /* Layout - Thin (phones) */
@@ -3114,12 +3963,18 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     --responsive-padding-ratio: 0.02;
   }
 
-  #cover-art-container {
+  #artwork-column {
     margin: 25px 0 5px 0;
     width: 100%;
     max-width: unset;
   }
 
+  #artwork-column .cover-artwork:not(:first-child),
+  #artwork-column .cover-artwork-joiner {
+    margin-left: 30px;
+    margin-right: 30px;
+  }
+
   #additional-names-box {
     width: unset;
     max-width: unset;
@@ -3131,7 +3986,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
 
   /* Show sticky heading above cover art */
 
-  .content-sticky-heading-container {
+  .content-sticky-heading-root {
     z-index: 2;
   }
 
diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js
index 558ef06f..195ba25d 100644
--- a/src/static/js/client/additional-names-box.js
+++ b/src/static/js/client/additional-names-box.js
@@ -3,12 +3,17 @@
 import {cssProp} from '../client-util.js';
 
 import {info as hashLinkInfo} from './hash-link.js';
+import {info as stickyHeadingInfo} from './sticky-heading.js';
 
 export const info = {
   id: 'additionalNamesBoxInfo',
 
   box: null,
+
   links: null,
+  stickyHeadingLink: null,
+
+  contentContainer: null,
   mainContentContainer: null,
 
   state: {
@@ -23,6 +28,16 @@ export function getPageReferences() {
   info.links =
     document.querySelectorAll('a[href="#additional-names-box"]');
 
+  info.stickyHeadingLink =
+    document.querySelector(
+      '.content-sticky-heading-container' +
+      ' ' +
+      'a[href="#additional-names-box"]' +
+      ':not(:where([inert] *))');
+
+  info.contentContainer =
+    document.querySelector('#content');
+
   info.mainContentContainer =
     document.querySelector('#content .main-content-container');
 }
@@ -33,6 +48,34 @@ export function addInternalListeners() {
       return false;
     }
   });
+
+  stickyHeadingInfo.event.whenStuckStatusChanges.push((index, stuck) => {
+    const {state} = info;
+
+    if (!info.stickyHeadingLink) return;
+
+    const container = stickyHeadingInfo.contentContainers[index];
+    if (container !== info.contentContainer) return;
+
+    if (stuck) {
+      if (!state.visible) {
+        info.stickyHeadingLink.removeAttribute('href');
+
+        if (info.stickyHeadingLink.hasAttribute('title')) {
+          info.stickyHeadingLink.dataset.restoreTitle = info.stickyHeadingLink.getAttribute('title');
+          info.stickyHeadingLink.removeAttribute('title');
+        }
+      }
+    } else {
+      info.stickyHeadingLink.setAttribute('href', '#additional-names-box');
+
+      const {restoreTitle} = info.stickyHeadingLink.dataset;
+      if (restoreTitle) {
+        info.stickyHeadingLink.setAttribute('title', restoreTitle);
+        delete info.stickyHeadingLink.dataset.restoreTitle;
+      }
+    }
+  });
 }
 
 export function addPageListeners() {
@@ -48,6 +91,7 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) {
 
   domEvent.preventDefault();
 
+  if (!domEvent.target.hasAttribute('href')) return;
   if (!info.box || !info.mainContentContainer) return;
 
   const margin =
@@ -58,7 +102,30 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) {
       ? info.box.getBoundingClientRect()
       : info.mainContentContainer.getBoundingClientRect());
 
-  if (top + 20 < margin || top > 0.4 * window.innerHeight) {
+  const {bottom, height} =
+    (state.visible
+      ? info.box.getBoundingClientRect()
+      : {bottom: null});
+
+  const boxFitsInFrame =
+    (height
+      ? height < window.innerHeight - margin - 60
+      : null);
+
+  const worthScrolling =
+    top + 20 < margin ||
+
+    (height && boxFitsInFrame
+      ? top > 0.7 * window.innerHeight
+   : height && !boxFitsInFrame
+      ? top > 0.4 * window.innerHeight
+      : top > 0.5 * window.innerHeight) ||
+
+    (bottom && boxFitsInFrame
+      ? bottom > window.innerHeight - 20
+      : false);
+
+  if (worthScrolling) {
     if (!state.visible) {
       toggleAdditionalNamesBox();
     }
diff --git a/src/static/js/client/art-tag-gallery-filter.js b/src/static/js/client/art-tag-gallery-filter.js
new file mode 100644
index 00000000..fd40d1a2
--- /dev/null
+++ b/src/static/js/client/art-tag-gallery-filter.js
@@ -0,0 +1,151 @@
+/* eslint-env browser */
+
+export const info = {
+  id: 'artTagGalleryFilterInfo',
+
+  featuredAllLine: null,
+  showingAllLine: null,
+  showingAllLink: null,
+
+  featuredDirectLine: null,
+  showingDirectLine: null,
+  showingDirectLink: null,
+
+  featuredIndirectLine: null,
+  showingIndirectLine: null,
+  showingIndirectLink: null,
+
+  gridItems: null,
+  gridItemsOnlyFeaturedIndirectly: null,
+  gridItemsFeaturedDirectly: null,
+};
+
+export function getPageReferences() {
+  if (document.documentElement.dataset.urlKey !== 'localized.artTagGallery') {
+    return;
+  }
+
+  info.featuredAllLine =
+    document.getElementById('featured-all-line');
+
+  info.featuredDirectLine =
+    document.getElementById('featured-direct-line');
+
+  info.featuredIndirectLine =
+    document.getElementById('featured-indirect-line');
+
+  info.showingAllLine =
+    document.getElementById('showing-all-line');
+
+  info.showingDirectLine =
+    document.getElementById('showing-direct-line');
+
+  info.showingIndirectLine =
+    document.getElementById('showing-indirect-line');
+
+  info.showingAllLink =
+    info.showingAllLine?.querySelector('a') ?? null;
+
+  info.showingDirectLink =
+    info.showingDirectLine?.querySelector('a') ?? null;
+
+  info.showingIndirectLink =
+    info.showingIndirectLine?.querySelector('a') ?? null;
+
+  info.gridItems =
+    Array.from(
+      document.querySelectorAll('#content .grid-listing .grid-item'));
+
+  info.gridItemsOnlyFeaturedIndirectly =
+    info.gridItems
+      .filter(gridItem => gridItem.classList.contains('featured-indirectly'));
+
+  info.gridItemsFeaturedDirectly =
+    info.gridItems
+      .filter(gridItem => !gridItem.classList.contains('featured-indirectly'));
+}
+
+function filterArtTagGallery(showing) {
+  let gridItemsToShow;
+
+  switch (showing) {
+    case 'all':
+      gridItemsToShow = info.gridItems;
+      break;
+
+    case 'direct':
+      gridItemsToShow = info.gridItemsFeaturedDirectly;
+      break;
+
+    case 'indirect':
+      gridItemsToShow = info.gridItemsOnlyFeaturedIndirectly;
+      break;
+  }
+
+  for (const gridItem of info.gridItems) {
+    if (gridItemsToShow.includes(gridItem)) {
+      gridItem.style.removeProperty('display');
+    } else {
+      gridItem.style.display = 'none';
+    }
+  }
+}
+
+export function addPageListeners() {
+  const orderShowing = [
+    'all',
+    'direct',
+    'indirect',
+  ];
+
+  const orderFeaturedLines = [
+    info.featuredAllLine,
+    info.featuredDirectLine,
+    info.featuredIndirectLine,
+  ];
+
+  const orderShowingLines = [
+    info.showingAllLine,
+    info.showingDirectLine,
+    info.showingIndirectLine,
+  ];
+
+  const orderShowingLinks = [
+    info.showingAllLink,
+    info.showingDirectLink,
+    info.showingIndirectLink,
+  ];
+
+  for (let index = 0; index < orderShowing.length; index++) {
+    if (!orderShowingLines[index]) continue;
+
+    let nextIndex = index;
+    do {
+      if (nextIndex === orderShowing.length) {
+        nextIndex = 0;
+      } else {
+        nextIndex++;
+      }
+    } while (!orderShowingLinks[nextIndex]);
+
+    const currentFeaturedLine = orderFeaturedLines[index];
+    const currentShowingLine = orderShowingLines[index];
+    const currentShowingLink = orderShowingLinks[index];
+
+    const nextFeaturedLine = orderFeaturedLines[nextIndex];
+    const nextShowingLine = orderShowingLines[nextIndex];
+    const nextShowing = orderShowing[nextIndex];
+
+    currentShowingLink.addEventListener('click', event => {
+      event.preventDefault();
+
+      currentFeaturedLine.style.display = 'none';
+      currentShowingLine.style.display = 'none';
+
+      nextFeaturedLine.style.display = 'block';
+      nextShowingLine.style.display = 'block';
+
+      filterArtTagGallery(nextShowing);
+    });
+  }
+}
diff --git a/src/static/js/client/art-tag-network.js b/src/static/js/client/art-tag-network.js
new file mode 100644
index 00000000..44e10c11
--- /dev/null
+++ b/src/static/js/client/art-tag-network.js
@@ -0,0 +1,147 @@
+/* eslint-env browser */
+
+import {cssProp} from '../client-util.js';
+
+import {atOffset, stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'artTagNetworkInfo',
+
+  noneStatLink: null,
+  totalUsesStatLink: null,
+  directUsesStatLink: null,
+  descendantsStatLink: null,
+  leavesStatLink: null,
+
+  tagsWithoutStats: null,
+  tagsWithStats: null,
+
+  totalUsesStats: null,
+  directUsesStats: null,
+  descendantsStats: null,
+  leavesStats: null,
+};
+
+export function getPageReferences() {
+  if (
+    document.documentElement.dataset.urlKey !== 'localized.listing' ||
+    document.documentElement.dataset.urlValue0 !== 'tags/network'
+  ) {
+    return;
+  }
+
+  info.noneStatLink =
+    document.getElementById('network-stat-none');
+
+  info.totalUsesStatLink =
+    document.getElementById('network-stat-total-uses');
+
+  info.directUsesStatLink =
+    document.getElementById('network-stat-direct-uses');
+
+  info.descendantsStatLink =
+    document.getElementById('network-stat-descendants');
+
+  info.leavesStatLink =
+    document.getElementById('network-stat-leaves');
+
+  info.tagsWithoutStats =
+    document.querySelectorAll('.network-tag:not(.with-stat)');
+
+  info.tagsWithStats =
+    document.querySelectorAll('.network-tag.with-stat');
+
+  info.totalUsesStats =
+    Array.from(document.getElementsByClassName('network-tag-total-uses-stat'));
+
+  info.directUsesStats =
+    Array.from(document.getElementsByClassName('network-tag-direct-uses-stat'));
+
+  info.descendantsStats =
+    Array.from(document.getElementsByClassName('network-tag-descendants-stat'));
+
+  info.leavesStats =
+    Array.from(document.getElementsByClassName('network-tag-leaves-stat'));
+}
+
+export function addPageListeners() {
+  if (!info.noneStatLink) return;
+
+  const linkOrder = [
+    info.noneStatLink,
+    info.totalUsesStatLink,
+    info.directUsesStatLink,
+    info.descendantsStatLink,
+    info.leavesStatLink,
+  ];
+
+  const statsOrder = [
+    null,
+    info.totalUsesStats,
+    info.directUsesStats,
+    info.descendantsStats,
+    info.leavesStats,
+  ];
+
+  const stitched =
+    stitchArrays({
+      link: linkOrder,
+      stats: statsOrder,
+    });
+
+  for (const [index, {link}] of stitched.entries()) {
+    const next = atOffset(stitched, index, +1, {wrap: true});
+
+    link.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+
+      cssProp(link, 'display', 'none');
+      cssProp(next.link, 'display', null);
+
+      if (next.stats === null) {
+        hideArtTagNetworkStats();
+      } else {
+        showArtTagNetworkStats(next.stats);
+      }
+    });
+  }
+}
+
+function showArtTagNetworkStats(stats) {
+  for (const tagElement of info.tagsWithoutStats) {
+    cssProp(tagElement, 'display', 'none');
+  }
+
+  for (const tagElement of info.tagsWithStats) {
+    cssProp(tagElement, 'display', null);
+  }
+
+  const allStats = [
+    ...info.totalUsesStats,
+    ...info.directUsesStats,
+    ...info.descendantsStats,
+    ...info.leavesStats,
+  ];
+
+  const otherStats =
+    allStats
+      .filter(stat => !stats.includes(stat));
+
+  for (const statElement of otherStats) {
+    cssProp(statElement, 'display', 'none');
+  }
+
+  for (const statElement of stats) {
+    cssProp(statElement, 'display', null);
+  }
+}
+
+function hideArtTagNetworkStats() {
+  for (const tagElement of info.tagsWithoutStats) {
+    cssProp(tagElement, 'display', null);
+  }
+
+  for (const tagElement of info.tagsWithStats) {
+    cssProp(tagElement, 'display', 'none');
+  }
+}
diff --git a/src/static/js/client/css-compatibility-assistant.js b/src/static/js/client/css-compatibility-assistant.js
index 6e7b15b5..aa637cc4 100644
--- a/src/static/js/client/css-compatibility-assistant.js
+++ b/src/static/js/client/css-compatibility-assistant.js
@@ -1,22 +1,30 @@
 /* eslint-env browser */
 
+import {stitchArrays} from '../../shared-util/sugar.js';
+
 export const info = {
   id: 'cssCompatibilityAssistantInfo',
 
-  coverArtContainer: null,
-  coverArtImageDetails: null,
+  coverArtworks: null,
+  coverArtworkImageDetails: null,
 };
 
 export function getPageReferences() {
-  info.coverArtContainer =
-    document.getElementById('cover-art-container');
+  info.coverArtworks =
+    Array.from(document.querySelectorAll('.cover-artwork'));
 
-  info.coverArtImageDetails =
-    info.coverArtContainer?.querySelector('.image-details');
+  info.coverArtworkImageDetails =
+    info.coverArtworks
+      .map(artwork => artwork.querySelector('.image-details'));
 }
 
 export function mutatePageContent() {
-  if (info.coverArtImageDetails) {
-    info.coverArtContainer.classList.add('has-image-details');
-  }
+  stitchArrays({
+    coverArtwork: info.coverArtworks,
+    imageDetails: info.coverArtworkImageDetails,
+  }).forEach(({coverArtwork, imageDetails}) => {
+      if (imageDetails) {
+        coverArtwork.classList.add('has-image-details');
+      }
+    });
 }
diff --git a/src/static/js/client/expandable-gallery-section.js b/src/static/js/client/expandable-gallery-section.js
new file mode 100644
index 00000000..dc83e8b7
--- /dev/null
+++ b/src/static/js/client/expandable-gallery-section.js
@@ -0,0 +1,77 @@
+/* eslint-env browser */
+
+// TODO: Combine this and quick-description.js
+
+import {cssProp} from '../client-util.js';
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'expandableGallerySectionInfo',
+
+  sections: null,
+
+  sectionContentBelowCut: null,
+
+  sectionExpandoToggles: null,
+
+  sectionExpandCues: null,
+  sectionCollapseCues: null,
+};
+
+export function getPageReferences() {
+  info.sections =
+    Array.from(document.querySelectorAll('.expandable-gallery-section'))
+      .filter(section => section.querySelector('.section-expando-toggle'));
+
+  info.sectionContentBelowCut =
+    info.sections
+      .map(section => section.querySelector('.section-content-below-cut'));
+
+  info.sectionExpandoToggles =
+    info.sections
+      .map(section => section.querySelector('.section-expando-toggle'));
+
+  info.sectionExpandCues =
+    info.sections
+      .map(section => section.querySelector('.section-expand-cue'));
+
+  info.sectionCollapseCues =
+    info.sections
+      .map(section => section.querySelector('.section-collapse-cue'));
+}
+
+export function addPageListeners() {
+  for (const {
+    section,
+    contentBelowCut,
+    expandoToggle,
+    expandCue,
+    collapseCue,
+  } of stitchArrays({
+    section: info.sections,
+    contentBelowCut: info.sectionContentBelowCut,
+    expandoToggle: info.sectionExpandoToggles,
+    expandCue: info.sectionExpandCues,
+    collapseCue: info.sectionCollapseCues,
+  })) {
+    expandoToggle.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+
+      const collapsed =
+        cssProp(contentBelowCut, 'display') === 'none';
+
+      if (collapsed) {
+        section.classList.add('expanded');
+        cssProp(contentBelowCut, 'display', null);
+        cssProp(expandCue, 'display', 'none');
+        cssProp(collapseCue, 'display', null);
+      } else {
+        section.classList.remove('expanded');
+        cssProp(contentBelowCut, 'display', 'none');
+        cssProp(expandCue, 'display', null);
+        cssProp(collapseCue, 'display', 'none');
+      }
+    });
+  }
+}
diff --git a/src/static/js/client/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js
index 484f2ab0..89119a47 100644
--- a/src/static/js/client/hoverable-tooltip.js
+++ b/src/static/js/client/hoverable-tooltip.js
@@ -118,17 +118,17 @@ export function registerTooltipElement(tooltip) {
     handleTooltipMouseLeft(tooltip);
   });
 
-  tooltip.addEventListener('focusin', event => {
-    handleTooltipReceivedFocus(tooltip, event.relatedTarget);
+  tooltip.addEventListener('focusin', domEvent => {
+    handleTooltipReceivedFocus(tooltip, domEvent.relatedTarget);
   });
 
-  tooltip.addEventListener('focusout', event => {
+  tooltip.addEventListener('focusout', domEvent => {
     // 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;
+    if (currentlyShownTooltipHasFocus(domEvent.relatedTarget)) return;
 
-    handleTooltipLostFocus(tooltip, event.relatedTarget);
+    handleTooltipLostFocus(tooltip, domEvent.relatedTarget);
   });
 }
 
@@ -158,20 +158,20 @@ export function registerTooltipHoverableElement(hoverable, tooltip) {
     handleTooltipHoverableMouseLeft(hoverable);
   });
 
-  hoverable.addEventListener('focusin', event => {
-    handleTooltipHoverableReceivedFocus(hoverable, event);
+  hoverable.addEventListener('focusin', domEvent => {
+    handleTooltipHoverableReceivedFocus(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('focusout', event => {
-    handleTooltipHoverableLostFocus(hoverable, event);
+  hoverable.addEventListener('focusout', domEvent => {
+    handleTooltipHoverableLostFocus(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('touchend', event => {
-    handleTooltipHoverableTouchEnded(hoverable, event);
+  hoverable.addEventListener('touchend', domEvent => {
+    handleTooltipHoverableTouchEnded(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('click', event => {
-    handleTooltipHoverableClicked(hoverable, event);
+  hoverable.addEventListener('click', domEvent => {
+    handleTooltipHoverableClicked(hoverable, domEvent);
   });
 }
 
@@ -416,7 +416,7 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
     }, 1200);
 }
 
-function handleTooltipHoverableClicked(hoverable) {
+function handleTooltipHoverableClicked(hoverable, domEvent) {
   const {state} = info;
 
   // Don't navigate away from the page if the this hoverable was recently
@@ -426,7 +426,7 @@ function handleTooltipHoverableClicked(hoverable) {
     state.currentlyActiveHoverable === hoverable &&
     state.hoverableWasRecentlyTouched
   ) {
-    event.preventDefault();
+    domEvent.preventDefault();
   }
 }
 
@@ -576,6 +576,17 @@ export function showTooltipFromHoverable(hoverable) {
 
   hoverable.classList.add('has-visible-tooltip');
 
+  const isolator =
+    hoverable.closest('.isolate-tooltip-z-indexing > *');
+
+  if (isolator) {
+    for (const child of isolator.parentElement.children) {
+      cssProp(child, 'z-index', null);
+    }
+
+    cssProp(isolator, 'z-index', '1');
+  }
+
   positionTooltipFromHoverableWithBrains(hoverable);
 
   cssProp(tooltip, 'display', 'block');
@@ -667,12 +678,12 @@ export function positionTooltipFromHoverableWithBrains(hoverable) {
 
     for (let i = 0; i < numBaselineRects; i++) {
       for (const [dir1, dir2] of [
+        ['down', 'right'],
+        ['down', 'left'],
         ['right', 'down'],
         ['left', 'down'],
         ['right', 'up'],
         ['left', 'up'],
-        ['down', 'right'],
-        ['down', 'left'],
         ['up', 'right'],
         ['up', 'left'],
       ]) {
@@ -995,6 +1006,14 @@ export function getTooltipBaselineOpportunityAreas(tooltip) {
   return results;
 }
 
+export function mutatePageContent() {
+  for (const isolatorRoot of document.querySelectorAll('.isolate-tooltip-z-indexing')) {
+    if (isolatorRoot.firstElementChild) {
+      cssProp(isolatorRoot.firstElementChild, 'z-index', '1');
+    }
+  }
+}
+
 export function addPageListeners() {
   const {state} = info;
 
diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js
index 1e0ebd75..e9e2708d 100644
--- a/src/static/js/client/image-overlay.js
+++ b/src/static/js/client/image-overlay.js
@@ -66,8 +66,13 @@ export function getPageReferences() {
   info.fileSizeWarning =
     document.getElementById('image-overlay-file-size-warning');
 
+  const linkQuery = [
+    '.image-link',
+    '.image-media-link',
+  ].join(', ');
+
   info.links =
-    Array.from(document.querySelectorAll('.image-link'))
+    Array.from(document.querySelectorAll(linkQuery))
       .filter(link => !link.closest('.no-image-preview'));
 }
 
@@ -88,10 +93,13 @@ function handleContainerClicked(evt) {
     return;
   }
 
-  // If you clicked anything close to or beneath the action bar, don't hide
-  // the image overlay.
+  // If you clicked anything near the action bar, don't hide the
+  // image overlay.
   const rect = info.actionContainer.getBoundingClientRect();
-  if (evt.clientY >= rect.top - 40) {
+  if (
+    evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40 &&
+    evt.clientX >= rect.left + 20 && evt.clientX <= rect.right - 20
+  ) {
     return;
   }
 
@@ -141,13 +149,23 @@ function getImageLinkDetails(imageLink) {
       a.href,
 
     embeddedSrc:
-      img.src,
+      img?.src ??
+      a.dataset.embedSrc,
 
     originalFileSize:
-      img.dataset.originalSize,
+      img?.dataset.originalSize ??
+      a.dataset.originalSize ??
+      null,
 
     availableThumbList:
-      img.dataset.thumbs,
+      img?.dataset.thumbs ??
+      a.dataset.thumbs ??
+      null,
+
+    dimensions:
+      img?.dataset.dimensions?.split('x') ??
+      a.dataset.dimensions?.split('x') ??
+      null,
 
     color:
       cssProp(imageLink, '--primary-color'),
@@ -211,15 +229,31 @@ async function loadOverlayImage(details) {
   if (details.thumbSrc) {
     info.thumbImage.src = details.thumbSrc;
     info.thumbImage.style.display = null;
+    info.container.classList.remove('no-thumb');
   } else {
     info.thumbImage.src = '';
     info.thumbImage.style.display = 'none';
+    info.container.classList.add('no-thumb');
   }
 
   // Show the thumbnail size on each <img> element's data attributes.
   // Y'know, just for debugging convenience.
   info.mainImage.dataset.displayingThumb = details.mainThumb;
-  info.thumbImage.dataset.displayingThumb = details.thumbThubm;
+  info.thumbImage.dataset.displayingThumb = details.thumbThumb;
+
+  if (details.dimensions) {
+    info.mainImage.width = details.dimensions[0];
+    info.mainImage.height = details.dimensions[1];
+    info.thumbImage.width = details.dimensions[0];
+    info.thumbImage.height = details.dimensions[1];
+    cssProp(info.thumbImage, 'aspect-ratio', details.dimensions.join('/'));
+  } else {
+    info.mainImage.removeAttribute('width');
+    info.mainImage.removeAttribute('height');
+    info.thumbImage.removeAttribute('width');
+    info.thumbImage.removeAttribute('height');
+    cssProp(info.thumbImage, 'aspect-ratio', null);
+  }
 
   info.mainImage.addEventListener('load', handleMainImageLoaded);
   info.mainImage.addEventListener('error', handleMainImageErrored);
diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js
index 52d2afd6..aeb9264a 100644
--- a/src/static/js/client/index.js
+++ b/src/static/js/client/index.js
@@ -4,10 +4,13 @@ import '../group-contributions-table.js';
 
 import * as additionalNamesBoxModule from './additional-names-box.js';
 import * as albumCommentarySidebarModule from './album-commentary-sidebar.js';
+import * as artTagGalleryFilterModule from './art-tag-gallery-filter.js';
+import * as artTagNetworkModule from './art-tag-network.js';
 import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip.js';
 import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js';
 import * as datetimestampTooltipModule from './datetimestamp-tooltip.js';
 import * as draggedLinkModule from './dragged-link.js';
+import * as expandableGallerySectionModule from './expandable-gallery-section.js';
 import * as hashLinkModule from './hash-link.js';
 import * as hoverableTooltipModule from './hoverable-tooltip.js';
 import * as imageOverlayModule from './image-overlay.js';
@@ -24,10 +27,13 @@ import * as wikiSearchModule from './wiki-search.js';
 export const modules = [
   additionalNamesBoxModule,
   albumCommentarySidebarModule,
+  artTagGalleryFilterModule,
+  artTagNetworkModule,
   artistExternalLinkTooltipModule,
   cssCompatibilityAssistantModule,
   datetimestampTooltipModule,
   draggedLinkModule,
+  expandableGallerySectionModule,
   hashLinkModule,
   hoverableTooltipModule,
   imageOverlayModule,
diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js
index cff82252..6a7a6023 100644
--- a/src/static/js/client/quick-description.js
+++ b/src/static/js/client/quick-description.js
@@ -1,5 +1,7 @@
 /* eslint-env browser */
 
+// TODO: Combine this and expandable-gallery-section.js
+
 import {stitchArrays} from '../../shared-util/sugar.js';
 
 export const info = {
diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js
index fb902636..eae1e74e 100644
--- a/src/static/js/client/sidebar-search.js
+++ b/src/static/js/client/sidebar-search.js
@@ -1,7 +1,7 @@
 /* eslint-env browser */
 
 import {getColors} from '../../shared-util/colors.js';
-import {accumulateSum, empty} from '../../shared-util/sugar.js';
+import {accumulateSum, empty, unique} from '../../shared-util/sugar.js';
 
 import {
   cssProp,
@@ -41,6 +41,14 @@ export const info = {
   failedRule: null,
   failedContainer: null,
 
+  filterContainer: null,
+  albumFilterLink: null,
+  artistFilterLink: null,
+  flashFilterLink: null,
+  groupFilterLink: null,
+  tagFilterLink: null,
+  trackFilterLink: null,
+
   resultsRule: null,
   resultsContainer: null,
   results: null,
@@ -65,6 +73,13 @@ export const info = {
   groupResultKindString: null,
   tagResultKindString: null,
 
+  albumResultFilterString: null,
+  artistResultFilterString: null,
+  flashResultFilterString: null,
+  groupResultFilterString: null,
+  tagResultFilterString: null,
+  trackResultFilterString: null,
+
   state: {
     sidebarColumnShownForSearch: null,
 
@@ -97,6 +112,10 @@ export const info = {
       maxLength: settings => settings.maxActiveResultsStorage,
     },
 
+    activeFilterType: {
+      type: 'string',
+    },
+
     repeatQueryOnReload: {
       type: 'boolean',
       default: false,
@@ -176,6 +195,24 @@ export function getPageReferences() {
 
   info.tagResultKindString =
     findString('tag-result-kind');
+
+  info.albumResultFilterString =
+    findString('album-result-filter');
+
+  info.artistResultFilterString =
+    findString('artist-result-filter');
+
+  info.flashResultFilterString =
+    findString('flash-result-filter');
+
+  info.groupResultFilterString =
+    findString('group-result-filter');
+
+  info.tagResultFilterString =
+    findString('tag-result-filter');
+
+  info.trackResultFilterString =
+    findString('track-result-filter');
 }
 
 export function addInternalListeners() {
@@ -265,6 +302,38 @@ export function mutatePageContent() {
   info.searchBox.appendChild(info.failedRule);
   info.searchBox.appendChild(info.failedContainer);
 
+  // Filter section
+
+  info.filterContainer =
+    document.createElement('div');
+
+  info.filterContainer.classList.add('wiki-search-filter-container');
+
+  cssProp(info.filterContainer, 'display', 'none');
+
+  forEachFilter((type, _filterLink) => {
+    // TODO: It's probably a sin to access `session` during this step LOL
+    const {session} = info;
+
+    const filterLink = document.createElement('a');
+
+    filterLink.href = '#';
+    filterLink.classList.add('wiki-search-filter-link');
+
+    if (session.activeFilterType === type) {
+      filterLink.classList.add('active');
+    }
+
+    const string = info[type + 'ResultFilterString'];
+    filterLink.appendChild(templateContent(string));
+
+    info[type + 'FilterLink'] = filterLink;
+
+    info.filterContainer.appendChild(filterLink);
+  });
+
+  info.searchBox.appendChild(info.filterContainer);
+
   // Results section
 
   info.resultsRule =
@@ -371,7 +440,7 @@ export function addPageListeners() {
     const {settings, state} = info;
 
     if (!info.searchInput.value) {
-      clearSidebarSearch();
+      clearSidebarSearch(); // ...but don't clear filter
       return;
     }
 
@@ -433,10 +502,18 @@ export function addPageListeners() {
   info.endSearchLink.addEventListener('click', domEvent => {
     domEvent.preventDefault();
     clearSidebarSearch();
+    clearSidebarFilter();
     possiblyHideSearchSidebarColumn();
     restoreSidebarSearchColumn();
   });
 
+  forEachFilter((type, filterLink) => {
+    filterLink.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+      toggleSidebarSearchFilter(type);
+    });
+  });
+
   info.resultsContainer.addEventListener('scroll', () => {
     const {settings, state} = info;
 
@@ -518,6 +595,21 @@ function trackSidebarSearchDownloadEnds(event) {
   }
 }
 
+function forEachFilter(callback) {
+  const filterOrder = [
+    'track',
+    'album',
+    'artist',
+    'group',
+    'flash',
+    'tag',
+  ];
+
+  for (const type of filterOrder) {
+    callback(type, info[type + 'FilterLink']);
+  }
+}
+
 async function activateSidebarSearch(query) {
   const {session, state} = info;
 
@@ -584,6 +676,16 @@ function clearSidebarSearch() {
   hideSidebarSearchResults();
 }
 
+function clearSidebarFilter() {
+  const {session} = info;
+
+  toggleSidebarSearchFilter(session.activeFilterType);
+
+  forEachFilter((_type, filterLink) => {
+    filterLink.classList.remove('shown', 'hidden');
+  });
+}
+
 function updateSidebarSearchStatus() {
   const {state} = info;
 
@@ -670,54 +772,122 @@ function showSidebarSearchFailed() {
 }
 
 function showSidebarSearchResults(results) {
-  console.debug(`Showing search results:`, results);
+  const {session} = info;
 
-  showSearchSidebarColumn();
+  console.debug(`Showing search results:`, tidyResults(results));
 
-  const flatResults =
-    Object.entries(results)
-      .filter(([index]) => index === 'generic')
-      .flatMap(([index, results]) => results
-        .flatMap(({doc, id}) => ({
-          index,
-          reference: id ?? null,
-          referenceType: (id ? id.split(':')[0] : null),
-          directory: (id ? id.split(':')[1] : null),
-          data: doc,
-        })));
+  showSearchSidebarColumn();
 
   info.searchBox.classList.add('showing-results');
   info.searchSidebarColumn.classList.add('search-showing-results');
 
-  while (info.results.firstChild) {
-    info.results.firstChild.remove();
+  let filterType = session.activeFilterType;
+  let shownAnyResults =
+    fillResultElements(results, {filterType: session.activeFilterType});
+
+  showFilterElements(results);
+
+  if (!shownAnyResults) {
+    shownAnyResults = toggleSidebarSearchFilter(filterType);
+    filterType = null;
   }
 
-  cssProp(info.resultsRule, 'display', 'block');
-  cssProp(info.resultsContainer, 'display', 'block');
+  if (shownAnyResults) {
+    cssProp(info.endSearchRule, 'display', 'block');
+    cssProp(info.endSearchLine, 'display', 'block');
 
-  if (empty(flatResults)) {
+    tidySidebarSearchColumn();
+  } else {
     const p = document.createElement('p');
     p.classList.add('wiki-search-no-results');
     p.appendChild(templateContent(info.noResultsString));
     info.results.appendChild(p);
   }
 
-  for (const result of flatResults) {
+  restoreSidebarSearchResultsScrollOffset();
+}
+
+function tidyResults(results) {
+  const tidiedResults =
+    results.results.map(({doc, id}) => ({
+      reference: id ?? null,
+      referenceType: (id ? id.split(':')[0] : null),
+      directory: (id ? id.split(':')[1] : null),
+      data: doc,
+    }));
+
+  return tidiedResults;
+}
+
+function fillResultElements(results, {
+  filterType = null,
+} = {}) {
+  const tidiedResults = tidyResults(results);
+
+  const filteredResults =
+    (filterType
+      ? tidiedResults.filter(result => result.referenceType === filterType)
+      : tidiedResults);
+
+  while (info.results.firstChild) {
+    info.results.firstChild.remove();
+  }
+
+  cssProp(info.resultsRule, 'display', 'block');
+  cssProp(info.resultsContainer, 'display', 'block');
+
+  if (empty(filteredResults)) {
+    return false;
+  }
+
+  for (const result of filteredResults) {
     const el = generateSidebarSearchResult(result);
     if (!el) continue;
 
     info.results.appendChild(el);
   }
 
-  if (!empty(flatResults)) {
-    cssProp(info.endSearchRule, 'display', 'block');
-    cssProp(info.endSearchLine, 'display', 'block');
+  return true;
+}
 
-    tidySidebarSearchColumn();
-  }
+function showFilterElements(results) {
+  const {queriedKind} = results;
 
-  restoreSidebarSearchResultsScrollOffset();
+  const tidiedResults = tidyResults(results);
+
+  const allReferenceTypes =
+    unique(tidiedResults.map(result => result.referenceType));
+
+  let shownAny = false;
+
+  forEachFilter((type, filterLink) => {
+    filterLink.classList.remove('shown', 'hidden');
+
+    if (allReferenceTypes.includes(type)) {
+      shownAny = true;
+      cssProp(filterLink, 'display', null);
+
+      if (queriedKind) {
+        filterLink.setAttribute('inert', 'inert');
+      } else {
+        filterLink.removeAttribute('inert');
+      }
+
+      if (type === queriedKind) {
+        filterLink.classList.add('active-from-query');
+      } else {
+        filterLink.classList.remove('active-from-query');
+      }
+    } else {
+      cssProp(filterLink, 'display', 'none');
+    }
+  });
+
+  if (shownAny) {
+    cssProp(info.filterContainer, 'display', null);
+  } else {
+    cssProp(info.filterContainer, 'display', 'none');
+  }
 }
 
 function generateSidebarSearchResult(result) {
@@ -908,6 +1078,8 @@ function generateSidebarSearchResultTemplate(slots) {
 }
 
 function hideSidebarSearchResults() {
+  cssProp(info.filterContainer, 'display', 'none');
+
   cssProp(info.resultsRule, 'display', 'none');
   cssProp(info.resultsContainer, 'display', 'none');
 
@@ -1040,6 +1212,36 @@ function tidySidebarSearchColumn() {
   }
 }
 
+function toggleSidebarSearchFilter(toggleType) {
+  const {session} = info;
+
+  if (!toggleType) return null;
+
+  let shownAnyResults = null;
+
+  forEachFilter((type, filterLink) => {
+    if (type === toggleType) {
+      const filterActive = filterLink.classList.toggle('active');
+      const filterType = (filterActive ? type : null);
+
+      if (cssProp(filterLink, 'display') !== 'none') {
+        filterLink.classList.add(filterActive ? 'shown' : 'hidden');
+      }
+
+      if (session.activeQueryResults) {
+        shownAnyResults =
+          fillResultElements(session.activeQueryResults, {filterType});
+      }
+
+      session.activeFilterType = filterType;
+    } else {
+      filterLink.classList.remove('active');
+    }
+  });
+
+  return shownAnyResults;
+}
+
 function restoreSidebarSearchColumn() {
   const {state} = info;
 
@@ -1073,6 +1275,8 @@ function forgetRecentSidebarSearch() {
 
   session.activeQuery = null;
   session.activeQueryResults = null;
+
+  clearSidebarFilter();
 }
 
 async function handleDroppedIntoSearchInput(domEvent) {
@@ -1101,7 +1305,7 @@ async function handleDroppedIntoSearchInput(domEvent) {
   let droppedURL;
   try {
     droppedURL = new URL(droppedText);
-  } catch (error) {
+  } catch {
     droppedURL = null;
   }
 
diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js
index ae63eab5..b65574d0 100644
--- a/src/static/js/client/sticky-heading.js
+++ b/src/static/js/client/sticky-heading.js
@@ -1,13 +1,19 @@
 /* eslint-env browser */
 
 import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js';
-import {dispatchInternalEvent, templateContent} from '../client-util.js';
+import {cssProp, dispatchInternalEvent, templateContent}
+  from '../client-util.js';
 
 export const info = {
   id: 'stickyHeadingInfo',
 
+  stickyRoots: null,
+
   stickyContainers: null,
+  staticContainers: null,
 
+  stickyHeadingRows: null,
+  stickyHeadings: null,
   stickySubheadingRows: null,
   stickySubheadings: null,
 
@@ -17,21 +23,33 @@ export const info = {
 
   contentContainers: null,
   contentHeadings: null,
+  contentCoverColumns: null,
   contentCovers: null,
   contentCoversReveal: null,
 
+  referenceCollapsedHeading: null,
+
   state: {
     displayedHeading: null,
   },
 
   event: {
     whenDisplayedHeadingChanges: [],
+    whenStuckStatusChanges: [],
   },
 };
 
 export function getPageReferences() {
+  info.stickyRoots =
+    Array.from(document.querySelectorAll('.content-sticky-heading-root:not([inert])'));
+
   info.stickyContainers =
-    Array.from(document.getElementsByClassName('content-sticky-heading-container'));
+    info.stickyRoots
+      .map(el => el.querySelector('.content-sticky-heading-container'));
+
+  info.staticContainers =
+    info.stickyRoots
+      .map(el => el.nextElementSibling);
 
   info.stickyCoverContainers =
     info.stickyContainers
@@ -45,6 +63,14 @@ export function getPageReferences() {
     info.stickyCovers
       .map(el => el?.querySelector('.image-text-area'));
 
+  info.stickyHeadingRows =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-heading-row'));
+
+  info.stickyHeadings =
+    info.stickyHeadingRows
+      .map(el => el.querySelector('h1'));
+
   info.stickySubheadingRows =
     info.stickyContainers
       .map(el => el.querySelector('.content-sticky-subheading-row'));
@@ -55,11 +81,15 @@ export function getPageReferences() {
 
   info.contentContainers =
     info.stickyContainers
-      .map(el => el.parentElement);
+      .map(el => el.closest('.content-sticky-heading-root').parentElement);
 
-  info.contentCovers =
+  info.contentCoverColumns =
     info.contentContainers
-      .map(el => el.querySelector('#cover-art-container'));
+      .map(el => el.querySelector('#artwork-column'));
+
+  info.contentCovers =
+    info.contentCoverColumns
+      .map(el => el ? el.querySelector('.cover-artwork') : null);
 
   info.contentCoversReveal =
     info.contentCovers
@@ -68,6 +98,10 @@ export function getPageReferences() {
   info.contentHeadings =
     info.contentContainers
       .map(el => Array.from(el.querySelectorAll('.content-heading')));
+
+  info.referenceCollapsedHeading =
+    info.stickyHeadings
+      .map(el => el.querySelector('.reference-collapsed-heading'));
 }
 
 export function mutatePageContent() {
@@ -137,15 +171,61 @@ function topOfViewInside(el, scroll = window.scrollY) {
     scroll < el.offsetTop + el.offsetHeight);
 }
 
+function updateStuckStatus(index) {
+  const {event} = info;
+
+  const contentContainer = info.contentContainers[index];
+  const stickyContainer = info.stickyContainers[index];
+
+  const wasStuck = stickyContainer.classList.contains('stuck');
+  const stuck = topOfViewInside(contentContainer);
+
+  if (stuck === wasStuck) return;
+
+  if (stuck) {
+    stickyContainer.classList.add('stuck');
+  } else {
+    stickyContainer.classList.remove('stuck');
+  }
+
+  dispatchInternalEvent(event, 'whenStuckStatusChanges', index, stuck);
+}
+
+function updateCollapseStatus(index) {
+  const stickyContainer = info.stickyContainers[index];
+  const staticContainer = info.staticContainers[index];
+  const stickyHeading = info.stickyHeadings[index];
+  const referenceCollapsedHeading = info.referenceCollapsedHeading[index];
+
+  const {height: uncollapsedHeight} = stickyHeading.getBoundingClientRect();
+  const {height: collapsedHeight} = referenceCollapsedHeading.getBoundingClientRect();
+
+  if (
+    staticContainer.getBoundingClientRect().bottom < 4 ||
+    staticContainer.getBoundingClientRect().top < -80
+  ) {
+    if (!stickyContainer.classList.contains('collapse')) {
+      stickyContainer.classList.add('collapse');
+      cssProp(stickyContainer, '--uncollapsed-heading-height', uncollapsedHeight + 'px');
+      cssProp(stickyContainer, '--collapsed-heading-height', collapsedHeight + 'px');
+    }
+  } else {
+    stickyContainer.classList.remove('collapse');
+  }
+}
+
 function updateStickyCoverVisibility(index) {
   const stickyCoverContainer = info.stickyCoverContainers[index];
-  const contentCover = info.contentCovers[index];
+  const stickyContainer = info.stickyContainers[index];
+  const contentCoverColumn = info.contentCoverColumns[index];
 
-  if (contentCover && stickyCoverContainer) {
-    if (contentCover.getBoundingClientRect().bottom < 4) {
+  if (contentCoverColumn && stickyCoverContainer) {
+    if (contentCoverColumn.getBoundingClientRect().bottom < 4) {
       stickyCoverContainer.classList.add('visible');
+      stickyContainer.classList.add('cover-visible');
     } else {
       stickyCoverContainer.classList.remove('visible');
+      stickyContainer.classList.remove('cover-visible');
     }
   }
 }
@@ -157,26 +237,27 @@ function getContentHeadingClosestToStickySubheading(index) {
     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();
+  const stickyHeadingRow = info.stickyHeadingRows[index];
+  const stickyRect = stickyHeadingRow.getBoundingClientRect();
 
-  // TODO: Should this compute with the subheading row instead of h2?
-  const subheadingRect = stickySubheading.getBoundingClientRect();
+  // Subheadings only appear when the sticky heading is collapsed,
+  // so the used bottom edge should always be *as though* it's only
+  // displaying one line of text. Subtract the current discrepancy.
+  const stickyHeading = info.stickyHeadings[index];
+  const referenceCollapsedHeading = info.referenceCollapsedHeading[index];
+  const correctBottomEdge =
+    stickyHeading.getBoundingClientRect().height -
+    referenceCollapsedHeading.getBoundingClientRect().height;
 
-  const stickyBottom = stickyRect.bottom + subheadingRect.height;
+  const stickyBottom =
+    (stickyRect.bottom
+   - correctBottomEdge);
 
   // Iterate from bottom to top of the content area.
   const contentHeadings = info.contentHeadings[index];
   for (const heading of contentHeadings.slice().reverse()) {
     const headingRect = heading.getBoundingClientRect();
-    if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) {
+    if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 40) {
       return heading;
     }
   }
@@ -187,7 +268,12 @@ function getContentHeadingClosestToStickySubheading(index) {
 function updateStickySubheadingContent(index) {
   const {event, state} = info;
 
-  const closestHeading = getContentHeadingClosestToStickySubheading(index);
+  const stickyContainer = info.stickyContainers[index];
+
+  const closestHeading =
+    (stickyContainer.classList.contains('collapse')
+      ? getContentHeadingClosestToStickySubheading(index)
+      : null);
 
   if (state.displayedHeading === closestHeading) return;
 
@@ -233,6 +319,8 @@ function updateStickySubheadingContent(index) {
 }
 
 export function updateStickyHeadings(index) {
+  updateStuckStatus(index);
+  updateCollapseStatus(index);
   updateStickyCoverVisibility(index);
   updateStickySubheadingContent(index);
 }
diff --git a/src/static/js/rectangles.js b/src/static/js/rectangles.js
index cdab2cb8..b00ed98e 100644
--- a/src/static/js/rectangles.js
+++ b/src/static/js/rectangles.js
@@ -510,4 +510,46 @@ export class WikiRect extends DOMRect {
       height: this.height,
     });
   }
+
+  // Other utilities
+
+  #display = null;
+
+  display() {
+    if (!this.#display) {
+      this.#display = document.createElement('div');
+      document.body.appendChild(this.#display);
+    }
+
+    Object.assign(this.#display.style, {
+      position: 'fixed',
+      background: '#000c',
+      border: '3px solid var(--primary-color)',
+      borderRadius: '4px',
+      top: this.top + 'px',
+      left: this.left + 'px',
+      width: this.width + 'px',
+      height: this.height + 'px',
+      pointerEvents: 'none',
+    });
+
+    let i = 0;
+    const int = setInterval(() => {
+      i++;
+      if (i >= 3) clearInterval(int);
+      if (!this.#display) return;
+
+      this.#display.style.display = 'none';
+      setTimeout(() => {
+        this.#display.style.display = '';
+      }, 200);
+    }, 600);
+  }
+
+  hide() {
+    if (this.#display) {
+      this.#display.remove();
+      this.#display = null;
+    }
+  }
 }
diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js
index 1b4684ad..e32b4ad5 100644
--- a/src/static/js/search-worker.js
+++ b/src/static/js/search-worker.js
@@ -130,7 +130,7 @@ async function loadDatabase() {
 
   try {
     idb = await promisifyIDBRequest(request);
-  } catch (error) {
+  } catch {
     console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`);
     console.warn(request.error);
     idb = null;
@@ -371,58 +371,75 @@ function postActionResult(id, status, value) {
 }
 
 function performSearchAction({query, options}) {
-  const {generic, ...otherIndexes} = indexes;
+  const {queriedKind} = processTerms(query);
+  const genericResults = queryGenericIndex(query, options);
+  const verbatimResults = queryVerbatimIndex(query, options);
 
-  const genericResults =
-    queryGenericIndex(generic, query, options);
+  const verbatimIDs =
+    new Set(verbatimResults?.map(result => result.id));
 
-  const otherResults =
-    withEntries(otherIndexes, entries => entries
-      .map(([indexName, index]) => [
-        indexName,
-        index.search(query, options),
-      ]));
+  const commonResults =
+    (verbatimResults && genericResults
+      ? genericResults
+          .filter(({id}) => verbatimIDs.has(id))
+      : verbatimResults ?? genericResults);
 
   return {
-    generic: genericResults,
-    ...otherResults,
+    results: commonResults,
+    queriedKind,
   };
 }
 
-function queryGenericIndex(index, query, options) {
-  const interestingFieldCombinations = [
-    ['primaryName', 'parentName', 'groups'],
-    ['primaryName', 'parentName'],
-    ['primaryName', 'groups', 'contributors'],
-    ['primaryName', 'groups', 'artTags'],
-    ['primaryName', 'groups'],
-    ['primaryName', 'contributors'],
-    ['primaryName', 'artTags'],
-    ['parentName', 'groups', 'artTags'],
-    ['parentName', 'artTags'],
-    ['groups', 'contributors'],
-    ['groups', 'artTags'],
-
-    // This prevents just matching *everything* tagged "john" if you
-    // only search "john", but it actually supports matching more than
-    // *two* tags at once: "john rose lowas" works! This is thanks to
-    // flexsearch matching multiple field values in a single query.
-    ['artTags', 'artTags'],
-
-    ['contributors', 'parentName'],
-    ['contributors', 'groups'],
-    ['primaryName', 'contributors'],
-    ['primaryName'],
-  ];
+const interestingFieldCombinations = [
+  ['primaryName', 'parentName', 'groups'],
+  ['primaryName', 'parentName'],
+  ['primaryName', 'groups', 'contributors'],
+  ['primaryName', 'groups', 'artTags'],
+  ['primaryName', 'groups'],
+  ['primaryName', 'contributors'],
+  ['primaryName', 'artTags'],
+  ['parentName', 'groups', 'artTags'],
+  ['parentName', 'artTags'],
+  ['groups', 'contributors'],
+  ['groups', 'artTags'],
+
+  // This prevents just matching *everything* tagged "john" if you
+  // only search "john", but it actually supports matching more than
+  // *two* tags at once: "john rose lowas" works! This is thanks to
+  // flexsearch matching multiple field values in a single query.
+  ['artTags', 'artTags'],
+
+  ['contributors', 'parentName'],
+  ['contributors', 'groups'],
+  ['primaryName', 'contributors'],
+  ['primaryName'],
+];
+
+function queryGenericIndex(query, options) {
+  return queryIndex({
+    indexKey: 'generic',
+    termsKey: 'genericTerms',
+  }, query, options);
+}
+
+function queryVerbatimIndex(query, options) {
+  return queryIndex({
+    indexKey: 'verbatim',
+    termsKey: 'verbatimTerms',
+  }, query, options);
+}
 
+function queryIndex({termsKey, indexKey}, query, options) {
   const interestingFields =
     unique(interestingFieldCombinations.flat());
 
-  const {genericTerms, queriedKind} =
+  const {[termsKey]: terms, queriedKind} =
     processTerms(query);
 
+  if (empty(terms)) return null;
+
   const particles =
-    particulate(genericTerms);
+    particulate(terms);
 
   const groupedParticles =
     groupArray(particles, ({length}) => length);
@@ -437,7 +454,7 @@ function queryGenericIndex(index, query, options) {
           query: values,
         }));
 
-  const boilerplate = queryBoilerplate(index);
+  const boilerplate = queryBoilerplate(indexes[indexKey]);
 
   const particleResults =
     Object.fromEntries(
@@ -459,62 +476,73 @@ function queryGenericIndex(index, query, options) {
             ])),
       ]));
 
-  const results = new Set();
+  let matchedResults = new Set();
 
   for (const interestingFieldCombination of interestingFieldCombinations) {
     for (const query of queriesBy(interestingFieldCombination)) {
-      const idToMatchingFieldsMap = new Map();
-      for (const {field, query: fieldQuery} of query) {
-        for (const id of particleResults[field][fieldQuery]) {
-          if (idToMatchingFieldsMap.has(id)) {
-            idToMatchingFieldsMap.get(id).push(field);
-          } else {
-            idToMatchingFieldsMap.set(id, [field]);
-          }
-        }
-      }
+      const [firstQueryFieldLine, ...restQueryFieldLines] = query;
 
       const commonAcrossFields =
-        Array.from(idToMatchingFieldsMap.entries())
-          .filter(([id, matchingFields]) =>
-            matchingFields.length === interestingFieldCombination.length)
-          .map(([id]) => id);
+        new Set(
+          particleResults
+            [firstQueryFieldLine.field]
+            [firstQueryFieldLine.query]);
+
+      for (const currQueryFieldLine of restQueryFieldLines) {
+        const tossResults = new Set(commonAcrossFields);
+
+        const keepResults =
+          particleResults
+            [currQueryFieldLine.field]
+            [currQueryFieldLine.query];
+
+        for (const result of keepResults) {
+          tossResults.delete(result);
+        }
+
+        for (const result of tossResults) {
+          commonAcrossFields.delete(result);
+        }
+      }
 
       for (const result of commonAcrossFields) {
-        results.add(result);
+        matchedResults.add(result);
       }
     }
   }
 
-  const constituted =
-    boilerplate.constitute(results);
+  matchedResults = Array.from(matchedResults);
 
-  const constitutedAndFiltered =
-    constituted
-      .filter(({id}) =>
-        (queriedKind
-          ? id.split(':')[0] === queriedKind
-          : true));
+  const filteredResults =
+    (queriedKind
+      ? matchedResults.filter(id => id.split(':')[0] === queriedKind)
+      : matchedResults);
 
-  return constitutedAndFiltered;
+  const constitutedResults =
+    boilerplate.constitute(filteredResults);
+
+  return constitutedResults;
 }
 
 function processTerms(query) {
   const kindTermSpec = [
-    {kind: 'album', terms: ['album']},
-    {kind: 'artist', terms: ['artist']},
-    {kind: 'flash', terms: ['flash']},
-    {kind: 'group', terms: ['group']},
-    {kind: 'tag', terms: ['art tag', 'tag']},
-    {kind: 'track', terms: ['track']},
+    {kind: 'album', terms: ['album', 'albums']},
+    {kind: 'artist', terms: ['artist', 'artists']},
+    {kind: 'flash', terms: ['flash', 'flashes']},
+    {kind: 'group', terms: ['group', 'groups']},
+    {kind: 'tag', terms: ['art tag', 'art tags', 'tag', 'tags']},
+    {kind: 'track', terms: ['track', 'tracks']},
   ];
 
   const genericTerms = [];
+  const verbatimTerms = [];
   let queriedKind = null;
 
   const termRegexp =
     new RegExp(
-      String.raw`(?<kind>${kindTermSpec.flatMap(spec => spec.terms).join('|')})` +
+      String.raw`(?<kind>(?<=^|\s)(?:${kindTermSpec.flatMap(spec => spec.terms).join('|')})(?=$|\s))` +
+      String.raw`|(?<=^|\s)(?<quote>["'])(?<regularVerbatim>.+?)\k<quote>(?=$|\s)` +
+      String.raw`|(?<=^|\s)[“”‘’](?<curlyVerbatim>.+?)[“”‘’](?=$|\s)` +
       String.raw`|[^\s\-]+`,
       'gi');
 
@@ -530,10 +558,16 @@ function processTerms(query) {
       continue;
     }
 
+    const verbatim = groups.regularVerbatim || groups.curlyVerbatim;
+    if (verbatim) {
+      verbatimTerms.push(verbatim);
+      continue;
+    }
+
     genericTerms.push(match[0]);
   }
 
-  return {genericTerms, queriedKind};
+  return {genericTerms, verbatimTerms, queriedKind};
 }
 
 function particulate(terms) {
diff --git a/src/static/misc/image.svg b/src/static/misc/image.svg
new file mode 100644
index 00000000..a251b373
--- /dev/null
+++ b/src/static/misc/image.svg
@@ -0,0 +1,11 @@
+<!-- Copyright © (c) 2019-2023 The Bootstrap authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+The Software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the Software or the use or other dealings in the Software. -->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-image-fill" viewBox="0 0 16 16">
+  <path d="M.002 3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-12a2 2 0 0 1-2-2V3zm1 9v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12zm5-6.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0z"/>
+</svg>
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index a3f997ee..ecc0cc14 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -57,6 +57,16 @@ count:
       many: ""
       other: "{ALBUMS} albums"
 
+  artTags:
+    _: "{TAGS}"
+    withUnit:
+      zero: ""
+      one: "{TAGS} tag"
+      two: ""
+      few: ""
+      many: ""
+      other: "{TAGS} tags"
+
   artworks:
     _: "{ARTWORKS}"
     withUnit:
@@ -139,6 +149,16 @@ count:
       many: ""
       other: "{MONTHS} months"
 
+  timesFeatured:
+    _: "{TIMES_FEATURED}"
+    withUnit:
+      zero: ""
+      one: "featured {TIMES_FEATURED} time"
+      two: ""
+      few: ""
+      many: ""
+      other: "featured {TIMES_FEATURED} times"
+
   timesReferenced:
     _: "{TIMES_REFERENCED}"
     withUnit:
@@ -255,28 +275,26 @@ releaseInfo:
 
   from: "From {ALBUM}."
 
-  coverArtBy: "Cover art by {ARTISTS}."
-  wallpaperArtBy: "Wallpaper art by {ARTISTS}."
-  bannerArtBy: "Banner art by {ARTISTS}."
+  wallpaperArtBy: "Wallpaper by {ARTISTS}"
+  bannerArtBy: "Banner by {ARTISTS}"
 
   released: "Released {DATE}."
   albumReleased: "Album released {DATE}."
-  artReleased: "Art released {DATE}."
   trackReleased: "Track released {DATE}."
   addedToWiki: "Added to wiki {DATE}."
 
   duration: "Duration: {DURATION}."
 
   contributors: "Contributors:"
-  lyrics: "Lyrics:"
-  note: "Context notes:"
 
-  alsoReleasedAs:
-    _: "Also released as:"
+  lyrics:
+    _: "Lyrics:"
 
-    item:
-      _: "{TRACK} ({ALBUM})"
-      withYear: "({YEAR}) {TRACK} ({ALBUM})"
+    switcher: "({ENTRIES})"
+
+  note: "Context notes:"
+
+  alsoReleasedOn: "Also released on {ALBUMS}."
 
   tracksReferenced:
     _: "Tracks that {TRACK} references:"
@@ -352,10 +370,14 @@ releaseInfo:
     _: "Read {LINK}."
     link: "artist commentary"
 
-  readCreditSources:
+  readCreditingSources:
     _: "Read {LINK}."
     link: "crediting sources"
 
+  readReferencingSources:
+    _: "Read {LINK}."
+    link: "referencing sources"
+
   additionalFiles:
     heading: "View or download additional files:"
 
@@ -470,7 +492,8 @@ misc:
   # artistCommentary:
 
   artistCommentary:
-    _: "Artist commentary:"
+    _: "Artist commentary for {THING}:"
+    sticky: "Artist commentary:"
 
     entry:
       title:
@@ -495,7 +518,18 @@ misc:
         date.sometime.range: "sometime {DATE_RANGE}"
         date.throughout.range: "throughout {DATE_RANGE}"
 
-      seeOriginalRelease: "See {ORIGINAL}!"
+    info:
+      fromMainRelease: >-
+        The following commentary is properly placed on this track's main release, {ALBUM}.
+
+      fromMainRelease.namedDifferently: >-
+        The following commentary is properly placed on this track's main release, {ALBUM}, where it's named {NAME}.
+
+      seeSpecificReleases: >-
+        For release-specific commentary, check out: {ALBUMS}.
+
+      seeSpecificReleases.withMainCommentary: >-
+        For release-specific commentary, see also: {ALBUMS}.
 
   artistCredit:
     withNormalArtists: >-
@@ -586,8 +620,9 @@ misc:
       trackArt: "{INDEX} track art by {ARTIST}"
       onlyIndex: "Only"
 
-  creditSources:
-    _: "Crediting sources:"
+  creditingSources:
+    _: "Crediting sources for {THING}:"
+    sticky: "Crediting sources:"
 
   # external:
   #   Links which will generally bring you somewhere off of the wiki.
@@ -615,7 +650,12 @@ misc:
     amazonMusic: "Amazon Music"
     appleMusic: "Apple Music"
     artstation: "ArtStation"
-    bandcamp: "Bandcamp"
+
+    bandcamp:
+      _: "Bandcamp"
+
+      composerRelease: "Bandcamp (composer's release)"
+      officialRelease: "Bandcamp (official release)"
 
     bgreco:
       _: "bgreco.net"
@@ -679,6 +719,18 @@ misc:
       playlist: "YouTube (playlist)"
       fullAlbum: "YouTube (full album)"
 
+  # lyrics:
+
+  lyrics:
+    source: >-
+      Via {SOURCE}
+
+    contributors: >-
+      Contributions from {CONTRIBUTORS}
+
+    squareBracketAnnotations: >-
+      Mind parts marked in [square brackets]
+
   # 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
@@ -786,6 +838,18 @@ misc:
       artist: "(artist)"
       group: "(group)"
 
+    resultFilter:
+      album: "Albums"
+      artTag: "Art Tags"
+      artist: "Artists"
+      flash: "Flashes"
+      group: "Groups"
+      track: "Tracks"
+
+  referencingSources:
+    _: "Referencing sources for {THING}:"
+    sticky: "Referencing sources:"
+
   # skippers:
   #
   #   These are navigational links that only show up when you're
@@ -813,7 +877,7 @@ misc:
     # Displayed on various info pages.
 
     artistCommentary: "Artist commentary"
-    creditSources: "Crediting sources"
+    creditingSources: "Crediting sources"
 
     # Displayed on artist info page.
 
@@ -834,6 +898,7 @@ misc:
     sampledBy: "Sampled by..."
     features: "Features..."
     featuredIn: "Featured in..."
+    referencingSources: "Referencing sources"
 
     lyrics: "Lyrics"
 
@@ -851,8 +916,6 @@ misc:
   socialEmbed:
     heading: "{WIKI_NAME} | {HEADING}"
 
-  trackArtFromAlbum: "Album cover for {ALBUM}"
-
   # jumpTo:
   #   Generic action displayed at the top of some longer pages, for
   #   quickly scrolling down to a particular section.
@@ -870,6 +933,48 @@ misc:
     warnings: "{WARNINGS}"
     reveal: "click to show"
 
+  # coverArtwork:
+  #   Generic or particular strings for artworks outside a grid
+  #   context, when just one cover is being spotlighted.
+
+  coverArtwork:
+    artworkBy: >-
+      Artwork by {ARTISTS}
+
+    artworkBy.customLabel: >-
+      {LABEL} by {ARTISTS}
+
+    artworkBy.withYear: >-
+      Artwork ({YEAR}) by {ARTISTS}
+
+    artworkBy.customLabel.withYear: >-
+      {LABEL} ({YEAR}) by {ARTISTS}
+
+    source: >-
+      Via {SOURCE}
+
+    source.customLabel: >-
+      {LABEL} via {SOURCE}
+
+    source.withYear: >-
+      Via {SOURCE} ({YEAR})
+
+    source.customLabel.withYear: >-
+      {LABEL} ({YEAR}) via {SOURCE}
+
+    customLabel: >-
+      {LABEL}
+
+    customLabel.withYear: >-
+      {LABEL} ({YEAR})
+
+    year: >-
+      Released {YEAR}
+
+    trackArtFromAlbum: "Album cover for {ALBUM}"
+
+    sameTagsAsMainArtwork: "Same tags as main artwork"
+
   # coverGrid:
   #   Generic strings for various sorts of gallery grids, displayed
   #   on the homepage, album galleries, artist artwork galleries, and
@@ -881,12 +986,20 @@ misc:
     noCoverArt: "{ALBUM}"
 
     details:
+      notFromThisGroup: "{NAME}{MARKER}"
+      notFromThisGroup.marker: "*"
+
       accent: "({DETAILS})"
 
       albumLength: "{TRACKS}, {TIME}"
+
       coverArtists: "Artwork by {ARTISTS}"
+      coverArtists.customLabel: "{LABEL} by {ARTISTS}"
+
       otherCoverArtists: "With {ARTISTS}"
 
+    expandCollapseCue: "({CUE})"
+
   albumGalleryGrid:
     noCoverArt: "{NAME}"
 
@@ -943,7 +1056,9 @@ albumSidebar:
 
     group:
       _: "{GROUP}"
-      withRange: "{GROUP} ({RANGE})"
+
+      withRange: "{GROUP} {RANGE_PART}"
+      withRange.rangePart: "({RANGE})"
 
   # groupBox:
   #   This is the box for groups. Apart from the next and previous
@@ -955,6 +1070,13 @@ albumSidebar:
     next: "Next: {ALBUM}"
     previous: "Previous: {ALBUM}"
 
+  # releaseBox:
+  #   This is the narrow box for alternate releases of the
+  #   current track.
+
+  releaseBox:
+    title: "{ALBUM}"
+
 #
 # albumSecondaryNav:
 #   The secondary nav bar is shown in medium and thin layouts,
@@ -1053,6 +1175,15 @@ albumGalleryPage:
   noTrackArtworksLine: >-
     This album doesn't have any track artwork.
 
+  # setSwitcher:
+  #   This is displayed if multiple sets of artwork are available
+  #   across the album.
+
+  setSwitcher:
+    _: "({SETS})"
+
+    unlabeledSet: "Main album art"
+
 #
 # albumCommentaryPage:
 #   The album commentary page is a more minimal layout that brings
@@ -1166,7 +1297,19 @@ artistPage:
       #   artists or contributors, and get dimmed a little compared
       #   to original release track entries.
 
-      rerelease: "{ENTRY} (rerelease)"
+      rerelease:
+        _: "{ENTRY} ({RERELEASE})"
+        term: "rerelease"
+
+        firstRelease: >-
+          First released on {ALBUM}
+
+      firstRelease:
+        _: "{ENTRY} ({FIRST_RELEASE})"
+        term: "first release"
+
+        rerelease: >-
+          Also released on {ALBUM}
 
       # track:
       #   The string without duration is used in both the artist's
@@ -1192,6 +1335,16 @@ artistPage:
       flash:
         _: "{FLASH}"
 
+      artwork.accent:
+        withLabel: >-
+          {LABEL}
+
+        withAnnotation: >-
+          {ANNOTATION}
+
+        withLabel.withAnnotation: >-
+          {LABEL}: {ANNOTATION}
+
   # contributedDurationLine:
   #   This is shown at the top of the artist's track list, provided
   #   any of their tracks have durations at all.
@@ -1265,6 +1418,111 @@ artistGalleryPage:
     Contributed to {COVER_ARTS}.
 
 #
+# artTagPage:
+#   Stuff that's common between art tag pages.
+#
+artTagPage:
+  nav:
+    tag: "Tag: {TAG}"
+
+  sidebar:
+    otherTagsExempt: "(…another {TAGS}…)"
+
+#
+# artTagInfoPage:
+#   The art tag info page displays general information about a tag,
+#   including details about how it's networked with other tags in
+#   particular.
+#
+artTagInfoPage:
+  title: "{TAG}"
+
+  viewArtGallery:
+    _: "View this tag's {LINK}!"
+    link: "art gallery"
+
+  readMoreOn: "Read more about '{TAG}' on {LINKS}."
+
+  seeAlso:
+    _: "See also: {TAGS}"
+    tagWithAnnotation: "{TAG} ({ANNOTATION})"
+
+  featuredIn:
+    notFeatured: >-
+      This tag hasn't been featured in any artworks yet.
+
+    directlyOnly: >-
+      This tag is featured in {ARTWORKS}.
+
+    indirectlyOnly: >-
+      This tag is featured in {ARTWORKS}, but only indirectly.
+
+    directlyAndIndirectly: >-
+      This tag is directly featured in {ARTWORKS_DIRECTLY}, and indirectly in {ARTWORKS_INDIRECTLY} more, for a total of {ARTWORKS_TOTAL}.
+
+  descendsFromTags:
+    _: "Tags which '{TAG}' falls under:"
+    item: "{TAG}"
+
+  descendantTags:
+    _: "Tags which fall under '{TAG}':"
+
+    item:
+      _: "{TAG}"
+
+      withGallery:
+        _: "{TAG} ({GALLERY})"
+        gallery: "view gallery"
+
+      withTimesUsed: "{TAG} ({TIMES_USED})"
+      withGallery.withTimesUsed: "{TAG} ({GALLERY}; {TIMES_USED})"
+
+#
+# artTagGalleryPage:
+#   The tag gallery page displays all the artworks that a tag has
+#   been featured in, in one neat grid, with each artwork displaying
+#   its illustrators, as well as a short info line that indicates
+#   how many artworks the tag's been featured in, whether directly,
+#   indirectly (via descendant tags), both, or neither. If a tag's
+#   been featured both directly and indirectly, there are buttons
+#   to switch what's being shown.
+#
+artTagGalleryPage:
+  title: "{TAG}"
+
+  descendsFrom: "Up: {TAGS}."
+  descendants: "Down: {TAGS}."
+
+  featuredLine:
+    all: >-
+      Featured in {COVER_ARTS}.
+
+    direct: >-
+      Featured directly in {COVER_ARTS}.
+
+    indirect: >-
+      Featured indirectly in {COVER_ARTS}.
+
+    notFeatured:
+      _: >-
+        This tag hasn't been featured in any artworks yet.
+
+      callToAction: >-
+        Maybe your track will be the first!
+
+  showingLine:
+    _: "({SHOWING})"
+
+    all: >-
+      Showing all artworks.
+
+    indirect: >-
+      Showing artworks where this tag is only featured indirectly.
+
+    direct: >-
+      Showing artworks where this tag is featured directly.
+
+#
 # commentaryIndex:
 #   The commentary index page shows a summary of all the commentary
 #   across the entire wiki, with a list linking to each album's
@@ -1435,6 +1693,37 @@ groupGalleryPage:
   infoLine: >-
     {TRACKS} across {ALBUMS}, totaling {TIME}.
 
+  albumViewSwitcher:
+    _: "Showing albums:"
+
+    bySeries: "By series"
+    byDate: "By date"
+
+  albumsByDate:
+    title: "All albums"
+
+  albumSection:
+    caption: >-
+      {TRACKS} across {ALBUMS}.
+
+    caption.withDate: >-
+      {TRACKS} across {ALBUMS}, released {DATE}.
+
+    caption.withYear: >-
+      {TRACKS} across {ALBUMS}, released during {YEAR}.
+
+    caption.withYearRange: >-
+      {TRACKS} across {ALBUMS}, released {YEAR_RANGE}.
+
+    caption.seriesAlbumsNotFromGroup: >-
+      Albums marked {MARKER} are part of {SERIES}, but not from {GROUP}.
+
+    expand: >-
+      Show the rest!
+
+    collapse: >-
+      Collapse these
+
 #
 # listingIndex:
 #   The listing index page shows all available listings on the wiki,
@@ -1562,6 +1851,79 @@ listingPage:
         title: "{DATE}"
         item: "{ALBUM}"
 
+  listArtTags:
+
+    # listArtTags.byName:
+    #   List art tags alphabetically without sorting or chunking by
+    #   any other criteria. Also displays the number of times each
+    #   art tag has been featured.
+
+    byName:
+      title: "Tags - by Name"
+      title.short: "...by Name"
+      item: "{TAG} ({TIMES_USED})"
+
+    # listArtTags.byUses:
+    #   List art tags by number of times used, falling back to an
+    #   alphabetical sort if two art tags have been featured the same
+    #   number of times. Art tags which haven't haven't been featured
+    #   at all yet are totally excluded from the list.
+
+    byUses:
+      title: "Tags - by Uses"
+      title.short: "...by Uses"
+      item: "{TAG} ({TIMES_USED})"
+
+    # listArtTags.network:
+    #   List art tags in a custom networked fashion, showing all
+    #   connections between ancestors and descendants. Each top-level
+    #   tag gets one section. Descendants are generally nested directly
+    #   under their ancestors, except if they have two or more direct
+    #   ancestors *and* at least one direct descendant, in which case
+    #   they're moved to a dedicated section further down the page.
+    #   "Orphan" art tags, if any - tags which don't have any ancestors
+    #   nor any descendants - are displayed in a section at the bottom.
+
+    network:
+      title: "Art Tag Network"
+      title.short: "Art Tag Network"
+
+      jumpToRoot:
+        title: "Jump to one of the upper-most tags:"
+        item: "{TAG}"
+
+      statLine:
+        _: "Displaying tag info: {STAT}"
+        none: "name only"
+        totalUses: "uses (total)"
+        directUses: "uses (direct only)"
+        descendants: "descendants (total)"
+        leaves: "descendants (leaves only)"
+
+      root:
+        _: "{TAG}"
+
+      root.jumpToTop:
+        _: "{TAG} ({LINK})"
+        link: "Jump back to top"
+
+      root.withAncestors:
+        _: "{TAG} (descends from {ANCESTORS})"
+
+      tag:
+        _: "{TAG}"
+
+        jumpToRoot: "Jump to: {TAG}"
+
+        withStat:
+          _: "{STAT} {TAG}"
+          stat: "({STAT})"
+          notApplicable: "-"
+
+      orphanArtTags:
+        title: "These tags don't have any descendants or ancestors:"
+        item: "{TAG}"
+
   listArtists:
 
     # listArtists.byName:
@@ -1889,29 +2251,6 @@ listingPage:
         title.withDate: "{ALBUM} ({DATE})"
         item: "{TRACK}"
 
-  listTags:
-
-    # listTags.byName:
-    #   List art tags alphabetically without sorting or chunking by
-    #   any other criteria. Also displays the number of times each
-    #   art tag has been featured.
-
-    byName:
-      title: "Tags - by Name"
-      title.short: "...by Name"
-      item: "{TAG} ({TIMES_USED})"
-
-    # listTags.byUses:
-    #   List art tags by number of times used, falling back to an
-    #   alphabetical sort if two art tags have been featured the same
-    #   number of times. Art tags which haven't haven't been featured
-    #   at all yet are totally excluded from the list.
-
-    byUses:
-      title: "Tags - by Uses"
-      title.short: "...by Uses"
-      item: "{TAG} ({TIMES_USED})"
-
   other:
 
     # other.allSheetMusic:
@@ -2102,22 +2441,6 @@ referencingArtworksPage:
     Referenced by {ARTWORKS}.
 
 #
-# 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
diff --git a/src/upd8.js b/src/upd8.js
index 5589877c..ae072d5a 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -42,8 +42,9 @@ import wrap from 'word-wrap';
 
 import {mapAggregate, openAggregate, showAggregate} from '#aggregate';
 import CacheableObject from '#cacheable-object';
-import {stringifyCache} from '#cli';
+import {formatDuration, stringifyCache} from '#cli';
 import {displayCompositeCacheAnalysis} from '#composite';
+import * as html from '#html';
 import find, {bindFind, getAllFindSpecs} from '#find';
 import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile}
   from '#language';
@@ -51,6 +52,7 @@ import {isMain, traverse} from '#node-utils';
 import {bindReverse} from '#reverse';
 import {writeSearchData} from '#search';
 import {sortByName} from '#sort';
+import thingConstructors from '#things';
 import {identifyAllWebRoutes} from '#web-routes';
 
 import {
@@ -67,8 +69,9 @@ import {
 
 import {
   filterReferenceErrors,
-  reportDirectoryErrors,
   reportContentTextErrors,
+  reportDirectoryErrors,
+  reportOrphanedArtworks,
 } from '#data-checks';
 
 import {
@@ -115,7 +118,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
 let COMMIT;
 try {
   COMMIT = execSync('git log --format="%h %B" -n 1 HEAD', {cwd: __dirname}).toString().trim();
-} catch (error) {
+} catch {
   COMMIT = '(failed to detect)';
 }
 
@@ -133,8 +136,8 @@ const defaultStepStatus = {status: STATUS_NOT_STARTED, annotation: null};
 // Defined globally for quick access outside the main() function's contents.
 // This will be initialized and mutated over the course of main().
 let stepStatusSummary;
-let showStepStatusSummary = false;
-let showStepMemoryInSummary = false;
+let shouldShowStepStatusSummary = false;
+let shouldShowStepMemoryInSummary = false;
 
 async function main() {
   Error.stackTraceLimit = Infinity;
@@ -174,6 +177,10 @@ async function main() {
       {...defaultStepStatus, name: `report directory errors`,
         for: ['verify']},
 
+    reportOrphanedArtworks:
+      {...defaultStepStatus, name: `report orphaned artworks`,
+        for: ['verify']},
+
     filterReferenceErrors:
       {...defaultStepStatus, name: `filter reference errors`,
         for: ['verify']},
@@ -190,6 +197,13 @@ async function main() {
       {...defaultStepStatus, name: `precache nearly all data`,
         for: ['build']},
 
+    sortWikiDataSourceFiles:
+      {...defaultStepStatus, name: `apply sorting rules to wiki data files`,
+        for: ['build']},
+
+    checkWikiDataSourceFileSorting:
+      {...defaultStepStatus, name: `check sorting rules against wiki data files`},
+
     loadURLFiles:
       {...defaultStepStatus, name: `load internal & custom url spec files`,
         for: ['build']},
@@ -270,6 +284,35 @@ async function main() {
     }));
 
   let selectedBuildModeFlag;
+  let sortInAdditionToBuild = false;
+
+  // As an exception, --sort can be combined with another build mode.
+  if (selectedBuildModeFlags.length >= 2 && selectedBuildModeFlags.includes('sort')) {
+    sortInAdditionToBuild = true;
+    selectedBuildModeFlags.splice(selectedBuildModeFlags.indexOf('sort'), 1);
+  }
+
+  if (sortInAdditionToBuild) {
+    Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+      status: STATUS_NOT_STARTED,
+      annotation: `--sort provided with another build mode`,
+    });
+
+    Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `--sort provided, dry run not applicable`,
+    });
+  } else {
+    Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `--sort not provided, dry run only`,
+    });
+
+    Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
+      status: STATUS_NOT_STARTED,
+      annotation: `--sort not provided, dry run applicable`,
+    });
+  }
 
   if (empty(selectedBuildModeFlags)) {
     // No build mode selected. This is not a valid state for building the wiki,
@@ -362,6 +405,11 @@ async function main() {
       type: 'flag',
     },
 
+    'skip-orphaned-artwork-validation': {
+      help: `Skips checking for internally orphaned artworks, which is a bad idea, unless you're debugging those in particular`,
+      type: 'flag',
+    },
+
     'skip-reference-validation': {
       help: `Skips checking and reporting reference errors, which speeds up the build but may silently allow erroneous data to pass through`,
       type: 'flag',
@@ -412,6 +460,11 @@ async function main() {
       type: 'flag',
     },
 
+    'skip-sorting-validation': {
+      help: `Skips checking the if custom sorting rules for this wiki are satisfied`,
+      type: 'flag',
+    },
+
     'skip-media-validation': {
       help: `Skips checking and reporting missing and misplaced media files, which isn't necessary if you aren't adding or removing data or updating directories`,
       type: 'flag',
@@ -466,6 +519,11 @@ async function main() {
       type: 'flag',
     },
 
+    'skip-self-diagnosis': {
+      help: `Disable some runtime validation for the wiki's own code, which speeds up long builds, but may allow unpredicted corner cases to fail strangely and silently`,
+      type: 'flag',
+    },
+
     'queue-size': {
       help: `Process more or fewer disk files at once to optimize performance or avoid I/O errors, unlimited if set to 0 (between 500 and 700 is usually a safe range for building HSMusic on Windows machines)\nDefaults to ${defaultQueueSize}`,
       type: 'value',
@@ -525,8 +583,8 @@ async function main() {
     ...buildOptions,
   });
 
-  showStepStatusSummary = cliOptions['show-step-summary'] ?? false;
-  showStepMemoryInSummary = cliOptions['show-step-memory'] ?? false;
+  shouldShowStepStatusSummary = cliOptions['show-step-summary'] ?? false;
+  shouldShowStepMemoryInSummary = cliOptions['show-step-memory'] ?? false;
 
   if (cliOptions['help']) {
     console.log(
@@ -604,7 +662,8 @@ async function main() {
   const thumbsOnly = cliOptions['thumbs-only'] ?? false;
   const noInput = cliOptions['no-input'] ?? false;
 
-  const showAggregateTraces = cliOptions['show-traces'] ?? false;
+  const skipSelfDiagnosis = cliOptions['skip-self-diagnosis'] ?? false;
+  const showTraces = cliOptions['show-traces'] ?? false;
 
   const precacheMode = cliOptions['precache-mode'] ?? 'common';
 
@@ -801,6 +860,16 @@ async function main() {
       },
     });
 
+    fallbackStep('reportOrphanedArtworks', {
+      default: 'perform',
+      cli: {
+        flag: 'skip-orphaned-artwork-validation',
+        negate: true,
+        warn:
+          `Skipping orphaned artwork validation. Hopefully you're debugging!`,
+      },
+    });
+
     fallbackStep('filterReferenceErrors', {
       default: 'perform',
       cli: {
@@ -946,6 +1015,18 @@ async function main() {
       paragraph = false;
     }
 
+    fallbackStep('checkWikiDataSourceFileSorting', {
+      default: 'perform',
+      buildConfig: 'sort',
+      cli: {
+        flag: 'skip-sorting-validation',
+        negate: true,
+        warning:
+          `Skipping sorting validation. If any of this wiki's sorting rules are not\n` +
+          `satisfied, those errors will be silently passed along to the build.`,
+      },
+    });
+
     fallbackStep('verifyImagePaths', {
       default: 'perform',
       buildConfig: 'mediaValidation',
@@ -1082,6 +1163,18 @@ async function main() {
     return false;
   }
 
+  if (skipSelfDiagnosis) {
+    logWarn`${'Skipping code self-diagnosis.'} (--skip-self-diagnosis provided)`;
+    logWarn`This build should run substantially faster, but corner cases`;
+    logWarn`not previously predicted may fail strangely and silently.`;
+
+    html.disableSlotValidation();
+  }
+
+  if (!showTraces) {
+    html.disableTagTracing();
+  }
+
   Object.assign(stepStatusSummary.determineMediaCachePath, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
@@ -1260,7 +1353,7 @@ async function main() {
 
   const niceShowAggregate = (error, ...opts) => {
     showAggregate(error, {
-      showTraces: showAggregateTraces,
+      showTraces,
       pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
       ...opts,
     });
@@ -1480,6 +1573,10 @@ async function main() {
           ? prop
           : wikiData[prop]);
 
+      if (array && empty(array)) {
+        return;
+      }
+
       logInfo` - ${array?.length ?? colors.red('(Missing!)')} ${colors.normal(colors.dim(label))}`;
     }
 
@@ -1506,6 +1603,7 @@ async function main() {
         logThings('newsData', 'news entries');
       }
       logThings('staticPageData', 'static pages');
+      logThings('sortingRules', 'sorting rules');
       if (wikiData.homepageLayout) {
         logInfo` - ${1} homepage layout (${
           wikiData.homepageLayout.sections.length
@@ -1677,8 +1775,8 @@ async function main() {
     });
   }
 
-  // Filter out any things with duplicate directories throughout the data,
-  // warning about them too.
+  // Check for things with duplicate directories throughout the data,
+  // and halt if any are found.
 
   if (stepStatusSummary.reportDirectoryErrors.status === STATUS_NOT_STARTED) {
     Object.assign(stepStatusSummary.reportDirectoryErrors, {
@@ -1719,8 +1817,42 @@ async function main() {
     }
   }
 
-  // Filter out any reference errors throughout the data, warning about them
-  // too.
+  // Check for artwork objects which have been orphaned from their things,
+  // and halt if any are found.
+
+  if (stepStatusSummary.reportOrphanedArtworks.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.reportOrphanedArtworks, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    try {
+      reportOrphanedArtworks(wikiData, {getAllFindSpecs});
+
+      Object.assign(stepStatusSummary.reportOrphanedArtworks, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    } catch (aggregate) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(aggregate);
+
+      logError`Failed to initialize artwork data connections properly.`;
+      fileIssue();
+
+      Object.assign(stepStatusSummary.reportOrphanedArtworks, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `orphaned artworks found`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
+    }
+  }
+
+  // Filter out any reference errors throughout the data, warning about these.
 
   if (stepStatusSummary.filterReferenceErrors.status === STATUS_NOT_STARTED) {
     Object.assign(stepStatusSummary.filterReferenceErrors, {
@@ -1841,6 +1973,77 @@ async function main() {
     });
   }
 
+  if (stepStatusSummary.sortWikiDataSourceFiles.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const {SortingRule} = thingConstructors;
+    const results =
+      await Array.fromAsync(SortingRule.go({dataPath, wikiData}));
+
+    if (results.some(result => result.changed)) {
+      logInfo`Updated data files to satisfy sorting.`;
+      logInfo`Restarting automatically, since that's now needed!`;
+
+      Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+        status: STATUS_DONE_CLEAN,
+        annotation: `changes cueing restart`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return 'restart';
+    } else {
+      logInfo`All sorting rules are satisfied. Nice!`;
+      paragraph = false;
+
+      Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+        status: STATUS_DONE_CLEAN,
+        annotation: `no changes needed`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    }
+  } else if (stepStatusSummary.checkWikiDataSourceFileSorting.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const {SortingRule} = thingConstructors;
+    const results =
+      await Array.fromAsync(SortingRule.go({dataPath, wikiData, dry: true}));
+
+    const needed = results.filter(result => result.changed);
+
+    if (empty(needed)) {
+      logInfo`All sorting rules are satisfied. Nice!`;
+      paragraph = false;
+
+      Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    } else {
+      logWarn`Some of this wiki's sorting rules currently aren't satisfied:`;
+      for (const {rule} of needed) {
+        logWarn`- ${rule.message}`;
+      }
+      logWarn`Run ${'hsmusic --sort'} to automatically update data files.`;
+      paragraph = false;
+
+      Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `not all rules satisfied`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    }
+  }
+
   if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
     displayCompositeCacheAnalysis();
 
@@ -2235,7 +2438,7 @@ async function main() {
       });
 
       internalDefaultLanguage = internalDefaultLanguageWatcher.language;
-    } catch (_error) {
+    } catch {
       // No need to display the error here - it's already printed by
       // watchLanguageFile.
       errorLoadingInternalDefaultLanguage = true;
@@ -3023,6 +3226,7 @@ async function main() {
     developersComment,
     languages,
     missingImagePaths,
+    niceShowAggregate,
     thumbsCache,
     urlSpec,
     urls,
@@ -3085,145 +3289,71 @@ async function main() {
 }
 
 // TODO: isMain detection isn't consistent across platforms here
-/* eslint-disable-next-line no-constant-condition */
+// eslint-disable-next-line no-constant-binary-expression
 if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmusic') {
   (async () => {
     let result;
+    let numRestarts = 0;
 
     const totalTimeStart = Date.now();
 
-    try {
-      result = await main();
-    } catch (error) {
-      if (error instanceof AggregateError) {
-        showAggregate(error);
-      } else if (error.cause) {
-        console.error(error);
-        showAggregate(error);
-      } else {
-        console.error(error);
-      }
-    }
-
-    const totalTimeEnd = Date.now();
-
-    const formatDuration = timeDelta => {
-      const seconds = timeDelta / 1000;
-
-      if (seconds > 90) {
-        const modSeconds = Math.floor(seconds % 60);
-        const minutes = Math.floor(seconds - seconds % 60) / 60;
-        return `${minutes}m${modSeconds}s`;
-      }
-
-      if (seconds < 0.1) {
-        return 'instant';
+    while (true) {
+      try {
+        result = await main();
+      } catch (error) {
+        if (error instanceof AggregateError) {
+          showAggregate(error);
+        } else if (error.cause) {
+          console.error(error);
+          showAggregate(error);
+        } else {
+          console.error(error);
+        }
       }
 
-      const precision = (seconds > 1 ? 3 : 2);
-      return `${seconds.toPrecision(precision)}s`;
-    };
-
-    if (showStepStatusSummary) {
-      const totalDuration = formatDuration(totalTimeEnd - totalTimeStart);
-
-      console.error(colors.bright(`Step summary:`));
-
-      const longestNameLength =
-        Math.max(...
-          Object.values(stepStatusSummary)
-            .map(({name}) => name.length));
-
-      const stepsNotClean =
-        Object.values(stepStatusSummary)
-          .map(({status}) =>
-            status === STATUS_HAS_WARNINGS ||
-            status === STATUS_FATAL_ERROR ||
-            status === STATUS_STARTED_NOT_DONE);
-
-      const anyStepsNotClean =
-        stepsNotClean.includes(true);
-
-      const stepDetails = Object.values(stepStatusSummary);
-
-      const stepDurations =
-        stepDetails.map(({status, timeStart, timeEnd}) => {
-          if (
-            status === STATUS_NOT_APPLICABLE ||
-            status === STATUS_NOT_STARTED ||
-            status === STATUS_STARTED_NOT_DONE
-          ) {
-            return '-';
-          }
+      if (result === 'restart') {
+        console.log('');
 
-          if (typeof timeStart !== 'number' || typeof timeEnd !== 'number') {
-            return 'unknown';
+        if (shouldShowStepStatusSummary) {
+          if (numRestarts >= 1) {
+            console.error(colors.bright(`Step summary since latest restart:`));
+          } else {
+            console.error(colors.bright(`Step summary before restart:`));
           }
 
-          return formatDuration(timeEnd - timeStart);
-        });
-
-      const longestDurationLength =
-        Math.max(...stepDurations.map(duration => duration.length));
-
-      const stepMemories =
-        stepDetails.map(({memory}) =>
-          (memory
-            ? Math.round(memory["heapUsed"] / 1024 / 1024) + 'MB'
-            : '-'));
-
-      const longestMemoryLength =
-        Math.max(...stepMemories.map(memory => memory.length));
-
-      for (let index = 0; index < stepDetails.length; index++) {
-        const {name, status, annotation} = stepDetails[index];
-        const duration = stepDurations[index];
-        const memory = stepMemories[index];
-
-        let message =
-          (stepsNotClean[index]
-            ? `!! `
-            : ` - `);
-
-        message += `(${duration} `.padStart(longestDurationLength + 2, ' ');
-
-        if (showStepMemoryInSummary) {
-          message += ` ${memory})`.padStart(longestMemoryLength + 2, ' ');
+          showStepStatusSummary();
+          console.log('');
         }
 
-        message += ` `;
-        message += `${name}: `.padEnd(longestNameLength + 4, '.');
-        message += ` `;
-        message += status;
-
-        if (annotation) {
-          message += ` (${annotation})`;
+        if (numRestarts > 5) {
+          logError`A restart was cued, but we've restarted a bunch already.`;
+          logError`Exiting because this is probably a bug!`;
+          console.log('');
+          break;
+        } else {
+          console.log('');
+          logInfo`A restart was cued. This is probably normal, and required`;
+          logInfo`to load updated data files. Restarting automatically now!`;
+          console.log('');
+          numRestarts++;
         }
+      } else {
+        break;
+      }
+    }
 
-        switch (status) {
-          case STATUS_DONE_CLEAN:
-            console.error(colors.green(message));
-            break;
-
-          case STATUS_NOT_STARTED:
-          case STATUS_NOT_APPLICABLE:
-            console.error(colors.dim(message));
-            break;
-
-          case STATUS_HAS_WARNINGS:
-          case STATUS_STARTED_NOT_DONE:
-            console.error(colors.yellow(message));
-            break;
+    if (shouldShowStepStatusSummary)  {
+      if (numRestarts >= 1) {
+        console.error(colors.bright(`Step summary after final restart:`));
+      } else {
+        console.error(colors.bright(`Step summary:`));
+      }
 
-          case STATUS_FATAL_ERROR:
-            console.error(colors.red(message));
-            break;
+      const {anyStepsNotClean} =
+        showStepStatusSummary();
 
-          default:
-            console.error(message);
-            break;
-        }
-      }
+      const totalTimeEnd = Date.now();
+      const totalDuration = formatDuration(totalTimeEnd - totalTimeStart);
 
       console.error(colors.bright(`Done in ${totalDuration}.`));
 
@@ -3252,3 +3382,103 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus
     process.exit(0);
   })();
 }
+
+function showStepStatusSummary() {
+  const longestNameLength =
+    Math.max(...
+      Object.values(stepStatusSummary)
+        .map(({name}) => name.length));
+
+  const stepsNotClean =
+    Object.values(stepStatusSummary)
+      .map(({status}) =>
+        status === STATUS_HAS_WARNINGS ||
+        status === STATUS_FATAL_ERROR ||
+        status === STATUS_STARTED_NOT_DONE);
+
+  const anyStepsNotClean =
+    stepsNotClean.includes(true);
+
+  const stepDetails = Object.values(stepStatusSummary);
+
+  const stepDurations =
+    stepDetails.map(({status, timeStart, timeEnd}) => {
+      if (
+        status === STATUS_NOT_APPLICABLE ||
+        status === STATUS_NOT_STARTED ||
+        status === STATUS_STARTED_NOT_DONE
+      ) {
+        return '-';
+      }
+
+      if (typeof timeStart !== 'number' || typeof timeEnd !== 'number') {
+        return 'unknown';
+      }
+
+      return formatDuration(timeEnd - timeStart);
+    });
+
+  const longestDurationLength =
+    Math.max(...stepDurations.map(duration => duration.length));
+
+  const stepMemories =
+    stepDetails.map(({memory}) =>
+      (memory
+        ? Math.round(memory["heapUsed"] / 1024 / 1024) + 'MB'
+        : '-'));
+
+  const longestMemoryLength =
+    Math.max(...stepMemories.map(memory => memory.length));
+
+  for (let index = 0; index < stepDetails.length; index++) {
+    const {name, status, annotation} = stepDetails[index];
+    const duration = stepDurations[index];
+    const memory = stepMemories[index];
+
+    let message =
+      (stepsNotClean[index]
+        ? `!! `
+        : ` - `);
+
+    message += `(${duration} `.padStart(longestDurationLength + 2, ' ');
+
+    if (shouldShowStepMemoryInSummary) {
+      message += ` ${memory})`.padStart(longestMemoryLength + 2, ' ');
+    }
+
+    message += ` `;
+    message += `${name}: `.padEnd(longestNameLength + 4, '.');
+    message += ` `;
+    message += status;
+
+    if (annotation) {
+      message += ` (${annotation})`;
+    }
+
+    switch (status) {
+      case STATUS_DONE_CLEAN:
+        console.error(colors.green(message));
+        break;
+
+      case STATUS_NOT_STARTED:
+      case STATUS_NOT_APPLICABLE:
+        console.error(colors.dim(message));
+        break;
+
+      case STATUS_HAS_WARNINGS:
+      case STATUS_STARTED_NOT_DONE:
+        console.error(colors.yellow(message));
+        break;
+
+      case STATUS_FATAL_ERROR:
+        console.error(colors.red(message));
+        break;
+
+      default:
+        console.error(message);
+        break;
+    }
+  }
+
+  return {anyStepsNotClean};
+}
diff --git a/src/urls-default.yaml b/src/urls-default.yaml
index dce871f6..cbdd8a23 100644
--- a/src/urls-default.yaml
+++ b/src/urls-default.yaml
@@ -11,7 +11,7 @@ yamlAliases:
   # part of a build. This is so that multiple builds of a wiki can coexist
   # served from the same server / file system root: older builds' HTML files
   # refer to earlier values of STATIC_VERSION, avoiding name collisions.
-  - &staticVersion 4p1
+  - &staticVersion 5p2
 
 data:
   prefix: 'data/'
@@ -36,6 +36,9 @@ localized:
     albumReferencedArtworks: 'album/<>/referenced-art/'
     albumReferencingArtworks: 'album/<>/referencing-art/'
 
+    artTagInfo: 'tag/<>/info/'
+    artTagGallery: 'tag/<>/'
+
     artist: 'artist/<>/'
     artistGallery: 'artist/<>/gallery/'
 
@@ -60,8 +63,6 @@ localized:
 
     staticPage: '<>/'
 
-    tag: 'tag/<>/'
-
     track: 'track/<>/'
     trackReferencedArtworks: 'track/<>/referenced-art/'
     trackReferencingArtworks: 'track/<>/referencing-art/'
diff --git a/src/urls.js b/src/urls.js
index 5e334c1e..b51ea459 100644
--- a/src/urls.js
+++ b/src/urls.js
@@ -129,6 +129,9 @@ export function generateURLs(urlSpec) {
         if (template === null) {
           // Self-diagnose, brutally.
 
+          // TODO: This variable isn't used, and has never been used,
+          // but it might have been *meant* to be used?
+          // eslint-disable-next-line no-unused-vars
           const otherTemplateKey = (device ? 'posix' : 'device');
 
           const {value: {[templateKey]: otherTemplate}} =
@@ -283,9 +286,14 @@ export function getURLsFrom({
       to = targetFullKey;
     }
 
-    return (
-      subdirectoryPrefix +
-      urls.from(from).to(to, ...args));
+    const toResult =
+      urls.from(from).to(to, ...args);
+
+    if (getOrigin(toResult)) {
+      return toResult;
+    } else {
+      return subdirectoryPrefix + toResult;
+    }
   };
 }
 
diff --git a/src/validators.js b/src/validators.js
index 3b23e8f6..59df80d4 100644
--- a/src/validators.js
+++ b/src/validators.js
@@ -3,8 +3,12 @@ import {inspect as nodeInspect} from 'node:util';
 import {openAggregate, withAggregate} from '#aggregate';
 import {colors, ENABLE_COLOR} from '#cli';
 import {cut, empty, matchMultiline, typeAppearance} from '#sugar';
-import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot}
-  from '#wiki-data';
+
+import {
+  commentaryRegexCaseInsensitive,
+  commentaryRegexCaseSensitiveOneShot,
+  multipleLyricsDetectionRegex,
+} from '#wiki-data';
 
 function inspect(value) {
   return nodeInspect(value, {colors: ENABLE_COLOR});
@@ -288,69 +292,108 @@ export function isColor(color) {
   throw new TypeError(`Unknown color format`);
 }
 
-export function isCommentary(commentaryText) {
-  isContentString(commentaryText);
+export function validateContentEntries({
+  headingPhrase,
+  entryPhrase,
 
-  const rawMatches =
-    Array.from(commentaryText.matchAll(commentaryRegexCaseInsensitive));
+  caseInsensitiveRegex,
+  caseSensitiveOneShotRegex,
+}) {
+  return content => {
+    isContentString(content);
 
-  if (empty(rawMatches)) {
-    throw new TypeError(`Expected at least one commentary heading`);
-  }
+    const rawMatches =
+      Array.from(content.matchAll(caseInsensitiveRegex));
 
-  const niceMatches =
-    rawMatches.map(match => ({
-      position: match.index,
-      length: match[0].length,
-    }));
-
-  validateArrayItems(({position, length}, index) => {
-    if (index === 0 && position > 0) {
-      throw new TypeError(`Expected first commentary heading to be at top`);
+    if (empty(rawMatches)) {
+      throw new TypeError(`Expected at least one ${headingPhrase}`);
     }
 
-    const ownInput = commentaryText.slice(position, position + length);
-    const restOfInput = commentaryText.slice(position + length);
+    const niceMatches =
+      rawMatches.map(match => ({
+        position: match.index,
+        length: match[0].length,
+      }));
+
+    validateArrayItems(({position, length}, index) => {
+      if (index === 0 && position > 0) {
+        throw new TypeError(`Expected first ${headingPhrase} to be at top`);
+      }
 
-    const upToNextLineBreak =
-      (restOfInput.includes('\n')
-        ? restOfInput.slice(0, restOfInput.indexOf('\n'))
-        : restOfInput);
+      const ownInput = content.slice(position, position + length);
+      const restOfInput = content.slice(position + length);
 
-    if (/\S/.test(upToNextLineBreak)) {
-      throw new TypeError(
-        `Expected commentary heading to occupy entire line, got extra text:\n` +
-        `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
-        `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
-        `(Check for missing "|-" in YAML, or a misshapen annotation)`);
-    }
+      const upToNextLineBreak =
+        (restOfInput.includes('\n')
+          ? restOfInput.slice(0, restOfInput.indexOf('\n'))
+          : restOfInput);
 
-    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>"`)})`);
-    }
+      if (/\S/.test(upToNextLineBreak)) {
+        throw new TypeError(
+          `Expected ${headingPhrase} to occupy entire line, got extra text:\n` +
+          `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
+          `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
+          `(Check for missing "|-" in YAML, or a misshapen annotation)`);
+      }
 
-    const nextHeading =
-      (index === niceMatches.length - 1
-        ? commentaryText.length
-        : niceMatches[index + 1].position);
+      if (!caseSensitiveOneShotRegex.test(ownInput)) {
+        throw new TypeError(
+          `Miscapitalization in ${headingPhrase}:\n` +
+          `${colors.red(`"${cut(ownInput, 60)}"`)}\n` +
+          `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`);
+      }
 
-    const upToNextHeading =
-      commentaryText.slice(position + length, nextHeading);
+      const nextHeading =
+        (index === niceMatches.length - 1
+          ? content.length
+          : niceMatches[index + 1].position);
 
-    if (!/\S/.test(upToNextHeading)) {
-      throw new TypeError(
-        `Expected commentary entry to have body text, only got a heading`);
-    }
+      const upToNextHeading =
+        content.slice(position + length, nextHeading);
+
+      if (!/\S/.test(upToNextHeading)) {
+        throw new TypeError(
+          `Expected ${entryPhrase} to have body text, only got a heading`);
+      }
+
+      return true;
+    })(niceMatches);
 
     return true;
-  })(niceMatches);
+  };
+}
+
+export const isCommentary =
+  validateContentEntries({
+    headingPhrase: `commentary heading`,
+    entryPhrase: `commentary entry`,
+
+    caseInsensitiveRegex: commentaryRegexCaseInsensitive,
+    caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot,
+  });
+
+export function isOldStyleLyrics(content) {
+  isContentString(content);
+
+  if (multipleLyricsDetectionRegex.test(content)) {
+    throw new TypeError(
+      `Expected old-style lyrics block not to include "<i> ... :</i>" at start of any line`);
+  }
 
   return true;
 }
 
+export const isLyrics =
+  anyOf(
+    isOldStyleLyrics,
+    validateContentEntries({
+      headingPhrase: `lyrics heading`,
+      entryPhrase: `lyrics entry`,
+
+      caseInsensitiveRegex: commentaryRegexCaseInsensitive,
+      caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot,
+    }));
+
 const isArtistRef = validateReference('artist');
 
 export function validateProperties(spec) {
@@ -695,14 +738,6 @@ export const isContributionPreset = validateProperties({
 
 export const isContributionPresetList = validateArrayItems(isContributionPreset);
 
-export const isAdditionalFile = validateProperties({
-  title: isName,
-  description: optional(isContentString),
-  files: optional(validateArrayItems(isString)),
-});
-
-export const isAdditionalFileList = validateArrayItems(isAdditionalFile);
-
 export const isTrackSection = validateProperties({
   name: optional(isName),
   color: optional(isColor),
@@ -713,17 +748,6 @@ export const isTrackSection = validateProperties({
 
 export const isTrackSectionList = validateArrayItems(isTrackSection);
 
-export const isSeries = validateProperties({
-  name: isName,
-  description: optional(isContentString),
-  albums: optional(validateReferenceList('album')),
-
-  showAlbumArtists:
-    optional(is('all', 'differing', 'none')),
-});
-
-export const isSeriesList = validateArrayItems(isSeries);
-
 export const isWallpaperPart = validateProperties({
   asset: optional(isString),
   style: optional(isString),
@@ -944,13 +968,6 @@ export function validateWikiData({
   };
 }
 
-export const isAdditionalName = validateProperties({
-  name: isContentString,
-  annotation: optional(isContentString),
-});
-
-export const isAdditionalNameList = validateArrayItems(isAdditionalName);
-
 // Compositional utilities
 
 export function anyOf(...validators) {
diff --git a/src/web-routes.js b/src/web-routes.js
index b93607d6..a7115bbd 100644
--- a/src/web-routes.js
+++ b/src/web-routes.js
@@ -1,11 +1,13 @@
-import {readdir} from 'node:fs/promises';
+import {readdir, stat} from 'node:fs/promises';
 import * as path from 'node:path';
 import {fileURLToPath} from 'node:url';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
+/* eslint-disable no-unused-vars */
 const codeSrcPath = __dirname;
 const codeRootPath = path.resolve(codeSrcPath, '..');
+/* eslint-enable no-unused-vars */
 
 function getNodeDependencyRootPath(dependencyName) {
   return (
@@ -106,6 +108,21 @@ export async function identifyDynamicWebRoutes({
     ]),
 
     () => {
+      const from =
+        path.resolve(path.join(mediaPath, 'favicon.ico'));
+
+      return stat(from).then(
+        // {statically: 'copy'} is not workable for individual files
+        // at the moment, so this remains a symlink.
+        () => [{
+          from,
+          to: ['shared.path', 'favicon.ico'],
+          statically: 'symlink',
+        }],
+        () => []);
+    },
+
+    () => {
       if (!wikiCachePath) return [];
 
       const from =
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index d55ab215..afbf8b2f 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -24,6 +24,7 @@ export function bindUtilities({
   language,
   languages,
   missingImagePaths,
+  niceShowAggregate,
   pagePath,
   pagePathStringFromRoot,
   thumbsCache,
@@ -42,6 +43,7 @@ export function bindUtilities({
     language,
     languages,
     missingImagePaths,
+    niceShowAggregate,
     pagePath,
     pagePathStringFromRoot,
     thumb,
diff --git a/src/write/build-modes/index.js b/src/write/build-modes/index.js
index 3ae2cfc6..4b61619d 100644
--- a/src/write/build-modes/index.js
+++ b/src/write/build-modes/index.js
@@ -1,3 +1,4 @@
 export * as 'live-dev-server' from './live-dev-server.js';
 export * as 'repl' from './repl.js';
+export * as 'sort' from './sort.js';
 export * as 'static-build' from './static-build.js';
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index cd84dcc0..5dece8d0 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -1,6 +1,7 @@
 import {spawn} from 'node:child_process';
 import * as http from 'node:http';
 import {open, stat} from 'node:fs/promises';
+import * as os from 'node:os';
 import * as path from 'node:path';
 import {pipeline} from 'node:stream/promises';
 import {inspect as nodeInspect} from 'node:util';
@@ -252,7 +253,7 @@ export async function go({
     let url;
     try {
       url = new URL(request.url, `http://${request.headers.host}`);
-    } catch (error) {
+    } catch {
       response.writeHead(500, contentTypePlain);
       response.end('Failed to parse request URL\n');
       return;
@@ -299,7 +300,7 @@ export async function go({
       let filePath;
       try {
         filePath = path.resolve(localDirectory, decodeURI(safePath.split('/').join(path.sep)));
-      } catch (error) {
+      } catch {
         response.writeHead(404, contentTypePlain);
         response.end(`File not found for: ${safePath}`);
         console.log(`${requestHead} [404] ${pathname}`);
@@ -492,7 +493,13 @@ export async function go({
     }
   });
 
-  const address = `http://${host}:${port}/`;
+  const addresses =
+    (host === '0.0.0.0'
+      ? [`http://localhost:${port}/`,
+         `http://${os.hostname()}:${port}/`]
+   : host === '127.0.0.1'
+      ? [`http://localhost:${port}/`]
+      : [`http://${host}:${port}/`]);
 
   server.on('error', error => {
     if (error.code === 'EADDRINUSE') {
@@ -509,7 +516,15 @@ export async function go({
   });
 
   server.on('listening', () => {
-    logInfo`${'All done!'} Listening at: ${address}`;
+    if (addresses.length === 1) {
+      logInfo`${'All done!'} Listening at: ${addresses[0]}`;
+    } else {
+      logInfo`${`All done!`} Listening at:`;
+      for (const address of addresses) {
+        logInfo`- ${address}`;
+      }
+    }
+
     logInfo`Press ^C here (control+C) to stop the server and exit.`;
     if (showTimings && loudResponses) {
       logInfo`Printing all HTTP responses, plus page generation timings.`;
diff --git a/src/write/build-modes/sort.js b/src/write/build-modes/sort.js
new file mode 100644
index 00000000..1a738ac8
--- /dev/null
+++ b/src/write/build-modes/sort.js
@@ -0,0 +1,76 @@
+export const description = `Update data files in-place to satisfy custom sorting rules`;
+
+import {logInfo} from '#cli';
+import {empty} from '#sugar';
+import thingConstructors from '#things';
+
+export const config = {
+  fileSizes: {
+    applicable: false,
+  },
+
+  languageReloading: {
+    applicable: false,
+  },
+
+  mediaValidation: {
+    applicable: false,
+  },
+
+  search: {
+    applicable: false,
+  },
+
+  thumbs: {
+    applicable: false,
+  },
+
+  webRoutes: {
+    applicable: false,
+  },
+
+  sort: {
+    applicable: false,
+  },
+};
+
+export function getCLIOptions() {
+  return {};
+}
+
+export async function go({wikiData, dataPath}) {
+  if (empty(wikiData.sortingRules)) {
+    logInfo`There aren't any sorting rules in for this wiki.`;
+    return true;
+  }
+
+  const {SortingRule} = thingConstructors;
+
+  let numUpdated = 0;
+  let numActive = 0;
+
+  for await (const result of SortingRule.go({wikiData, dataPath})) {
+    numActive++;
+
+    const niceMessage = `"${result.rule.message}"`;
+
+    if (result.changed) {
+      numUpdated++;
+      logInfo`Updating to satisfy ${niceMessage}.`;
+    } else {
+      logInfo`Already good: ${niceMessage}`;
+    }
+  }
+
+  if (numUpdated > 1) {
+    logInfo`Updated data files to satisfy ${numUpdated} sorting rules.`;
+  } else if (numUpdated === 1) {
+    logInfo`Updated data files to satisfy ${1} sorting rule.`
+  } else if (numActive >= 1) {
+    logInfo`All sorting rules were already satisfied. Good to go!`;
+  } else {
+    logInfo`No sorting rules are currently active.`;
+  }
+
+  return true;
+}
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index 2baed816..b5ded04c 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -1,15 +1,6 @@
+import {cp, mkdir, stat, symlink, writeFile, unlink} from 'node:fs/promises';
 import * as path from 'node:path';
 
-import {
-  copyFile,
-  cp,
-  mkdir,
-  stat,
-  symlink,
-  writeFile,
-  unlink,
-} from 'node:fs/promises';
-
 import {rimraf} from 'rimraf';
 
 import {quickLoadContentDependencies} from '#content-dependencies';
@@ -115,8 +106,6 @@ export async function go({
 
   universalUtilities,
 
-  mediaPath,
-
   defaultLanguage,
   languages,
   urls,
@@ -166,11 +155,6 @@ export async function go({
   });
 
   if (writeAll) {
-    await writeFavicon({
-      mediaPath,
-      outputPath,
-    });
-
     await writeSharedFilesAndPages({
       outputPath,
       randomLinkDataJSON: generateRandomLinkDataJSON({wikiData}),
@@ -595,30 +579,6 @@ async function writeWebRouteCopies({
   }
 }
 
-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,
diff --git a/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs
deleted file mode 100644
index 4f09569d..00000000
--- a/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs
+++ /dev/null
@@ -1,56 +0,0 @@
-/* IMPORTANT
- * This snapshot file is auto-generated, but designed for humans.
- * It should be checked into source control and tracked carefully.
- * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
- * Make sure to inspect the output below.  Do not ignore changes!
- */
-'use strict'
-exports[`test/snapshot/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`] = `
-
-`
diff --git a/test/snapshot/generateAlbumAdditionalFilesList.js b/test/snapshot/generateAlbumAdditionalFilesList.js
deleted file mode 100644
index c25e5682..00000000
--- a/test/snapshot/generateAlbumAdditionalFilesList.js
+++ /dev/null
@@ -1,84 +0,0 @@
-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/linkThing.js b/test/snapshot/linkThing.js
index 502db6d7..9b5cff33 100644
--- a/test/snapshot/linkThing.js
+++ b/test/snapshot/linkThing.js
@@ -20,7 +20,7 @@ testContentFunctions(t, 'linkThing (snapshot)', async (t, evaluate) => {
   });
 
   quickSnapshot('preferShortName', {
-    args: ['localized.tag', {
+    args: ['localized.artTagGallery', {
       directory: 'five-oceanfalls',
       name: 'Five (Oceanfalls)',
       nameShort: 'Five',