« 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--package-lock.json479
-rw-r--r--package.json1
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js60
-rw-r--r--src/content/dependencies/generateAlbumChronologyLinks.js53
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js125
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js11
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js214
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js11
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js124
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js79
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js25
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js60
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js41
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js97
-rw-r--r--src/content/dependencies/generateAlbumTrackListMissingDuration.js34
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js15
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js21
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js194
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js341
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js23
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js68
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js97
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkItem.js19
-rw-r--r--src/content/dependencies/generateChronologyLinks.js112
-rw-r--r--src/content/dependencies/generateChronologyLinksScopeSwitcher.js67
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js123
-rw-r--r--src/content/dependencies/generateCommentaryIndexPage.js86
-rw-r--r--src/content/dependencies/generateContributionList.js11
-rw-r--r--src/content/dependencies/generateContributionTooltip.js48
-rw-r--r--src/content/dependencies/generateContributionTooltipChronologySection.js117
-rw-r--r--src/content/dependencies/generateContributionTooltipExternalLinkSection.js70
-rw-r--r--src/content/dependencies/generateExternalHandle.js20
-rw-r--r--src/content/dependencies/generateExternalIcon.js26
-rw-r--r--src/content/dependencies/generateExternalPlatform.js20
-rw-r--r--src/content/dependencies/generateFlashActGalleryPage.js78
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js143
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js153
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js13
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js71
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsSection.js128
-rw-r--r--src/content/dependencies/generateGroupSidebarCategoryDetails.js66
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesChunk.js79
-rw-r--r--src/content/dependencies/generateNewsEntryPage.js74
-rw-r--r--src/content/dependencies/generateNewsIndexPage.js67
-rw-r--r--src/content/dependencies/generatePageLayout.js70
-rw-r--r--src/content/dependencies/generatePageSidebarBox.js2
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js13
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js73
-rw-r--r--src/content/dependencies/generateTooltip.js3
-rw-r--r--src/content/dependencies/generateTrackChronologyLinks.js177
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js432
-rw-r--r--src/content/dependencies/generateTrackList.js35
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js104
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js87
-rw-r--r--src/content/dependencies/generateTrackSocialEmbed.js52
-rw-r--r--src/content/dependencies/generateWikiHomeNewsBox.js81
-rw-r--r--src/content/dependencies/linkAnythingMan.js25
-rw-r--r--src/content/dependencies/linkContribution.js187
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js51
-rw-r--r--src/content/dependencies/listRandomPageLinks.js56
-rw-r--r--src/content/dependencies/transformContent.js19
-rw-r--r--src/content/util/getChronologyRelations.js57
-rw-r--r--src/data/composite/data/excludeFromList.js5
-rw-r--r--src/data/composite/data/fillMissingListItems.js5
-rw-r--r--src/data/composite/data/index.js30
-rw-r--r--src/data/composite/data/withFilteredList.js6
-rw-r--r--src/data/composite/data/withFlattenedList.js6
-rw-r--r--src/data/composite/data/withIndexInList.js38
-rw-r--r--src/data/composite/data/withMappedList.js6
-rw-r--r--src/data/composite/data/withNearbyItemFromList.js73
-rw-r--r--src/data/composite/data/withPropertiesFromList.js6
-rw-r--r--src/data/composite/data/withPropertyFromList.js6
-rw-r--r--src/data/composite/data/withSortedList.js6
-rw-r--r--src/data/composite/data/withUnflattenedList.js6
-rw-r--r--src/data/composite/things/contribution/index.js1
-rw-r--r--src/data/composite/things/contribution/withContainingReverseContributionList.js40
-rw-r--r--src/data/composite/things/contribution/withContributionArtist.js7
-rw-r--r--src/data/composite/wiki-data/withRecontextualizedContributionList.js28
-rw-r--r--src/data/composite/wiki-data/withResolvedContribs.js8
-rw-r--r--src/data/composite/wiki-properties/contributionList.js8
-rw-r--r--src/data/things/album.js8
-rw-r--r--src/data/things/contribution.js48
-rw-r--r--src/data/things/flash.js1
-rw-r--r--src/data/things/language.js31
-rw-r--r--src/data/things/track.js5
-rw-r--r--src/static/css/site.css245
-rw-r--r--src/static/js/client.js259
-rw-r--r--src/strings-default.yaml29
-rw-r--r--src/url-spec.js2
-rw-r--r--src/web-routes.js26
-rw-r--r--src/write/build-modes/static-build.js120
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs2
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs15
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs28
-rw-r--r--tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs8
-rw-r--r--tap-snapshots/test/snapshot/linkContribution.js.test.cjs231
-rw-r--r--test/snapshot/generateAlbumReleaseInfo.js12
-rw-r--r--test/snapshot/generateAlbumTrackList.js6
-rw-r--r--test/snapshot/generateTrackReleaseInfo.js4
-rw-r--r--test/snapshot/linkContribution.js45
-rw-r--r--test/unit/content/dependencies/linkContribution.js41
101 files changed, 3411 insertions, 3328 deletions
diff --git a/package-lock.json b/package-lock.json
index 83a2969b..11bc6a41 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
                 "js-yaml": "^4.1.0",
                 "marked": "^10.0.0",
                 "msgpackr": "^1.10.2",
+                "rimraf": "^5.0.7",
                 "striptags": "^4.0.0-alpha.4",
                 "word-wrap": "^1.2.3"
             },
@@ -170,7 +171,6 @@
             "version": "8.0.2",
             "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
             "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
-            "dev": true,
             "dependencies": {
                 "string-width": "^5.1.2",
                 "string-width-cjs": "npm:string-width@^4.2.0",
@@ -187,7 +187,6 @@
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
             "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-            "dev": true,
             "engines": {
                 "node": ">=12"
             },
@@ -199,7 +198,6 @@
             "version": "7.1.0",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
             "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-            "dev": true,
             "dependencies": {
                 "ansi-regex": "^6.0.1"
             },
@@ -658,7 +656,6 @@
             "version": "0.11.0",
             "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
             "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
-            "dev": true,
             "optional": true,
             "engines": {
                 "node": ">=14"
@@ -954,70 +951,6 @@
                 "@tapjs/core": "2.0.1"
             }
         },
-        "node_modules/@tapjs/fixture/node_modules/brace-expansion": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
-            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
-            "dev": true,
-            "dependencies": {
-                "balanced-match": "^1.0.0"
-            }
-        },
-        "node_modules/@tapjs/fixture/node_modules/glob": {
-            "version": "10.4.1",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
-            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
-            "dev": true,
-            "dependencies": {
-                "foreground-child": "^3.1.0",
-                "jackspeak": "^3.1.2",
-                "minimatch": "^9.0.4",
-                "minipass": "^7.1.2",
-                "path-scurry": "^1.11.1"
-            },
-            "bin": {
-                "glob": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=16 || 14 >=14.18"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
-        "node_modules/@tapjs/fixture/node_modules/minimatch": {
-            "version": "9.0.4",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
-            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
-            "dev": true,
-            "dependencies": {
-                "brace-expansion": "^2.0.1"
-            },
-            "engines": {
-                "node": ">=16 || 14 >=14.17"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
-        "node_modules/@tapjs/fixture/node_modules/rimraf": {
-            "version": "5.0.7",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
-            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
-            "dev": true,
-            "dependencies": {
-                "glob": "^10.3.7"
-            },
-            "bin": {
-                "rimraf": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=14.18"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
         "node_modules/@tapjs/intercept": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/@tapjs/intercept/-/intercept-2.0.1.tgz",
@@ -1264,24 +1197,6 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/@tapjs/run/node_modules/rimraf": {
-            "version": "5.0.7",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
-            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
-            "dev": true,
-            "dependencies": {
-                "glob": "^10.3.7"
-            },
-            "bin": {
-                "rimraf": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=14.18"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
         "node_modules/@tapjs/run/node_modules/tcompare": {
             "version": "7.0.1",
             "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
@@ -1468,24 +1383,6 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/@tapjs/test/node_modules/rimraf": {
-            "version": "5.0.7",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
-            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
-            "dev": true,
-            "dependencies": {
-                "glob": "^10.3.7"
-            },
-            "bin": {
-                "rimraf": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=14.18"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
         "node_modules/@tapjs/typescript": {
             "version": "1.4.6",
             "resolved": "https://registry.npmjs.org/@tapjs/typescript/-/typescript-1.4.6.tgz",
@@ -1792,6 +1689,7 @@
             "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
             "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "fill-range": "^7.1.1"
             },
@@ -2236,14 +2134,12 @@
         "node_modules/eastasianwidth": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
-            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
-            "dev": true
+            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
         },
         "node_modules/emoji-regex": {
             "version": "9.2.2",
             "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
-            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
-            "dev": true
+            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
         },
         "node_modules/encoding": {
             "version": "0.1.13",
@@ -2477,6 +2373,7 @@
             "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
             "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "to-regex-range": "^5.0.1"
             },
@@ -2511,6 +2408,22 @@
                 "node": "^10.12.0 || >=12.0.0"
             }
         },
+        "node_modules/flat-cache/node_modules/rimraf": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+            "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+            "deprecated": "Rimraf versions prior to v4 are no longer supported",
+            "license": "ISC",
+            "dependencies": {
+                "glob": "^7.1.3"
+            },
+            "bin": {
+                "rimraf": "bin.js"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
         "node_modules/flatted": {
             "version": "3.2.5",
             "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
@@ -2525,7 +2438,6 @@
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
             "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
-            "dev": true,
             "dependencies": {
                 "cross-spawn": "^7.0.0",
                 "signal-exit": "^4.0.1"
@@ -3050,6 +2962,7 @@
             "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
             "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=0.12.0"
             }
@@ -3125,7 +3038,6 @@
             "version": "3.1.2",
             "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.1.2.tgz",
             "integrity": "sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==",
-            "dev": true,
             "dependencies": {
                 "@isaacs/cliui": "^8.0.2"
             },
@@ -3257,7 +3169,6 @@
             "version": "10.2.2",
             "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
             "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
-            "dev": true,
             "engines": {
                 "node": "14 || >=16.14"
             }
@@ -3341,7 +3252,6 @@
             "version": "7.1.2",
             "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
             "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
-            "dev": true,
             "engines": {
                 "node": ">=16 || 14 >=14.17"
             }
@@ -3975,7 +3885,6 @@
             "version": "1.11.1",
             "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
             "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
-            "dev": true,
             "dependencies": {
                 "lru-cache": "^10.2.0",
                 "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
@@ -4411,14 +4320,64 @@
             }
         },
         "node_modules/rimraf": {
-            "version": "3.0.2",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
-            "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
+            "license": "ISC",
             "dependencies": {
-                "glob": "^7.1.3"
+                "glob": "^10.3.7"
             },
             "bin": {
-                "rimraf": "bin.js"
+                "rimraf": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=14.18"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/rimraf/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "license": "MIT",
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/rimraf/node_modules/glob": {
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
+            "license": "ISC",
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.18"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/rimraf/node_modules/minimatch": {
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
+            "license": "ISC",
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
@@ -4497,7 +4456,6 @@
             "version": "4.1.0",
             "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
             "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
-            "dev": true,
             "engines": {
                 "node": ">=14"
             },
@@ -4705,7 +4663,6 @@
             "version": "5.1.2",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
             "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
-            "dev": true,
             "dependencies": {
                 "eastasianwidth": "^0.2.0",
                 "emoji-regex": "^9.2.2",
@@ -4723,7 +4680,6 @@
             "version": "4.2.3",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
             "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-            "dev": true,
             "dependencies": {
                 "emoji-regex": "^8.0.0",
                 "is-fullwidth-code-point": "^3.0.0",
@@ -4736,14 +4692,12 @@
         "node_modules/string-width-cjs/node_modules/emoji-regex": {
             "version": "8.0.0",
             "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-            "dev": true
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
         },
         "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
             "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             }
@@ -4752,7 +4706,6 @@
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
             "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-            "dev": true,
             "engines": {
                 "node": ">=12"
             },
@@ -4764,7 +4717,6 @@
             "version": "7.1.0",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
             "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-            "dev": true,
             "dependencies": {
                 "ansi-regex": "^6.0.1"
             },
@@ -4791,7 +4743,6 @@
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
             "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-            "dev": true,
             "dependencies": {
                 "ansi-regex": "^5.0.1"
             },
@@ -4893,24 +4844,6 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/sync-content/node_modules/rimraf": {
-            "version": "5.0.7",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
-            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
-            "dev": true,
-            "dependencies": {
-                "glob": "^10.3.7"
-            },
-            "bin": {
-                "rimraf": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=14.18"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
         "node_modules/tap": {
             "version": "19.0.2",
             "resolved": "https://registry.npmjs.org/tap/-/tap-19.0.2.tgz",
@@ -5075,6 +5008,7 @@
             "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
             "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "is-number": "^7.0.0"
             },
@@ -5137,28 +5071,6 @@
                 "url": "https://github.com/chalk/chalk?sponsor=1"
             }
         },
-        "node_modules/tshy/node_modules/glob": {
-            "version": "10.4.1",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
-            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
-            "dev": true,
-            "dependencies": {
-                "foreground-child": "^3.1.0",
-                "jackspeak": "^3.1.2",
-                "minimatch": "^9.0.4",
-                "minipass": "^7.1.2",
-                "path-scurry": "^1.11.1"
-            },
-            "bin": {
-                "glob": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=16 || 14 >=14.18"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
         "node_modules/tshy/node_modules/minimatch": {
             "version": "9.0.4",
             "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
@@ -5174,24 +5086,6 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/tshy/node_modules/rimraf": {
-            "version": "5.0.7",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
-            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
-            "dev": true,
-            "dependencies": {
-                "glob": "^10.3.7"
-            },
-            "bin": {
-                "rimraf": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=14.18"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
         "node_modules/tslib": {
             "version": "2.6.2",
             "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
@@ -5390,7 +5284,6 @@
             "version": "8.1.0",
             "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
             "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
-            "dev": true,
             "dependencies": {
                 "ansi-styles": "^6.1.0",
                 "string-width": "^5.0.1",
@@ -5408,7 +5301,6 @@
             "version": "7.0.0",
             "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
             "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-            "dev": true,
             "dependencies": {
                 "ansi-styles": "^4.0.0",
                 "string-width": "^4.1.0",
@@ -5424,14 +5316,12 @@
         "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
             "version": "8.0.0",
             "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-            "dev": true
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
         },
         "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
             "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             }
@@ -5440,7 +5330,6 @@
             "version": "4.2.3",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
             "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-            "dev": true,
             "dependencies": {
                 "emoji-regex": "^8.0.0",
                 "is-fullwidth-code-point": "^3.0.0",
@@ -5454,7 +5343,6 @@
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
             "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-            "dev": true,
             "engines": {
                 "node": ">=12"
             },
@@ -5466,7 +5354,6 @@
             "version": "6.2.1",
             "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
             "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
-            "dev": true,
             "engines": {
                 "node": ">=12"
             },
@@ -5478,7 +5365,6 @@
             "version": "7.1.0",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
             "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-            "dev": true,
             "dependencies": {
                 "ansi-regex": "^6.0.1"
             },
@@ -5495,10 +5381,11 @@
             "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
         },
         "node_modules/ws": {
-            "version": "8.17.0",
-            "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz",
-            "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==",
+            "version": "8.17.1",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+            "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=10.0.0"
             },
@@ -5727,7 +5614,6 @@
             "version": "8.0.2",
             "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
             "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
-            "dev": true,
             "requires": {
                 "string-width": "^5.1.2",
                 "string-width-cjs": "npm:string-width@^4.2.0",
@@ -5740,14 +5626,12 @@
                 "ansi-regex": {
                     "version": "6.0.1",
                     "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
-                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-                    "dev": true
+                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="
                 },
                 "strip-ansi": {
                     "version": "7.1.0",
                     "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
                     "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-                    "dev": true,
                     "requires": {
                         "ansi-regex": "^6.0.1"
                     }
@@ -6062,7 +5946,6 @@
             "version": "0.11.0",
             "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
             "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
-            "dev": true,
             "optional": true
         },
         "@sigstore/bundle": {
@@ -6260,48 +6143,6 @@
             "requires": {
                 "mkdirp": "^3.0.0",
                 "rimraf": "^5.0.5"
-            },
-            "dependencies": {
-                "brace-expansion": {
-                    "version": "2.0.1",
-                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
-                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
-                    "dev": true,
-                    "requires": {
-                        "balanced-match": "^1.0.0"
-                    }
-                },
-                "glob": {
-                    "version": "10.4.1",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
-                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
-                    "dev": true,
-                    "requires": {
-                        "foreground-child": "^3.1.0",
-                        "jackspeak": "^3.1.2",
-                        "minimatch": "^9.0.4",
-                        "minipass": "^7.1.2",
-                        "path-scurry": "^1.11.1"
-                    }
-                },
-                "minimatch": {
-                    "version": "9.0.4",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
-                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
-                    "dev": true,
-                    "requires": {
-                        "brace-expansion": "^2.0.1"
-                    }
-                },
-                "rimraf": {
-                    "version": "5.0.7",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
-                    "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
-                    "dev": true,
-                    "requires": {
-                        "glob": "^10.3.7"
-                    }
-                }
             }
         },
         "@tapjs/intercept": {
@@ -6471,15 +6312,6 @@
                         "brace-expansion": "^2.0.1"
                     }
                 },
-                "rimraf": {
-                    "version": "5.0.7",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
-                    "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
-                    "dev": true,
-                    "requires": {
-                        "glob": "^10.3.7"
-                    }
-                },
                 "tcompare": {
                     "version": "7.0.1",
                     "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
@@ -6608,15 +6440,6 @@
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
-                },
-                "rimraf": {
-                    "version": "5.0.7",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
-                    "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
-                    "dev": true,
-                    "requires": {
-                        "glob": "^10.3.7"
-                    }
                 }
             }
         },
@@ -7169,14 +6992,12 @@
         "eastasianwidth": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
-            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
-            "dev": true
+            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
         },
         "emoji-regex": {
             "version": "9.2.2",
             "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
-            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
-            "dev": true
+            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
         },
         "encoding": {
             "version": "0.1.13",
@@ -7376,6 +7197,16 @@
             "requires": {
                 "flatted": "^3.1.0",
                 "rimraf": "^3.0.2"
+            },
+            "dependencies": {
+                "rimraf": {
+                    "version": "3.0.2",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+                    "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+                    "requires": {
+                        "glob": "^7.1.3"
+                    }
+                }
             }
         },
         "flatted": {
@@ -7392,7 +7223,6 @@
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
             "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
-            "dev": true,
             "requires": {
                 "cross-spawn": "^7.0.0",
                 "signal-exit": "^4.0.1"
@@ -7821,7 +7651,6 @@
             "version": "3.1.2",
             "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.1.2.tgz",
             "integrity": "sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==",
-            "dev": true,
             "requires": {
                 "@isaacs/cliui": "^8.0.2",
                 "@pkgjs/parseargs": "^0.11.0"
@@ -7919,8 +7748,7 @@
         "lru-cache": {
             "version": "10.2.2",
             "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
-            "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
-            "dev": true
+            "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="
         },
         "make-dir": {
             "version": "4.0.0",
@@ -7979,8 +7807,7 @@
         "minipass": {
             "version": "7.1.2",
             "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
-            "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
-            "dev": true
+            "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="
         },
         "minipass-collect": {
             "version": "2.0.1",
@@ -8448,7 +8275,6 @@
             "version": "1.11.1",
             "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
             "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
-            "dev": true,
             "requires": {
                 "lru-cache": "^10.2.0",
                 "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
@@ -8746,11 +8572,41 @@
             "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="
         },
         "rimraf": {
-            "version": "3.0.2",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
-            "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
             "requires": {
-                "glob": "^7.1.3"
+                "glob": "^10.3.7"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "glob": {
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                }
             }
         },
         "run-parallel": {
@@ -8799,8 +8655,7 @@
         "signal-exit": {
             "version": "4.1.0",
             "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
-            "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
-            "dev": true
+            "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
         },
         "sigstore": {
             "version": "2.3.1",
@@ -8955,7 +8810,6 @@
             "version": "5.1.2",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
             "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
-            "dev": true,
             "requires": {
                 "eastasianwidth": "^0.2.0",
                 "emoji-regex": "^9.2.2",
@@ -8965,14 +8819,12 @@
                 "ansi-regex": {
                     "version": "6.0.1",
                     "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
-                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-                    "dev": true
+                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="
                 },
                 "strip-ansi": {
                     "version": "7.1.0",
                     "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
                     "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-                    "dev": true,
                     "requires": {
                         "ansi-regex": "^6.0.1"
                     }
@@ -8983,7 +8835,6 @@
             "version": "npm:string-width@4.2.3",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
             "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-            "dev": true,
             "requires": {
                 "emoji-regex": "^8.0.0",
                 "is-fullwidth-code-point": "^3.0.0",
@@ -8993,14 +8844,12 @@
                 "emoji-regex": {
                     "version": "8.0.0",
                     "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-                    "dev": true
+                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
                 },
                 "is-fullwidth-code-point": {
                     "version": "3.0.0",
                     "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
-                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-                    "dev": true
+                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
                 }
             }
         },
@@ -9016,7 +8865,6 @@
             "version": "npm:strip-ansi@6.0.1",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
             "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-            "dev": true,
             "requires": {
                 "ansi-regex": "^5.0.1"
             }
@@ -9081,15 +8929,6 @@
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
-                },
-                "rimraf": {
-                    "version": "5.0.7",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
-                    "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
-                    "dev": true,
-                    "requires": {
-                        "glob": "^10.3.7"
-                    }
                 }
             }
         },
@@ -9263,19 +9102,6 @@
                     "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
                     "dev": true
                 },
-                "glob": {
-                    "version": "10.4.1",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
-                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
-                    "dev": true,
-                    "requires": {
-                        "foreground-child": "^3.1.0",
-                        "jackspeak": "^3.1.2",
-                        "minimatch": "^9.0.4",
-                        "minipass": "^7.1.2",
-                        "path-scurry": "^1.11.1"
-                    }
-                },
                 "minimatch": {
                     "version": "9.0.4",
                     "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
@@ -9284,15 +9110,6 @@
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
-                },
-                "rimraf": {
-                    "version": "5.0.7",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
-                    "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
-                    "dev": true,
-                    "requires": {
-                        "glob": "^10.3.7"
-                    }
                 }
             }
         },
@@ -9447,7 +9264,6 @@
             "version": "8.1.0",
             "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
             "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
-            "dev": true,
             "requires": {
                 "ansi-styles": "^6.1.0",
                 "string-width": "^5.0.1",
@@ -9457,20 +9273,17 @@
                 "ansi-regex": {
                     "version": "6.0.1",
                     "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
-                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-                    "dev": true
+                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="
                 },
                 "ansi-styles": {
                     "version": "6.2.1",
                     "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
-                    "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
-                    "dev": true
+                    "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="
                 },
                 "strip-ansi": {
                     "version": "7.1.0",
                     "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
                     "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-                    "dev": true,
                     "requires": {
                         "ansi-regex": "^6.0.1"
                     }
@@ -9481,7 +9294,6 @@
             "version": "npm:wrap-ansi@7.0.0",
             "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
             "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-            "dev": true,
             "requires": {
                 "ansi-styles": "^4.0.0",
                 "string-width": "^4.1.0",
@@ -9491,20 +9303,17 @@
                 "emoji-regex": {
                     "version": "8.0.0",
                     "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-                    "dev": true
+                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
                 },
                 "is-fullwidth-code-point": {
                     "version": "3.0.0",
                     "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
-                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-                    "dev": true
+                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
                 },
                 "string-width": {
                     "version": "4.2.3",
                     "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
                     "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-                    "dev": true,
                     "requires": {
                         "emoji-regex": "^8.0.0",
                         "is-fullwidth-code-point": "^3.0.0",
@@ -9519,9 +9328,9 @@
             "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
         },
         "ws": {
-            "version": "8.17.0",
-            "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz",
-            "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==",
+            "version": "8.17.1",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+            "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
             "dev": true,
             "requires": {}
         },
diff --git a/package.json b/package.json
index edb8111d..c4b74c26 100644
--- a/package.json
+++ b/package.json
@@ -70,6 +70,7 @@
         "js-yaml": "^4.1.0",
         "marked": "^10.0.0",
         "msgpackr": "^1.10.2",
+        "rimraf": "^5.0.7",
         "striptags": "^4.0.0-alpha.4",
         "word-wrap": "^1.2.3"
     },
diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js
index 5804115a..e66560fc 100644
--- a/src/content/dependencies/generateAdditionalFilesListChunk.js
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -17,37 +17,31 @@ export default {
     },
   },
 
-  generate(slots, {html, language}) {
-    const summary =
-      html.tag('summary',
-        html.tag('span',
-          language.$('releaseInfo.additionalFiles.entry', {
-            title:
-              html.tag('span', {class: 'group-name'},
-                slots.title),
-          })));
-
-    const description =
-      html.tag('li', {class: 'entry-description'},
-        {[html.onlyIfContent]: true},
-        slots.description);
-
-    const items =
-      (html.isBlank(slots.items)
-        ? html.tag('li',
-            language.$('releaseInfo.additionalFiles.entry.noFilesAvailable'))
-        : slots.items);
-
-    const content =
-      html.tag('ul', [description, items]);
-
-    const details =
-      html.tag('details',
-        html.isBlank(slots.items) &&
-          {open: true},
-
-        [summary, content]);
-
-    return html.tag('li', details);
-  },
+  generate: (slots, {html, language}) =>
+    language.encapsulate('releaseInfo.additionalFiles.entry', capsule =>
+      html.tag('li',
+        html.tag('details',
+          html.isBlank(slots.items) &&
+            {open: true},
+
+          [
+            html.tag('summary',
+              html.tag('span',
+                language.$(capsule, {
+                  title:
+                    html.tag('span', {class: 'group-name'},
+                      slots.title),
+                }))),
+
+            html.tag('ul', [
+              html.tag('li', {class: 'entry-description'},
+                {[html.onlyIfContent]: true},
+                slots.description),
+
+              (html.isBlank(slots.items)
+                ? html.tag('li',
+                    language.$(capsule, 'noFilesAvailable'))
+                : slots.items),
+            ]),
+          ]))),
 };
diff --git a/src/content/dependencies/generateAlbumChronologyLinks.js b/src/content/dependencies/generateAlbumChronologyLinks.js
deleted file mode 100644
index 3dd7a18e..00000000
--- a/src/content/dependencies/generateAlbumChronologyLinks.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import {sortAlbumsTracksChronologically} from '#sort';
-
-import getChronologyRelations from '../util/getChronologyRelations.js';
-
-export default {
-  contentDependencies: [
-    'generateChronologyLinks',
-    'linkAlbum',
-    'linkArtist',
-    'linkTrack',
-  ],
-
-  relations: (relation, album) => ({
-    chronologyLinks:
-      relation('generateChronologyLinks'),
-
-    coverArtistChronologyContributions:
-      getChronologyRelations(album, {
-        contributions: album.coverArtistContribs ?? [],
-
-        linkArtist: artist => relation('linkArtist', artist),
-
-        linkThing: trackOrAlbum =>
-          (trackOrAlbum.album
-            ? relation('linkTrack', trackOrAlbum)
-            : relation('linkAlbum', trackOrAlbum)),
-
-        getThings(artist) {
-          const getDate = thing => thing.coverArtDate ?? thing.date;
-
-          const things =
-            ([
-              artist.albumCoverArtistContributions,
-              artist.trackCoverArtistContributions,
-            ]).flat()
-              .map(({thing}) => thing)
-              .filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      }),
-  }),
-
-  generate: (relations) =>
-    relations.chronologyLinks.slots({
-      chronologyInfoSets: [
-        {
-          headingString: 'misc.chronology.heading.coverArt',
-          contributions: relations.coverArtistChronologyContributions,
-        },
-      ],
-    }),
-}
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 05dbdcf3..c14640af 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -130,11 +130,11 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumCommentaryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('albumCommentaryPage.title', {
+          language.$(pageCapsule, 'title', {
             album: data.name,
           }),
 
@@ -146,7 +146,7 @@ export default {
         mainClasses: ['long-content'],
         mainContent: [
           html.tag('p',
-            language.$('albumCommentaryPage.infoLine', {
+            language.$(pageCapsule, 'infoLine', {
               words:
                 html.tag('b',
                   language.formatWordCount(data.wordCount, {unit: true})),
@@ -156,39 +156,41 @@ export default {
                   language.countCommentaryEntries(data.entryCount, {unit: true})),
             })),
 
-          relations.albumCommentaryEntries && [
-            relations.albumCommentaryHeading.slots({
-              tag: 'h3',
-              color: data.color,
-
-              title:
-                language.$('albumCommentaryPage.entry.title.albumCommentary', {
-                  album: relations.albumCommentaryLink,
-                }),
-
-              stickyTitle:
-                language.$('albumCommentaryPage.entry.title.albumCommentary.sticky', {
-                  album: data.name,
-                }),
-
-              accent:
-                language.$('albumCommentaryPage.entry.title.albumCommentary.accent', {
-                  [language.onlyIfOptions]: ['listeningLinks'],
-                  listeningLinks:
-                    language.formatUnitList(
-                      relations.albumCommentaryListeningLinks
-                        .map(link => link.slots({
-                          context: 'album',
-                          tab: 'separate',
-                        }))),
-                }),
-            }),
-
-            relations.albumCommentaryCover
-              ?.slots({mode: 'commentary'}),
-
-            relations.albumCommentaryEntries,
-          ],
+          relations.albumCommentaryEntries &&
+            language.encapsulate(pageCapsule, 'entry', entryCapsule => [
+              language.encapsulate(entryCapsule, 'title.albumCommentary', titleCapsule =>
+                relations.albumCommentaryHeading.slots({
+                  tag: 'h3',
+                  color: data.color,
+
+                  title:
+                    language.$(titleCapsule, {
+                      album: relations.albumCommentaryLink,
+                    }),
+
+                  stickyTitle:
+                    language.$(titleCapsule, 'sticky', {
+                      album: data.name,
+                    }),
+
+                  accent:
+                    language.$(titleCapsule, 'accent', {
+                      [language.onlyIfOptions]: ['listeningLinks'],
+                      listeningLinks:
+                        language.formatUnitList(
+                          relations.albumCommentaryListeningLinks
+                            .map(link => link.slots({
+                              context: 'album',
+                              tab: 'separate',
+                            }))),
+                    }),
+                })),
+
+              relations.albumCommentaryCover
+                ?.slots({mode: 'commentary'}),
+
+              relations.albumCommentaryEntries,
+            ]),
 
           stitchArrays({
             heading: relations.trackCommentaryHeadings,
@@ -206,31 +208,33 @@ export default {
               cover,
               entries,
               color,
-            }) => [
-              heading.slots({
-                tag: 'h3',
-                attributes: {id: directory},
-                color,
-
-                title:
-                  language.$('albumCommentaryPage.entry.title.trackCommentary', {
-                    track: link,
-                  }),
-
-                accent:
-                  language.$('albumCommentaryPage.entry.title.trackCommentary.accent', {
-                    [language.onlyIfOptions]: ['listeningLinks'],
-                    listeningLinks:
-                      language.formatUnitList(
-                        listeningLinks.map(link =>
-                          link.slot('tab', 'separate'))),
-                  }),
-              }),
+            }) =>
+              language.encapsulate(pageCapsule, 'entry', entryCapsule => [
+                language.encapsulate(entryCapsule, 'title.trackCommentary', titleCapsule =>
+                  heading.slots({
+                    tag: 'h3',
+                    attributes: {id: directory},
+                    color,
+
+                    title:
+                      language.$(titleCapsule, {
+                        track: link,
+                      }),
+
+                    accent:
+                      language.$(titleCapsule, 'accent', {
+                        [language.onlyIfOptions]: ['listeningLinks'],
+                        listeningLinks:
+                          language.formatUnitList(
+                            listeningLinks.map(link =>
+                              link.slot('tab', 'separate'))),
+                      }),
+                  })),
 
               cover?.slots({mode: 'commentary'}),
 
               entries.map(entry => entry.slot('color', color)),
-            ]),
+            ])),
         ],
 
         navLinkStyle: 'hierarchical',
@@ -251,6 +255,5 @@ export default {
         ],
 
         leftSidebar: relations.sidebar,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index aa025688..44d49c54 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -160,11 +160,11 @@ export default {
     return data;
   },
 
-  generate(data, relations, {language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {language}) =>
+    language.encapsulate('albumGalleryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('albumGalleryPage.title', {
+          language.$(pageCapsule, 'title', {
             album: data.name,
           }),
 
@@ -223,6 +223,5 @@ export default {
         ],
 
         secondaryNav: relations.secondaryNav,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 9e4b8816..1bffe2d0 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -10,7 +10,6 @@ export default {
     'generateAlbumSocialEmbed',
     'generateAlbumStyleRules',
     'generateAlbumTrackList',
-    'generateAlbumChronologyLinks',
     'generateCommentarySection',
     'generateContentHeading',
     'generatePageLayout',
@@ -33,9 +32,6 @@ export default {
     albumNavAccent:
       relation('generateAlbumNavAccent', album, null),
 
-    chronologyLinks:
-      relation('generateAlbumChronologyLinks', album),
-
     secondaryNav:
       relation('generateAlbumSecondaryNav', album),
 
@@ -92,109 +88,117 @@ export default {
   }),
 
   generate: (data, relations, {html, language}) =>
-    relations.layout.slots({
-      title: language.$('albumPage.title', {album: data.name}),
-      headingMode: 'sticky',
-
-      color: data.color,
-      styleRules: [relations.albumStyleRules],
-
-      cover:
-        relations.cover
-          ?.slots({
-            alt: language.$('misc.alt.albumCover'),
-          })
-          ?? null,
-
-      mainContent: [
-        relations.releaseInfo,
-
-        html.tag('p',
-          {[html.onlyIfContent]: true},
-          {[html.joinChildren]: html.tag('br')},
-
-          [
-            !html.isBlank(relations.additionalFilesList) &&
-              language.$('releaseInfo.additionalFiles.shortcut', {
-                link: html.tag('a',
-                  {href: '#additional-files'},
-                  language.$('releaseInfo.additionalFiles.shortcut.link')),
+    language.encapsulate('albumPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            album: data.name,
+          }),
+
+        color: data.color,
+        headingMode: 'sticky',
+        styleRules: [relations.albumStyleRules],
+
+        cover:
+          relations.cover
+            ?.slots({
+              alt: language.$('misc.alt.albumCover'),
+            })
+            ?? null,
+
+        mainContent: [
+          relations.releaseInfo,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.additionalFilesList) &&
+                language.$(capsule, 'additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#additional-files'},
+                    language.$(capsule, 'additionalFiles.shortcut.link')),
+                }),
+
+              (relations.galleryLink && relations.commentaryLink
+                ? language.encapsulate(capsule, 'viewGalleryOrCommentary', capsule =>
+                    language.$(capsule, {
+                      gallery:
+                        relations.galleryLink
+                          .slot('content', language.$(capsule, 'gallery')),
+
+                      commentary:
+                        relations.commentaryLink
+                          .slot('content', language.$(capsule, 'commentary')),
+                    }))
+
+             : relations.galleryLink
+                ? language.encapsulate(capsule, 'viewGallery', capsule =>
+                    language.$(capsule, {
+                      link:
+                        relations.galleryLink
+                          .slot('content', language.$(capsule, 'link')),
+                    }))
+
+             : relations.commentaryLink
+                ? language.encapsulate(capsule, 'viewCommentary', capsule =>
+                    language.$(capsule, {
+                      link:
+                        relations.commentaryLink
+                          .slot('content', language.$(capsule, 'link')),
+                    }))
+
+                : html.blank()),
+            ])),
+
+          relations.trackList,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              language.$(capsule, 'addedToWiki', {
+                [language.onlyIfOptions]: ['date'],
+                date: language.formatDate(data.dateAddedToWiki),
               }),
-
-            relations.galleryLink && relations.commentaryLink &&
-              language.$('releaseInfo.viewGalleryOrCommentary', {
-                gallery:
-                  relations.galleryLink
-                    .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')),
-                commentary:
-                  relations.commentaryLink
-                    .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')),
+            ])),
+
+          language.encapsulate('releaseInfo.additionalFiles', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'additional-files'},
+                  title: language.$(capsule, 'heading'),
+                }),
+
+              relations.additionalFilesList,
+            ])),
+
+          relations.artistCommentarySection,
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {
+            auto: 'current',
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: true,
+                showExtraLinks: true,
               }),
+          },
+        ],
 
-            relations.galleryLink && !relations.commentaryLink &&
-              language.$('releaseInfo.viewGallery', {
-                link:
-                  relations.galleryLink
-                    .slot('content', language.$('releaseInfo.viewGallery.link')),
-              }),
+        banner: relations.banner ?? null,
+        bannerPosition: 'top',
 
-            !relations.galleryLink && relations.commentaryLink &&
-              language.$('releaseInfo.viewCommentary', {
-                link:
-                  relations.commentaryLink
-                    .slot('content', language.$('releaseInfo.viewCommentary.link')),
-              }),
-          ]),
-
-        relations.trackList,
-
-        html.tag('p',
-          {[html.onlyIfContent]: true},
-          {[html.joinChildren]: html.tag('br')},
-
-          [
-            language.$('releaseInfo.addedToWiki', {
-              [language.onlyIfOptions]: ['date'],
-              date: language.formatDate(data.dateAddedToWiki),
-            }),
-          ]),
-
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'additional-files'},
-              title: language.$('releaseInfo.additionalFiles.heading'),
-            }),
-
-          relations.additionalFilesList,
-        ]),
-
-        relations.artistCommentarySection,
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {
-          auto: 'current',
-          accent:
-            relations.albumNavAccent.slots({
-              showTrackNavigation: true,
-              showExtraLinks: true,
-            }),
-        },
-      ],
-
-      navContent:
-        relations.chronologyLinks,
-
-      banner: relations.banner ?? null,
-      bannerPosition: 'top',
-
-      secondaryNav: relations.secondaryNav,
-
-      leftSidebar: relations.sidebar,
-
-      socialEmbed: relations.socialEmbed,
-    }),
+        secondaryNav: relations.secondaryNav,
+
+        leftSidebar: relations.sidebar,
+
+        socialEmbed: relations.socialEmbed,
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
index 121af439..4b6fb062 100644
--- a/src/content/dependencies/generateAlbumNavAccent.js
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -62,18 +62,21 @@ export default {
   },
 
   generate(data, relations, slots, {html, language}) {
+    const albumNavCapsule = language.encapsulate('albumPage.nav');
+    const trackNavCapsule = language.encapsulate('trackPage.nav');
+
     const {content: extraLinks = []} =
       slots.showExtraLinks &&
         {content: [
           (!data.galleryIsStub || slots.currentExtra === 'gallery') &&
             relations.albumGalleryLink?.slots({
               attributes: {class: slots.currentExtra === 'gallery' && 'current'},
-              content: language.$('albumPage.nav.gallery'),
+              content: language.$(albumNavCapsule, 'gallery'),
             }),
 
           relations.albumCommentaryLink?.slots({
             attributes: {class: slots.currentExtra === 'commentary' && 'current'},
-            content: language.$('albumPage.nav.commentary'),
+            content: language.$(albumNavCapsule, 'commentary'),
           }),
         ]};
 
@@ -94,8 +97,8 @@ export default {
           {href: '#', 'data-random': 'track-in-sidebar'},
 
           (data.isTrackPage
-            ? language.$('trackPage.nav.random')
-            : language.$('albumPage.nav.randomTrack')));
+            ? language.$(trackNavCapsule, 'random')
+            : language.$(albumNavCapsule, 'randomTrack')));
 
     const allLinks = [
       ...previousNextLinks,
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 26e2e160..28227f45 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -59,61 +59,71 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return html.tags([
-      html.tag('p',
-        {[html.onlyIfContent]: true},
-        {[html.joinChildren]: html.tag('br')},
-
-        [
-          relations.artistContributionsLine
-            .slots({stringKey: 'releaseInfo.by'}),
-
-          relations.coverArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.coverArtBy'}),
-
-          relations.wallpaperArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.wallpaperArtBy'}),
-
-          relations.bannerArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.bannerArtBy'}),
-
-          language.$('releaseInfo.released', {
-            [language.onlyIfOptions]: ['date'],
-            date: language.formatDate(data.date),
-          }),
-
-          language.$('releaseInfo.artReleased', {
-            [language.onlyIfOptions]: ['date'],
-            date: language.formatDate(data.coverArtDate),
-          }),
-
-          language.$('releaseInfo.duration', {
-            [language.onlyIfOptions]: ['duration'],
-            duration:
-              language.formatDuration(data.duration, {
-                approximate: data.durationApproximate,
-              }),
-          }),
-        ]),
-
-      html.tag('p',
-        {[html.onlyIfContent]: true},
-        language.$('releaseInfo.listenOn', {
-          [language.onlyIfOptions]: ['links'],
-          links:
-            language.formatDisjunctionList(
-              relations.externalLinks
-                .map(link =>
-                  link.slot('context', [
-                    'album',
-                    (data.numTracks === 0
-                      ? 'albumNoTracks'
-                   : data.numTracks === 1
-                      ? 'albumOneTrack'
-                      : 'albumMultipleTracks'),
-                  ]))),
-        })),
-    ]);
-  },
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule =>
+      html.tags([
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.artistContributionsLine.slots({
+              stringKey: capsule + '.by',
+              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:
+                language.formatDuration(data.duration, {
+                  approximate: data.durationApproximate,
+                }),
+            }),
+          ]),
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+
+          language.$(capsule, 'listenOn', {
+            [language.onlyIfOptions]: ['links'],
+
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link =>
+                    link.slot('context', [
+                      'album',
+                      (data.numTracks === 0
+                        ? 'albumNoTracks'
+                     : data.numTracks === 1
+                        ? 'albumOneTrack'
+                        : 'albumMultipleTracks'),
+                    ]))),
+          })),
+      ])),
 };
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index cc9b2c13..f3be74f7 100644
--- a/src/content/dependencies/generateAlbumSidebarGroupBox.js
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -77,45 +77,50 @@ export default {
   },
 
   generate: (relations, slots, {html, language}) =>
-    relations.box.slots({
-      attributes: {class: 'individual-group-sidebar-box'},
-      content: [
-        html.tag('h1',
-          language.$('albumSidebar.groupBox.title', {
-            group: relations.groupLink,
-          })),
-
-        slots.mode === 'album' &&
-          relations.description
-            ?.slot('mode', 'multiline'),
-
-        html.tag('p',
-          {[html.onlyIfContent]: true},
-
-          language.$('releaseInfo.visitOn', {
-            [language.onlyIfOptions]: ['links'],
-
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link => link.slot('context', 'group'))),
-          })),
-
-        slots.mode === 'album' &&
-          html.tag('p', {class: 'group-chronology-link'},
-            {[html.onlyIfContent]: true},
-            language.$('albumSidebar.groupBox.next', {
-              [language.onlyIfOptions]: ['album'],
-              album: relations.nextAlbumLink,
+    language.encapsulate('albumSidebar.groupBox', boxCapsule =>
+      relations.box.slots({
+        attributes: {class: 'individual-group-sidebar-box'},
+        content: [
+          html.tag('h1',
+            language.$(boxCapsule, 'title', {
+              group: relations.groupLink,
             })),
 
-        slots.mode === 'album' &&
-          html.tag('p', {class: 'group-chronology-link'},
+          slots.mode === 'album' &&
+            relations.description
+              ?.slot('mode', 'multiline'),
+
+          html.tag('p',
             {[html.onlyIfContent]: true},
-            language.$('albumSidebar.groupBox.previous', {
-              [language.onlyIfOptions]: ['album'],
-              album: relations.previousAlbumLink,
+
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'group'))),
             })),
-      ],
-    }),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'group-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'next', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.nextAlbumLink,
+              })),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'group-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'previous', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.previousAlbumLink,
+              })),
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index aa5c723d..d0c46060 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -55,10 +55,12 @@ export default {
   },
 
   generate(data, relations, slots, {getColors, html, language}) {
+    const capsule = language.encapsulate('albumSidebar.trackList');
+
     const sectionName =
       html.tag('span', {class: 'group-name'},
         (data.isDefaultTrackSection
-          ? language.$('albumSidebar.trackList.fallbackSectionName')
+          ? language.$(capsule, 'fallbackSectionName')
           : data.name));
 
     let colorStyle;
@@ -78,7 +80,7 @@ export default {
           data.tracksAreMissingCommentary[index] &&
             {class: 'no-commentary'},
 
-          language.$('albumSidebar.trackList.item', {
+          language.$(capsule, 'item', {
             track:
               (slots.mode === 'commentary' && data.tracksAreMissingCommentary[index]
                 ? trackLink.slots({
@@ -117,14 +119,17 @@ export default {
           colorStyle,
 
           html.tag('span',
-            (data.hasTrackNumbers
-              ? language.$('albumSidebar.trackList.group.withRange', {
-                  group: sectionName,
-                  range: `${data.firstTrackNumber}–${data.lastTrackNumber}`
-                })
-              : language.$('albumSidebar.trackList.group', {
-                  group: sectionName,
-                })))),
+            language.encapsulate(capsule, 'group', workingCapsule => {
+              const workingOptions = {group: sectionName};
+
+              if (data.hasTrackNumbers) {
+                workingCapsule += '.withRange';
+                workingOptions.range =
+                  `${data.firstTrackNumber}–${data.lastTrackNumber}`;
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            }))),
 
         (data.hasTrackNumbers
           ? html.tag('ol',
diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js
index c8b123fe..7500109e 100644
--- a/src/content/dependencies/generateAlbumSocialEmbed.js
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -41,34 +41,34 @@ export default {
     return data;
   },
 
-  generate(data, relations, {absoluteTo, language, urls}) {
-    return relations.socialEmbed.slots({
-      title:
-        language.$('albumPage.socialEmbed.title', {
-          album: data.albumName,
-        }),
-
-      description: relations.description,
-
-      headingContent:
-        (data.hasHeading
-          ? language.$('albumPage.socialEmbed.heading', {
-              group: data.headingGroupName,
-            })
-          : null),
-
-      headingLink:
-        (data.hasHeading
-          ? absoluteTo('localized.groupGallery', data.headingGroupDirectory)
-          : null),
-
-      imagePath:
-        (data.hasImage
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension)
-          : null),
-    });
-  },
+  generate: (data, relations, {absoluteTo, language, urls}) =>
+    language.encapsulate('albumPage.socialEmbed', embedCapsule =>
+      relations.socialEmbed.slots({
+        title:
+          language.$(embedCapsule, 'title', {
+            album: data.albumName,
+          }),
+
+        description: relations.description,
+
+        headingContent:
+          (data.hasHeading
+            ? language.$(embedCapsule, 'heading', {
+                group: data.headingGroupName,
+              })
+            : null),
+
+        headingLink:
+          (data.hasHeading
+            ? absoluteTo('localized.groupGallery', data.headingGroupDirectory)
+            : null),
+
+        imagePath:
+          (data.hasImage
+            ? '/' +
+              urls
+                .from('shared.root')
+                .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension)
+            : null),
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
index dd3e85e3..a3435bea 100644
--- a/src/content/dependencies/generateAlbumTrackList.js
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -147,27 +147,30 @@ export default {
               durationApproximate,
               startIndex,
             }) => [
-              heading.slots({
-                tag: 'dt',
-
-                title:
-                  (duration === 0
-                    ? language.$('trackList.section', {
-                        section: name,
-                      })
-                    : language.$('trackList.section.withDuration', {
-                        section: name,
-                        duration:
+              language.encapsulate('trackList.section', capsule =>
+                heading.slots({
+                  tag: 'dt',
+
+                  title:
+                    language.encapsulate(capsule, capsule => {
+                      const options = {section: name};
+
+                      if (duration !== 0) {
+                        capsule += '.withDuration';
+                        options.duration =
                           language.formatDuration(duration, {
                             approximate: durationApproximate,
-                          }),
-                      })),
-
-                stickyTitle:
-                  language.$('trackList.section.sticky', {
-                    section: name,
-                  }),
-              }),
+                          });
+                      }
+
+                      return language.$(capsule, options);
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky', {
+                      section: name,
+                    }),
+                })),
 
               html.tag('dd',
                 html.tag(listTag,
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index 7190fb4c..7d5d2c6e 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -80,54 +80,51 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {getColors, html, language}) {
-    let colorStyle;
-    if (data.color) {
-      const {primary} = getColors(data.color);
-      colorStyle = {style: `--primary-color: ${primary}`};
-    }
-
-    const parts = ['trackList.item'];
-    const options = {};
-
-    options.track =
-      relations.trackLink
-        .slot('color', false);
-
-    const collapseDuration =
-      (slots.collapseDurationScope === 'track'
-        ? !data.trackHasDuration
-     : slots.collapseDurationScope === 'section'
-        ? !data.sectionHasDuration
-     : slots.collapseDurationScope === 'album'
-        ? !data.albumHasDuration
-        : false);
-
-    if (!collapseDuration) {
-      parts.push('withDuration');
-
-      options.duration =
-        (data.trackHasDuration
-          ? language.$('trackList.item.withDuration.duration', {
-              duration:
-                language.formatDuration(data.duration),
-            })
-          : relations.missingDuration);
-    }
-
-    if (data.showArtists) {
-      parts.push('withArtists');
-      options.by =
-        html.tag('span', {class: 'by'},
-          html.metatag('chunkwrap', {split: ','},
-            html.resolve(
-              language.$('trackList.item.withArtists.by', {
-                artists: language.formatConjunctionList(relations.contributionLinks),
-              }))));
-    }
-
-    return html.tag('li',
-      colorStyle,
-      language.formatString(...parts, options));
-  },
+  generate: (data, relations, slots, {getColors, html, language}) =>
+    language.encapsulate('trackList.item', itemCapsule =>
+      html.tag('li',
+        data.color &&
+          {style: `--primary-color: ${getColors(data.color).primary}`},
+
+        language.encapsulate(itemCapsule, workingCapsule => {
+          const workingOptions = {};
+
+          workingOptions.track =
+            relations.trackLink
+              .slot('color', false);
+
+          const collapseDuration =
+            (slots.collapseDurationScope === 'track'
+              ? !data.trackHasDuration
+           : slots.collapseDurationScope === 'section'
+              ? !data.sectionHasDuration
+           : slots.collapseDurationScope === 'album'
+              ? !data.albumHasDuration
+              : false);
+
+          if (!collapseDuration) {
+            workingCapsule += '.withDuration';
+            workingOptions.duration =
+              (data.trackHasDuration
+                ? language.$(itemCapsule, 'withDuration.duration', {
+                    duration:
+                      language.formatDuration(data.duration),
+                  })
+                : relations.missingDuration);
+          }
+
+          if (data.showArtists) {
+            workingCapsule += '.withArtists';
+            workingOptions.by =
+              html.tag('span', {class: 'by'},
+                html.metatag('chunkwrap', {split: ','},
+                  html.resolve(
+                    language.$(itemCapsule, 'withArtists.by', {
+                      artists:
+                        language.formatConjunctionList(relations.contributionLinks),
+                    }))));
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }))),
 };
diff --git a/src/content/dependencies/generateAlbumTrackListMissingDuration.js b/src/content/dependencies/generateAlbumTrackListMissingDuration.js
index 6d4a6ec8..b5917982 100644
--- a/src/content/dependencies/generateAlbumTrackListMissingDuration.js
+++ b/src/content/dependencies/generateAlbumTrackListMissingDuration.js
@@ -11,23 +11,25 @@ export default {
   }),
 
   generate: (relations, {html, language}) =>
-    relations.textWithTooltip.slots({
-      attributes: {class: 'missing-duration'},
-      customInteractionCue: true,
+    language.encapsulate('trackList.item.withDuration', itemCapsule =>
+      language.encapsulate(itemCapsule, 'duration', durationCapsule =>
+        relations.textWithTooltip.slots({
+          attributes: {class: 'missing-duration'},
+          customInteractionCue: true,
 
-      text:
-        language.$('trackList.item.withDuration.duration', {
-          duration:
-            html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
-              language.$('trackList.item.withDuration.duration.missing')),
-        }),
+          text:
+            language.$(durationCapsule, {
+              duration:
+                html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
+                  language.$(durationCapsule, 'missing')),
+            }),
 
-      tooltip:
-        relations.tooltip.slots({
-          attributes: {class: 'missing-duration-tooltip'},
+          tooltip:
+            relations.tooltip.slots({
+              attributes: {class: 'missing-duration-tooltip'},
 
-          content:
-            language.$('trackList.item.withDuration.duration.missing.info'),
-        }),
-    }),
+              content:
+                language.$(durationCapsule, 'missing.info'),
+            }),
+        }))),
 };
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index eae48f05..c51faeba 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -85,11 +85,11 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('tagPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('tagPage.title', {
+          language.$(pageCapsule, 'title', {
             tag: data.name,
           }),
 
@@ -100,7 +100,7 @@ export default {
         mainClasses: ['top-index'],
         mainContent: [
           html.tag('p', {class: 'quick-info'},
-            language.$('tagPage.infoLine', {
+            language.$(pageCapsule, 'infoLine', {
               coverArts: language.countCoverArts(data.numArtworks, {
                 unit: true,
               }),
@@ -143,11 +143,10 @@ export default {
 
           {
             html:
-              language.$('tagPage.nav.tag', {
+              language.$(pageCapsule, 'nav.tag', {
                 tag: relations.artTagMainLink,
               }),
           },
         ],
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index 26a894c6..28f06a21 100644
--- a/src/content/dependencies/generateArtistGalleryPage.js
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -84,11 +84,11 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistGalleryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('artistGalleryPage.title', {
+          language.$(pageCapsule, 'title', {
             artist: data.name,
           }),
 
@@ -97,10 +97,11 @@ export default {
         mainClasses: ['top-index'],
         mainContent: [
           html.tag('p', {class: 'quick-info'},
-            language.$('artistGalleryPage.infoLine', {
-              coverArts: language.countCoverArts(data.numArtworks, {
-                unit: true,
-              }),
+            language.$(pageCapsule, 'infoLine', {
+              coverArts:
+                language.countCoverArts(data.numArtworks, {
+                  unit: true,
+                }),
             })),
 
           relations.coverGrid
@@ -119,6 +120,7 @@ export default {
                       dimensions,
                     })),
 
+              // TODO: Can this be [language.onlyIfOptions]?
               info:
                 data.otherCoverArtists.map(names =>
                   (names === null
@@ -137,6 +139,5 @@ export default {
               currentExtra: 'gallery',
             })
             .content,
-      })
-  },
+      })),
 }
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index ef81739d..f84d00de 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -131,100 +131,104 @@ export default {
     countUnit: {validate: v => v.is('tracks', 'artworks')},
   },
 
-  generate(data, relations, slots, {html, language}) {
-    if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) {
-      return html.blank();
-    } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) {
-      return html.blank();
-    }
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('artistPage.groupContributions', capsule => {
+      if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) {
+        return html.blank();
+      } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) {
+        return html.blank();
+      }
 
-    const getCounts = counts =>
-      counts.map(count => {
-        switch (slots.countUnit) {
-          case 'tracks': return language.countTracks(count, {unit: true});
-          case 'artworks': return language.countArtworks(count, {unit: true});
-        }
-      });
-
-    // We aren't displaying the "~" approximate symbol here for now.
-    // The general notion that these sums aren't going to be 100% accurate
-    // is made clear by the "XYZ has contributed ~1:23:45 hours of music..."
-    // line that's always displayed above this table.
-    const getDurations = (durations, approximate) =>
-      stitchArrays({
-        duration: durations,
-        approximate: approximate,
-      }).map(({duration}) => language.formatDuration(duration));
-
-    const topLevelClasses = [
-      'group-contributions-sorted-by-' + slots.sort,
-      slots.visible && 'visible',
-    ];
-
-    // TODO: It feels pretty awkward that this component is the only one that
-    // has enough knowledge to decide if the sort button is even applicable...
-    const switchingSortPossible =
-      !empty(relations.groupLinksSortedByCount) &&
-      !empty(relations.groupLinksSortedByDuration);
-
-    return html.tags([
-      html.tag('dt', {class: topLevelClasses},
-        (switchingSortPossible && slots.showSortButton
-          ? language.$('artistPage.groupContributions.title.withSortButton', {
-              title: slots.title,
-              sort:
-                html.tag('a', {class: 'group-contributions-sort-button'},
-                  {href: '#'},
-
-                  (slots.sort === 'count'
-                    ? language.$('artistPage.groupContributions.title.sorting.count')
-                    : language.$('artistPage.groupContributions.title.sorting.duration'))),
-            })
-          : slots.title)),
-
-      html.tag('dd', {class: topLevelClasses},
-        html.tag('ul', {class: 'group-contributions-table'},
-          {role: 'list'},
-
-          (slots.sort === 'count'
-            ? stitchArrays({
-                group: relations.groupLinksSortedByCount,
-                count: getCounts(data.groupCountsSortedByCount),
-                duration:
-                  getDurations(
-                    data.groupDurationsSortedByCount,
-                    data.groupDurationsApproximateSortedByCount),
-              }).map(({group, count, duration}) =>
-                  html.tag('li',
-                    html.tag('div', {class: 'group-contributions-row'}, [
-                      group,
-                      html.tag('span', {class: 'group-contributions-metrics'},
-                        // When sorting by count, duration details aren't necessarily
-                        // available for all items.
-                        (slots.showBothColumns && duration
-                          ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration})
-                          : language.$('artistPage.groupContributions.item.countAccent', {count}))),
-                    ])))
-
-            : stitchArrays({
-                group: relations.groupLinksSortedByDuration,
-                count: getCounts(data.groupCountsSortedByDuration),
-                duration:
-                  getDurations(
-                    data.groupDurationsSortedByDuration,
-                    data.groupDurationsApproximateSortedByDuration),
-              }).map(({group, count, duration}) =>
-                  html.tag('li',
-                    html.tag('div', {class: 'group-contributions-row'}, [
-                      group,
-                      html.tag('span', {class: 'group-contributions-metrics'},
-                        // Count details are always available, since they're just the
-                        // number of contributions directly. And duration details are
-                        // guaranteed for every item when sorting by duration.
-                        (slots.showBothColumns
-                          ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count})
-                          : language.$('artistPage.groupContributions.item.durationAccent', {duration}))),
-                    ])))))),
-    ]);
-  },
+      const getCounts = counts =>
+        counts.map(count => {
+          switch (slots.countUnit) {
+            case 'tracks': return language.countTracks(count, {unit: true});
+            case 'artworks': return language.countArtworks(count, {unit: true});
+          }
+        });
+
+      // We aren't displaying the "~" approximate symbol here for now.
+      // The general notion that these sums aren't going to be 100% accurate
+      // is made clear by the "XYZ has contributed ~1:23:45 hours of music..."
+      // line that's always displayed above this table.
+      const getDurations = (durations, approximate) =>
+        stitchArrays({
+          duration: durations,
+          approximate: approximate,
+        }).map(({duration}) => language.formatDuration(duration));
+
+      const topLevelClasses = [
+        'group-contributions-sorted-by-' + slots.sort,
+        slots.visible && 'visible',
+      ];
+
+      // TODO: It feels pretty awkward that this component is the only one that
+      // has enough knowledge to decide if the sort button is even applicable...
+      const switchingSortPossible =
+        !empty(relations.groupLinksSortedByCount) &&
+        !empty(relations.groupLinksSortedByDuration);
+
+      return html.tags([
+        html.tag('dt', {class: topLevelClasses},
+          language.encapsulate(capsule, 'title', capsule =>
+            (switchingSortPossible && slots.showSortButton
+              ? language.$(capsule, 'withSortButton', {
+                  title: slots.title,
+                  sort:
+                    html.tag('a', {class: 'group-contributions-sort-button'},
+                      {href: '#'},
+
+                      (slots.sort === 'count'
+                        ? language.$(capsule, 'sorting.count')
+                        : language.$(capsule, 'sorting.duration'))),
+                })
+              : slots.title))),
+
+        html.tag('dd', {class: topLevelClasses},
+          html.tag('ul', {class: 'group-contributions-table'},
+            {role: 'list'},
+
+            (slots.sort === 'count'
+              ? stitchArrays({
+                  group: relations.groupLinksSortedByCount,
+                  count: getCounts(data.groupCountsSortedByCount),
+                  duration:
+                    getDurations(
+                      data.groupDurationsSortedByCount,
+                      data.groupDurationsApproximateSortedByCount),
+                }).map(({group, count, duration}) =>
+                    language.encapsulate(capsule, 'item', capsule =>
+                      html.tag('li',
+                        html.tag('div', {class: 'group-contributions-row'}, [
+                          group,
+                          html.tag('span', {class: 'group-contributions-metrics'},
+                            // When sorting by count, duration details aren't necessarily
+                            // available for all items.
+                            (slots.showBothColumns && duration
+                              ? language.$(capsule, 'countDurationAccent', {count, duration})
+                              : language.$(capsule, 'countAccent', {count}))),
+                        ]))))
+
+              : stitchArrays({
+                  group: relations.groupLinksSortedByDuration,
+                  count: getCounts(data.groupCountsSortedByDuration),
+                  duration:
+                    getDurations(
+                      data.groupDurationsSortedByDuration,
+                      data.groupDurationsApproximateSortedByDuration),
+                }).map(({group, count, duration}) =>
+                    language.encapsulate(capsule, 'item', capsule =>
+                      html.tag('li',
+                        html.tag('div', {class: 'group-contributions-row'}, [
+                          group,
+                          html.tag('span', {class: 'group-contributions-metrics'},
+                            // Count details are always available, since they're just the
+                            // number of contributions directly. And duration details are
+                            // guaranteed for every item when sorting by duration.
+                            (slots.showBothColumns
+                              ? language.$(capsule, 'durationCountAccent', {duration, count})
+                              : language.$(capsule, 'durationAccent', {duration}))),
+                        ]))))))),
+      ]);
+    }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index dd56b757..f9ce7e3b 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -115,185 +115,198 @@ export default {
   }),
 
   generate: (data, relations, {html, language}) =>
-    relations.layout.slots({
-      title: data.name,
-      headingMode: 'sticky',
-
-      cover:
-        (relations.cover
-          ? relations.cover.slots({
-              path: [
-                'media.artistAvatar',
-                data.directory,
-                data.avatarFileExtension,
-              ],
-            })
-          : null),
+    language.encapsulate('artistPage', pageCapsule =>
+      relations.layout.slots({
+        title: data.name,
+        headingMode: 'sticky',
+
+        cover:
+          (relations.cover
+            ? relations.cover.slots({
+                path: [
+                  'media.artistAvatar',
+                  data.directory,
+                  data.avatarFileExtension,
+                ],
+              })
+            : null),
+
+        mainContent: [
+          html.tags([
+            html.tag('p',
+              {[html.onlyIfSiblings]: true},
+              language.$('releaseInfo.note')),
+
+            html.tag('blockquote',
+              {[html.onlyIfContent]: true},
+              relations.contextNotes),
+          ]),
 
-      mainContent: [
-        html.tags([
           html.tag('p',
-            {[html.onlyIfSiblings]: true},
-            language.$('releaseInfo.note')),
+            {[html.onlyIfContent]: true},
 
-          html.tag('blockquote',
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatDisjunctionList(
+                  relations.visitLinks
+                    .map(link => link.slot('context', 'artist'))),
+            })),
+
+          html.tag('p',
             {[html.onlyIfContent]: true},
-            relations.contextNotes),
-        ]),
-
-        html.tag('p',
-          {[html.onlyIfContent]: true},
-          language.$('releaseInfo.visitOn', {
-            [language.onlyIfOptions]: ['links'],
-            links:
-              language.formatDisjunctionList(
-                relations.visitLinks
-                  .map(link => link.slot('context', 'artist'))),
-          })),
-
-        html.tag('p',
-          {[html.onlyIfContent]: true},
-          language.$('artistPage.viewArtGallery', {
-            [language.onlyIfOptions]: ['link'],
-            link:
-              relations.artistGalleryLink?.slots({
-                content: language.$('artistPage.viewArtGallery.link'),
-              }),
-          })),
-
-        html.tag('p',
-          {[html.onlyIfContent]: true},
-          language.$('misc.jumpTo.withLinks', {
-            [language.onlyIfOptions]: ['links'],
-            links:
-              language.formatUnitList([
-                !html.isBlank(relations.tracksChunkedList) &&
-                  html.tag('a',
-                    {href: '#tracks'},
-                    language.$('artistPage.trackList.title')),
-
-                !html.isBlank(relations.artworksChunkedList) &&
-                  html.tag('a',
-                    {href: '#art'},
-                    language.$('artistPage.artList.title')),
-
-                !html.isBlank(relations.flashesChunkedList) &&
-                  html.tag('a',
-                    {href: '#flashes'},
-                    language.$('artistPage.flashList.title')),
-
-                !html.isBlank(relations.commentaryChunkedList) &&
-                  html.tag('a',
-                    {href: '#commentary'},
-                    language.$('artistPage.commentaryList.title')),
-              ].filter(Boolean)),
-          })),
-
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              tag: 'h2',
-              attributes: {id: 'tracks'},
-              title: language.$('artistPage.trackList.title'),
-            }),
 
-          data.totalDuration > 0 &&
-            html.tag('p',
-              {[html.onlyIfSiblings]: true},
-              language.$('artistPage.contributedDurationLine', {
-                artist: data.name,
-                duration:
-                  language.formatDuration(data.totalDuration, {
-                    approximate: data.totalTrackCount > 1,
-                    unit: true,
+            language.encapsulate(pageCapsule, 'viewArtGallery', capsule =>
+              language.$(capsule, {
+                [language.onlyIfOptions]: ['link'],
+
+                link:
+                  relations.artistGalleryLink?.slots({
+                    content:
+                      language.$(capsule, 'link'),
                   }),
-              })),
-
-          relations.tracksChunkedList.slots({
-            groupInfo: [
-              relations.tracksGroupInfo
-                .clone()
-                .slots({
-                  title: language.$('artistPage.groupContributions.title.music'),
-                  showSortButton: true,
-                  sort: 'count',
-                  countUnit: 'tracks',
-                  visible: true,
-                }),
-
-              relations.tracksGroupInfo
-                .clone()
-                .slots({
-                  title: language.$('artistPage.groupContributions.title.music'),
-                  showSortButton: true,
-                  sort: 'duration',
-                  countUnit: 'tracks',
-                  visible: false,
-                }),
-            ],
-          }),
-        ]),
-
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              tag: 'h2',
-              attributes: {id: 'art'},
-              title: language.$('artistPage.artList.title'),
-            }),
+              }))),
 
           html.tag('p',
             {[html.onlyIfContent]: true},
-            language.$('artistPage.viewArtGallery.orBrowseList', {
-              [language.onlyIfOptions]: ['link'],
-              link:
-                relations.artistGalleryLink?.slots({
-                  content: language.$('artistPage.viewArtGallery.link'),
-                }),
+
+            language.$('misc.jumpTo.withLinks', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatUnitList([
+                  !html.isBlank(relations.tracksChunkedList) &&
+                    html.tag('a',
+                      {href: '#tracks'},
+                      language.$(pageCapsule, 'trackList.title')),
+
+                  !html.isBlank(relations.artworksChunkedList) &&
+                    html.tag('a',
+                      {href: '#art'},
+                      language.$(pageCapsule, 'artList.title')),
+
+                  !html.isBlank(relations.flashesChunkedList) &&
+                    html.tag('a',
+                      {href: '#flashes'},
+                      language.$(pageCapsule, 'flashList.title')),
+
+                  !html.isBlank(relations.commentaryChunkedList) &&
+                    html.tag('a',
+                      {href: '#commentary'},
+                      language.$(pageCapsule, 'commentaryList.title')),
+                ].filter(Boolean)),
             })),
 
-          relations.artworksChunkedList
-            .slots({
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'tracks'},
+                title: language.$(pageCapsule, 'trackList.title'),
+              }),
+
+            data.totalDuration > 0 &&
+              html.tag('p',
+                {[html.onlyIfSiblings]: true},
+
+                language.$(pageCapsule, 'contributedDurationLine', {
+                  artist: data.name,
+                  duration:
+                    language.formatDuration(data.totalDuration, {
+                      approximate: data.totalTrackCount > 1,
+                      unit: true,
+                    }),
+                })),
+
+            relations.tracksChunkedList.slots({
               groupInfo:
-                relations.artworksGroupInfo
-                  .slots({
-                    title: language.$('artistPage.groupContributions.title.artworks'),
-                    showBothColumns: false,
-                    sort: 'count',
-                    countUnit: 'artworks',
-                  }),
+                language.encapsulate(pageCapsule, 'groupContributions', capsule => [
+                  relations.tracksGroupInfo.clone()
+                    .slots({
+                      title: language.$(capsule, 'title.music'),
+                      showSortButton: true,
+                      sort: 'count',
+                      countUnit: 'tracks',
+                      visible: true,
+                    }),
+
+                  relations.tracksGroupInfo.clone()
+                    .slots({
+                      title: language.$(capsule, 'title.music'),
+                      showSortButton: true,
+                      sort: 'duration',
+                      countUnit: 'tracks',
+                      visible: false,
+                    }),
+                ]),
             }),
-        ]),
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'art'},
+                title: language.$(pageCapsule, 'artList.title'),
+              }),
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              tag: 'h2',
-              attributes: {id: 'flashes'},
-              title: language.$('artistPage.flashList.title'),
-            }),
+            html.tag('p',
+              {[html.onlyIfContent]: true},
+
+              language.encapsulate(pageCapsule, 'viewArtGallery', capsule =>
+                language.$(capsule, 'orBrowseList', {
+                  [language.onlyIfOptions]: ['link'],
+
+                  link:
+                    relations.artistGalleryLink?.slots({
+                      content: language.$(capsule, 'link'),
+                    }),
+                }))),
+
+            relations.artworksChunkedList
+              .slots({
+                groupInfo:
+                  language.encapsulate(pageCapsule, 'groupContributions', capsule =>
+                    relations.artworksGroupInfo
+                      .slots({
+                        title: language.$(capsule, 'title.artworks'),
+                        showBothColumns: false,
+                        sort: 'count',
+                        countUnit: 'artworks',
+                      })),
+              }),
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'flashes'},
+                title: language.$(pageCapsule, 'flashList.title'),
+              }),
 
-          relations.flashesChunkedList,
-        ]),
+            relations.flashesChunkedList,
+          ]),
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              tag: 'h2',
-              attributes: {id: 'commentary'},
-              title: language.$('artistPage.commentaryList.title'),
-            }),
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'commentary'},
+                title: language.$(pageCapsule, 'commentaryList.title'),
+              }),
+
+            relations.commentaryChunkedList,
+          ]),
+        ],
 
-          relations.commentaryChunkedList,
-        ]),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks:
-        relations.artistNavLinks
-          .slots({
-            showExtraLinks: true,
-          })
-          .content,
-    }),
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.artistNavLinks
+            .slots({
+              showExtraLinks: true,
+            })
+            .content,
+      })),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
index 098b9e8f..e8d887b1 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
@@ -46,16 +46,17 @@ export default {
       annotation: data.annotation,
 
       content:
-        (data.kind === 'track-cover'
-          ? language.$('artistPage.creditList.entry.track', {
-              track: relations.trackLink,
-            })
-          : html.tag('i',
-              language.$('artistPage.creditList.entry.album',
-                {
-                  'wallpaper': 'wallpaperArt',
-                  'banner': 'bannerArt',
-                  'album-cover': 'coverArt',
-                }[data.kind]))),
+        language.encapsulate('artistPage.creditList.entry', capsule =>
+          (data.kind === 'track-cover'
+            ? language.$(capsule, 'track', {
+                track: relations.trackLink,
+              })
+            : html.tag('i',
+                language.encapsulate(capsule, 'album', capsule =>
+                  (data.kind === 'wallpaper'
+                    ? language.$(capsule, 'wallpaperArt')
+                 : data.kind === 'banner'
+                    ? language.$(capsule, 'bannerArt')
+                    : language.$(capsule, 'coverArt')))))),
     }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js
index ee172f48..9d406c67 100644
--- a/src/content/dependencies/generateArtistInfoPageChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -21,42 +21,38 @@ export default {
     rerelease: {type: 'boolean'},
   },
 
-  generate(slots, {html, language}) {
-    let accentedContent = slots.content;
-
-    accent: {
-      if (slots.rerelease) {
-        accentedContent =
-          language.$('artistPage.creditList.entry.rerelease', {
-            entry: accentedContent,
-          });
-
-        break accent;
-      }
-
-      const parts = ['artistPage.creditList.entry'];
-      const options = {entry: accentedContent};
-
-      if (!empty(slots.otherArtistLinks)) {
-        parts.push('withArtists');
-        options.artists = language.formatConjunctionList(slots.otherArtistLinks);
-      }
-
-      if (!html.isBlank(slots.annotation)) {
-        parts.push('withAnnotation');
-        options.annotation = slots.annotation;
-      }
-
-      if (parts.length === 1) {
-        break accent;
-      }
-
-      accentedContent = language.formatString(...parts, options);
-    }
-
-    return (
+  generate: (slots, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry', entryCapsule =>
       html.tag('li',
         slots.rerelease && {class: 'rerelease'},
-        accentedContent));
-  },
+
+        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;
+          }
+        }))),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
index f4413197..72bbf1b6 100644
--- a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -216,53 +216,52 @@ export default {
           itemAnnotations,
           itemTypes,
         }) =>
-          (chunkType === 'album'
-            ? chunk.slots({
-                mode: 'album',
-                albumLink: chunkLink,
-                items:
-                  stitchArrays({
-                    item: items,
-                    link: itemLinks,
-                    annotation: itemAnnotations,
-                    type: itemTypes,
-                  }).map(({item, link, annotation, type}) =>
-                    item.slots({
-                      annotation:
-                        (annotation
-                          ? annotation.slot('mode', 'inline')
-                          : null),
-
-                      content:
-                        (type === 'album'
-                          ? html.tag('i',
-                              language.$('artistPage.creditList.entry.album.commentary'))
-                          : language.$('artistPage.creditList.entry.track', {
-                              track: link,
-                            })),
-                    })),
-              })
-         : chunkType === 'flash-act'
-            ? chunk.slots({
-                mode: 'flash',
-                flashActLink: chunkLink,
-                items:
-                  stitchArrays({
-                    item: items,
-                    link: itemLinks,
-                    annotation: itemAnnotations,
-                  }).map(({item, link, annotation}) =>
-                    item.slots({
-                      annotation:
-                        (annotation
-                          ? annotation.slot('mode', 'inline')
-                          : null),
-
-                      content:
-                        language.$('artistPage.creditList.entry.flash', {
-                          flash: link,
-                        }),
-                    })),
-              })
-            : null))),
+          language.encapsulate('artistPage.creditList.entry', capsule =>
+            (chunkType === 'album'
+              ? chunk.slots({
+                  mode: 'album',
+                  albumLink: chunkLink,
+                  items:
+                    stitchArrays({
+                      item: items,
+                      link: itemLinks,
+                      annotation: itemAnnotations,
+                      type: itemTypes,
+                    }).map(({item, link, annotation, type}) =>
+                      item.slots({
+                        annotation:
+                          (annotation
+                            ? annotation.slot('mode', 'inline')
+                            : null),
+
+                        content:
+                          (type === 'album'
+                            ? html.tag('i',
+                                language.$(capsule, 'album.commentary'))
+                            : language.$(capsule, 'track', {track: link})),
+                      })),
+                })
+           : chunkType === 'flash-act'
+              ? chunk.slots({
+                  mode: 'flash',
+                  flashActLink: chunkLink,
+                  items:
+                    stitchArrays({
+                      item: items,
+                      link: itemLinks,
+                      annotation: itemAnnotations,
+                    }).map(({item, link, annotation}) =>
+                      item.slots({
+                        annotation:
+                          (annotation
+                            ? annotation.slot('mode', 'inline')
+                            : null),
+
+                        content:
+                          language.$(capsule, 'flash', {
+                            flash: link,
+                          }),
+                      })),
+                })
+              : null)))),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
index d7460c80..96976826 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
@@ -100,13 +100,16 @@ export default {
           : html.blank()),
 
       content:
-        (data.duration
-          ? language.$('artistPage.creditList.entry.track.withDuration', {
-              track: relations.trackLink,
-              duration: language.formatDuration(data.duration),
-            })
-          : language.$('artistPage.creditList.entry.track', {
-              track: relations.trackLink,
-            })),
+        language.encapsulate('artistPage.creditList.entry.track', workingCapsule => {
+          const workingOptions = {track: relations.trackLink};
+
+          if (data.duration) {
+            workingCapsule += '.withDuration';
+            workingOptions.duration =
+              language.formatDuration(data.duration);
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
     }),
 };
diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js
deleted file mode 100644
index 7f24ded7..00000000
--- a/src/content/dependencies/generateChronologyLinks.js
+++ /dev/null
@@ -1,112 +0,0 @@
-import {accumulateSum, empty} from '#sugar';
-
-export default {
-  extraDependencies: ['html', 'language'],
-
-  slots: {
-    allowCollapsing: {
-      type: 'boolean',
-      default: true,
-    },
-
-    showOnly: {
-      type: 'boolean',
-      default: false,
-    },
-
-    chronologyInfoSets: {
-      validate: v =>
-        v.strictArrayOf(
-          v.validateProperties({
-            headingString: v.isString,
-            contributions: v.strictArrayOf(v.validateProperties({
-              index: v.isCountingNumber,
-              only: v.isBoolean,
-              artistDirectory: v.isDirectory,
-              artistLink: v.isHTML,
-              previousLink: v.isHTML,
-              nextLink: v.isHTML,
-            })),
-          })),
-    }
-  },
-
-  generate(slots, {html, language}) {
-    if (empty(slots.chronologyInfoSets)) {
-      return html.blank();
-    }
-
-    let infoSets = slots.chronologyInfoSets;
-
-    if (!slots.showOnly) {
-      infoSets = infoSets
-        .map(({contributions, ...entry}) => ({
-          ...entry,
-          contributions:
-            contributions
-              .filter(({only}) => !only),
-        }))
-        .filter(({contributions}) => !empty(contributions));
-    }
-
-    const totalContributionCount =
-      accumulateSum(
-        infoSets,
-        ({contributions}) => contributions.length);
-
-    if (totalContributionCount === 0) {
-      return html.blank();
-    }
-
-    if (slots.allowCollapsing && totalContributionCount > 8) {
-      return html.tag('div', {class: 'chronology'},
-        language.$('misc.chronology.seeArtistPages'));
-    }
-
-    return html.tags(
-      infoSets.map(({
-        headingString,
-        contributions,
-      }) =>
-        contributions.map(({
-          index,
-          artistLink,
-          previousLink,
-          nextLink,
-          only,
-        }) => {
-          const heading =
-            html.tag('span', {class: 'heading'},
-              language.$(headingString, {
-                index:
-                  (only
-                    ? language.formatString('misc.chronology.heading.onlyIndex')
-                    : language.formatIndex(index)),
-
-                artist: artistLink,
-              }));
-
-          const navigation =
-            !only &&
-              html.tag('span', {class: 'buttons'},
-                language.formatUnitList([
-                  previousLink?.slots({
-                    tooltipStyle: 'browser',
-                    color: false,
-                    content: language.$('misc.nav.previous'),
-                  }),
-
-                  nextLink?.slots({
-                    tooltipStyle: 'browser',
-                    color: false,
-                    content: language.$('misc.nav.next'),
-                  }),
-                ].filter(Boolean)));
-
-          return html.tag('div', {class: 'chronology'},
-            (navigation
-              ? language.$('misc.chronology.withNavigation', {heading, navigation})
-              : heading));
-        })));
-  },
-};
diff --git a/src/content/dependencies/generateChronologyLinksScopeSwitcher.js b/src/content/dependencies/generateChronologyLinksScopeSwitcher.js
deleted file mode 100644
index 23c44268..00000000
--- a/src/content/dependencies/generateChronologyLinksScopeSwitcher.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import {stitchArrays} from '#sugar';
-
-export default {
-  extraDependencies: ['html', 'language'],
-
-  slots: {
-    scopes: {
-      validate: v => v.strictArrayOf(v.isStringNonEmpty),
-    },
-
-    contents: {
-      validate: v => v.strictArrayOf(v.isHTML),
-    },
-
-    open: {
-      type: 'boolean',
-      default: true,
-    },
-  },
-
-  generate(slots, {html, language}) {
-    // TODO: Manual [html.onlyIfContent]-alike here is a bit unfortunate.
-    // We can't use a normal [html.onlyIfContent] because the summary counts
-    // as content - we'd need to encode that we want to exclude it from the
-    // content check (for the <details> element), somehow.
-    if (slots.contents.every(content => html.isBlank(content))) {
-      return html.blank();
-    }
-
-    const summary =
-      html.tag('summary',
-        {class: 'underline-white'},
-
-        html.tag('span',
-          language.$('trackPage.nav.chronology.scope.title', {
-            scope:
-              slots.scopes.map((scope, index) =>
-                html.tag('a', {class: 'switcher-link'},
-                  {href: '#'},
-
-                  (index === 0
-                    ? {style: 'display: inline'}
-                    : {style: 'display: none'}),
-
-                  language.$('trackPage.nav.chronology.scope', scope))),
-          })));
-
-    const scopeContents =
-      stitchArrays({
-        scope: slots.scopes,
-        content: slots.contents,
-      }).map(({scope, content}, index) =>
-          html.tag('div', {class: 'scope-' + scope},
-            (index === 0
-              ? {style: 'display: block'}
-              : {style: 'display: none'}),
-
-            content));
-
-    return (
-      html.tag('details', {class: 'scoped-chronology-switcher'},
-        slots.open &&
-          {open: true},
-
-        [summary, scopeContents]));
-  },
-};
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
index 036f8a6f..7c4aed80 100644
--- a/src/content/dependencies/generateCommentaryEntry.js
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -43,60 +43,71 @@ export default {
     color: {validate: v => v.isColor},
   },
 
-  generate(data, relations, slots, {html, language}) {
-    const artistsSpan =
-      html.tag('span', {class: 'commentary-entry-artists'},
-        (relations.artistsContent
-          ? relations.artistsContent.slot('mode', 'inline')
-       : relations.artistLinks
-          ? language.formatConjunctionList(relations.artistLinks)
-          : language.$('misc.artistCommentary.entry.title.noArtists')));
-
-    const accentParts = ['misc.artistCommentary.entry.title.accent'];
-    const accentOptions = {};
-
-    if (relations.annotationContent) {
-      accentParts.push('withAnnotation');
-      accentOptions.annotation =
-        relations.annotationContent.slot('mode', 'inline');
-    }
-
-    const accent =
-      (accentParts.length > 1
-        ? html.tag('span', {class: 'commentary-entry-accent'},
-            language.$(...accentParts, accentOptions))
-        : null);
-
-    const titlePrefix = 'misc.artistCommentary.entry.title';
-    const titleParts = [titlePrefix];
-    const titleOptions = {artists: artistsSpan};
-
-    if (accent) {
-      titleParts.push('withAccent');
-      titleOptions.accent = accent;
-    }
-
-    const style =
-      slots.color &&
-        relations.colorStyle.slot('color', slots.color);
-
-    return html.tags([
-      html.tag('p', {class: 'commentary-entry-heading'},
-        style,
-        [
-          html.tag('time',
-            {[html.onlyIfContent]: true},
-            language.$(titlePrefix, 'date', {
-              [language.onlyIfOptions]: ['date'],
-              date: language.formatDate(data.date),
-            })),
-
-          language.$(...titleParts, titleOptions),
-        ]),
-
-      html.tag('blockquote', {class: 'commentary-entry-body'},
-        style,
-        relations.bodyContent.slot('mode', 'multiline')),
-    ]);
-  },
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('misc.artistCommentary.entry', entryCapsule =>
+      html.tags([
+        html.tag('p', {class: 'commentary-entry-heading'},
+          slots.color &&
+            relations.colorStyle.clone()
+              .slot('color', slots.color),
+
+          language.encapsulate(entryCapsule, 'title', titleCapsule => [
+            html.tag('time',
+              {[html.onlyIfContent]: true},
+
+              language.$(titleCapsule, 'date', {
+                [language.onlyIfOptions]: ['date'],
+
+                date:
+                  language.formatDate(data.date),
+              })),
+
+            language.encapsulate(titleCapsule, workingCapsule => {
+              const workingOptions = {};
+
+              workingOptions.artists =
+                html.tag('span', {class: 'commentary-entry-artists'},
+                  (relations.artistsContent
+                    ? relations.artistsContent.slot('mode', 'inline')
+                 : relations.artistLinks
+                    ? language.formatConjunctionList(relations.artistLinks)
+                    : language.$(titleCapsule, 'noArtists')));
+
+              const accent =
+                html.tag('span', {class: 'commentary-entry-accent'},
+                  {[html.onlyIfContent]: true},
+
+                  language.encapsulate(titleCapsule, 'accent', accentCapsule =>
+                    language.encapsulate(accentCapsule, workingCapsule => {
+                      const workingOptions = {};
+
+                      if (relations.annotationContent) {
+                        workingCapsule += '.withAnnotation';
+                        workingOptions.annotation =
+                          relations.annotationContent.slot('mode', 'inline');
+                      }
+
+                      if (workingCapsule === accentCapsule) {
+                        return html.blank();
+                      } else {
+                        return language.$(workingCapsule, workingOptions);
+                      }
+                    })));
+
+              if (!html.isBlank(accent)) {
+                workingCapsule += '.withAccent';
+                workingOptions.accent = accent;
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            }),
+          ])),
+
+        html.tag('blockquote', {class: 'commentary-entry-body'},
+          slots.color &&
+            relations.colorStyle.clone()
+              .slot('color', slots.color),
+
+          relations.bodyContent.slot('mode', 'multiline')),
+      ])),
 };
diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js
index 3c3504d2..d68ba42e 100644
--- a/src/content/dependencies/generateCommentaryIndexPage.js
+++ b/src/content/dependencies/generateCommentaryIndexPage.js
@@ -57,46 +57,48 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title: language.$('commentaryIndex.title'),
-
-      headingMode: 'static',
-
-      mainClasses: ['long-content'],
-      mainContent: [
-        html.tag('p', language.$('commentaryIndex.infoLine', {
-          words:
-            html.tag('b',
-              language.formatWordCount(data.totalWordCount, {unit: true})),
-
-          entries:
-            html.tag('b',
-                language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
-        })),
-
-        html.tag('p',
-          language.$('commentaryIndex.albumList.title')),
-
-        html.tag('ul',
-          stitchArrays({
-            albumLink: relations.albumLinks,
-            wordCount: data.wordCounts,
-            entryCount: data.entryCounts,
-          }).map(({albumLink, wordCount, entryCount}) =>
-            html.tag('li',
-              language.$('commentaryIndex.albumList.item', {
-                album: albumLink,
-                words: language.formatWordCount(wordCount, {unit: true}),
-                entries: language.countCommentaryEntries(entryCount, {unit: true}),
-              })))),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    });
-  },
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('commentaryIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+
+        headingMode: 'static',
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p', language.$(pageCapsule, 'infoLine', {
+            words:
+              html.tag('b',
+                language.formatWordCount(data.totalWordCount, {unit: true})),
+
+            entries:
+              html.tag('b',
+                  language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
+          })),
+
+          language.encapsulate(pageCapsule, 'albumList', listCapsule => [
+            html.tag('p',
+              language.$(listCapsule, 'title')),
+
+            html.tag('ul',
+              stitchArrays({
+                albumLink: relations.albumLinks,
+                wordCount: data.wordCounts,
+                entryCount: data.entryCounts,
+              }).map(({albumLink, wordCount, entryCount}) =>
+                html.tag('li',
+                  language.$(listCapsule, 'item', {
+                    album: albumLink,
+                    words: language.formatWordCount(wordCount, {unit: true}),
+                    entries: language.countCommentaryEntries(entryCount, {unit: true}),
+                  })))),
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js
index 0c4ef87a..8e8c5020 100644
--- a/src/content/dependencies/generateContributionList.js
+++ b/src/content/dependencies/generateContributionList.js
@@ -8,7 +8,11 @@ export default {
         .map(contrib => relation('linkContribution', contrib)),
   }),
 
-  generate: (relations, {html}) =>
+  slots: {
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
     html.tag('ul',
       {[html.onlyIfContent]: true},
 
@@ -16,9 +20,10 @@ export default {
         .map(contributionLink =>
           html.tag('li',
             contributionLink.slots({
-              showIcons: true,
+              showExternalLinks: true,
               showContribution: true,
+              showChronology: true,
               preventWrapping: false,
-              iconMode: 'tooltip',
+              chronologyKind: slots.chronologyKind,
             })))),
 };
diff --git a/src/content/dependencies/generateContributionTooltip.js b/src/content/dependencies/generateContributionTooltip.js
new file mode 100644
index 00000000..3a31014d
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltip.js
@@ -0,0 +1,48 @@
+export default {
+  contentDependencies: [
+    'generateContributionTooltipChronologySection',
+    'generateContributionTooltipExternalLinkSection',
+    'generateTooltip',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, contribution) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    externalLinkSection:
+      relation('generateContributionTooltipExternalLinkSection', contribution),
+
+    chronologySection:
+      relation('generateContributionTooltipChronologySection', contribution),
+  }),
+
+  slots: {
+    showExternalLinks: {type: 'boolean'},
+    showChronology: {type: 'boolean'},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.tooltip.slots({
+      attributes:
+        {class: 'contribution-tooltip'},
+
+      contentAttributes: {
+        [html.joinChildren]:
+          html.tag('span', {class: 'tooltip-divider'}),
+      },
+
+      content: [
+        slots.showExternalLinks &&
+          relations.externalLinkSection,
+
+        slots.showChronology &&
+          relations.chronologySection.slots({
+            kind: slots.chronologyKind,
+          }),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js
new file mode 100644
index 00000000..78c9051c
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltipChronologySection.js
@@ -0,0 +1,117 @@
+export default {
+  contentDependencies: ['linkAnythingMan'],
+  extraDependencies: ['html', 'language'],
+
+  query(contribution) {
+    let previous = contribution;
+    while (previous && previous.thing === contribution.thing) {
+      previous = previous.previousBySameArtist;
+    }
+
+    let next = contribution;
+    while (next && next.thing === contribution.thing) {
+      next = next.nextBySameArtist;
+    }
+
+    return {previous, next};
+  },
+
+  relations: (relation, query, _contribution) => ({
+    previousLink:
+      (query.previous
+        ? relation('linkAnythingMan', query.previous.thing)
+        : null),
+
+    nextLink:
+      (query.next
+        ? relation('linkAnythingMan', query.next.thing)
+        : null),
+  }),
+
+  data: (query, _contribution) => ({
+    previousName:
+      (query.previous
+        ? query.previous.thing.name
+        : null),
+
+    nextName:
+      (query.next
+        ? query.next.thing.name
+        : null),
+  }),
+
+  slots: {
+    kind: {
+      validate: v =>
+        v.is(
+          'album',
+          'bannerArt',
+          'coverArt',
+          'flash',
+          'track',
+          'trackArt',
+          'trackContribution',
+          'wallpaperArt'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('misc.artistLink.chronology', capsule =>
+      html.tags([
+        html.tags([
+          relations.previousLink?.slots({
+            attributes: {class: 'chronology-link'},
+            content: [
+              html.tag('span', {class: 'chronology-symbol'},
+                language.$(capsule, 'previous.symbol')),
+
+              html.tag('span', {class: 'chronology-text'},
+                language.sanitize(data.previousName)),
+            ],
+          }),
+
+          html.tag('span', {class: 'chronology-info'},
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(capsule, 'previous.info', workingCapsule => {
+              const workingOptions = {};
+
+              if (slots.kind) {
+                workingCapsule += '.withKind';
+                workingOptions.kind =
+                  language.$(capsule, 'kind', slots.kind);
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            })),
+        ]),
+
+        html.tags([
+          relations.nextLink?.slots({
+            attributes: {class: 'chronology-link'},
+            content: [
+              html.tag('span', {class: 'chronology-symbol'},
+                language.$(capsule, 'next.symbol')),
+
+              html.tag('span', {class: 'chronology-text'},
+                language.sanitize(data.nextName)),
+            ],
+          }),
+
+          html.tag('span', {class: 'chronology-info'},
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(capsule, 'next.info', workingCapsule => {
+              const workingOptions = {};
+
+              if (slots.kind) {
+                workingCapsule += '.withKind';
+                workingOptions.kind =
+                  language.$(capsule, 'kind', slots.kind);
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            }))
+        ]),
+      ])),
+};
diff --git a/src/content/dependencies/generateContributionTooltipExternalLinkSection.js b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js
new file mode 100644
index 00000000..4f9a23ed
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js
@@ -0,0 +1,70 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateExternalHandle',
+    'generateExternalIcon',
+    'generateExternalPlatform',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, contribution) => ({
+    icons:
+      contribution.artist.urls
+        .map(url => relation('generateExternalIcon', url)),
+
+    handles:
+      contribution.artist.urls
+        .map(url => relation('generateExternalHandle', url)),
+
+    platforms:
+      contribution.artist.urls
+        .map(url => relation('generateExternalPlatform', url)),
+  }),
+
+  data: (contribution) => ({
+    urls: contribution.artist.urls,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.artistLink', capsule =>
+      html.tags(
+        stitchArrays({
+          icon: relations.icons,
+          handle: relations.handles,
+          platform: relations.platforms,
+          url: data.urls,
+        }).map(({icon, handle, platform, url}) => {
+            for (const template of [icon, handle, platform]) {
+              template.setSlot('context', 'artist');
+            }
+
+            return [
+              html.tag('a', {class: 'external-link'},
+                {href: url},
+
+                [
+                  icon,
+
+                  html.tag('span', {class: 'external-handle'},
+                    (html.isBlank(handle)
+                      ? platform
+                      : handle)),
+                ]),
+
+              html.tag('span', {class: 'external-platform'},
+                // This is a pretty ridiculous hack, but we currently
+                // don't have a way of telling formatExternalLink to *not*
+                // use the fallback string, which just formats the URL as
+                // its host/domain... so is technically detectable.
+                (((new URL(url))
+                    .host
+                    .endsWith(
+                      html.resolve(platform, {normalize: 'string'})))
+
+                  ? language.$(capsule, 'noExternalLinkPlatformName')
+                  : platform)),
+            ];
+          }))),
+};
diff --git a/src/content/dependencies/generateExternalHandle.js b/src/content/dependencies/generateExternalHandle.js
new file mode 100644
index 00000000..8c0368a4
--- /dev/null
+++ b/src/content/dependencies/generateExternalHandle.js
@@ -0,0 +1,20 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {language}) =>
+    language.formatExternalLink(data.url, {
+      style: 'handle',
+      context: slots.context,
+    }),
+};
diff --git a/src/content/dependencies/generateExternalIcon.js b/src/content/dependencies/generateExternalIcon.js
new file mode 100644
index 00000000..637af658
--- /dev/null
+++ b/src/content/dependencies/generateExternalIcon.js
@@ -0,0 +1,26 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language', 'to'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {html, language, to}) =>
+    html.tag('span', {class: 'external-icon'},
+      html.tag('svg',
+        html.tag('use', {
+          href:
+            to('staticMisc.icon',
+              language.formatExternalLink(data.url, {
+                style: 'icon-id',
+                context: slots.context,
+              })),
+        }))),
+};
diff --git a/src/content/dependencies/generateExternalPlatform.js b/src/content/dependencies/generateExternalPlatform.js
new file mode 100644
index 00000000..c4f63ecf
--- /dev/null
+++ b/src/content/dependencies/generateExternalPlatform.js
@@ -0,0 +1,20 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {language}) =>
+    language.formatExternalLink(data.url, {
+      style: 'platform',
+      context: slots.context,
+    }),
+};
diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js
index 17078124..1fa6de51 100644
--- a/src/content/dependencies/generateFlashActGalleryPage.js
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -11,7 +11,7 @@ export default {
     'linkFlashIndex',
   ],
 
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['language'],
 
   relations: (relation, act) => ({
     layout:
@@ -50,42 +50,42 @@ export default {
         ['media.flashArt', flash.directory, flash.coverArtFileExtension])
   }),
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title:
-        language.$('flashPage.title', {
-          flash: new html.Tag(null, null, data.name),
-        }),
-
-      color: data.color,
-      headingMode: 'static',
-
-      mainClasses: ['flash-index'],
-      mainContent: [
-        relations.coverGrid.slots({
-          links: relations.flashLinks,
-          names: data.flashNames,
-          lazy: 6,
-
-          images:
-            stitchArrays({
-              image: relations.coverGridImages,
-              path: data.flashCoverPaths,
-            }).map(({image, path}) =>
-                image.slot('path', path)),
-        }),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: relations.flashIndexLink},
-        {auto: 'current'},
-      ],
-
-      navBottomRowContent: relations.flashActNavAccent,
-
-      leftSidebar: relations.sidebar,
-    });
-  },
+  generate: (data, relations, {language}) =>
+    language.encapsulate('flashPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            flash: data.name,
+          }),
+
+        color: data.color,
+        headingMode: 'static',
+
+        mainClasses: ['flash-index'],
+        mainContent: [
+          relations.coverGrid.slots({
+            links: relations.flashLinks,
+            names: data.flashNames,
+            lazy: 6,
+
+            images:
+              stitchArrays({
+                image: relations.coverGridImages,
+                path: data.flashCoverPaths,
+              }).map(({image, path}) =>
+                  image.slot('path', path)),
+          }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.flashIndexLink},
+          {auto: 'current'},
+        ],
+
+        navBottomRowContent: relations.flashActNavAccent,
+
+        leftSidebar: relations.sidebar,
+      })),
 };
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index eaea7e9c..a21bb49e 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -81,76 +81,77 @@ export default {
   }),
 
   generate: (data, relations, {html, language}) =>
-    relations.layout.slots({
-      title: language.$('flashIndex.title'),
-      headingMode: 'static',
-
-      mainClasses: ['flash-index'],
-      mainContent: [
-        html.tags([
-          html.tag('p', {class: 'quick-info'},
-            {[html.onlyIfSiblings]: true},
-            language.$('misc.jumpTo')),
-
-          html.tag('ul', {class: 'quick-info'},
-            {[html.onlyIfContent]: true},
-            stitchArrays({
-              colorStyle: relations.jumpLinkColorStyles,
-              anchor: data.jumpLinkAnchors,
-              label: data.jumpLinkLabels,
-            }).map(({colorStyle, anchor, label}) =>
-                html.tag('li',
-                  html.tag('a',
-                    {href: '#' + anchor},
-                    colorStyle,
-                    label)))),
-        ]),
-
-        stitchArrays({
-          colorStyle: relations.actColorStyles,
-          actLink: relations.actLinks,
-          anchor: data.actAnchors,
-
-          coverGrid: relations.actCoverGrids,
-          coverGridImages: relations.actCoverGridImages,
-          coverGridLinks: relations.actCoverGridLinks,
-          coverGridNames: data.actCoverGridNames,
-          coverGridPaths: data.actCoverGridPaths,
-        }).map(({
-            colorStyle,
-            actLink,
-            anchor,
-
-            coverGrid,
-            coverGridImages,
-            coverGridLinks,
-            coverGridNames,
-            coverGridPaths,
-          }, index) => [
-            html.tag('h2',
-              {id: anchor},
-              colorStyle,
-              actLink),
-
-            coverGrid.slots({
-              links: coverGridLinks,
-              names: coverGridNames,
-              lazy: index === 0 ? 4 : true,
-
-              images:
-                stitchArrays({
-                  image: coverGridImages,
-                  path: coverGridPaths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
-            }),
+    language.encapsulate('flashIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+        headingMode: 'static',
+
+        mainClasses: ['flash-index'],
+        mainContent: [
+          html.tags([
+            html.tag('p', {class: 'quick-info'},
+              {[html.onlyIfSiblings]: true},
+              language.$('misc.jumpTo')),
+
+            html.tag('ul', {class: 'quick-info'},
+              {[html.onlyIfContent]: true},
+              stitchArrays({
+                colorStyle: relations.jumpLinkColorStyles,
+                anchor: data.jumpLinkAnchors,
+                label: data.jumpLinkLabels,
+              }).map(({colorStyle, anchor, label}) =>
+                  html.tag('li',
+                    html.tag('a',
+                      {href: '#' + anchor},
+                      colorStyle,
+                      label)))),
           ]),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    }),
+
+          stitchArrays({
+            colorStyle: relations.actColorStyles,
+            actLink: relations.actLinks,
+            anchor: data.actAnchors,
+
+            coverGrid: relations.actCoverGrids,
+            coverGridImages: relations.actCoverGridImages,
+            coverGridLinks: relations.actCoverGridLinks,
+            coverGridNames: data.actCoverGridNames,
+            coverGridPaths: data.actCoverGridPaths,
+          }).map(({
+              colorStyle,
+              actLink,
+              anchor,
+
+              coverGrid,
+              coverGridImages,
+              coverGridLinks,
+              coverGridNames,
+              coverGridPaths,
+            }, index) => [
+              html.tag('h2',
+                {id: anchor},
+                colorStyle,
+                actLink),
+
+              coverGrid.slots({
+                links: coverGridLinks,
+                names: coverGridNames,
+                lazy: index === 0 ? 4 : true,
+
+                images:
+                  stitchArrays({
+                    image: coverGridImages,
+                    path: coverGridPaths,
+                  }).map(({image, path}) =>
+                      image.slot('path', path)),
+              }),
+            ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index d6066a95..d06f0c01 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -77,86 +77,93 @@ export default {
   }),
 
   generate: (data, relations, {html, language}) =>
-    relations.layout.slots({
-      title:
-        language.$('flashPage.title', {
-          flash: data.name,
-        }),
-
-      color: data.color,
-      headingMode: 'sticky',
-
-      cover:
-        (relations.cover
-          ? relations.cover.slots({
-              alt: language.$('misc.alt.flashArt'),
-            })
-          : null),
-
-      mainContent: [
-        html.tag('p',
-          language.$('releaseInfo.released', {
-            date: language.formatDate(data.date),
-          })),
-
-        html.tag('p',
-          {[html.onlyIfContent]: true},
-          language.$('releaseInfo.playOn', {
-            [language.onlyIfOptions]: ['links'],
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link => link.slot('context', 'flash'))),
-          })),
-
-        html.tag('p',
-          {[html.onlyIfContent]: true},
-          {[html.joinChildren]: html.tag('br')},
-
-          [
-            !html.isBlank(relations.artistCommentarySection) &&
-              language.$('releaseInfo.readCommentary', {
-                link: html.tag('a',
-                  {href: '#artist-commentary'},
-                  language.$('releaseInfo.readCommentary.link')),
+    language.encapsulate('flashPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            flash: data.name,
+          }),
+
+        color: data.color,
+        headingMode: 'sticky',
+
+        cover:
+          (relations.cover
+            ? relations.cover.slots({
+                alt: language.$('misc.alt.flashArt'),
+              })
+            : null),
+
+        mainContent: [
+          html.tag('p',
+            language.$('releaseInfo.released', {
+              date: language.formatDate(data.date),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('releaseInfo.playOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'flash'))),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.artistCommentarySection) &&
+                language.encapsulate(capsule, 'readCommentary', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#artist-commentary'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'features'},
+                title:
+                  language.$('releaseInfo.tracksFeatured', {
+                    flash: html.tag('i', data.name),
+                  }),
               }),
-          ]),
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'features'},
-              title:
-                language.$('releaseInfo.tracksFeatured', {
-                  flash: html.tag('i', data.name),
-                }),
-            }),
+            relations.featuredTracksList,
+          ]),
 
-          relations.featuredTracksList,
-        ]),
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'contributors'},
+                title: language.$('releaseInfo.contributors'),
+              }),
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'contributors'},
-              title: language.$('releaseInfo.contributors'),
+            relations.contributorContributionList.slots({
+              chronologyKind: 'flash',
             }),
+          ]),
 
-          relations.contributorContributionList,
-        ]),
-
-        relations.artistCommentarySection,
-      ],
+          relations.artistCommentarySection,
+        ],
 
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: relations.flashActLink.slot('color', false)},
-        {auto: 'current'},
-      ],
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.flashActLink.slot('color', false)},
+          {auto: 'current'},
+        ],
 
-      navBottomRowContent: relations.flashNavAccent,
+        navBottomRowContent: relations.flashNavAccent,
 
-      leftSidebar: relations.sidebar,
-    }),
+        leftSidebar: relations.sidebar,
+      })),
 };
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index 34c789b3..ceb54322 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -111,10 +111,10 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
-        title: language.$('groupGalleryPage.title', {group: data.name}),
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupGalleryPage', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title', {group: data.name}),
         headingMode: 'static',
 
         color: data.color,
@@ -135,7 +135,7 @@ export default {
           relations.quickDescription,
 
           html.tag('p', {class: 'quick-info'},
-            language.$('groupGalleryPage.infoLine', {
+            language.$(pageCapsule, 'infoLine', {
               tracks:
                 html.tag('b',
                   language.countTracks(data.numTracks, {
@@ -199,6 +199,5 @@ export default {
 
         secondaryNav:
           relations.secondaryNav ?? null,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index 956d56d8..87f35656 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -53,38 +53,41 @@ export default {
   }),
 
   generate: (data, relations, {html, language}) =>
-    relations.layout.slots({
-      title: language.$('groupInfoPage.title', {group: data.name}),
-      headingMode: 'sticky',
-      color: data.color,
-
-      mainContent: [
-        html.tag('p',
-          {[html.onlyIfContent]: true},
-          language.$('releaseInfo.visitOn', {
-            [language.onlyIfOptions]: ['links'],
-            links:
-              language.formatDisjunctionList(
-                relations.visitLinks
-                  .map(link => link.slot('context', 'group'))),
-          })),
-
-        html.tag('blockquote',
-          {[html.onlyIfContent]: true},
-          relations.description.slot('mode', 'multiline')),
-
-        relations.albumSection,
-      ],
-
-      leftSidebar:
-        (relations.sidebar
-          ? relations.sidebar
-              .content /* TODO: Kludge. */
-          : null),
-
-      navLinkStyle: 'hierarchical',
-      navLinks: relations.navLinks.content,
-
-      secondaryNav: relations.secondaryNav ?? null,
-    }),
+    language.encapsulate('groupInfoPage', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title', {group: data.name}),
+        headingMode: 'sticky',
+        color: data.color,
+
+        mainContent: [
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatDisjunctionList(
+                  relations.visitLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
+
+          html.tag('blockquote',
+            {[html.onlyIfContent]: true},
+            relations.description.slot('mode', 'multiline')),
+
+          relations.albumSection,
+        ],
+
+        leftSidebar:
+          (relations.sidebar
+            ? relations.sidebar
+                .content /* TODO: Kludge. */
+            : null),
+
+        navLinkStyle: 'hierarchical',
+        navLinks: relations.navLinks.content,
+
+        secondaryNav: relations.secondaryNav ?? null,
+      })),
 };
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsSection.js b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js
index d1dab542..8899e98e 100644
--- a/src/content/dependencies/generateGroupInfoPageAlbumsSection.js
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js
@@ -66,67 +66,71 @@ export default {
   }),
 
   generate: (relations, {html, language}) =>
-    html.tags([
-      relations.contentHeading
-        .slots({
-          tag: 'h2',
-          title: language.$('groupInfoPage.albumList.title'),
-        }),
-
-      html.tag('p',
-        {[html.onlyIfSiblings]: true},
-        language.$('groupInfoPage.viewAlbumGallery', {
-          link:
-            relations.galleryLink
-              .slot('content', language.$('groupInfoPage.viewAlbumGallery.link')),
-        })),
-
-      html.tag('ul',
-        {[html.onlyIfContent]: true},
-
-        stitchArrays({
-          albumLink: relations.albumLinks,
-          otherGroupLinks: relations.otherGroupLinks,
-          datetimestamp: relations.datetimestamps,
-          albumColorStyle: relations.albumColorStyles,
-        }).map(({
-            albumLink,
-            otherGroupLinks,
-            datetimestamp,
-            albumColorStyle,
-          }) => {
-            const prefix = 'groupInfoPage.albumList.item';
-            const parts = [prefix];
-            const options = {};
-
-            options.album =
-              albumLink.slot('color', false);
-
-            if (datetimestamp) {
-              parts.push('withYear');
-              options.yearAccent =
-                language.$(prefix, 'yearAccent', {
-                  year:
-                    datetimestamp.slots({style: 'year', tooltip: true}),
-                });
-            }
-
-            if (!empty(otherGroupLinks)) {
-              parts.push('withOtherGroup');
-              options.otherGroupAccent =
-                html.tag('span', {class: 'other-group-accent'},
-                  language.$(prefix, 'otherGroupAccent', {
-                    groups:
-                      language.formatConjunctionList(
-                        otherGroupLinks.map(groupLink =>
-                          groupLink.slot('color', false))),
-                  }));
-            }
-
-            return (
-              html.tag('li',
+    language.encapsulate('groupInfoPage', pageCapsule =>
+      language.encapsulate(pageCapsule, 'albumList', listCapsule =>
+        html.tags([
+          relations.contentHeading
+            .slots({
+              tag: 'h2',
+              title: language.$(listCapsule, 'title'),
+            }),
+
+          html.tag('p',
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(pageCapsule, 'viewAlbumGallery', capsule =>
+              language.$(capsule, {
+                link:
+                  relations.galleryLink
+                    .slot('content', language.$(capsule, 'link')),
+              }))),
+
+          html.tag('ul',
+            {[html.onlyIfContent]: true},
+
+            stitchArrays({
+              albumLink: relations.albumLinks,
+              otherGroupLinks: relations.otherGroupLinks,
+              datetimestamp: relations.datetimestamps,
+              albumColorStyle: relations.albumColorStyles,
+            }).map(({
+                albumLink,
+                otherGroupLinks,
+                datetimestamp,
                 albumColorStyle,
-                language.$(...parts, options)));
-          })),
-    ]),
+              }) =>
+                html.tag('li',
+                  albumColorStyle,
+
+                  language.encapsulate(listCapsule, 'item', itemCapsule =>
+                    language.encapsulate(itemCapsule, workingCapsule => {
+                      const workingOptions = {};
+
+                      workingOptions.album =
+                        albumLink.slot('color', false);
+
+                      if (datetimestamp) {
+                        workingCapsule += '.withYear';
+                        workingOptions.yearAccent =
+                          language.$(itemCapsule, 'yearAccent', {
+                            year:
+                              datetimestamp.slots({style: 'year', tooltip: true}),
+                          });
+                      }
+
+                      if (!empty(otherGroupLinks)) {
+                        workingCapsule += '.withOtherGroup';
+                        workingOptions.otherGroupAccent =
+                          html.tag('span', {class: 'other-group-accent'},
+                            language.$(itemCapsule, 'otherGroupAccent', {
+                              groups:
+                                language.formatConjunctionList(
+                                  otherGroupLinks.map(groupLink =>
+                                    groupLink.slot('color', false))),
+                            }));
+                      }
+
+                      return language.$(workingCapsule, workingOptions);
+                    }))))),
+        ]))),
 };
diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
index 69de373b..d52c77b8 100644
--- a/src/content/dependencies/generateGroupSidebarCategoryDetails.js
+++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
@@ -46,37 +46,37 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {html, language}) {
-    return html.tag('details',
-      data.isCurrentCategory &&
-        {class: 'current', open: true},
-
-      [
-        html.tag('summary',
-          relations.colorStyle,
-
-          html.tag('span',
-            language.$('groupSidebar.groupList.category', {
-              category:
-                html.tag('span', {class: 'group-name'},
-                  data.name),
-            }))),
-
-        html.tag('ul',
-          stitchArrays(({
-            infoLink: relations.groupInfoLinks,
-            galleryLink: relations.groupGalleryLinks,
-          })).map(({infoLink, galleryLink}, index) =>
-                html.tag('li',
-                  index === data.currentGroupIndex &&
-                    {class: 'current'},
-
-                  language.$('groupSidebar.groupList.item', {
-                    group:
-                      (slots.currentExtra === 'gallery'
-                        ? galleryLink ?? infoLink
-                        : infoLink),
-                  })))),
-      ]);
-  },
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('groupSidebar.groupList', capsule =>
+      html.tag('details',
+        data.isCurrentCategory &&
+          {class: 'current', open: true},
+
+        [
+          html.tag('summary',
+            relations.colorStyle,
+
+            html.tag('span',
+              language.$(capsule, 'category', {
+                category:
+                  html.tag('span', {class: 'group-name'},
+                    data.name),
+              }))),
+
+          html.tag('ul',
+            stitchArrays(({
+              infoLink: relations.groupInfoLinks,
+              galleryLink: relations.groupGalleryLinks,
+            })).map(({infoLink, galleryLink}, index) =>
+                  html.tag('li',
+                    index === data.currentGroupIndex &&
+                      {class: 'current'},
+
+                    language.$(capsule, 'item', {
+                      group:
+                        (slots.currentExtra === 'gallery'
+                          ? galleryLink ?? infoLink
+                          : infoLink),
+                    })))),
+        ])),
 };
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
index 43a78cb3..659cf4e5 100644
--- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -42,49 +42,50 @@ export default {
               additionalFileLinks,
               additionalFileFiles,
             }) =>
-              (additionalFileLinks.length === 1
-                ? html.tag('li',
-                    additionalFileLinks[0].slots({
-                      content:
-                        language.$('listingPage', slots.stringsKey, 'file', {
-                          title: additionalFileTitle,
-                        }),
-                    }))
+              language.encapsulate('listingPage', slots.stringsKey, 'file', capsule =>
+                (additionalFileLinks.length === 1
+                  ? html.tag('li',
+                      additionalFileLinks[0].slots({
+                        content:
+                          language.$(capsule, {
+                            title: additionalFileTitle,
+                          }),
+                      }))
 
-             : additionalFileLinks.length === 0
-                ? html.tag('li',
-                    language.$('listingPage', slots.stringsKey, 'file.withNoFiles', {
-                      title: additionalFileTitle,
-                    }))
+               : additionalFileLinks.length === 0
+                  ? html.tag('li',
+                      language.$(capsule, 'withNoFiles', {
+                        title: additionalFileTitle,
+                      }))
 
-                : html.tag('li', {class: 'has-details'},
-                    html.tag('details', [
-                      html.tag('summary',
-                        html.tag('span',
-                          language.$('listingPage', slots.stringsKey, 'file.withMultipleFiles', {
-                            title:
-                              html.tag('span', {class: 'group-name'},
-                                additionalFileTitle),
+                  : html.tag('li', {class: 'has-details'},
+                      html.tag('details', [
+                        html.tag('summary',
+                          html.tag('span',
+                            language.$(capsule, 'withMultipleFiles', {
+                              title:
+                                html.tag('span', {class: 'group-name'},
+                                  additionalFileTitle),
 
-                            files:
-                              language.countAdditionalFiles(
-                                additionalFileLinks.length,
-                                {unit: true}),
-                          }))),
+                              files:
+                                language.countAdditionalFiles(
+                                  additionalFileLinks.length,
+                                  {unit: true}),
+                            }))),
 
-                      html.tag('ul',
-                        stitchArrays({
-                          additionalFileLink: additionalFileLinks,
-                          additionalFileFile: additionalFileFiles,
-                        }).map(({additionalFileLink, additionalFileFile}) =>
-                            html.tag('li',
-                              additionalFileLink.slots({
-                                content:
-                                  language.$('listingPage', slots.stringsKey, 'file', {
-                                    title: additionalFileFile,
-                                  }),
-                              })))),
-                    ])))))),
+                        html.tag('ul',
+                          stitchArrays({
+                            additionalFileLink: additionalFileLinks,
+                            additionalFileFile: additionalFileFiles,
+                          }).map(({additionalFileLink, additionalFileFile}) =>
+                              html.tag('li',
+                                additionalFileLink.slots({
+                                  content:
+                                    language.$(capsule, {
+                                      title: additionalFileFile,
+                                    }),
+                                })))),
+                      ]))))))),
     ]);
   },
 };
diff --git a/src/content/dependencies/generateNewsEntryPage.js b/src/content/dependencies/generateNewsEntryPage.js
index bcba7194..2c382cfa 100644
--- a/src/content/dependencies/generateNewsEntryPage.js
+++ b/src/content/dependencies/generateNewsEntryPage.js
@@ -91,41 +91,41 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title:
-        language.$('newsEntryPage.title', {
-          entry: data.name,
-        }),
-
-      headingMode: 'sticky',
-
-      mainClasses: ['long-content'],
-      mainContent: [
-        html.tag('p',
-          language.$('newsEntryPage.published', {
-            date: language.formatDate(data.date),
-          })),
-
-        relations.content,
-        relations.readAnotherLinks,
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: relations.newsIndexLink},
-        {
-          auto: 'current',
-          accent:
-            (relations.previousNextLinks
-              ? `(${language.formatUnitList(relations.previousNextLinks.slots({
-                  previousLink: relations.previousEntryNavLink ?? null,
-                  nextLink: relations.nextEntryNavLink ?? null,
-                }).content)})`
-              : null),
-        },
-      ],
-    });
-  },
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('newsEntryPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            entry: data.name,
+          }),
+
+        headingMode: 'sticky',
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p',
+            language.$(pageCapsule, 'published', {
+              date: language.formatDate(data.date),
+            })),
+
+          relations.content,
+          relations.readAnotherLinks,
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.newsIndexLink},
+          {
+            auto: 'current',
+            accent:
+              (relations.previousNextLinks
+                ? `(${language.formatUnitList(relations.previousNextLinks.slots({
+                    previousLink: relations.previousEntryNavLink ?? null,
+                    nextLink: relations.nextEntryNavLink ?? null,
+                  }).content)})`
+                : null),
+          },
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateNewsIndexPage.js b/src/content/dependencies/generateNewsIndexPage.js
index 539af804..02964ce8 100644
--- a/src/content/dependencies/generateNewsIndexPage.js
+++ b/src/content/dependencies/generateNewsIndexPage.js
@@ -57,37 +57,38 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title: language.$('newsIndex.title'),
-      headingMode: 'sticky',
-
-      mainClasses: ['long-content', 'news-index'],
-      mainContent:
-        stitchArrays({
-          entryLink: relations.entryLinks,
-          viewRestLink: relations.viewRestLinks,
-          content: relations.entryContents,
-          date: data.entryDates,
-          directory: data.entryDirectories,
-        }).map(({entryLink, viewRestLink, content, date, directory}) =>
-            html.tag('article', {id: directory}, [
-              html.tag('h2', [
-                html.tag('time', language.formatDate(date)),
-                entryLink,
-              ]),
-
-              content,
-
-              viewRestLink
-                ?.slot('content', language.$('newsIndex.entry.viewRest')),
-            ])),
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    });
-  },
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('newsIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+        headingMode: 'sticky',
+
+        mainClasses: ['long-content', 'news-index'],
+        mainContent:
+          stitchArrays({
+            entryLink: relations.entryLinks,
+            viewRestLink: relations.viewRestLinks,
+            content: relations.entryContents,
+            date: data.entryDates,
+            directory: data.entryDirectories,
+          }).map(({entryLink, viewRestLink, content, date, directory}) =>
+              language.encapsulate(pageCapsule, 'entry', entryCapsule =>
+                html.tag('article', {id: directory}, [
+                  html.tag('h2', [
+                    html.tag('time', language.formatDate(date)),
+                    entryLink,
+                  ]),
+
+                  content,
+
+                  viewRestLink
+                    ?.slot('content', language.$(entryCapsule, 'viewRest')),
+                ]))),
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index e138a981..7e9e49a0 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -506,41 +506,43 @@ export default {
           html.tag('img', {id: 'image-overlay-image'}),
           html.tag('img', {id: 'image-overlay-image-thumb'}),
         ]),
-        html.tag('div', {id: 'image-overlay-action-container'}, [
-          html.tag('div', {id: 'image-overlay-action-content-without-size'},
-            language.$('releaseInfo.viewOriginalFile', {
-              link: html.tag('a', {class: 'image-overlay-view-original'},
-                language.$('releaseInfo.viewOriginalFile.link')),
-            })),
-
-          html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
-            language.$('releaseInfo.viewOriginalFile.withSize', {
-              link:
-                html.tag('a', {class: 'image-overlay-view-original'},
-                  language.$('releaseInfo.viewOriginalFile.link')),
-
-              size:
-                html.tag('span',
-                  {[html.joinChildren]: ''},
-                  [
-                    html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
-                      language.$('count.fileSize.kilobytes', {
-                        kilobytes:
-                          html.tag('span', {class: 'image-overlay-file-size-count'}),
-                      })),
-
-                    html.tag('span', {id: 'image-overlay-file-size-megabytes'},
-                      language.$('count.fileSize.megabytes', {
-                        megabytes:
-                          html.tag('span', {class: 'image-overlay-file-size-count'}),
-                      })),
-                  ]),
-            }),
 
-            html.tag('span', {id: 'image-overlay-file-size-warning'},
-              language.$('releaseInfo.viewOriginalFile.sizeWarning')),
-          ]),
-        ]),
+        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')),
+            ]),
+          ])),
       ]));
 
     const layoutHTML = [
diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js
index e11efc3f..26b30494 100644
--- a/src/content/dependencies/generatePageSidebarBox.js
+++ b/src/content/dependencies/generatePageSidebarBox.js
@@ -20,6 +20,8 @@ export default {
 
   generate: (slots, {html}) =>
     html.tag('div', {class: 'sidebar'},
+      {[html.onlyIfContent]: true},
+
       slots.collapsible &&
         {class: 'collapsible'},
 
diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
index 2e6c4709..3e96ed44 100644
--- a/src/content/dependencies/generateReleaseInfoContributionsLine.js
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -17,10 +17,12 @@ export default {
   },
 
   slots: {
-    stringKey: {type: 'string'},
-
     showContribution: {type: 'boolean', default: true},
-    showIcons: {type: 'boolean', default: true},
+    showExternalLinks: {type: 'boolean', default: true},
+    showChronology: {type: 'boolean', default: true},
+
+    stringKey: {type: 'string'},
+    chronologyKind: {type: 'string'},
   },
 
   generate(relations, slots, {html, language}) {
@@ -34,8 +36,9 @@ export default {
           relations.contributionLinks.map(link =>
             link.slots({
               showContribution: slots.showContribution,
-              showIcons: slots.showIcons,
-              iconMode: 'tooltip',
+              showExternalLinks: slots.showExternalLinks,
+              showChronology: slots.showChronology,
+              chronologyKind: slots.chronologyKind,
             }))),
     });
   },
diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js
index 6607c789..188a678f 100644
--- a/src/content/dependencies/generateSearchSidebarBox.js
+++ b/src/content/dependencies/generateSearchSidebarBox.js
@@ -8,50 +8,55 @@ export default {
   }),
 
   generate: (relations, {html, language}) =>
-    relations.sidebarBox.slots({
-      attributes: {class: 'wiki-search-sidebar-box'},
-      collapsible: false,
+    language.encapsulate('misc.search', capsule =>
+      relations.sidebarBox.slots({
+        attributes: {class: 'wiki-search-sidebar-box'},
+        collapsible: false,
 
-      content: [
-        html.tag('input', {class: 'wiki-search-input'},
-          {
-            placeholder:
-              language.$('misc.search.placeholder').toString(),
-          },
-          {type: 'search'}),
+        content: [
+          html.tag('label', {class: 'wiki-search-label'},
+            html.tag('input', {class: 'wiki-search-input'},
+              {type: 'search'},
 
-        html.tag('template', {class: 'wiki-search-preparing-string'},
-          language.$('misc.search.preparing')),
+              {
+                placeholder:
+                  language.$(capsule, 'placeholder').toString(),
+              })),
 
-        html.tag('template', {class: 'wiki-search-loading-data-string'},
-          language.$('misc.search.loadingData')),
+          html.tag('template', {class: 'wiki-search-preparing-string'},
+            language.$(capsule, 'preparing')),
 
-        html.tag('template', {class: 'wiki-search-searching-string'},
-          language.$('misc.search.searching')),
+          html.tag('template', {class: 'wiki-search-loading-data-string'},
+            language.$(capsule, 'loadingData')),
 
-        html.tag('template', {class: 'wiki-search-failed-string'},
-          language.$('misc.search.failed')),
+          html.tag('template', {class: 'wiki-search-searching-string'},
+            language.$(capsule, 'searching')),
 
-        html.tag('template', {class: 'wiki-search-no-results-string'},
-          language.$('misc.search.noResults')),
+          html.tag('template', {class: 'wiki-search-failed-string'},
+            language.$(capsule, 'failed')),
 
-        html.tag('template', {class: 'wiki-search-current-result-string'},
-          language.$('misc.search.currentResult')),
+          html.tag('template', {class: 'wiki-search-no-results-string'},
+            language.$(capsule, 'noResults')),
 
-        html.tag('template', {class: 'wiki-search-end-search-string'},
-          language.$('misc.search.endSearch')),
+          html.tag('template', {class: 'wiki-search-current-result-string'},
+            language.$(capsule, 'currentResult')),
 
-        html.tag('template', {class: 'wiki-search-album-result-kind-string'},
-          language.$('misc.search.resultKind.album')),
+          html.tag('template', {class: 'wiki-search-end-search-string'},
+            language.$(capsule, 'endSearch')),
 
-        html.tag('template', {class: 'wiki-search-artist-result-kind-string'},
-          language.$('misc.search.resultKind.artist')),
+          language.encapsulate(capsule, 'resultKind', capsule => [
+            html.tag('template', {class: 'wiki-search-album-result-kind-string'},
+              language.$(capsule, 'album')),
 
-        html.tag('template', {class: 'wiki-search-group-result-kind-string'},
-          language.$('misc.search.resultKind.group')),
+            html.tag('template', {class: 'wiki-search-artist-result-kind-string'},
+              language.$(capsule, 'artist')),
 
-        html.tag('template', {class: 'wiki-search-tag-result-kind-string'},
-          language.$('misc.search.resultKind.artTag')),
-      ],
-    }),
+            html.tag('template', {class: 'wiki-search-group-result-kind-string'},
+              language.$(capsule, 'group')),
+
+            html.tag('template', {class: 'wiki-search-tag-result-kind-string'},
+              language.$(capsule, 'artTag')),
+          ]),
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateTooltip.js b/src/content/dependencies/generateTooltip.js
index 81f74aec..8314d33c 100644
--- a/src/content/dependencies/generateTooltip.js
+++ b/src/content/dependencies/generateTooltip.js
@@ -21,10 +21,13 @@ export default {
   generate: (slots, {html}) =>
     html.tag('span', {class: 'tooltip'},
       {[html.noEdgeWhitespace]: true},
+      {[html.onlyIfContent]: true},
       slots.attributes,
 
       html.tag('span', {class: 'tooltip-content'},
         {[html.noEdgeWhitespace]: true},
+        {[html.onlyIfContent]: true},
         slots.contentAttributes,
+
         slots.content)),
 };
diff --git a/src/content/dependencies/generateTrackChronologyLinks.js b/src/content/dependencies/generateTrackChronologyLinks.js
deleted file mode 100644
index f9ad6299..00000000
--- a/src/content/dependencies/generateTrackChronologyLinks.js
+++ /dev/null
@@ -1,177 +0,0 @@
-import {sortAlbumsTracksChronologically} from '#sort';
-import {accumulateSum, stitchArrays} from '#sugar';
-
-import getChronologyRelations from '../util/getChronologyRelations.js';
-
-export default {
-  contentDependencies: [
-    'generateChronologyLinks',
-    'generateChronologyLinksScopeSwitcher',
-    'linkAlbum',
-    'linkArtist',
-    'linkTrack',
-  ],
-
-  relations(relation, track) {
-    function getScopedRelations(album) {
-      const albumFilter =
-        (album
-          ? track => track.album === album
-          : () => true);
-
-      return {
-        chronologyLinks:
-          relation('generateChronologyLinks'),
-
-        artistChronologyContributions:
-          getChronologyRelations(track, {
-            contributions: [
-              ...track.artistContribs ?? [],
-              ...track.contributorContribs ?? [],
-            ],
-
-            linkArtist: artist => relation('linkArtist', artist),
-            linkThing: track => relation('linkTrack', track),
-
-            getThings(artist) {
-              const getDate = thing => thing.date;
-
-              const things =
-                ([
-                  artist.trackArtistContributions,
-                  artist.trackContributorContributions,
-                ]).flat()
-                  .map(({thing}) => thing)
-                  .filter(getDate)
-                  .filter(albumFilter);
-
-              return sortAlbumsTracksChronologically(things, {getDate});
-            },
-          }),
-
-        coverArtistChronologyContributions:
-          getChronologyRelations(track, {
-            contributions: track.coverArtistContribs ?? [],
-
-            linkArtist: artist => relation('linkArtist', artist),
-
-            linkThing: trackOrAlbum =>
-              (trackOrAlbum.album
-                ? relation('linkTrack', trackOrAlbum)
-                : relation('linkAlbum', trackOrAlbum)),
-
-            getThings(artist) {
-              const getDate = thing => thing.coverArtDate ?? thing.date;
-
-              // Album artwork isn't part of cover artist chronology scoped to
-              // even the same album - we use this list to show "nth track art".
-              const applicableContributions =
-                (album
-                  ? artist.trackCoverArtistContributions
-                  : ([
-                      artist.albumCoverArtistContributions,
-                      artist.trackCoverArtistContributions,
-                    ]).flat());
-
-              const things =
-                applicableContributions
-                  .map(({thing}) => thing)
-                  .filter(getDate)
-                  .filter(albumFilter);
-
-              return sortAlbumsTracksChronologically(things, {getDate});
-            },
-          }),
-      };
-    }
-
-    const relations = {};
-
-    relations.scopeSwitcher =
-      relation('generateChronologyLinksScopeSwitcher');
-
-    relations.wiki =
-      getScopedRelations(null);
-
-    relations.album =
-      getScopedRelations(track.album);
-
-    for (const setKey of [
-      'artistChronologyContributions',
-      'coverArtistChronologyContributions',
-    ]) {
-      const wikiSet = relations.wiki[setKey];
-      const albumSet = relations.album[setKey];
-
-      const wikiArtistDirectories =
-        wikiSet
-          .map(({artistDirectory}) => artistDirectory);
-
-      albumSet.sort((a, b) =>
-        (a.only === b.only && a.index === b.index
-          ? (wikiArtistDirectories.indexOf(a.artistDirectory)
-           - wikiArtistDirectories.indexOf(b.artistDirectory))
-          : 0));
-    }
-
-    return relations;
-  },
-
-  generate(relations) {
-    function slotScopedRelations({content, artworkHeadingString}) {
-      return content.chronologyLinks.slots({
-        showOnly: true,
-        allowCollapsing: false,
-
-        chronologyInfoSets: [
-          {
-            headingString: 'misc.chronology.heading.track',
-            contributions: content.artistChronologyContributions,
-          },
-          {
-            headingString: `misc.chronology.heading.${artworkHeadingString}`,
-            contributions: content.coverArtistChronologyContributions,
-          },
-        ],
-      });
-    }
-
-    const scopes = [
-      'wiki',
-      'album',
-    ];
-
-    const contents = [
-      relations.wiki,
-      relations.album,
-    ];
-
-    const artworkHeadingStrings = [
-      'coverArt',
-      'trackArt',
-    ];
-
-    const totalContributionCount =
-      Math.max(...
-        contents.map(content =>
-          accumulateSum([
-            content.artistChronologyContributions,
-            content.coverArtistChronologyContributions,
-          ], contributions => contributions.length)));
-
-    relations.scopeSwitcher.setSlots({
-      scopes,
-
-      open:
-        totalContributionCount <= 5,
-
-      contents:
-        stitchArrays({
-          content: contents,
-          artworkHeadingString: artworkHeadingStrings,
-        }).map(slotScopedRelations),
-    });
-
-    return relations.scopeSwitcher;
-  },
-};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 1cbbc8a8..64ed0cb4 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -10,7 +10,6 @@ export default {
     'generateContributionList',
     'generatePageLayout',
     'generateTrackAdditionalNamesBox',
-    'generateTrackChronologyLinks',
     'generateTrackCoverArtwork',
     'generateTrackInfoPageFeaturedByFlashesList',
     'generateTrackInfoPageOtherReleasesList',
@@ -49,9 +48,6 @@ export default {
     albumNavAccent:
       relation('generateAlbumNavAccent', track.album, track),
 
-    chronologyLinks:
-      relation('generateTrackChronologyLinks', track),
-
     secondaryNav:
       relation('generateAlbumSecondaryNav', track.album),
 
@@ -134,251 +130,277 @@ export default {
   }),
 
   generate: (data, relations, {html, language}) =>
-    relations.layout.slots({
-      title: language.$('trackPage.title', {track: data.name}),
-      headingMode: 'sticky',
-
-      additionalNames: relations.additionalNamesBox,
-
-      color: data.color,
-      styleRules: [relations.albumStyleRules],
-
-      cover:
-        (relations.cover
-          ? relations.cover.slots({
-              alt: language.$('misc.alt.trackCover'),
-            })
-          : null),
-
-      mainContent: [
-        relations.releaseInfo,
-
-        html.tag('p',
-          {[html.onlyIfContent]: true},
-          {[html.joinChildren]: html.tag('br')},
-
-          [
-            !html.isBlank(relations.sheetMusicFilesList) &&
-              language.$('releaseInfo.sheetMusicFiles.shortcut', {
-                link: html.tag('a',
-                  {href: '#sheet-music-files'},
-                  language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
-              }),
+    language.encapsulate('trackPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            track: data.name,
+          }),
 
-            !html.isBlank(relations.midiProjectFilesList) &&
-              language.$('releaseInfo.midiProjectFiles.shortcut', {
-                link: html.tag('a',
-                  {href: '#midi-project-files'},
-                  language.$('releaseInfo.midiProjectFiles.shortcut.link')),
-              }),
+        headingMode: 'sticky',
 
-            !html.isBlank(relations.additionalFilesList) &&
-              language.$('releaseInfo.additionalFiles.shortcut', {
-                link: html.tag('a',
-                  {href: '#midi-project-files'},
-                  language.$('releaseInfo.additionalFiles.shortcut.link')),
-              }),
+        additionalNames: relations.additionalNamesBox,
+
+        color: data.color,
+        styleRules: [relations.albumStyleRules],
+
+        cover:
+          (relations.cover
+            ? relations.cover.slots({
+                alt: language.$('misc.alt.trackCover'),
+              })
+            : null),
 
-            !html.isBlank(relations.artistCommentarySection) &&
-              language.$('releaseInfo.readCommentary', {
-                link: html.tag('a',
-                  {href: '#artist-commentary'},
-                  language.$('releaseInfo.readCommentary.link')),
+        mainContent: [
+          relations.releaseInfo,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.sheetMusicFilesList) &&
+                language.encapsulate(capsule, 'sheetMusicFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#sheet-music-files'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.midiProjectFilesList) &&
+                language.encapsulate(capsule, 'midiProjectFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#midi-project-files'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.additionalFilesList) &&
+                language.encapsulate(capsule, 'additionalFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#midi-project-files'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.artistCommentarySection) &&
+                language.encapsulate(capsule, 'readCommentary', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#artist-commentary'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'also-released-as'},
+                title: language.$('releaseInfo.alsoReleasedAs'),
               }),
+
+            relations.otherReleasesList,
           ]),
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'also-released-as'},
-              title: language.$('releaseInfo.alsoReleasedAs'),
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'contributors'},
+                title: language.$('releaseInfo.contributors'),
+              }),
+
+            relations.contributorContributionList.slots({
+              chronologyKind: 'trackContribution',
             }),
+          ]),
 
-          relations.otherReleasesList,
-        ]),
+          html.tags([
+            language.encapsulate('releaseInfo.tracksReferenced', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'references'},
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'contributors'},
-              title: language.$('releaseInfo.contributors'),
-            }),
+                  title:
+                    language.$(capsule, {
+                      track:
+                        html.tag('i', data.name),
+                    }),
 
-          relations.contributorContributionList,
-        ]),
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'references'},
+            relations.referencedTracksList,
+          ]),
 
-              title:
-                language.$('releaseInfo.tracksReferenced', {
-                  track: html.tag('i', data.name),
-                }),
+          html.tags([
+            language.encapsulate('releaseInfo.tracksSampled', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'samples'},
 
-              stickyTitle:
-                language.$('releaseInfo.tracksReferenced.sticky'),
-            }),
+                  title:
+                    language.$(capsule, {
+                      track:
+                        html.tag('i', data.name),
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
+
+            relations.sampledTracksList,
+          ]),
 
-          relations.referencedTracksList,
-        ]),
+          language.encapsulate('releaseInfo.tracksThatReference', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'referenced-by'},
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'samples'},
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
 
-              title:
-                language.$('releaseInfo.tracksSampled', {
-                  track: html.tag('i', data.name),
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
                 }),
 
-              stickyTitle:
-                language.$('releaseInfo.tracksSampled.sticky'),
-            }),
+              relations.referencedByTracksList
+                .slots({
+                  headingString: capsule,
+                }),
+            ])),
 
-          relations.sampledTracksList,
-        ]),
+          language.encapsulate('releaseInfo.tracksThatSample', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'sampled-by'},
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'referenced-by'},
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
 
-              title:
-                language.$('releaseInfo.tracksThatReference', {
-                  track: html.tag('i', data.name),
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
                 }),
 
-              stickyTitle:
-                language.$('releaseInfo.tracksThatReference.sticky'),
-            }),
+              relations.sampledByTracksList
+                .slots({
+                  headingString: capsule,
+                }),
+            ])),
 
-          relations.referencedByTracksList
-            .slots({
-              headingString: 'releaseInfo.tracksThatReference',
-            }),
-        ]),
+          html.tags([
+            language.encapsulate('releaseInfo.flashesThatFeature', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'featured-in'},
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'sampled-by'},
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
 
-              title:
-                language.$('releaseInfo.tracksThatSample', {
-                  track: html.tag('i', data.name),
-                }),
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
 
-              stickyTitle:
-                language.$('releaseInfo.tracksThatSample.sticky'),
-            }),
+            relations.flashesThatFeatureList,
+          ]),
 
-          relations.sampledByTracksList
-            .slots({
-              headingString: 'releaseInfo.tracksThatSample',
-            }),
-        ]),
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'lyrics'},
+                title: language.$('releaseInfo.lyrics'),
+              }),
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'featured-in'},
+            html.tag('blockquote',
+              {[html.onlyIfContent]: true},
+              relations.lyrics.slot('mode', 'lyrics')),
+          ]),
 
-              title:
-                language.$('releaseInfo.flashesThatFeature', {
-                  track: html.tag('i', data.name),
-                }),
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'sheet-music-files'},
+                title: language.$('releaseInfo.sheetMusicFiles.heading'),
+              }),
 
-              stickyTitle:
-                language.$('releaseInfo.flashesThatFeature.sticky'),
-            }),
+            relations.sheetMusicFilesList,
+          ]),
 
-          relations.flashesThatFeatureList,
-        ]),
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'midi-project-files'},
+                title: language.$('releaseInfo.midiProjectFiles.heading'),
+              }),
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'lyrics'},
-              title: language.$('releaseInfo.lyrics'),
-            }),
+            relations.midiProjectFilesList,
+          ]),
 
-          html.tag('blockquote',
-            {[html.onlyIfContent]: true},
-            relations.lyrics.slot('mode', 'lyrics')),
-        ]),
-
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'sheet-music-files'},
-              title: language.$('releaseInfo.sheetMusicFiles.heading'),
-            }),
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'additional-files'},
+                title: language.$('releaseInfo.additionalFiles.heading'),
+              }),
 
-          relations.sheetMusicFilesList,
-        ]),
+            relations.additionalFilesList,
+          ]),
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'midi-project-files'},
-              title: language.$('releaseInfo.midiProjectFiles.heading'),
-            }),
+          relations.artistCommentarySection,
+        ],
 
-          relations.midiProjectFilesList,
-        ]),
+        navLinkStyle: 'hierarchical',
 
-        html.tags([
-          relations.contentHeading.clone()
-            .slots({
-              attributes: {id: 'additional-files'},
-              title: language.$('releaseInfo.additionalFiles.heading'),
-            }),
+        navLinks: [
+          {auto: 'home'},
 
-          relations.additionalFilesList,
-        ]),
-
-        relations.artistCommentarySection,
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: relations.albumLink.slot('color', false)},
-        {
-          html:
-            (data.hasTrackNumbers
-              ? language.$('trackPage.nav.track.withNumber', {
-                  number: data.trackNumber,
-                  track: relations.trackLink
-                    .slot('attributes', {class: 'current'}),
-                })
-              : language.$('trackPage.nav.track', {
-                  track: relations.trackLink
-                    .slot('attributes', {class: 'current'}),
-                })),
-        },
-      ],
+          {html: relations.albumLink.slot('color', false)},
 
-      navBottomRowContent:
-        relations.albumNavAccent.slots({
-          showTrackNavigation: true,
-          showExtraLinks: false,
-        }),
+          {
+            html:
+              language.encapsulate(pageCapsule, 'nav.track', workingCapsule => {
+                const workingOptions = {};
 
-      navContent:
-        relations.chronologyLinks,
+                workingOptions.track =
+                  relations.trackLink
+                    .slot('attributes', {class: 'current'});
 
-      secondaryNav:
-        relations.secondaryNav
-          .slot('mode', 'track'),
+                if (data.hasTrackNumbers) {
+                  workingCapsule += '.withNumber';
+                  workingOptions.number = data.trackNumber;
+                }
 
-      leftSidebar: relations.sidebar,
+                return language.$(workingCapsule, workingOptions);
+              }),
+          },
+        ],
 
-      socialEmbed: relations.socialEmbed,
-    }),
+        navBottomRowContent:
+          relations.albumNavAccent.slots({
+            showTrackNavigation: true,
+            showExtraLinks: false,
+          }),
+
+        secondaryNav:
+          relations.secondaryNav
+            .slot('mode', 'track'),
+
+        leftSidebar: relations.sidebar,
+
+        socialEmbed: relations.socialEmbed,
+      })),
 };
 
 /*
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index 4760ff2f..7c3b11c1 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -17,12 +17,7 @@ export default {
             .map(contrib => relation('linkContribution', contrib))),
   }),
 
-  slots: {
-    showContribution: {type: 'boolean', default: false},
-    showIcons: {type: 'boolean', default: false},
-  },
-
-  generate: (relations, slots, {html, language}) =>
+  generate: (relations, {html, language}) =>
     html.tag('ul',
       {[html.onlyIfContent]: true},
 
@@ -31,21 +26,21 @@ export default {
         contributionLinks: relations.contributionLinks,
       }).map(({trackLink, contributionLinks}) =>
           html.tag('li',
-            (empty(contributionLinks)
-              ? trackLink
-              : language.$('trackList.item.withArtists', {
-                  track: trackLink,
-                  by:
+            language.encapsulate('trackList.item', itemCapsule =>
+              language.encapsulate(itemCapsule, workingCapsule => {
+                const workingOptions = {track: trackLink};
+
+                if (!empty(contributionLinks)) {
+                  workingCapsule += '.withArtists';
+                  workingOptions.by =
                     html.tag('span', {class: 'by'},
                       html.metatag('chunkwrap', {split: ','},
-                        language.$('trackList.item.withArtists.by', {
+                        language.$(itemCapsule, 'withArtists.by', {
                           artists:
-                            language.formatConjunctionList(
-                              contributionLinks.map(link =>
-                                link.slots({
-                                  showContribution: slots.showContribution,
-                                  showIcons: slots.showIcons,
-                                }))),
-                        }))),
-                }))))),
+                            language.formatConjunctionList(contributionLinks),
+                        })));
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              }))))),
 };
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
index 21dc9ac1..3cba479e 100644
--- a/src/content/dependencies/generateTrackListDividedByGroups.js
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -80,53 +80,59 @@ export default {
 
   generate: (data, relations, slots, {html, language}) =>
     relations.flatList ??
-    html.tag('dl', {[html.onlyIfContent]: true}, [
-      stitchArrays({
-        groupName: data.groupNames,
-        groupLink: relations.groupLinks,
-        trackList: relations.groupedTrackLists,
-      }).map(({
-          groupName,
-          groupLink,
-          trackList,
-        }) => [
-          (slots.headingString
-            ? relations.contentHeading.clone().slots({
-                tag: 'dt',
-
-                title:
-                  language.$('trackList.fromGroup', {
-                    group: groupLink
-                  }),
-
-                stickyTitle:
-                  language.$(slots.headingString, 'sticky', 'fromGroup', {
-                    group: groupName,
-                  }),
-              })
-            : html.tag('dt',
-                language.$('trackList.fromGroup', {
-                  group: groupLink
-                }))),
-
-          html.tag('dd', trackList),
-        ]),
-
-      relations.ungroupedTrackList && [
-        (slots.headingString
-          ? relations.contentHeading.clone().slots({
-              tag: 'dt',
-
-              title:
-                language.$('trackList.fromOther'),
-
-              stickyTitle:
-                language.$(slots.headingString, 'sticky', 'fromOther'),
-            })
-          : html.tag('dt',
-              language.$('trackList.fromOther'))),
-
-        html.tag('dd', relations.ungroupedTrackList),
-      ],
-    ]),
+
+    html.tag('dl',
+      {[html.onlyIfContent]: true},
+
+      language.encapsulate('trackList', listCapsule => [
+        stitchArrays({
+          groupName: data.groupNames,
+          groupLink: relations.groupLinks,
+          trackList: relations.groupedTrackLists,
+        }).map(({
+            groupName,
+            groupLink,
+            trackList,
+          }) => [
+            language.encapsulate(listCapsule, 'fromGroup', capsule =>
+              (slots.headingString
+                ? relations.contentHeading.clone().slots({
+                    tag: 'dt',
+
+                    title:
+                      language.$(capsule, {
+                        group: groupLink
+                      }),
+
+                    stickyTitle:
+                      language.$(slots.headingString, 'sticky', 'fromGroup', {
+                        group: groupName,
+                      }),
+                  })
+                : html.tag('dt',
+                    language.$(capsule, {
+                      group: groupLink
+                    })))),
+
+            html.tag('dd', trackList),
+          ]),
+
+        relations.ungroupedTrackList && [
+          language.encapsulate(listCapsule, 'fromOther', capsule =>
+            (slots.headingString
+              ? relations.contentHeading.clone().slots({
+                  tag: 'dt',
+
+                  title:
+                    language.$(capsule),
+
+                  stickyTitle:
+                    language.$(slots.headingString, 'sticky', 'fromOther'),
+                })
+              : html.tag('dt',
+                  language.$(capsule)))),
+
+          html.tag('dd', relations.ungroupedTrackList),
+        ],
+      ])),
 };
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 88a4cdc7..8a081046 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -47,44 +47,51 @@ export default {
   },
 
   generate: (data, relations, {html, language}) =>
-    html.tags([
-      html.tag('p',
-        {[html.onlyIfContent]: true},
-        {[html.joinChildren]: html.tag('br')},
-
-        [
-          relations.artistContributionLinks
-            .slots({stringKey: 'releaseInfo.by'}),
-
-          relations.coverArtistContributionsLine
-            ?.slots({stringKey: 'releaseInfo.coverArtBy'}),
-
-          language.$('releaseInfo.released', {
-            [language.onlyIfOptions]: ['date'],
-            date: language.formatDate(data.date),
-          }),
-
-          language.$('releaseInfo.artReleased', {
-            [language.onlyIfOptions]: ['date'],
-            date: language.formatDate(data.coverArtDate),
-          }),
-
-          language.$('releaseInfo.duration', {
-            [language.onlyIfOptions]: ['duration'],
-            duration: language.formatDuration(data.duration),
-          }),
-        ]),
-
-      html.tag('p',
-        (relations.externalLinks
-          ? language.$('releaseInfo.listenOn', {
-              links:
-                language.formatDisjunctionList(
-                  relations.externalLinks
-                    .map(link => link.slot('context', 'track'))),
-            })
-          : language.$('releaseInfo.listenOn.noLinks', {
-              name: html.tag('i', data.name),
-            }))),
-    ]),
+    language.encapsulate('releaseInfo', capsule =>
+      html.tags([
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.artistContributionLinks.slots({
+              stringKey: capsule + '.by',
+              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),
+            }),
+          ]),
+
+        html.tag('p',
+          language.encapsulate(capsule, 'listenOn', capsule =>
+            (relations.externalLinks
+              ? language.$(capsule, {
+                  links:
+                    language.formatDisjunctionList(
+                      relations.externalLinks
+                        .map(link => link.slot('context', 'track'))),
+                })
+              : language.$(capsule, 'noLinks', {
+                  name:
+                    html.tag('i', data.name),
+                })))),
+      ])),
 };
diff --git a/src/content/dependencies/generateTrackSocialEmbed.js b/src/content/dependencies/generateTrackSocialEmbed.js
index 0337fc46..9868f0e2 100644
--- a/src/content/dependencies/generateTrackSocialEmbed.js
+++ b/src/content/dependencies/generateTrackSocialEmbed.js
@@ -39,35 +39,35 @@ export default {
     return data;
   },
 
-  generate(data, relations, {absoluteTo, language, urls}) {
-    return relations.socialEmbed.slots({
-      title:
-        language.$('trackPage.socialEmbed.title', {
-          track: data.trackName,
-        }),
+  generate: (data, relations, {absoluteTo, language, urls}) =>
+    language.encapsulate('trackPage.socialEmbed', embedCapsule =>
+      relations.socialEmbed.slots({
+        title:
+          language.$(embedCapsule, 'title', {
+            track: data.trackName,
+          }),
 
-      headingContent:
-        language.$('trackPage.socialEmbed.heading', {
-          album: data.albumName,
-        }),
+        headingContent:
+          language.$(embedCapsule, 'heading', {
+            album: data.albumName,
+          }),
 
-      headingLink:
-        absoluteTo('localized.album', data.albumDirectory),
+        headingLink:
+          absoluteTo('localized.album', data.albumDirectory),
 
-      imagePath:
-        (data.imageSource === 'album'
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.albumCover', data.albumDirectory, data.coverArtFileExtension)
-       : data.imageSource === 'track'
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension)
-          : null),
-    });
-  },
+        imagePath:
+          (data.imageSource === 'album'
+            ? '/' +
+              urls
+                .from('shared.root')
+                .to('media.albumCover', data.albumDirectory, data.coverArtFileExtension)
+         : data.imageSource === 'track'
+            ? '/' +
+              urls
+                .from('shared.root')
+                .to('media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension)
+            : null),
+      })),
 };
 
 /*
diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js
index bd0e4797..83a27695 100644
--- a/src/content/dependencies/generateWikiHomeNewsBox.js
+++ b/src/content/dependencies/generateWikiHomeNewsBox.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -39,49 +39,48 @@ export default {
         .map(entry => entry.date),
   }),
 
-  generate(data, relations, {html, language}) {
-    if (empty(relations.entryContents)) {
-      return html.blank();
-    }
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('homepage.news', boxCapsule =>
+      relations.box.slots({
+        attributes: {class: 'latest-news-sidebar-box'},
+        collapsible: false,
 
-    return relations.box.slots({
-      attributes: {class: 'latest-news-sidebar-box'},
-      collapsible: false,
+        content: [
+          html.tag('h1',
+            {[html.onlyIfSiblings]: true},
+            language.$(boxCapsule, 'title')),
 
-      content: [
-        html.tag('h1', language.$('homepage.news.title')),
+          stitchArrays({
+            date: data.entryDates,
+            content: relations.entryContents,
+            mainLink: relations.entryMainLinks,
+            readMoreLink: relations.entryReadMoreLinks,
+          }).map(({
+              date,
+              content,
+              mainLink,
+              readMoreLink,
+            }, index) =>
+              language.encapsulate(boxCapsule, 'entry', entryCapsule =>
+                html.tag('article', {class: 'news-entry'},
+                  index === 0 &&
+                    {class: 'first-news-entry'},
 
-        stitchArrays({
-          date: data.entryDates,
-          content: relations.entryContents,
-          mainLink: relations.entryMainLinks,
-          readMoreLink: relations.entryReadMoreLinks,
-        }).map(({
-            date,
-            content,
-            mainLink,
-            readMoreLink,
-          }, index) =>
-            html.tag('article', {class: 'news-entry'},
-              index === 0 &&
-                {class: 'first-news-entry'},
+                  [
+                    html.tag('h2', [
+                      html.tag('time', language.formatDate(date)),
+                      mainLink,
+                    ]),
 
-              [
-                html.tag('h2', [
-                  html.tag('time', language.formatDate(date)),
-                  mainLink,
-                ]),
+                    content.slot('thumb', 'medium'),
 
-                content.slot('thumb', 'medium'),
-
-                html.tag('p',
-                  {[html.onlyIfContent]: true},
-                  readMoreLink
-                    ?.slots({
-                      content: language.$('homepage.news.entry.viewRest'),
-                    })),
-              ])),
-      ],
-    });
-  },
+                    html.tag('p',
+                      {[html.onlyIfContent]: true},
+                      readMoreLink
+                        ?.slots({
+                          content: language.$(entryCapsule, 'viewRest'),
+                        })),
+                  ]))),
+        ],
+      })),
 };
diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js
new file mode 100644
index 00000000..d4697403
--- /dev/null
+++ b/src/content/dependencies/linkAnythingMan.js
@@ -0,0 +1,25 @@
+export default {
+  contentDependencies: [
+    'linkAlbum',
+    'linkFlash',
+    'linkTrack',
+  ],
+
+  query: (thing) => ({
+    referenceType: thing.constructor[Symbol.for('Thing.referenceType')],
+  }),
+
+  relations: (relation, query, thing) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbum', thing)
+     : query.referenceType === 'flash'
+        ? relation('linkFlash', thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrack', thing)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.link,
+};
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index 1a51c387..26f0b2d7 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -1,145 +1,78 @@
-import {empty, stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: [
+    'generateContributionTooltip',
     'generateTextWithTooltip',
-    'generateTooltip',
     'linkArtist',
-    'linkExternalAsIcon',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, contribution) {
-    const relations = {};
-
-    relations.artistLink =
-      relation('linkArtist', contribution.artist);
+  relations: (relation, contribution) => ({
+    artistLink:
+      relation('linkArtist', contribution.artist),
 
-    relations.textWithTooltip =
-      relation('generateTextWithTooltip');
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
 
-    relations.tooltip =
-      relation('generateTooltip');
+    tooltip:
+      relation('generateContributionTooltip', contribution),
+  }),
 
-    if (!empty(contribution.artist.urls)) {
-      relations.artistIcons =
-        contribution.artist.urls
-          .map(url => relation('linkExternalAsIcon', url));
-    }
-
-    return relations;
-  },
-
-  data(contribution) {
-    return {
-      contribution: contribution.annotation,
-      urls: contribution.artist.urls,
-    };
-  },
+  data: (contribution) => ({
+    contribution: contribution.annotation,
+    urls: contribution.artist.urls,
+  }),
 
   slots: {
     showContribution: {type: 'boolean', default: false},
-    showIcons: {type: 'boolean', default: false},
-    preventWrapping: {type: 'boolean', default: true},
+    showExternalLinks: {type: 'boolean', default: false},
+    showChronology: {type: 'boolean', default: false},
 
-    iconMode: {
-      validate: v => v.is('inline', 'tooltip'),
-      default: 'inline'
-    },
+    preventWrapping: {type: 'boolean', default: true},
+    chronologyKind: {type: 'string'},
   },
 
-  generate(data, relations, slots, {html, language}) {
-    const hasContribution = !!(slots.showContribution && data.contribution);
-    const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
-
-    const parts = ['misc.artistLink'];
-    const options = {};
-
-    options.artist =
-      (hasExternalIcons && slots.iconMode === 'tooltip'
-        ? relations.textWithTooltip.slots({
-            customInteractionCue: true,
-
-            text:
-              relations.artistLink.slots({
-                attributes: {class: 'text-with-tooltip-interaction-cue'},
-              }),
-
-            tooltip:
-              relations.tooltip.slots({
-                attributes:
-                  {class: ['icons', 'icons-tooltip']},
-
-                contentAttributes:
-                  {[html.joinChildren]: ''},
-
-                content:
-                  stitchArrays({
-                    icon: relations.artistIcons,
-                    url: data.urls,
-                  }).map(({icon, url}) => {
-                      icon.setSlots({
-                        context: 'artist',
-                        withText: true,
-                      });
-
-                      let platformText =
-                        language.formatExternalLink(url, {
-                          context: 'artist',
-                          style: 'platform',
-                        });
-
-                      // This is a pretty ridiculous hack, but we currently
-                      // don't have a way of telling formatExternalLink to *not*
-                      // use the fallback string, which just formats the URL as
-                      // its host/domain... so is technically detectable.
-                      if (platformText.toString() === (new URL(url)).host) {
-                        platformText =
-                          language.$('misc.artistLink.noExternalLinkPlatformName');
-                      }
-
-                      const platformSpan =
-                        html.tag('span', {class: 'icon-platform'},
-                          platformText);
-
-                      return [icon, platformSpan];
-                    }),
-              }),
-          })
-        : relations.artistLink);
-
-    if (hasContribution) {
-      parts.push('withContribution');
-      options.contrib = data.contribution;
-    }
-
-    if (hasExternalIcons && slots.iconMode === 'inline') {
-      parts.push('withExternalLinks');
-      options.links =
-        html.tag('span', {class: ['icons', 'icons-inline']},
-          {[html.noEdgeWhitespace]: true},
-          language.formatUnitList(
-            relations.artistIcons
-              .slice(0, 4)
-              .map(icon => icon.slot('context', 'artist'))));
-    }
-
-    const contributionPart =
-      language.formatString(...parts, options);
-
-    if (!hasContribution && !hasExternalIcons) {
-      return contributionPart;
-    }
-
-    return (
-      html.tag('span', {class: 'contribution'},
-        {[html.noEdgeWhitespace]: true},
-
-        parts.length > 1 &&
-        slots.preventWrapping &&
-          {class: 'nowrap'},
-
-        contributionPart));
-  },
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('span', {class: 'contribution'},
+      {[html.noEdgeWhitespace]: true},
+
+      slots.preventWrapping &&
+        {class: 'nowrap'},
+
+      language.encapsulate('misc.artistLink', workingCapsule => {
+        const workingOptions = {};
+
+        relations.tooltip.setSlots({
+          showExternalLinks: slots.showExternalLinks,
+          showChronology: slots.showChronology,
+          chronologyKind: slots.chronologyKind,
+        });
+
+        workingOptions.artist =
+          (html.isBlank(relations.tooltip)
+            ? relations.artistLink
+            : relations.textWithTooltip.slots({
+                customInteractionCue: true,
+
+                text:
+                  relations.artistLink.slots({
+                    attributes: {class: 'text-with-tooltip-interaction-cue'},
+                  }),
+
+                tooltip:
+                  relations.tooltip.slots({
+                    showExternalLinks: slots.showExternalLinks,
+                    showChronology: slots.showChronology,
+                    chronologyKind: slots.chronologyKind,
+                  }),
+              }));
+
+        if (slots.showContribution && data.contribution) {
+          workingCapsule += '.withContribution';
+          workingOptions.contrib =
+            data.contribution;
+        }
+
+        return language.formatString(workingCapsule, workingOptions);
+      })),
 };
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
deleted file mode 100644
index e2ce4b3c..00000000
--- a/src/content/dependencies/linkExternalAsIcon.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import {isExternalLinkContext} from '#external-links';
-
-export default {
-  extraDependencies: ['html', 'language', 'to'],
-
-  data: (url) => ({url}),
-
-  slots: {
-    context: {
-      // This awkward syntax is because the slot descriptor validator can't
-      // differentiate between a function that returns a validator (the usual
-      // syntax) and a function that is itself a validator.
-      validate: () => isExternalLinkContext,
-      default: 'generic',
-    },
-
-    withText: {type: 'boolean'},
-  },
-
-  generate(data, slots, {html, language, to}) {
-    const format = style =>
-      language.formatExternalLink(data.url, {style, context: slots.context});
-
-    const platformText = format('platform');
-    const handleText = format('handle');
-    const iconId = format('icon-id');
-
-    return html.tag('a', {class: 'icon'},
-      {href: data.url},
-
-      slots.withText &&
-        {class: 'has-text'},
-
-      [
-        html.tag('svg', [
-          !slots.withText &&
-            html.tag('title', platformText),
-
-          html.tag('use', {
-            href: to('staticMisc.icon', iconId),
-          }),
-        ]),
-
-        slots.withText &&
-          html.tag('span', {class: 'icon-text'},
-            (html.isBlank(handleText)
-              ? platformText
-              : handleText)),
-      ]);
-  },
-};
diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js
index ab2eca93..79bba441 100644
--- a/src/content/dependencies/listRandomPageLinks.js
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -74,20 +74,22 @@ export default {
   },
 
   generate(data, relations, {html, language}) {
+    const capsule = language.encapsulate('listingPage.other.randomPages');
+
     const miscellaneousChunkRows = [
-      {
+      language.encapsulate(capsule, 'chunk.item.randomArtist', capsule => ({
         stringsKey: 'randomArtist',
 
         mainLink:
           html.tag('a',
             {href: '#', 'data-random': 'artist'},
-            language.$('listingPage.other.randomPages.chunk.item.randomArtist.mainLink')),
+            language.$(capsule, 'mainLink')),
 
         atLeastTwoContributions:
           html.tag('a',
             {href: '#', 'data-random': 'artist-more-than-one-contrib'},
-            language.$('listingPage.other.randomPages.chunk.item.randomArtist.atLeastTwoContributions')),
-      },
+            language.$(capsule, 'atLeastTwoContributions')),
+      })),
 
       {stringsKey: 'randomAlbumWholeSite'},
       {stringsKey: 'randomTrackWholeSite'},
@@ -104,24 +106,25 @@ export default {
 
       content: [
         html.tag('p',
-          language.$('listingPage.other.randomPages.chooseLinkLine', {
-            fromPart:
-              (relations.groupLinks
-                ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups')
-                : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')),
+          language.encapsulate(capsule, 'chooseLinkLine', capsule =>
+            language.$(capsule, {
+              fromPart:
+                (relations.groupLinks
+                  ? language.$(capsule, 'fromPart.dividedByGroups')
+                  : language.$(capsule, 'fromPart.notDividedByGroups')),
 
-            browserSupportPart:
-              language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'),
-          })),
+              browserSupportPart:
+                language.$(capsule, 'browserSupportPart'),
+            }))),
 
         html.tag('p', {id: 'data-loading-line'},
-          language.$('listingPage.other.randomPages.dataLoadingLine')),
+          language.$(capsule, 'dataLoadingLine')),
 
         html.tag('p', {id: 'data-loaded-line'},
-          language.$('listingPage.other.randomPages.dataLoadedLine')),
+          language.$(capsule, 'dataLoadedLine')),
 
         html.tag('p', {id: 'data-error-line'},
-          language.$('listingPage.other.randomPages.dataErrorLine')),
+          language.$(capsule, 'dataErrorLine')),
       ],
 
       showSkipToSection: true,
@@ -148,17 +151,18 @@ export default {
 
         ...
           (relations.groupLinks
-            ? relations.groupLinks.map(() => ({
-                randomAlbum:
-                  html.tag('a',
-                    {href: '#', 'data-random': 'album-in-group-dl'},
-                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomAlbum')),
-
-                randomTrack:
-                  html.tag('a',
-                    {href: '#', 'data-random': 'track-in-group-dl'},
-                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomTrack')),
-              }))
+            ? relations.groupLinks.map(() =>
+                language.encapsulate(capsule, 'chunk.title.fromGroup.accent', capsule => ({
+                  randomAlbum:
+                    html.tag('a',
+                      {href: '#', 'data-random': 'album-in-group-dl'},
+                      language.$(capsule, 'randomAlbum')),
+
+                  randomTrack:
+                    html.tag('a',
+                      {href: '#', 'data-random': 'track-in-group-dl'},
+                      language.$(capsule, 'randomTrack')),
+                })))
             : [null]),
       ],
 
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index ee2c3938..5f803a3b 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -279,15 +279,16 @@ export default {
                       {class: 'align-center'},
 
                     {title:
-                      language.$('misc.external.opensInNewTab', {
-                        link:
-                          language.formatExternalLink(link, {
-                            style: 'platform',
-                          }),
-
-                        annotation:
-                          language.$('misc.external.opensInNewTab.annotation'),
-                      }).toString()},
+                      language.encapsulate('misc.external.opensInNewTab', capsule =>
+                        language.$(capsule, {
+                          link:
+                            language.formatExternalLink(link, {
+                              style: 'platform',
+                            }),
+
+                          annotation:
+                            language.$(capsule, 'annotation'),
+                        }).toString())},
 
                     content);
               }
diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js
deleted file mode 100644
index c601a990..00000000
--- a/src/content/util/getChronologyRelations.js
+++ /dev/null
@@ -1,57 +0,0 @@
-export default function getChronologyRelations(thing, {
-  contributions,
-  linkArtist,
-  linkThing,
-  getThings,
-}) {
-  // One call to getChronologyRelations is considered "lumping" together all
-  // contributions as carrying equivalent meaning (for example, "artist"
-  // contributions and "contributor" contributions are bunched together in
-  // one call to getChronologyRelations, while "cover artist" contributions
-  // are a separate call). getChronologyRelations prevents duplicates that
-  // carry the same meaning by only using the first instance of each artist
-  // in the contributions array passed to it. It's expected that the string
-  // identifying which kind of contribution ("track" or "cover art") is
-  // shared and applied to all contributions, as providing them together
-  // in one call to getChronologyRelations implies they carry the same
-  // meaning.
-
-  const artistsSoFar = new Set();
-
-  contributions = contributions.filter(({artist}) => {
-    if (artistsSoFar.has(artist)) {
-      return false;
-    } else {
-      artistsSoFar.add(artist);
-      return true;
-    }
-  });
-
-  return contributions.map(({artist}) => {
-    const things = Array.from(new Set(getThings(artist)));
-
-    // Don't show a line if this contribution isn't part of the artist's
-    // chronology at all (usually because this thing isn't dated).
-    const index = things.indexOf(thing);
-    if (index === -1) {
-      return;
-    }
-
-    const previous = things[index - 1];
-    const next = things[index + 1];
-
-    return {
-      index: index + 1,
-      artistDirectory: artist.directory,
-      only: !(previous || next),
-
-      artistLink: linkArtist(artist),
-      previousLink: previous ? linkThing(previous) : null,
-      nextLink: next ? linkThing(next) : null,
-    };
-  }).filter(Boolean)
-    .sort((a, b) =>
-      (a.only === b.only ?  b.index - a.index
-     : a.only            ? +1
-                         : -1))
-}
diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js
index d798dcdc..2a3e818e 100644
--- a/src/data/composite/data/excludeFromList.js
+++ b/src/data/composite/data/excludeFromList.js
@@ -5,11 +5,6 @@
 // See also:
 //  - fillMissingListItems
 //
-// More list utilities:
-//  - withFilteredList, withMappedList, withSortedList
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 import {empty} from '#sugar';
diff --git a/src/data/composite/data/fillMissingListItems.js b/src/data/composite/data/fillMissingListItems.js
index 4f818a79..356b1119 100644
--- a/src/data/composite/data/fillMissingListItems.js
+++ b/src/data/composite/data/fillMissingListItems.js
@@ -4,11 +4,6 @@
 // See also:
 //  - excludeFromList
 //
-// More list utilities:
-//  - withFilteredList, withMappedList, withSortedList
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js
index 0a47c43c..c80bb350 100644
--- a/src/data/composite/data/index.js
+++ b/src/data/composite/data/index.js
@@ -3,16 +3,32 @@
 // Entries here may depend on entries in #composite/control-flow.
 //
 
+// Utilities which act on generic objects
+
+export {default as withPropertiesFromObject} from './withPropertiesFromObject.js';
+export {default as withPropertyFromObject} from './withPropertyFromObject.js';
+
+// Utilities which act on generic lists
+
 export {default as excludeFromList} from './excludeFromList.js';
+
 export {default as fillMissingListItems} from './fillMissingListItems.js';
+export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js';
+
 export {default as withFilteredList} from './withFilteredList.js';
-export {default as withFlattenedList} from './withFlattenedList.js';
 export {default as withMappedList} from './withMappedList.js';
-export {default as withPropertiesFromList} from './withPropertiesFromList.js';
-export {default as withPropertiesFromObject} from './withPropertiesFromObject.js';
-export {default as withPropertyFromList} from './withPropertyFromList.js';
-export {default as withPropertyFromObject} from './withPropertyFromObject.js';
 export {default as withSortedList} from './withSortedList.js';
-export {default as withSum} from './withSum.js';
+
+export {default as withPropertyFromList} from './withPropertyFromList.js';
+export {default as withPropertiesFromList} from './withPropertiesFromList.js';
+
+export {default as withFlattenedList} from './withFlattenedList.js';
 export {default as withUnflattenedList} from './withUnflattenedList.js';
-export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js';
+
+export {default as withIndexInList} from './withIndexInList.js';
+export {default as withNearbyItemFromList} from './withNearbyItemFromList.js';
+
+// Utilities which act on slightly more particular data forms
+// (probably, containers of particular kinds of values)
+
+export {default as withSum} from './withSum.js';
diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js
index 82e56903..60fe66f4 100644
--- a/src/data/composite/data/withFilteredList.js
+++ b/src/data/composite/data/withFilteredList.js
@@ -16,12 +16,6 @@
 //  - withMappedList
 //  - withSortedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/withFlattenedList.js b/src/data/composite/data/withFlattenedList.js
index edfa3403..31b1a742 100644
--- a/src/data/composite/data/withFlattenedList.js
+++ b/src/data/composite/data/withFlattenedList.js
@@ -5,12 +5,6 @@
 // See also:
 //  - withUnflattenedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFilteredList, withMappedList, withSortedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/withIndexInList.js b/src/data/composite/data/withIndexInList.js
new file mode 100644
index 00000000..b1af2033
--- /dev/null
+++ b/src/data/composite/data/withIndexInList.js
@@ -0,0 +1,38 @@
+// Gets the index of the provided item in the provided list. Note that this
+// will output -1 if the item is not found, and this may be detected using
+// any availability check with type: 'index'. If the list includes the item
+// twice, the output index will be of the first match.
+//
+// Both the list and item must be provided.
+//
+// See also:
+//  - withNearbyItemFromList
+//  - exitWithoutDependency
+//  - raiseOutputWithoutDependency
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withIndexInList`,
+
+  inputs: {
+    list: input({acceptsNull: false, type: 'array'}),
+    item: input({acceptsNull: false}),
+  },
+
+  outputs: ['#index'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('item')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('item')]: item,
+      }) => continuation({
+        ['#index']:
+          list.indexOf(item),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withMappedList.js b/src/data/composite/data/withMappedList.js
index e0a700b2..0bc63a92 100644
--- a/src/data/composite/data/withMappedList.js
+++ b/src/data/composite/data/withMappedList.js
@@ -5,12 +5,6 @@
 //  - withFilteredList
 //  - withSortedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/withNearbyItemFromList.js b/src/data/composite/data/withNearbyItemFromList.js
new file mode 100644
index 00000000..83a8cc21
--- /dev/null
+++ b/src/data/composite/data/withNearbyItemFromList.js
@@ -0,0 +1,73 @@
+// Gets a nearby (typically adjacent) item in a list, meaning the item which is
+// placed at a particular offset compared to the provided item. This is null if
+// the provided list doesn't include the provided item at all, and also if the
+// offset would read past either end of the list - except if configured:
+//
+//  - If the 'wrap' input is provided (as true), the offset will loop around
+//    and continue from the opposing end.
+//
+//  - If the 'valuePastEdge' input is provided, that value will be output
+//    instead of null.
+//
+// Both the list and item must be provided.
+//
+// See also:
+//  - withIndexInList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {atOffset} from '#sugar';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withIndexInList from './withIndexInList.js';
+
+export default templateCompositeFrom({
+  annotation: `withNearbyItemFromList`,
+
+  inputs: {
+    list: input({acceptsNull: false, type: 'array'}),
+    item: input({acceptsNull: false}),
+
+    offset: input({type: 'number'}),
+    wrap: input({type: 'boolean', defaultValue: false}),
+  },
+
+  outputs: ['#nearbyItem'],
+
+  steps: () => [
+    withIndexInList({
+      list: input('list'),
+      item: input('item'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#index',
+      mode: input.value('index'),
+
+      output: input.value({
+        ['#nearbyItem']:
+          null,
+      }),
+    }),
+
+    {
+      dependencies: [
+        input('list'),
+        input('offset'),
+        input('wrap'),
+        '#index',
+      ],
+
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('offset')]: offset,
+        [input('wrap')]: wrap,
+        ['#index']: index,
+      }) => continuation({
+        ['#nearbyItem']:
+          atOffset(list, index, offset, {wrap}),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js
index 08907bab..fb4134bc 100644
--- a/src/data/composite/data/withPropertiesFromList.js
+++ b/src/data/composite/data/withPropertiesFromList.js
@@ -8,12 +8,6 @@
 //  - withPropertiesFromObject
 //  - withPropertyFromList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFilteredList, withMappedList, withSortedList
-//  - withFlattenedList, withUnflattenedList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 import {isString, validateArrayItems} from '#validators';
diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js
index a2c66d77..65ebf77b 100644
--- a/src/data/composite/data/withPropertyFromList.js
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -9,12 +9,6 @@
 //  - withPropertiesFromList
 //  - withPropertyFromObject
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFilteredList, withMappedList, withSortedList
-//  - withFlattenedList, withUnflattenedList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/withSortedList.js b/src/data/composite/data/withSortedList.js
index dd810786..a7d21768 100644
--- a/src/data/composite/data/withSortedList.js
+++ b/src/data/composite/data/withSortedList.js
@@ -27,12 +27,6 @@
 //  - withFilteredList
 //  - withMappedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/withUnflattenedList.js b/src/data/composite/data/withUnflattenedList.js
index 39a666dc..820d628a 100644
--- a/src/data/composite/data/withUnflattenedList.js
+++ b/src/data/composite/data/withUnflattenedList.js
@@ -7,12 +7,6 @@
 // See also:
 //  - withFlattenedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFilteredList, withMappedList, withSortedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 import {isWholeNumber, validateArrayItems} from '#validators';
diff --git a/src/data/composite/things/contribution/index.js b/src/data/composite/things/contribution/index.js
index 2c812644..9b22be2e 100644
--- a/src/data/composite/things/contribution/index.js
+++ b/src/data/composite/things/contribution/index.js
@@ -1,6 +1,7 @@
 export {default as inheritFromContributionPresets} from './inheritFromContributionPresets.js';
 export {default as thingPropertyMatches} from './thingPropertyMatches.js';
 export {default as thingReferenceTypeMatches} from './thingReferenceTypeMatches.js';
+export {default as withContainingReverseContributionList} from './withContainingReverseContributionList.js';
 export {default as withContributionArtist} from './withContributionArtist.js';
 export {default as withContributionContext} from './withContributionContext.js';
 export {default as withMatchingContributionPresets} from './withMatchingContributionPresets.js';
diff --git a/src/data/composite/things/contribution/withContainingReverseContributionList.js b/src/data/composite/things/contribution/withContainingReverseContributionList.js
new file mode 100644
index 00000000..56704c8b
--- /dev/null
+++ b/src/data/composite/things/contribution/withContainingReverseContributionList.js
@@ -0,0 +1,40 @@
+// Get the artist's contribution list containing this property.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withContributionArtist from './withContributionArtist.js';
+
+export default templateCompositeFrom({
+  annotation: `withContainingReverseContributionList`,
+
+  inputs: {
+    artistProperty: input({
+      defaultDependency: 'artistProperty',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#containingReverseContributionList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('artistProperty'),
+      output: input.value({
+        ['#containingReverseContributionList']:
+          null,
+      }),
+    }),
+
+    withContributionArtist(),
+
+    withPropertyFromObject({
+      object: '#artist',
+      property: input('artistProperty'),
+    }).outputs({
+      ['#value']: '#containingReverseContributionList',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/contribution/withContributionArtist.js b/src/data/composite/things/contribution/withContributionArtist.js
index 9e588936..5a611c1a 100644
--- a/src/data/composite/things/contribution/withContributionArtist.js
+++ b/src/data/composite/things/contribution/withContributionArtist.js
@@ -5,10 +5,13 @@ import {withPropertyFromObject} from '#composite/data';
 import {withResolvedReference} from '#composite/wiki-data';
 
 export default templateCompositeFrom({
-  annotation: `withOwnContributionArtist`,
+  annotation: `withContributionArtist`,
 
   inputs: {
-    ref: input({type: 'string'}),
+    ref: input({
+      type: 'string',
+      defaultDependency: 'artist',
+    }),
   },
 
   outputs: ['#artist'],
diff --git a/src/data/composite/wiki-data/withRecontextualizedContributionList.js b/src/data/composite/wiki-data/withRecontextualizedContributionList.js
index 06c997b5..d2401eac 100644
--- a/src/data/composite/wiki-data/withRecontextualizedContributionList.js
+++ b/src/data/composite/wiki-data/withRecontextualizedContributionList.js
@@ -1,12 +1,14 @@
 // Clones all the contributions in a list, with thing and thingProperty both
 // updated to match the current thing. Overwrites the provided dependency.
-// Doesn't do anything if the provided dependency is null.
+// Optionally updates artistProperty as well. Doesn't do anything if
+// the provided dependency is null.
 //
 // See also:
 //  - withRedatedContributionList
 //
 
 import {input, templateCompositeFrom} from '#composite';
+import {isStringNonEmpty} from '#validators';
 
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
 import {withClonedThings} from '#composite/wiki-data';
@@ -19,6 +21,11 @@ export default templateCompositeFrom({
       type: 'array',
       acceptsNull: true,
     }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
   },
 
   outputs: ({
@@ -47,16 +54,25 @@ export default templateCompositeFrom({
     },
 
     {
-      dependencies: [input.myself(), input.thisProperty()],
+      dependencies: [
+        input.myself(),
+        input.thisProperty(),
+        input('artistProperty'),
+      ],
 
       compute: (continuation, {
         [input.myself()]: myself,
         [input.thisProperty()]: thisProperty,
+        [input('artistProperty')]: artistProperty,
       }) => continuation({
-        ['#assignment']: {
-          thing: myself,
-          thingProperty: thisProperty,
-        },
+        ['#assignment']:
+          Object.assign(
+            {thing: myself},
+            {thingProperty: thisProperty},
+
+            (artistProperty
+              ? {artistProperty}
+              : {})),
       }),
     },
 
diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js
index 23b91691..b5d7255b 100644
--- a/src/data/composite/wiki-data/withResolvedContribs.js
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -36,6 +36,11 @@ export default templateCompositeFrom({
       validate: isStringNonEmpty,
       defaultValue: null,
     }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
   },
 
   outputs: ['#resolvedContribs'],
@@ -103,12 +108,14 @@ export default templateCompositeFrom({
       dependencies: [
         '#details',
         '#thingProperty',
+        input('artistProperty'),
         input.myself(),
       ],
 
       compute: (continuation, {
         ['#details']: details,
         ['#thingProperty']: thingProperty,
+        [input('artistProperty')]: artistProperty,
         [input.myself()]: myself,
       }) => continuation({
         ['#contributions']:
@@ -119,6 +126,7 @@ export default templateCompositeFrom({
               ...details,
               thing: myself,
               thingProperty: thingProperty,
+              artistProperty: artistProperty,
             });
 
             return contrib;
diff --git a/src/data/composite/wiki-properties/contributionList.js b/src/data/composite/wiki-properties/contributionList.js
index a0e6e52b..d9a6b417 100644
--- a/src/data/composite/wiki-properties/contributionList.js
+++ b/src/data/composite/wiki-properties/contributionList.js
@@ -15,7 +15,7 @@
 //
 
 import {input, templateCompositeFrom} from '#composite';
-import {isContributionList, isDate} from '#validators';
+import {isContributionList, isDate, isStringNonEmpty} from '#validators';
 
 import {exposeConstant, exposeDependencyOrContinue} from '#composite/control-flow';
 import {withResolvedContribs} from '#composite/wiki-data';
@@ -30,6 +30,11 @@ export default templateCompositeFrom({
       validate: isDate,
       acceptsNull: true,
     }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
   },
 
   update: {validate: isContributionList},
@@ -38,6 +43,7 @@ export default templateCompositeFrom({
     withResolvedContribs({
       from: input.updateValue(),
       thingProperty: input.thisProperty(),
+      artistProperty: input('artistProperty'),
       date: input('date'),
     }),
 
diff --git a/src/data/things/album.js b/src/data/things/album.js
index ae5226ba..a0021946 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -142,6 +142,7 @@ export class Album extends Thing {
 
     artistContribs: contributionList({
       date: 'date',
+      artistProperty: input.value('albumArtistContributions'),
     }),
 
     coverArtistContribs: [
@@ -151,6 +152,7 @@ export class Album extends Thing {
 
       contributionList({
         date: '#coverArtDate',
+        artistProperty: input.value('albumCoverArtistContributions'),
       }),
     ],
 
@@ -158,6 +160,10 @@ export class Album extends Thing {
       // May be null, indicating cover art was added for tracks on the date
       // each track specifies, or else the track's own release date.
       date: 'trackArtDate',
+
+      // This is the "correct" value, but it gets overwritten - with the same
+      // value - regardless.
+      artistProperty: input.value('trackCoverArtistContributions'),
     }),
 
     wallpaperArtistContribs: [
@@ -167,6 +173,7 @@ export class Album extends Thing {
 
       contributionList({
         date: '#coverArtDate',
+        artistProperty: input.value('albumWallpaperArtistContributions'),
       }),
     ],
 
@@ -177,6 +184,7 @@ export class Album extends Thing {
 
       contributionList({
         date: '#coverArtDate',
+        artistProperty: input.value('albumBannerArtistContributions'),
       }),
     ],
 
diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js
index 9d6a9711..79acf1e1 100644
--- a/src/data/things/contribution.js
+++ b/src/data/things/contribution.js
@@ -8,7 +8,7 @@ import Thing from '#thing';
 import {isStringNonEmpty, isThing, validateReference} from '#validators';
 
 import {exitWithoutDependency, exposeDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
+import {withNearbyItemFromList, withPropertyFromObject} from '#composite/data';
 import {withResolvedReference} from '#composite/wiki-data';
 import {flag, simpleDate} from '#composite/wiki-properties';
 
@@ -16,6 +16,7 @@ import {
   inheritFromContributionPresets,
   thingPropertyMatches,
   thingReferenceTypeMatches,
+  withContainingReverseContributionList,
   withContributionArtist,
   withContributionContext,
   withMatchingContributionPresets,
@@ -35,6 +36,11 @@ export class Contribution extends Thing {
       update: {validate: isStringNonEmpty},
     },
 
+    artistProperty: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
     date: simpleDate(),
 
     artist: [
@@ -155,6 +161,46 @@ export class Contribution extends Thing {
     isForFlash: thingReferenceTypeMatches({
       value: input.value('flash'),
     }),
+
+    previousBySameArtist: [
+      withContainingReverseContributionList().outputs({
+        '#containingReverseContributionList': '#list',
+      }),
+
+      exitWithoutDependency({
+        dependency: '#list',
+      }),
+
+      withNearbyItemFromList({
+        list: '#list',
+        item: input.myself(),
+        offset: input.value(-1),
+      }),
+
+      exposeDependency({
+        dependency: '#nearbyItem',
+      }),
+    ],
+
+    nextBySameArtist: [
+      withContainingReverseContributionList().outputs({
+        '#containingReverseContributionList': '#list',
+      }),
+
+      exitWithoutDependency({
+        dependency: '#list',
+      }),
+
+      withNearbyItemFromList({
+        list: '#list',
+        item: input.myself(),
+        offset: input.value(+1),
+      }),
+
+      exposeDependency({
+        dependency: '#nearbyItem',
+      }),
+    ],
   });
 
   [inspect.custom](depth, options, inspect) {
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index 7d37ed81..89e59fe7 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -100,6 +100,7 @@ export class Flash extends Thing {
 
     contributorContribs: contributionList({
       date: 'date',
+      artistProperty: input.value('flashContributorContributions'),
     }),
 
     featuredTracks: referenceList({
diff --git a/src/data/things/language.js b/src/data/things/language.js
index f20927a4..88f16ecb 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -208,9 +208,7 @@ export class Language extends Thing {
       args.at(-1) !== null;
 
     const key =
-      (hasOptions ? args.slice(0, -1) : args)
-        .filter(Boolean)
-        .join('.');
+      this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args);
 
     const options =
       (hasOptions
@@ -843,6 +841,33 @@ export class Language extends Thing {
       return this.formatString('count.fileSize.bytes', {bytes});
     }
   }
+
+  // Utility function to quickly provide a useful string key
+  // (generally a prefix) to stuff nested beneath it.
+  encapsulate(...args) {
+    const fn =
+      (typeof args.at(-1) === 'function'
+        ? args.at(-1)
+        : null);
+
+    const parts =
+      (fn
+        ? args.slice(0, -1)
+        : args);
+
+    const capsule =
+      this.#joinKeyParts(parts);
+
+    if (fn) {
+      return fn(capsule);
+    } else {
+      return capsule;
+    }
+  }
+
+  #joinKeyParts(parts) {
+    return parts.filter(Boolean).join('.');
+  }
 }
 
 const countHelper = (stringKey, optionName = stringKey) =>
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 28a784f5..4aaf364c 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -203,6 +203,7 @@ export class Track extends Thing {
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
         thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
         date: '#date',
       }).outputs({
         '#resolvedContribs': '#artistContribs',
@@ -219,6 +220,7 @@ export class Track extends Thing {
 
       withRecontextualizedContributionList({
         list: '#album.artistContribs',
+        artistProperty: input.value('trackArtistContributions'),
       }),
 
       withRedatedContributionList({
@@ -236,6 +238,7 @@ export class Track extends Thing {
 
       contributionList({
         date: '#date',
+        artistProperty: input.value('trackContributorContributions'),
       }),
     ],
 
@@ -254,6 +257,7 @@ export class Track extends Thing {
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
         thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackCoverArtistContributions'),
         date: '#trackArtDate',
       }).outputs({
         '#resolvedContribs': '#coverArtistContribs',
@@ -270,6 +274,7 @@ export class Track extends Thing {
 
       withRecontextualizedContributionList({
         list: '#album.trackCoverArtistContribs',
+        artistProperty: input.value('trackCoverArtistContributions'),
       }),
 
       withRedatedContributionList({
diff --git a/src/static/css/site.css b/src/static/css/site.css
index de28ad58..80801c85 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -514,23 +514,77 @@ summary .group-name {
   filter: brightness(0.7);
 }
 
-.wiki-search-input {
+.wiki-search-label {
   width: calc(100% - 4px);
   padding: 2px 4px;
   margin: 2px 2px 3px 2px;
   box-sizing: border-box;
 
+  display: flex;
+  flex-direction: row;
+
   background: transparent;
   border: 1px solid var(--dim-color);
   border-radius: 3px;
+}
+
+.wiki-search-label::before {
+  display: inline-block;
+  padding-left: 3px;
+  padding-right: 3px;
+  margin-right: 3px;
+  width: 1.8em;
+  text-align: center;
+  content: '\1f50d\fe0e';
+}
+
+.wiki-search-input {
+  background: transparent;
+  border: transparent;
   color: inherit;
+  flex-grow: 1;
 }
 
-.wiki-search-input[disabled] {
+.wiki-search-input::-webkit-search-cancel-button {
+  filter: grayscale(1) invert(1);
+}
+
+.wiki-search-label.disabled {
   opacity: 0.6;
+}
+
+.wiki-search-label.disabled,
+.wiki-search-input[disabled] {
   cursor: not-allowed;
 }
 
+.wiki-search-label:not(.disabled):hover,
+.wiki-search-label:focus-within {
+  background: var(--light-ghost-color);
+}
+
+.wiki-search-label:focus-within {
+  border-color: var(--primary-color);
+}
+
+.wiki-search-label:focus-within::before {
+  opacity: 0.7;
+}
+
+.wiki-search-input:focus {
+  border: none;
+  outline: none;
+}
+
+.wiki-search-input::placeholder {
+  color: var(--primary-color);
+  font-style: oblique;
+}
+
+.wiki-search-input:focus::placeholder {
+  color: var(--dim-color);
+}
+
 .wiki-search-sidebar-box hr {
   border-color: var(--primary-color);
   border-style: none none dotted none;
@@ -694,19 +748,6 @@ summary .group-name {
   border-color: var(--deep-color);
 }
 
-.wiki-search-input:focus {
-  border-color: var(--primary-color);
-}
-
-.wiki-search-input::placeholder {
-  color: var(--primary-color);
-  font-style: oblique;
-}
-
-.wiki-search-input:focus::placeholder {
-  color: var(--dim-color);
-}
-
 #content {
   overflow-wrap: anywhere;
 }
@@ -776,24 +817,6 @@ a:not([href]):hover {
   content: "\0020/\0020";
 }
 
-#header .chronology .heading,
-#header .chronology .buttons {
-  white-space: nowrap;
-}
-
-#header .scoped-chronology {
-  display: none;
-}
-
-#header .scoped-chronology-switcher .switcher-link {
-  text-decoration: underline;
-  text-decoration-style: dotted;
-}
-
-#header .scoped-chronology-switcher > div {
-  margin-left: 20px;
-}
-
 #secondary-nav {
   text-align: center;
 }
@@ -869,7 +892,7 @@ li:not(:first-child:last-child) .tooltip,
     0 -2px 4px -2px var(--primary-color) inset;
 }
 
-.icons-tooltip {
+.contribution-tooltip {
   padding: 3px 6px 6px 6px;
   left: -34px;
 }
@@ -890,7 +913,7 @@ li:not(:first-child:last-child) .tooltip,
   margin-right: -120px;
 }
 
-.icons-tooltip .tooltip-content {
+.contribution-tooltip .tooltip-content {
   padding: 6px 2px 2px 2px;
 
   -webkit-user-select: none;
@@ -901,42 +924,122 @@ li:not(:first-child:last-child) .tooltip,
   display: grid;
 
   grid-template-columns:
-    [icon-start] auto [icon-end domain-start] auto [domain-end];
+    [icon-start] 26px [icon-end handle-start] auto [handle-end platform-start] auto [platform-end];
 }
 
-.icons-tooltip .icon {
+.contribution-tooltip .external-link {
+  display: grid;
+  grid-column-start: icon-start;
+  grid-column-end: handle-end;
+  grid-template-columns: subgrid;
+
+  height: 1.4em;
+}
+
+.contribution-tooltip .chronology-link {
+  display: grid;
+  grid-column-start: icon-start;
+  grid-column-end: handle-end;
+  grid-template-columns: subgrid;
+
+  height: 1.2em;
+}
+
+.contribution-tooltip .external-icon,
+.contribution-tooltip .chronology-symbol {
   grid-column-start: icon-start;
   grid-column-end: icon-end;
 }
 
-.icons-tooltip .icon-platform {
+.contribution-tooltip .external-icon svg {
+  width: 18px;
+  height: 18px;
+  top: -0.1em;
+}
+
+.contribution-tooltip .chronology-symbol {
+  text-align: center;
+}
+
+.contribution-tooltip .external-handle,
+.contribution-tooltip .chronology-text {
+  grid-column-start: handle-start;
+  grid-column-end: handle-end;
+
+  width: max-content;
+  max-width: 200px;
+
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.contribution-tooltip .external-handle {
+  padding-right: 8px;
+}
+
+.contribution-tooltip .chronology-text {
+  padding-right: 6px;
+}
+
+.contribution-tooltip .chronology-text,
+.contribution-tooltip .chronology-info {
+  font-size: 0.85em;
+}
+
+.contribution-tooltip .tooltip-divider {
+  grid-column-start: icon-start;
+  grid-column-end: platform-end;
+
+  border-top: 1px dotted var(--primary-color);
+  margin-top: 3px;
+  margin-bottom: 4px;
+}
+
+.contribution-tooltip .external-platform,
+.contribution-tooltip .chronology-info {
   display: none;
 
-  grid-column-start: domain-start;
-  grid-column-end: domain-end;
+  grid-column-start: platform-start;
+  grid-column-end: platform-end;
 
-  --icon-platform-opacity: 0.8;
-  padding-right: 4px;
+  --external-platform-opacity: 0.8;
   opacity: 0.8;
+  padding-right: 4px;
+
+  white-space: nowrap;
 }
 
-.icons-tooltip.show-info .icon-platform {
+.contribution-tooltip.show-info .external-platform,
+.contribution-tooltip.show-info .chronology-info {
   display: inline;
-  animation: icon-platform 0.2s forwards linear;
+  animation: external-platform 0.2s forwards linear;
 }
 
-@keyframes icon-platform {
+@keyframes external-platform {
   from {
     opacity: 0;
   }
 
   to {
-    opacity: var(--icon-platform-opacity);
+    opacity: var(--external-platform-opacity);
   }
 }
 
-.icons-tooltip .icon:hover + .icon-platform {
-  --icon-platform-opacity: 1;
+.contribution-tooltip .external-link:hover,
+.contribution-tooltip .chronology-link:hover {
+  filter: brightness(1.4);
+  text-decoration: none;
+}
+
+.contribution-tooltip .external-link:hover .external-handle,
+.contribution-tooltip .chronology-link:hover .chronology-text {
+  text-decoration: underline;
+}
+
+.contribution-tooltip .external-link:hover + .external-platform,
+.contribution-tooltip .chronology-link:hover + .chronology-info {
+  --external-platform-opacity: 1;
   text-decoration: underline;
   text-decoration-color: #ffffffaa;
 }
@@ -952,27 +1055,15 @@ li:not(:first-child:last-child) .tooltip,
   padding: 3px 4.5px;
 }
 
-.icons {
-  font-style: normal;
-  white-space: nowrap;
-}
-
-.icons a:hover {
-  filter: brightness(1.4);
-}
-
-.icons a {
-  padding: 0 3px;
-}
-
-.icon {
+.external-icon {
   display: inline-block;
+  padding: 0 3px;
   width: 24px;
   height: 1em;
   position: relative;
 }
 
-.icon > svg {
+.external-icon svg {
   width: 24px;
   height: 24px;
   top: -0.25em;
@@ -980,23 +1071,6 @@ li:not(:first-child:last-child) .tooltip,
   fill: var(--primary-color);
 }
 
-.icon.has-text {
-  display: block;
-  width: unset;
-  height: 1.4em;
-}
-
-.icon.has-text > svg {
-  width: 18px;
-  height: 18px;
-  top: -0.1em;
-}
-
-.icon.has-text > .icon-text {
-  margin-left: 24px;
-  padding-right: 8px;
-}
-
 .rerelease,
 .other-group-accent {
   opacity: 0.7;
@@ -1268,10 +1342,13 @@ ul.quick-info li:not(:last-child)::after {
 }
 
 .quick-description:not(.has-external-links-only) {
-  margin-left: 8%;
-  margin-right: 8%;
-  padding-left: 4%;
-  padding-right: 4%;
+  --clamped-padding-ratio: max(var(--responsive-padding-ratio), 0.06);
+  margin-left: auto;
+  margin-right: auto;
+  padding-left: calc(0.40 * var(--clamped-padding-ratio) * 100%);
+  padding-right: calc(0.40 * var(--clamped-padding-ratio) * 100%);
+  max-width: 500px;
+
   padding-top: 0.25em;
   padding-bottom: 0.75em;
   border-left: 1px solid var(--dim-color);
diff --git a/src/static/js/client.js b/src/static/js/client.js
index 935a9d87..21c3911a 100644
--- a/src/static/js/client.js
+++ b/src/static/js/client.js
@@ -1271,6 +1271,11 @@ const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', {
     // from causing the current tooltip to be hidden.
     currentTouchIdentifiers: new Set(),
     touchIdentifiersBanishedByScrolling: new Set(),
+
+    // This is a two-item array that tracks the direction we've already
+    // dynamically placed the current tooltip. If we *reposition* the tooltip
+    // (because its dimensions changed), we'll try to follow this anchor first.
+    dynamicTooltipAnchorDirection: null,
   },
 
   event: {
@@ -1731,6 +1736,8 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) {
   state.currentlyShownTooltip = null;
   state.currentlyActiveHoverable = null;
 
+  state.dynamicTooltipAnchorDirection = null;
+
   // Set this for one tick of the event cycle.
   state.tooltipWasJustHidden = true;
   setTimeout(() => {
@@ -1758,6 +1765,11 @@ function showTooltipFromHoverable(hoverable) {
 
   positionTooltipFromHoverableWithBrains(hoverable);
 
+  // After a tooltip is shown, if we *didn't* specify an anchor,
+  // assume it was shown in its default position - generally presented
+  // as down and to the right. Successive repositioning will base on this.
+  state.dynamicTooltipAnchorDirection ??= ['down', 'right'];
+
   cssProp(tooltip, 'display', 'block');
   tooltip.inert = false;
 
@@ -1792,10 +1804,23 @@ function peekTooltipClientRect(tooltip) {
   }
 }
 
+function repositionCurrentTooltip() {
+  const {state} = hoverableTooltipInfo;
+  const {currentlyActiveHoverable} = state;
+
+  if (!currentlyActiveHoverable) {
+    throw new Error(`No hoverable active to reposition tooltip from`);
+  }
+
+  positionTooltipFromHoverableWithBrains(currentlyActiveHoverable);
+}
+
 function positionTooltipFromHoverableWithBrains(hoverable) {
   const {state} = hoverableTooltipInfo;
   const {tooltip} = state.registeredHoverables.get(hoverable);
 
+  const anchorDirection = state.dynamicTooltipAnchorDirection;
+
   // Reset before doing anything else. We're going to adapt to
   // its natural placement, adjusted by CSS, which otherwise
   // could be obscured by a placement we've previously provided.
@@ -1817,23 +1842,42 @@ function positionTooltipFromHoverableWithBrains(hoverable) {
     return;
   }
 
-  let selectedRect = null;
-  for (let i = 0; i < numBaselineRects; i++) {
-    selectedRect = opportunities.right.down[i];
-    if (selectedRect) break;
+  const tryDirection = (dir1, dir2, i) => {
+    selectedRect = opportunities[dir1][dir2][i];
+    return !!selectedRect;
+  };
 
-    selectedRect = opportunities.left.down[i];
-    if (selectedRect) break;
+  let selectedRect = null;
+  selectRect: {
+    if (anchorDirection) {
+      for (let i = 0; i < numBaselineRects; i++) {
+        if (tryDirection(...anchorDirection, i)) {
+          break selectRect;
+        }
+      }
+    }
 
-    selectedRect = opportunities.right.up[i];
-    if (selectedRect) break;
+    for (let i = 0; i < numBaselineRects; i++) {
+      for (const [dir1, dir2] of [
+        ['right', 'down'],
+        ['left', 'down'],
+        ['right', 'up'],
+        ['left', 'up'],
+        ['down', 'right'],
+        ['down', 'left'],
+        ['up', 'right'],
+        ['up', 'left'],
+      ]) {
+        if (tryDirection(dir1, dir2, i)) {
+          state.dynamicTooltipAnchorDirection = [dir1, dir2];
+          break selectRect;
+        }
+      }
+    }
 
-    selectedRect = opportunities.left.up[i];
-    if (selectedRect) break;
+    selectedRect = baselineRect;
   }
 
-  selectedRect ??= baselineRect;
-
   positionTooltip(tooltip, selectedRect.x, selectedRect.y);
 }
 
@@ -1929,18 +1973,18 @@ function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
   const neededVerticalOverlap = 30;
   const neededHorizontalOverlap = 30;
 
+  const upTopDown =
+    WikiRect.beneath(
+      hoverableRect.top + neededVerticalOverlap - tooltipRect.height);
+
+  const downBottomUp =
+    WikiRect.above(
+      hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height);
+
   // Please don't ask us to make this but horizontal?
   const prepareVerticalOrientationRects = (regionRects) => {
     const orientations = {};
 
-    const upTopDown =
-      WikiRect.beneath(
-        hoverableRect.top + neededVerticalOverlap - tooltipRect.height);
-
-    const downBottomUp =
-      WikiRect.above(
-        hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height);
-
     const orientHorizontally = (rect, i) => {
       if (!rect) return null;
 
@@ -1996,9 +2040,67 @@ function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
     return orientations;
   };
 
+  const rightRightLeft =
+    WikiRect.leftOf(
+      hoverableRect.left - neededHorizontalOverlap + tooltipRect.width);
+
+  const leftLeftRight =
+    WikiRect.rightOf(
+      hoverableRect.left + neededHorizontalOverlap - tooltipRect.width);
+
+  // Oops.
+  const prepareHorizontalOrientationRects = (regionRects) => {
+    const orientations = {};
+
+    const orientVertically = (rect, i) => {
+      if (!rect) return null;
+
+      const regionRect = regionRects[i];
+
+      if (regionRect.height > 0) {
+        return rect;
+      } else {
+        return WikiRect.fromRect({
+          x: rect.x,
+          y: regionRect.bottom - tooltipRect.height,
+          width: rect.width,
+          height: rect.height,
+        });
+      }
+    };
+
+    orientations.left =
+      regionRects
+        .map(rect => rect?.intersectionWith(leftLeftRight))
+        .map(orientVertically)
+        .map(keepIfFits);
+
+    orientations.right =
+      regionRects
+        .map(rect => rect?.intersectionWith(rightRightLeft))
+        .map(rect =>
+          (rect
+            ? rect.intersectionWith(WikiRect.fromRect({
+                x: rect.right - tooltipRect.width,
+                y: rect.y,
+                width: rect.width,
+                height: tooltipRect.height,
+              }))
+            : null))
+        .map(orientVertically)
+        .map(keepIfFits);
+
+    // No analogous center because we don't actually use
+    // center alignment...
+
+    return orientations;
+  };
+
   const orientationRects = {
     left: prepareVerticalOrientationRects(regionRects.left),
     right: prepareVerticalOrientationRects(regionRects.right),
+    down: prepareHorizontalOrientationRects(regionRects.bottom),
+    up: prepareHorizontalOrientationRects(regionRects.top),
   };
 
   return {
@@ -3106,114 +3208,6 @@ clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences);
 clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners);
 clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners);
 
-// Scoped chronology links --------------------------------
-
-const scopedChronologyLinksInfo = initInfo('scopedChronologyLinksInfo', {
-  switcher: null,
-  containers: null,
-  switcherLinks: null,
-  modes: null,
-
-  session: {
-    selectedMode: 'wiki',
-  },
-});
-
-function getScopedChronologyLinksReferences() {
-  const info = scopedChronologyLinksInfo;
-
-  info.switcher =
-    document.querySelector('.scoped-chronology-switcher');
-
-  if (!info.switcher) {
-    return;
-  }
-
-  info.containers =
-    Array.from(info.switcher.querySelectorAll(':scope > div'));
-
-  info.switcherLinks =
-    Array.from(info.switcher.querySelectorAll('.switcher-link'));
-
-  info.modes =
-    info.containers
-      .map(container =>
-        Array.from(container.classList)
-          .find(className => className.startsWith('scope-'))
-          .slice('scope-'.length));
-}
-
-function addScopedChronologyLinksPageHandlers() {
-  const info = scopedChronologyLinksInfo;
-  const {session} = scopedChronologyLinksInfo;
-
-  if (!info.switcher) {
-    return;
-  }
-
-  for (const [index, {
-    container: currentContainer,
-    switcherLink: currentSwitcherLink,
-  }] of stitchArrays({
-    container: info.containers,
-    switcherLink: info.switcherLinks,
-  }).entries()) {
-    const nextContainer =
-      atOffset(info.containers, index, +1, {wrap: true});
-
-    const nextSwitcherLink =
-      atOffset(info.switcherLinks, index, +1, {wrap: true});
-
-    const nextMode =
-      atOffset(info.modes, index, +1, {wrap: true});
-
-    currentSwitcherLink.addEventListener('click', domEvent => {
-      domEvent.preventDefault();
-
-      cssProp(currentContainer, 'display', 'none');
-      cssProp(currentSwitcherLink, 'display', 'none');
-      cssProp(nextContainer, 'display', 'block');
-      cssProp(nextSwitcherLink, 'display', 'inline');
-
-      session.selectedMode = nextMode;
-    });
-  }
-}
-
-function mutateScopedChronologyLinksContent() {
-  const info = scopedChronologyLinksInfo;
-
-  if (!info.switcher) {
-    return;
-  }
-
-  const {selectedMode} = info.session;
-
-  if (info.modes.includes(selectedMode)) {
-    const selectedIndex = info.modes.indexOf(selectedMode);
-
-    for (const [index, {
-      container,
-      switcherLink,
-    }] of stitchArrays({
-      container: info.containers,
-      switcherLink: info.switcherLinks,
-    }).entries()) {
-      if (index === selectedIndex) {
-        cssProp(container, 'display', 'block');
-        cssProp(switcherLink, 'display', 'inline');
-      } else {
-        cssProp(container, 'display', 'none');
-        cssProp(switcherLink, 'display', 'none');
-      }
-    }
-  }
-}
-
-clientSteps.getPageReferences.push(getScopedChronologyLinksReferences);
-clientSteps.mutatePageContent.push(mutateScopedChronologyLinksContent);
-clientSteps.addPageListeners.push(addScopedChronologyLinksPageHandlers);
-
 // Group contributions table ------------------------------
 
 // TODO: Update to clientSteps style.
@@ -3363,7 +3357,7 @@ function getArtistExternalLinkTooltipPageReferences() {
   const info = artistExternalLinkTooltipInfo;
 
   info.tooltips =
-    Array.from(document.getElementsByClassName('icons-tooltip'));
+    Array.from(document.getElementsByClassName('contribution-tooltip'));
 
   info.tooltipRows =
     info.tooltips.map(tooltip =>
@@ -3507,6 +3501,8 @@ function showArtistExternalLinkTooltipInfo() {
   for (const tooltip of info.tooltips) {
     tooltip.classList.add('show-info');
   }
+
+  repositionCurrentTooltip();
 }
 
 function hideArtistExternalLinkTooltipInfo() {
@@ -3831,6 +3827,7 @@ const sidebarSearchInfo = initInfo('sidebarSearchInfo', {
 
   searchSidebarColumn: null,
   searchBox: null,
+  searchLabel: null,
   searchInput: null,
 
   progressRule: null,
@@ -3919,6 +3916,9 @@ function getSidebarSearchReferences() {
     return;
   }
 
+  info.searchLabel =
+    info.searchBox.querySelector('.wiki-search-label');
+
   info.searchInput =
     info.searchBox.querySelector('.wiki-search-input');
 
@@ -4360,6 +4360,7 @@ function showSidebarSearchFailed() {
   cssProp(info.failedRule, 'display', null);
   cssProp(info.failedContainer, 'display', null);
 
+  info.searchLabel.classList.add('disabled');
   info.searchInput.disabled = true;
 
   if (state.stoppedTypingTimeout) {
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 8429c4cd..26107c0b 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -476,16 +476,33 @@ misc:
     # Contribution to a track, artwork, or other thing.
     withContribution: "{ARTIST} ({CONTRIB})"
 
-    # External links to visit the artist's own websites or profiles.
-    withExternalLinks: "{ARTIST} ({LINKS})"
-
-    # Combination of above.
-    withContribution.withExternalLinks: "{ARTIST} ({CONTRIB}) ({LINKS})"
-
     # Displayed in an artist's tooltip, if one of their URLs
     # isn't a specially detected web platform.
     noExternalLinkPlatformName: "Other"
 
+    chronology:
+      previous:
+        symbol: "←"
+        info:
+          _: "Previous by this artist"
+          withKind: "Previous {KIND} by this artist"
+
+      next:
+        symbol: "→"
+        info:
+          _: "Next by this artist"
+          withKind: "Next {KIND} by this artist"
+
+      kind:
+        album: "album"
+        bannerArt: "banner art"
+        coverArt: "cover art"
+        flash: "flash"
+        track: "track"
+        trackArt: "track art"
+        trackContribution: "track contribution"
+        wallpaperArt: "wallpaper art"
+
   # chronology:
   #
   #   "Chronology links" are a section that appear in the nav bar for
diff --git a/src/url-spec.js b/src/url-spec.js
index c0e99571..56366ed4 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -4,7 +4,7 @@ import {withEntries} from '#sugar';
 // 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.
-const STATIC_VERSION = '3p1';
+const STATIC_VERSION = '3p2';
 
 const genericPaths = {
   root: '',
diff --git a/src/web-routes.js b/src/web-routes.js
index 7e08d06f..762b26c3 100644
--- a/src/web-routes.js
+++ b/src/web-routes.js
@@ -18,21 +18,25 @@ export const stationaryCodeRoutes = [
   {
     from: path.join(codeSrcPath, 'static', 'css'),
     to: ['staticCSS.root'],
+    statically: 'copy',
   },
 
   {
     from: path.join(codeSrcPath, 'static', 'js'),
     to: ['staticJS.root'],
+    statically: 'copy',
   },
 
   {
     from: path.join(codeSrcPath, 'static', 'misc'),
     to: ['staticMisc.root'],
+    statically: 'copy',
   },
 
   {
     from: path.join(codeSrcPath, 'util'),
     to: ['staticSharedUtil.root'],
+    statically: 'copy',
   },
 ];
 
@@ -50,6 +54,8 @@ function quickNodeDependency({
           : root),
 
       to: ['staticLib.path', name],
+
+      statically: 'copy',
     },
   ];
 }
@@ -86,8 +92,17 @@ export async function identifyDynamicWebRoutes({
 }) {
   const routeFunctions = [
     () => Promise.resolve([
-      {from: path.resolve(mediaPath), to: ['media.root']},
-      {from: path.resolve(mediaCachePath), to: ['thumb.root']},
+      {
+        from: path.resolve(mediaPath),
+        to: ['media.root'],
+        statically: 'symlink',
+      },
+
+      {
+        from: path.resolve(mediaCachePath),
+        to: ['thumb.root'],
+        statically: 'symlink',
+      },
     ]),
 
     () => {
@@ -98,7 +113,12 @@ export async function identifyDynamicWebRoutes({
 
       return (
         readdir(from).then(
-          () => [{from, to: ['searchData.root']}],
+          () => [
+            {
+              from,
+              to: ['searchData.root'],
+              statically: 'copy',
+            }],
           () => []));
     },
   ];
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index 1ab0604e..86e3da0f 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -2,6 +2,7 @@ import * as path from 'node:path';
 
 import {
   copyFile,
+  cp,
   mkdir,
   stat,
   symlink,
@@ -9,6 +10,8 @@ import {
   unlink,
 } from 'node:fs/promises';
 
+import {rimraf} from 'rimraf';
+
 import {quickLoadContentDependencies} from '#content-dependencies';
 import {quickEvaluate} from '#content-function';
 import * as html from '#html';
@@ -159,6 +162,11 @@ export async function go({
     webRoutes,
   });
 
+  await writeWebRouteCopies({
+    outputPath,
+    webRoutes,
+  });
+
   if (writeAll) {
     await writeFavicon({
       mediaPath,
@@ -438,8 +446,11 @@ function writeWebRouteSymlinks({
   outputPath,
   webRoutes,
 }) {
+  const symlinkRoutes =
+    webRoutes.filter(route => route.statically === 'symlink');
+
   const promises =
-    webRoutes.map(async route => {
+    symlinkRoutes.map(async route => {
       const parts = route.to.split('/');
       const parentDirectoryParts = parts.slice(0, -1);
       const symlinkNamePart = parts.at(-1);
@@ -471,6 +482,113 @@ function writeWebRouteSymlinks({
   return progressPromiseAll(`Writing web route symlinks.`, promises);
 }
 
+async function writeWebRouteCopies({
+  outputPath,
+  webRoutes,
+}) {
+  const copyRoutes =
+    webRoutes.filter(route => route.statically === 'copy');
+
+  const promises =
+    copyRoutes.map(async route => {
+      const permissionName = '__hsmusic-ok-for-deletion.txt';
+
+      const parts = route.to.split('/');
+      const parentDirectoryParts = parts.slice(0, -1);
+      const copyNamePart = parts.at(-1);
+
+      const parentDirectory = path.join(outputPath, ...parentDirectoryParts);
+      const copyPath = path.join(parentDirectory, copyNamePart);
+
+      // We're going to do a rimraf call! This is freaking terrifying,
+      // so nope out on a couple important conditions.
+
+      let needsDelete;
+      try {
+        await stat(copyPath);
+        needsDelete = true;
+      } catch (error) {
+        if (error.code === 'ENOENT') {
+          needsDelete = false;
+        } else {
+          throw error;
+        }
+      }
+
+      if (needsDelete) {
+        // First remove it directly, in case it's a symlink.
+        try {
+          await unlink(copyPath);
+          needsDelete = false;
+        } catch (error) {
+          // EPERM is POSIX, but libuv may or may not flat-out just raise
+          // the system error (which is ostensibly EISDIR on Linux).
+          // https://github.com/nodejs/node-v0.x-archive/issues/5791
+          // https://man7.org/linux/man-pages/man2/unlink.2.html
+          //
+          // Both of these indidcate "a directory, probably" and we'll
+          // still check for the deletion permission file where we expect
+          // it before actually touching anything.
+          if (error.code !== 'EPERM' && error.code !== 'EISDIR') {
+            throw error;
+          }
+        }
+      }
+
+      if (needsDelete) {
+        // Then check that the deletion permission file exists
+        // where we expect it.
+        try {
+          await stat(path.join(copyPath, permissionName));
+        } catch (error) {
+          if (error.code === 'ENOENT') {
+            throw new Error(`Couldn't find ${permissionName} in ${copyPath} - please delete or move away this folder manually`);
+          } else {
+            throw error;
+          }
+        }
+
+        // And *then* actually delete that directory.
+        await rimraf(copyPath);
+      }
+
+      // Actually copy the source path where it's wanted.
+      await cp(route.from, copyPath, {recursive: true});
+
+      // And certify that it's OK to delete this path, next time around.
+      await writeFile(path.join(copyPath, permissionName),
+        `The presence of this file (by its name, not its contents)\n` +
+        `indicates hsmusic may delete everything contained in this\n` +
+        `directory (the one which directly contains this file, *not*\n` +
+        `any further-up parent directories).\n` +
+        `\n` +
+        `If you make edits, or add any files, they will be deleted or\n` +
+        `overwritten the next time you run the build.\n` +
+        `\n` +
+        `If you delete *this* file, hsmusic will error during the next\n` +
+        `build, and will ask that you delete the containing directory\n` +
+        `yourself.\n`);
+    });
+
+  const results =
+    await Promise.allSettled(promises);
+
+  const errors =
+    results
+      .filter(({status}) => status === 'rejected')
+      .map(({reason}) => reason)
+      .map(err =>
+        (err.message.startsWith(`Couldn't find`)
+          ? err.message
+          : err));
+
+  if (empty(errors)) {
+    logInfo`Wrote web route copies.`;
+  } else {
+    throw new AggregateError(errors, `Errors copying internal files ("web routes")`);
+  }
+}
+
 async function writeFavicon({
   mediaPath,
   outputPath,
diff --git a/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs
index d8f1e974..4f09569d 100644
--- a/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs
@@ -52,5 +52,5 @@ exports[`test/snapshot/generateAlbumAdditionalFilesList.js > TAP > generateAlbum
 `
 
 exports[`test/snapshot/generateAlbumAdditionalFilesList.js > TAP > generateAlbumAdditionalFilesList (snapshot) > no additional files 1`] = `
-<ul class="additional-files-list"></ul>
+
 `
diff --git a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
index 4a7f35c3..14cce64e 100644
--- a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
@@ -7,16 +7,17 @@
 'use strict'
 exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > basic behavior 1`] = `
 <p>
-    By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (music probably)</span> and <span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/tensei/">Tensei</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tenseimusic.bandcamp.com/">
-                        <svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg>
-                        <span class="icon-text">tenseimusic</span>
-                    </a><span class="icon-platform">Bandcamp</span></span></span></span> (hot jams)</span>.
+    By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (music probably)</span> and <span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/tensei/">Tensei</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://tenseimusic.bandcamp.com/">
+                        <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg></span>
+                        <span class="external-handle">tenseimusic</span>
+                    </a>
+                    <span class="external-platform">Bandcamp</span></span></span></span> (hot jams)</span>.
     <br>
-    Cover art by <a href="artist/hb/">Hanni Brosh</a>.
+    Cover art by <span class="contribution nowrap"><a href="artist/hb/">Hanni Brosh</a></span>.
     <br>
-    Wallpaper art by <a href="artist/hb/">Hanni Brosh</a> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
+    Wallpaper art by <span class="contribution nowrap"><a href="artist/hb/">Hanni Brosh</a></span> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
     <br>
-    Banner art by <a href="artist/hb/">Hanni Brosh</a> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
+    Banner art by <span class="contribution nowrap"><a href="artist/hb/">Hanni Brosh</a></span> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
     <br>
     Released 3/14/2011.
     <br>
diff --git a/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
index 091f1b9f..10ab17c4 100644
--- a/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
@@ -10,7 +10,7 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 `
 
@@ -31,7 +31,7 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
         <span class="content-heading-main-title">Second section:</span>
         <template class="content-heading-sticky-title">Second section:</template>
     </dt>
-    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li></ul></dd>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li></ul></dd>
 </dl>
 `
 
@@ -52,17 +52,17 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
         <span class="content-heading-main-title">Second section:</span>
         <template class="content-heading-sticky-title">Second section:</template>
     </dt>
-    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li></ul></dd>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 <ul>
     <li><a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 `
 
@@ -83,17 +83,17 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
         <span class="content-heading-main-title">Second section:</span>
         <template class="content-heading-sticky-title">Second section:</template>
     </dt>
-    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li></ul></dd>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 <ul>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 `
 
@@ -114,17 +114,17 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
         <span class="content-heading-main-title">Second section:</span>
         <template class="content-heading-sticky-title">Second section:</template>
     </dt>
-    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li></ul></dd>
+    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 <ul>
     <li><a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 `
 
@@ -145,16 +145,16 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
         <span class="content-heading-main-title">Second section:</span>
         <template class="content-heading-sticky-title">Second section:</template>
     </dt>
-    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li></ul></dd>
+    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li><a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 <ul>
     <li><a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 `
diff --git a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
index e35f9358..098fe145 100644
--- a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
@@ -7,7 +7,7 @@
 'use strict'
 exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > basic behavior 1`] = `
 <p>
-    By <a href="artist/toby-fox/">Toby Fox</a>.
+    By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a></span>.
     <br>
     Released 11/29/2011.
     <br>
@@ -17,13 +17,13 @@ exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseI
 `
 
 exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > cover artist contribs, non-unique 1`] = `
-<p>By <a href="artist/toby-fox/">Toby Fox</a>.</p>
+<p>By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a></span>.</p>
 <p>This wiki doesn't have any listening links for <i>Suspicious Track</i>.</p>
 `
 
 exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > cover artist contribs, unique 1`] = `
 <p>
-    By <a href="artist/toby-fox/">Toby Fox</a>.
+    By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a></span>.
     <br>
     Cover art by <span class="contribution nowrap"><a href="artist/alpaca/">Alpaca</a> (&#x1F525;)</span>.
 </p>
@@ -31,6 +31,6 @@ exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseI
 `
 
 exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > reduced details 1`] = `
-<p>By <a href="artist/toby-fox/">Toby Fox</a>.</p>
+<p>By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a></span>.</p>
 <p>This wiki doesn't have any listening links for <i>Suspicious Track</i>.</p>
 `
diff --git a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
index 92d697e7..a9ac916e 100644
--- a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
+++ b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
@@ -5,159 +5,114 @@
  * Make sure to inspect the output below.  Do not ignore changes!
  */
 'use strict'
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (inline) 1`] = `
-<span class="contribution nowrap"><a href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a> (<span class="icons icons-inline"><a class="icon" href="https://loremipsum.io">
-            <svg>
-                <title>loremipsum.io</title>
-                <use href="static/misc/icons.svg#icon-globe"></use>
-            </svg>
-        </a>, <a class="icon" href="https://loremipsum.io/generator/">
-            <svg>
-                <title>loremipsum.io</title>
-                <use href="static/misc/icons.svg#icon-globe"></use>
-            </svg>
-        </a>, <a class="icon" href="https://loremipsum.io/#meaning">
-            <svg>
-                <title>loremipsum.io</title>
-                <use href="static/misc/icons.svg#icon-globe"></use>
-            </svg>
-        </a>, <a class="icon" href="https://loremipsum.io/#usage-and-examples">
-            <svg>
-                <title>loremipsum.io</title>
-                <use href="static/misc/icons.svg#icon-globe"></use>
-            </svg>
-        </a></span>)</span>
-`
-
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (tooltip) 1`] = `
-<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://loremipsum.io">
-                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/generator/">
-                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#meaning">
-                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#usage-and-examples">
-                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#controversy">
-                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#when-to-use-lorem-ipsum">
-                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#lorem-ipsum-all-the-things">
-                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#original-source">
-                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><span class="icon-platform">Other</span></span></span></span></span>
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links 1`] = `
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://loremipsum.io">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/generator/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#meaning">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#usage-and-examples">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#controversy">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#when-to-use-lorem-ipsum">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#lorem-ipsum-all-the-things">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#original-source">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span></span></span></span></span>
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no accents 1`] = `
-<a href="artist/clark-powell/">Clark Powell</a>
-<a href="artist/the-big-baddies/">Grounder &amp; Scratch</a>
-<a href="artist/toby-fox/">Toby Fox</a>
+<span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a></span>
+<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a></span>
+<span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a></span>
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no preventWrapping 1`] = `
-<span class="contribution"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
-            <svg>
-                <title>SoundCloud</title>
-                <use href="static/misc/icons.svg#icon-soundcloud"></use>
-            </svg>
-        </a></span>)</span>
+<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://soundcloud.com/plazmataz">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-soundcloud"></use></svg></span>
+                    <span class="external-handle">plazmataz</span>
+                </a>
+                <span class="external-platform">SoundCloud</span></span></span></span></span>
 <span class="contribution"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
-<span class="contribution"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
-            <svg>
-                <title>Bandcamp</title>
-                <use href="static/misc/icons.svg#icon-bandcamp"></use>
-            </svg>
-        </a>, <a class="icon" href="https://toby.fox/">
-            <svg>
-                <title>toby.fox</title>
-                <use href="static/misc/icons.svg#icon-globe"></use>
-            </svg>
-        </a></span>)</span>
+<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://tobyfox.bandcamp.com/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg></span>
+                    <span class="external-handle">tobyfox</span>
+                </a>
+                <span class="external-platform">Bandcamp</span>
+                <a class="external-link" href="https://toby.fox/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">toby.fox</span>
+                </a>
+                <span class="external-platform">Other</span></span></span></span> (Arrangement)</span>
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showContribution 1`] = `
-<a href="artist/clark-powell/">Clark Powell</a>
+<span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a></span>
 <span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
 <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement)</span>
 `
 
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (inline) 1`] = `
-<span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
-            <svg>
-                <title>SoundCloud</title>
-                <use href="static/misc/icons.svg#icon-soundcloud"></use>
-            </svg>
-        </a></span>)</span>
-<a href="artist/the-big-baddies/">Grounder &amp; Scratch</a>
-<span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
-            <svg>
-                <title>Bandcamp</title>
-                <use href="static/misc/icons.svg#icon-bandcamp"></use>
-            </svg>
-        </a>, <a class="icon" href="https://toby.fox/">
-            <svg>
-                <title>toby.fox</title>
-                <use href="static/misc/icons.svg#icon-globe"></use>
-            </svg>
-        </a></span>)</span>
-`
-
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (tooltip) 1`] = `
-<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://soundcloud.com/plazmataz">
-                    <svg><use href="static/misc/icons.svg#icon-soundcloud"></use></svg>
-                    <span class="icon-text">plazmataz</span>
-                </a><span class="icon-platform">SoundCloud</span></span></span></span></span>
-<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
-<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tobyfox.bandcamp.com/">
-                    <svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg>
-                    <span class="icon-text">tobyfox</span>
-                </a><span class="icon-platform">Bandcamp</span><a class="icon has-text" href="https://toby.fox/">
-                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">toby.fox</span>
-                </a><span class="icon-platform">Other</span></span></span></span> (Arrangement)</span>
-`
-
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (inline) 1`] = `
-<span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
-            <svg>
-                <title>SoundCloud</title>
-                <use href="static/misc/icons.svg#icon-soundcloud"></use>
-            </svg>
-        </a></span>)</span>
-<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
-<span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
-            <svg>
-                <title>Bandcamp</title>
-                <use href="static/misc/icons.svg#icon-bandcamp"></use>
-            </svg>
-        </a>, <a class="icon" href="https://toby.fox/">
-            <svg>
-                <title>toby.fox</title>
-                <use href="static/misc/icons.svg#icon-globe"></use>
-            </svg>
-        </a></span>)</span>
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showExternalLinks 1`] = `
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://soundcloud.com/plazmataz">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-soundcloud"></use></svg></span>
+                    <span class="external-handle">plazmataz</span>
+                </a>
+                <span class="external-platform">SoundCloud</span></span></span></span></span>
+<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a></span>
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://tobyfox.bandcamp.com/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg></span>
+                    <span class="external-handle">tobyfox</span>
+                </a>
+                <span class="external-platform">Bandcamp</span>
+                <a class="external-link" href="https://toby.fox/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">toby.fox</span>
+                </a>
+                <span class="external-platform">Other</span></span></span></span></span>
 `
 
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (tooltip) 1`] = `
-<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://soundcloud.com/plazmataz">
-                    <svg><use href="static/misc/icons.svg#icon-soundcloud"></use></svg>
-                    <span class="icon-text">plazmataz</span>
-                </a><span class="icon-platform">SoundCloud</span></span></span></span></span>
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showExternalLinks 1`] = `
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://soundcloud.com/plazmataz">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-soundcloud"></use></svg></span>
+                    <span class="external-handle">plazmataz</span>
+                </a>
+                <span class="external-platform">SoundCloud</span></span></span></span></span>
 <span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
-<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tobyfox.bandcamp.com/">
-                    <svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg>
-                    <span class="icon-text">tobyfox</span>
-                </a><span class="icon-platform">Bandcamp</span><a class="icon has-text" href="https://toby.fox/">
-                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">toby.fox</span>
-                </a><span class="icon-platform">Other</span></span></span></span> (Arrangement)</span>
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://tobyfox.bandcamp.com/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg></span>
+                    <span class="external-handle">tobyfox</span>
+                </a>
+                <span class="external-platform">Bandcamp</span>
+                <a class="external-link" href="https://toby.fox/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">toby.fox</span>
+                </a>
+                <span class="external-platform">Other</span></span></span></span> (Arrangement)</span>
 `
diff --git a/test/snapshot/generateAlbumReleaseInfo.js b/test/snapshot/generateAlbumReleaseInfo.js
index a109912f..f41e502d 100644
--- a/test/snapshot/generateAlbumReleaseInfo.js
+++ b/test/snapshot/generateAlbumReleaseInfo.js
@@ -8,22 +8,22 @@ testContentFunctions(t, 'generateAlbumReleaseInfo (snapshot)', async (t, evaluat
     name: 'generateAlbumReleaseInfo',
     args: [{
       artistContribs: [
-        {artist: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, annotation: 'music probably'},
+        {artist: {name: 'Toby Fox', directory: 'toby-fox', urls: []}, annotation: 'music probably'},
         {artist: {name: 'Tensei', directory: 'tensei', urls: ['https://tenseimusic.bandcamp.com/']}, annotation: 'hot jams'},
       ],
 
       coverArtistContribs: [
-        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: null}, annotation: null},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: []}, annotation: null},
       ],
 
       wallpaperArtistContribs: [
-        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: null}, annotation: null},
-        {artist: {name: 'Niklink', directory: 'niklink', urls: null}, annotation: 'edits'},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: []}, annotation: null},
+        {artist: {name: 'Niklink', directory: 'niklink', urls: []}, annotation: 'edits'},
       ],
 
       bannerArtistContribs: [
-        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: null}, annotation: null},
-        {artist: {name: 'Niklink', directory: 'niklink', urls: null}, annotation: 'edits'},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: []}, annotation: null},
+        {artist: {name: 'Niklink', directory: 'niklink', urls: []}, annotation: 'edits'},
       ],
 
       name: 'AlterniaBound',
diff --git a/test/snapshot/generateAlbumTrackList.js b/test/snapshot/generateAlbumTrackList.js
index 08b31902..a7c3f591 100644
--- a/test/snapshot/generateAlbumTrackList.js
+++ b/test/snapshot/generateAlbumTrackList.js
@@ -10,13 +10,13 @@ testContentFunctions(t, 'generateAlbumTrackList (snapshot)', async (t, evaluate)
   });
 
   const contribs1 = [
-    {artist: {name: 'Apricot', directory: 'apricot', urls: null}},
+    {artist: {name: 'Apricot', directory: 'apricot', urls: []}},
   ];
 
   const contribs2 = [
-    {artist: {name: 'Apricot', directory: 'apricot', urls: null}},
+    {artist: {name: 'Apricot', directory: 'apricot', urls: []}},
     {artist: {name: 'Peach', directory: 'peach', urls: ['https://peach.bandcamp.com/']}},
-    {artist: {name: 'Cerise', directory: 'cerise', urls: null}},
+    {artist: {name: 'Cerise', directory: 'cerise', urls: []}},
   ];
 
   const color1 = '#fb07ff';
diff --git a/test/snapshot/generateTrackReleaseInfo.js b/test/snapshot/generateTrackReleaseInfo.js
index 78f0fee7..931377c8 100644
--- a/test/snapshot/generateTrackReleaseInfo.js
+++ b/test/snapshot/generateTrackReleaseInfo.js
@@ -4,8 +4,8 @@ import {testContentFunctions} from '#test-lib';
 testContentFunctions(t, 'generateTrackReleaseInfo (snapshot)', async (t, evaluate) => {
   await evaluate.load();
 
-  const artistContribs = [{artist: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, annotation: null}];
-  const coverArtistContribs = [{artist: {name: 'Alpaca', directory: 'alpaca', urls: null}, annotation: '🔥'}];
+  const artistContribs = [{artist: {name: 'Toby Fox', directory: 'toby-fox', urls: []}, annotation: null}];
+  const coverArtistContribs = [{artist: {name: 'Alpaca', directory: 'alpaca', urls: []}, annotation: '🔥'}];
 
   evaluate.snapshot('basic behavior', {
     name: 'generateTrackReleaseInfo',
diff --git a/test/snapshot/linkContribution.js b/test/snapshot/linkContribution.js
index 1043ddc6..5844b0b9 100644
--- a/test/snapshot/linkContribution.js
+++ b/test/snapshot/linkContribution.js
@@ -33,53 +33,22 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
       slots,
     });
 
-  quickSnapshot('showContribution & showIcons (inline)', {
+  quickSnapshot('showContribution & showExternalLinks', {
     showContribution: true,
-    showIcons: true,
-    iconMode: 'inline',
-  });
-
-  quickSnapshot('showContribution & showIcons (tooltip)', {
-    showContribution: true,
-    showIcons: true,
-    iconMode: 'tooltip',
+    showExternalLinks: true,
   });
 
   quickSnapshot('only showContribution', {
     showContribution: true,
   });
 
-  quickSnapshot('only showIcons (inline)', {
-    showIcons: true,
-    iconMode: 'inline',
-  });
-
-  quickSnapshot('only showIcons (tooltip)', {
-    showContribution: true,
-    showIcons: true,
-    iconMode: 'tooltip',
+  quickSnapshot('only showExternalLinks', {
+    showExternalLinks: true,
   });
 
   quickSnapshot('no accents', {});
 
-  evaluate.snapshot('loads of links (inline)', {
-    name: 'linkContribution',
-    args: [
-      {artist: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
-        'https://loremipsum.io',
-        'https://loremipsum.io/generator/',
-        'https://loremipsum.io/#meaning',
-        'https://loremipsum.io/#usage-and-examples',
-        'https://loremipsum.io/#controversy',
-        'https://loremipsum.io/#when-to-use-lorem-ipsum',
-        'https://loremipsum.io/#lorem-ipsum-all-the-things',
-        'https://loremipsum.io/#original-source',
-      ]}, annotation: null},
-    ],
-    slots: {showIcons: true},
-  });
-
-  evaluate.snapshot('loads of links (tooltip)', {
+  evaluate.snapshot('loads of links', {
     name: 'linkContribution',
     args: [
       {artist: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
@@ -93,12 +62,12 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
         'https://loremipsum.io/#original-source',
       ]}, annotation: null},
     ],
-    slots: {showIcons: true, iconMode: 'tooltip'},
+    slots: {showExternalLinks: true},
   });
 
   quickSnapshot('no preventWrapping', {
     showContribution: true,
-    showIcons: true,
+    showExternalLinks: true,
     preventWrapping: false,
   });
 });
diff --git a/test/unit/content/dependencies/linkContribution.js b/test/unit/content/dependencies/linkContribution.js
index ab45b03a..e7a29310 100644
--- a/test/unit/content/dependencies/linkContribution.js
+++ b/test/unit/content/dependencies/linkContribution.js
@@ -27,18 +27,20 @@ t.test('generateContributionLinks (unit)', async t => {
   await testContentFunctions(t, 'generateContributionLinks (unit 1)', async (t, evaluate) => {
     const slots = {
       showContribution: true,
-      showIcons: true,
+      showExternalLinks: true,
     };
 
     await evaluate.load({
       mock: evaluate.mock(mock => ({
         linkArtist: {
-          relations: mock.function('linkArtist.relations', () => ({}))
+          relations: mock
+            .function('linkArtist.relations', () => ({}))
             .args([undefined, artist1]).next()
             .args([undefined, artist2]).next()
             .args([undefined, artist3]),
 
-          data: mock.function('linkArtist.data', () => ({}))
+          data: mock
+            .function('linkArtist.data', () => ({}))
             .args([artist1]).next()
             .args([artist2]).next()
             .args([artist3]),
@@ -49,13 +51,18 @@ t.test('generateContributionLinks (unit)', async t => {
             .repeat(3),
         },
 
-        linkExternalAsIcon: {
-          data: mock.function('linkExternalAsIcon.data', () => ({}))
+        generateExternalIcon: {
+          data: mock
+            .function('generateExternalIcon.data', () => ({}))
             .args([artist1.urls[0]]).next()
             .args([artist3.urls[0]]).next()
             .args([artist3.urls[1]]),
 
-          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+          generate: mock
+            .function('generateExternalIcon.generate', () => ({
+              toString: () => 'icon',
+              setSlot: () => {},
+            }))
             .repeat(3),
         }
       })),
@@ -75,23 +82,26 @@ t.test('generateContributionLinks (unit)', async t => {
   await testContentFunctions(t, 'generateContributionLinks (unit 2)', async (t, evaluate) => {
     const slots = {
       showContribution: false,
-      showIcons: false,
+      showExternalLinks: false,
     };
 
     await evaluate.load({
       mock: evaluate.mock(mock => ({
         linkArtist: {
-          relations: mock.function('linkArtist.relations', () => ({}))
+          relations: mock
+            .function('linkArtist.relations', () => ({}))
             .args([undefined, artist1]).next()
             .args([undefined, artist2]).next()
             .args([undefined, artist3]),
 
-          data: mock.function('linkArtist.data', () => ({}))
+          data: mock
+            .function('linkArtist.data', () => ({}))
             .args([artist1]).next()
             .args([artist2]).next()
             .args([artist3]),
 
-          generate: mock.function(() => 'artist link')
+          generate: mock
+            .function(() => 'artist link')
             .repeat(3),
         },
 
@@ -99,11 +109,16 @@ t.test('generateContributionLinks (unit)', async t => {
         // tree is the same since whether or not the external icon links are
         // shown is dependent on a slot, which is undefined and arbitrary at
         // relations/data time (it might change on a whim at generate time).
-        linkExternalAsIcon: {
-          data: mock.function('linkExternalAsIcon.data', () => ({}))
+        generateExternalIcon: {
+          data: mock
+            .function('generateExternalIcon.data', () => ({}))
             .repeat(3),
 
-          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+          generate: mock
+            .function('generateExternalIcon.generate', () => ({
+              toString: () => 'icon',
+              setSlot: () => {},
+            }))
             .repeat(3),
         },
       })),