« get me outta code hell

Merge branch 'preview' into news-tweaks - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-12-03 17:55:58 -0400
committer(quasar) nebula <qznebula@protonmail.com>2023-12-03 17:55:58 -0400
commit11493b1a70c26d9aa11b98acf93b4d09d89f88bf (patch)
treefe062bc0b32698c3af6c41f4c7043dc1afb09b92
parent213bddbd9851ee01f256835b1bca0c4bc0cf5fc6 (diff)
parent7039d7fa471318df40c1905cd5ac52688dc6adcf (diff)
Merge branch 'preview' into news-tweaks
-rw-r--r--README.md2
-rw-r--r--package-lock.json66
-rw-r--r--package.json9
-rw-r--r--src/content/dependencies/generateAbsoluteDatetimestamp.js41
-rw-r--r--src/content/dependencies/generateAdditionalNamesBox.js20
-rw-r--r--src/content/dependencies/generateAdditionalNamesBoxItem.js68
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js110
-rw-r--r--src/content/dependencies/generateAlbumCoverArtwork.js20
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js22
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js2
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js6
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js8
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js5
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js8
-rw-r--r--src/content/dependencies/generateColorStyleRules.js7
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js51
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js99
-rw-r--r--src/content/dependencies/generateCommentaryIndexPage.js4
-rw-r--r--src/content/dependencies/generateCommentarySection.js29
-rw-r--r--src/content/dependencies/generateContentHeading.js28
-rw-r--r--src/content/dependencies/generateContributionList.js1
-rw-r--r--src/content/dependencies/generateCoverArtwork.js7
-rw-r--r--src/content/dependencies/generateDatetimestampTemplate.js28
-rw-r--r--src/content/dependencies/generateFlashActSidebar.js42
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js38
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js2
-rw-r--r--src/content/dependencies/generateFooterLocalizationLinks.js53
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js117
-rw-r--r--src/content/dependencies/generateGroupSidebar.js1
-rw-r--r--src/content/dependencies/generateListRandomPageLinksAlbumLink.js18
-rw-r--r--src/content/dependencies/generateListRandomPageLinksGroupSection.js81
-rw-r--r--src/content/dependencies/generateListingPage.js180
-rw-r--r--src/content/dependencies/generateListingSidebar.js1
-rw-r--r--src/content/dependencies/generatePageLayout.js79
-rw-r--r--src/content/dependencies/generateRelativeDatetimestamp.js58
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js1
-rw-r--r--src/content/dependencies/generateStaticPage.js11
-rw-r--r--src/content/dependencies/generateTrackAdditionalNamesBox.js53
-rw-r--r--src/content/dependencies/generateTrackCoverArtwork.js32
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js99
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js5
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js20
-rw-r--r--src/content/dependencies/generateWikiHomeNewsBox.js1
-rw-r--r--src/content/dependencies/generateWikiHomePage.js1
-rw-r--r--src/content/dependencies/image.js34
-rw-r--r--src/content/dependencies/linkContribution.js92
-rw-r--r--src/content/dependencies/linkExternal.js152
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js71
-rw-r--r--src/content/dependencies/linkExternalFlash.js41
-rw-r--r--src/content/dependencies/linkTemplate.js8
-rw-r--r--src/content/dependencies/linkTrackDynamically.js34
-rw-r--r--src/content/dependencies/listArtistsByContributions.js116
-rw-r--r--src/content/dependencies/listArtistsByGroup.js133
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js592
-rw-r--r--src/content/dependencies/listArtistsByName.js45
-rw-r--r--src/content/dependencies/listRandomPageLinks.js222
-rw-r--r--src/content/dependencies/listTracksByDate.js9
-rw-r--r--src/content/dependencies/transformContent.js68
-rw-r--r--src/data/composite/control-flow/index.js5
-rw-r--r--src/data/composite/data/excludeFromList.js7
-rw-r--r--src/data/composite/data/fillMissingListItems.js7
-rw-r--r--src/data/composite/data/index.js9
-rw-r--r--src/data/composite/data/withFilteredList.js50
-rw-r--r--src/data/composite/data/withFlattenedList.js6
-rw-r--r--src/data/composite/data/withMappedList.js39
-rw-r--r--src/data/composite/data/withPropertiesFromList.js4
-rw-r--r--src/data/composite/data/withPropertyFromList.js4
-rw-r--r--src/data/composite/data/withSortedList.js126
-rw-r--r--src/data/composite/data/withUnflattenedList.js10
-rw-r--r--src/data/composite/data/withUniqueItemsOnly.js40
-rw-r--r--src/data/composite/things/album/withTrackSections.js4
-rw-r--r--src/data/composite/things/album/withTracks.js4
-rw-r--r--src/data/composite/things/track/index.js2
-rw-r--r--src/data/composite/things/track/inferredAdditionalNameList.js67
-rw-r--r--src/data/composite/things/track/sharedAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/trackAdditionalNameList.js38
-rw-r--r--src/data/composite/wiki-data/index.js8
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js179
-rw-r--r--src/data/composite/wiki-data/withThingsSortedAlphabetically.js122
-rw-r--r--src/data/composite/wiki-properties/additionalNameList.js14
-rw-r--r--src/data/composite/wiki-properties/commentary.js32
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js58
-rw-r--r--src/data/composite/wiki-properties/index.js6
-rw-r--r--src/data/language.js23
-rw-r--r--src/data/serialize.js4
-rw-r--r--src/data/things/album.js8
-rw-r--r--src/data/things/artist.js17
-rw-r--r--src/data/things/index.js5
-rw-r--r--src/data/things/language.js150
-rw-r--r--src/data/things/static-page.js1
-rw-r--r--src/data/things/track.js20
-rw-r--r--src/data/things/validators.js125
-rw-r--r--src/data/things/wiki-info.js12
-rw-r--r--src/data/yaml.js194
-rw-r--r--src/find.js2
-rw-r--r--src/gen-thumbs.js2
-rw-r--r--src/listing-spec.js11
-rw-r--r--src/repl.js12
-rw-r--r--src/static/client3.js (renamed from src/static/client2.js)1171
-rw-r--r--src/static/site6.css (renamed from src/static/site5.css)249
-rw-r--r--src/strings-default.yaml300
-rwxr-xr-xsrc/upd8.js13
-rw-r--r--src/util/external-links.js679
-rw-r--r--src/util/html.js4
-rw-r--r--src/util/sugar.js175
-rw-r--r--src/util/wiki-data.js35
-rw-r--r--src/write/build-modes/live-dev-server.js11
-rw-r--r--src/write/build-modes/static-build.js18
-rw-r--r--src/write/common-templates.js40
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs4
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs14
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs4
-rw-r--r--tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs99
-rw-r--r--tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs8
-rw-r--r--tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs2
-rw-r--r--tap-snapshots/test/snapshot/image.js.test.cjs2
-rw-r--r--tap-snapshots/test/snapshot/linkContribution.js.test.cjs116
-rw-r--r--tap-snapshots/test/snapshot/linkExternal.js.test.cjs92
-rw-r--r--tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs18
-rw-r--r--tap-snapshots/test/snapshot/transformContent.js.test.cjs39
-rw-r--r--test/lib/content-function.js11
-rw-r--r--test/lib/wiki-data.js35
-rw-r--r--test/snapshot/generateAlbumCoverArtwork.js1
-rw-r--r--test/snapshot/generateTrackAdditionalNamesBox.js107
-rw-r--r--test/snapshot/generateTrackCoverArtwork.js2
-rw-r--r--test/snapshot/linkContribution.js37
-rw-r--r--test/snapshot/linkExternal.js87
-rw-r--r--test/snapshot/linkExternalFlash.js24
-rw-r--r--test/snapshot/transformContent.js39
-rw-r--r--test/unit/data/cacheable-object.js2
-rw-r--r--test/unit/data/composite/data/withUniqueItemsOnly.js84
-rw-r--r--test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js102
-rw-r--r--test/unit/data/things/album.js20
-rw-r--r--test/unit/data/things/track.js69
-rw-r--r--test/unit/data/things/validators.js17
135 files changed, 6560 insertions, 1786 deletions
diff --git a/README.md b/README.md
index a7fc5824..2c99e3d8 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagini
 
 Install dependencies:
 
-- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 16.x LTS should also work
+- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 20.x LTS should also work
 - [ImageMagick](https://imagemagick.org/) - check your package manager if it's available (e.g. apt or homebrew) or follow [installation info right from the official website](https://imagemagick.org/script/download.php)
 
 Make a new empty folder for storing all your HSMusic repositories, then clone 'em with git:
diff --git a/package-lock.json b/package-lock.json
index 6433ea16..1da6e9ad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,13 +9,15 @@
             "version": "0.1.0",
             "license": "GPL-3.0",
             "dependencies": {
+                "@js-temporal/polyfill": "^0.4.4",
                 "chroma-js": "^2.4.2",
                 "command-exists": "^1.2.9",
                 "eslint": "^8.37.0",
                 "he": "^1.2.0",
                 "image-size": "^1.0.2",
                 "js-yaml": "^4.1.0",
-                "marked": "^5.0.2",
+                "marked": "^10.0.0",
+                "printable-characters": "^1.0.42",
                 "striptags": "^4.0.0-alpha.4",
                 "word-wrap": "^1.2.3"
             },
@@ -26,6 +28,9 @@
                 "chokidar": "^3.5.3",
                 "tap": "^18.4.0",
                 "tcompare": "^6.0.0"
+            },
+            "engines": {
+                "node": ">= 20.9.0"
             }
         },
         "node_modules/@alcalzone/ansi-tokenize": {
@@ -237,6 +242,18 @@
                 "@jridgewell/sourcemap-codec": "^1.4.10"
             }
         },
+        "node_modules/@js-temporal/polyfill": {
+            "version": "0.4.4",
+            "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.4.tgz",
+            "integrity": "sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==",
+            "dependencies": {
+                "jsbi": "^4.3.0",
+                "tslib": "^2.4.1"
+            },
+            "engines": {
+                "node": ">=12"
+            }
+        },
         "node_modules/@nodelib/fs.scandir": {
             "version": "2.1.5",
             "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3073,6 +3090,11 @@
                 "js-yaml": "bin/js-yaml.js"
             }
         },
+        "node_modules/jsbi": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
+            "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
+        },
         "node_modules/json-parse-even-better-errors": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz",
@@ -3303,9 +3325,9 @@
             }
         },
         "node_modules/marked": {
-            "version": "5.0.2",
-            "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz",
-            "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ==",
+            "version": "10.0.0",
+            "resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz",
+            "integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA==",
             "bin": {
                 "marked": "bin/marked.js"
             },
@@ -3954,6 +3976,11 @@
                 "node": ">= 0.8.0"
             }
         },
+        "node_modules/printable-characters": {
+            "version": "1.0.42",
+            "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",
+            "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="
+        },
         "node_modules/prismjs": {
             "version": "1.29.0",
             "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
@@ -5222,8 +5249,7 @@
         "node_modules/tslib": {
             "version": "2.6.2",
             "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
-            "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
-            "dev": true
+            "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
         },
         "node_modules/tuf-js": {
             "version": "2.1.0",
@@ -5869,6 +5895,15 @@
                 "@jridgewell/sourcemap-codec": "^1.4.10"
             }
         },
+        "@js-temporal/polyfill": {
+            "version": "0.4.4",
+            "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.4.tgz",
+            "integrity": "sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==",
+            "requires": {
+                "jsbi": "^4.3.0",
+                "tslib": "^2.4.1"
+            }
+        },
         "@nodelib/fs.scandir": {
             "version": "2.1.5",
             "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -7919,6 +7954,11 @@
                 "argparse": "^2.0.1"
             }
         },
+        "jsbi": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
+            "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
+        },
         "json-parse-even-better-errors": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz",
@@ -8096,9 +8136,9 @@
             }
         },
         "marked": {
-            "version": "5.0.2",
-            "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz",
-            "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ=="
+            "version": "10.0.0",
+            "resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz",
+            "integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA=="
         },
         "mimic-fn": {
             "version": "2.1.0",
@@ -8576,6 +8616,11 @@
             "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
             "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
         },
+        "printable-characters": {
+            "version": "1.0.42",
+            "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",
+            "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="
+        },
         "prismjs": {
             "version": "1.29.0",
             "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
@@ -9459,8 +9504,7 @@
         "tslib": {
             "version": "2.6.2",
             "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
-            "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
-            "dev": true
+            "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
         },
         "tuf-js": {
             "version": "2.1.0",
diff --git a/package.json b/package.json
index 194c4060..a1c165bc 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
         "dev": "eslint src && node src/upd8.js"
     },
     "imports": {
+        "#cacheable-object": "./src/data/things/cacheable-object.js",
         "#colors": "./src/util/colors.js",
         "#composite": "./src/data/things/composite.js",
         "#composite/control-flow": "./src/data/composite/control-flow/index.js",
@@ -24,6 +25,7 @@
         "#content-dependencies": "./src/content/dependencies/index.js",
         "#content-function": "./src/content-function.js",
         "#cli": "./src/util/cli.js",
+        "#external-links": "./src/util/external-links.js",
         "#find": "./src/find.js",
         "#html": "./src/util/html.js",
         "#language": "./src/data/language.js",
@@ -41,14 +43,19 @@
         "#wiki-data": "./src/util/wiki-data.js",
         "#yaml": "./src/data/yaml.js"
     },
+    "engines": {
+        "node": ">= 20.9.0"
+    },
     "dependencies": {
+        "@js-temporal/polyfill": "^0.4.4",
         "chroma-js": "^2.4.2",
         "command-exists": "^1.2.9",
         "eslint": "^8.37.0",
         "he": "^1.2.0",
         "image-size": "^1.0.2",
         "js-yaml": "^4.1.0",
-        "marked": "^5.0.2",
+        "marked": "^10.0.0",
+        "printable-characters": "^1.0.42",
         "striptags": "^4.0.0-alpha.4",
         "word-wrap": "^1.2.3"
     },
diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js
new file mode 100644
index 00000000..63acecf2
--- /dev/null
+++ b/src/content/dependencies/generateAbsoluteDatetimestamp.js
@@ -0,0 +1,41 @@
+export default {
+  contentDependencies: ['generateDatetimestampTemplate'],
+  extraDependencies: ['html', 'language'],
+
+  data: (date) =>
+    ({date}),
+
+  relations: (relation) =>
+    ({template: relation('generateDatetimestampTemplate')}),
+
+  slots: {
+    style: {
+      validate: v => v.is('full', 'year'),
+      default: 'full',
+    },
+
+    // Only has an effect for 'year' style.
+    tooltip: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {language}) =>
+    relations.template.slots({
+      mainContent:
+        (slots.style === 'full'
+          ? language.formatDate(data.date)
+       : slots.style === 'year'
+          ? data.date.getFullYear().toString()
+          : null),
+
+      tooltipContent:
+        slots.tooltip &&
+        slots.style === 'year' &&
+          language.formatDate(data.date),
+
+      datetime:
+        data.date.toISOString(),
+    }),
+};
diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js
new file mode 100644
index 00000000..63427c58
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBox.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['generateAdditionalNamesBoxItem'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, additionalNames) => ({
+    items:
+      additionalNames
+        .map(entry => relation('generateAdditionalNamesBoxItem', entry)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('div', {id: 'additional-names-box'}, [
+      html.tag('p',
+        language.$('misc.additionalNames.title')),
+
+      html.tag('ul',
+        relations.items
+          .map(item => html.tag('li', item))),
+    ]),
+};
diff --git a/src/content/dependencies/generateAdditionalNamesBoxItem.js b/src/content/dependencies/generateAdditionalNamesBoxItem.js
new file mode 100644
index 00000000..bb4c8477
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js
@@ -0,0 +1,68 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack', 'transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    nameContent:
+      relation('transformContent', entry.name),
+
+    annotationContent:
+      (entry.annotation
+        ? relation('transformContent', entry.annotation)
+        : null),
+
+    trackLinks:
+      (entry.from
+        ? entry.from.map(track => relation('linkTrack', track))
+        : null),
+  }),
+
+  data: (entry) => ({
+    albumNames:
+      (entry.from
+        ? entry.from.map(track => track.album.name)
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) => {
+    const prefix = 'misc.additionalNames.item';
+
+    const itemParts = [prefix];
+    const itemOptions = {};
+
+    itemOptions.name =
+      html.tag('span', {class: 'additional-name'},
+        relations.nameContent.slot('mode', 'inline'));
+
+    const accentParts = [prefix, 'accent'];
+    const accentOptions = {};
+
+    if (relations.annotationContent) {
+      accentParts.push('withAnnotation');
+      accentOptions.annotation =
+        relations.annotationContent.slot('mode', 'inline');
+    }
+
+    if (relations.trackLinks) {
+      accentParts.push('withAlbums');
+      accentOptions.albums =
+        language.formatConjunctionList(
+          stitchArrays({
+            trackLink: relations.trackLinks,
+            albumName: data.albumNames,
+          }).map(({trackLink, albumName}) =>
+              trackLink.slot('content', albumName)));
+    }
+
+    if (accentParts.length > 2) {
+      itemParts.push('withAccent');
+      itemOptions.accent =
+        html.tag('span', {class: 'accent'},
+          language.$(...accentParts, accentOptions));
+    }
+
+    return language.$(...itemParts, itemOptions);
+  },
+};
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 3ad1549e..5a7142e5 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -1,4 +1,4 @@
-import {stitchArrays} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -6,13 +6,13 @@ export default {
     'generateAlbumNavAccent',
     'generateAlbumSidebarTrackSection',
     'generateAlbumStyleRules',
-    'generateColorStyleVariables',
+    'generateCommentaryEntry',
     'generateContentHeading',
     'generateTrackCoverArtwork',
     'generatePageLayout',
     'linkAlbum',
+    'linkExternal',
     'linkTrack',
-    'transformContent',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -33,13 +33,23 @@ export default {
       relation('generateAlbumNavAccent', album, null);
 
     if (album.commentary) {
+      relations.albumCommentaryHeading =
+        relation('generateContentHeading');
+
+      relations.albumCommentaryLink =
+        relation('linkAlbum', album);
+
+      relations.albumCommentaryListeningLinks =
+        album.urls.map(url => relation('linkExternal', url));
+
       if (album.hasCoverArt) {
         relations.albumCommentaryCover =
           relation('generateAlbumCoverArtwork', album);
       }
 
-      relations.albumCommentaryContent =
-        relation('transformContent', album.commentary);
+      relations.albumCommentaryEntries =
+        album.commentary
+          .map(entry => relation('generateCommentaryEntry', entry));
     }
 
     const tracksWithCommentary =
@@ -54,6 +64,11 @@ export default {
       tracksWithCommentary
         .map(track => relation('linkTrack', track));
 
+    relations.trackCommentaryListeningLinks =
+      tracksWithCommentary
+        .map(track =>
+          track.urls.map(url => relation('linkExternal', url)));
+
     relations.trackCommentaryCovers =
       tracksWithCommentary
         .map(track =>
@@ -61,16 +76,11 @@ export default {
             ? relation('generateTrackCoverArtwork', track)
             : null));
 
-    relations.trackCommentaryContent =
-      tracksWithCommentary
-        .map(track => relation('transformContent', track.commentary));
-
-    relations.trackCommentaryColorVariables =
+    relations.trackCommentaryEntries =
       tracksWithCommentary
         .map(track =>
-          (track.color === album.color
-            ? null
-            : relation('generateColorStyleVariables')));
+          track.commentary
+            .map(entry => relation('generateCommentaryEntry', entry)));
 
     relations.sidebarAlbumLink =
       relation('linkAlbum', album);
@@ -97,11 +107,15 @@ export default {
         ? [album, ...tracksWithCommentary]
         : tracksWithCommentary);
 
-    data.entryCount = thingsWithCommentary.length;
+    data.entryCount =
+      thingsWithCommentary
+        .flatMap(({commentary}) => commentary)
+        .length;
 
     data.wordCount =
       thingsWithCommentary
-        .map(({commentary}) => commentary)
+        .flatMap(({commentary}) => commentary)
+        .map(({body}) => body)
         .join(' ')
         .split(' ')
         .length;
@@ -146,40 +160,75 @@ export default {
                   language.countCommentaryEntries(data.entryCount, {unit: true})),
             })),
 
-          relations.albumCommentaryContent && [
-            html.tag('h3',
-              {class: ['content-heading']},
-              language.$('albumCommentaryPage.entry.title.albumCommentary')),
+          relations.albumCommentaryEntries && [
+            relations.albumCommentaryHeading.slots({
+              tag: 'h3',
+              color: data.color,
+
+              title:
+                language.$('albumCommentaryPage.entry.title.albumCommentary', {
+                  album: relations.albumCommentaryLink,
+                }),
+
+              accent:
+                !empty(relations.albumCommentaryListeningLinks) &&
+                  language.$('albumCommentaryPage.entry.title.albumCommentary.accent', {
+                    listeningLinks:
+                      language.formatUnitList(
+                        relations.albumCommentaryListeningLinks
+                          .map(link => link.slots({
+                            context: 'album',
+                            tab: 'separate',
+                          }))),
+                  }),
+            }),
 
             relations.albumCommentaryCover
               ?.slots({mode: 'commentary'}),
 
-            html.tag('blockquote',
-              relations.albumCommentaryContent),
+            relations.albumCommentaryEntries,
           ],
 
           stitchArrays({
             heading: relations.trackCommentaryHeadings,
             link: relations.trackCommentaryLinks,
+            listeningLinks: relations.trackCommentaryListeningLinks,
             directory: data.trackCommentaryDirectories,
             cover: relations.trackCommentaryCovers,
-            content: relations.trackCommentaryContent,
-            colorVariables: relations.trackCommentaryColorVariables,
+            entries: relations.trackCommentaryEntries,
             color: data.trackCommentaryColors,
-          }).map(({heading, link, directory, cover, content, colorVariables, color}) => [
+          }).map(({
+              heading,
+              link,
+              listeningLinks,
+              directory,
+              cover,
+              entries,
+              color,
+            }) => [
               heading.slots({
                 tag: 'h3',
                 id: directory,
-                title: link,
+                color,
+
+                title:
+                  language.$('albumCommentaryPage.entry.title.trackCommentary', {
+                    track: link,
+                  }),
+
+                accent:
+                  !empty(listeningLinks) &&
+                    language.$('albumCommentaryPage.entry.title.trackCommentary.accent', {
+                      listeningLinks:
+                        language.formatUnitList(
+                          listeningLinks.map(link =>
+                            link.slot('tab', 'separate'))),
+                    }),
               }),
 
               cover?.slots({mode: 'commentary'}),
 
-              html.tag('blockquote',
-                (color
-                  ? {style: colorVariables.slot('color', color).content}
-                  : {}),
-                content),
+              entries.map(entry => entry.slot('color', color)),
             ]),
         ],
 
@@ -201,6 +250,7 @@ export default {
         ],
 
         leftSidebarStickyMode: 'column',
+        leftSidebarClass: 'commentary-track-list-sidebar-box',
         leftSidebarContent: [
           html.tag('h1', relations.sidebarAlbumLink),
           relations.sidebarTrackSections.map(section =>
diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js
index cbec930e..ce8cde21 100644
--- a/src/content/dependencies/generateAlbumCoverArtwork.js
+++ b/src/content/dependencies/generateAlbumCoverArtwork.js
@@ -1,12 +1,22 @@
 export default {
   contentDependencies: ['generateCoverArtwork'],
 
-  relations: (relation, album) =>
-    ({coverArtwork: relation('generateCoverArtwork', album.artTags)}),
+  relations: (relation, album) => ({
+    coverArtwork:
+      relation('generateCoverArtwork', album.artTags),
+  }),
 
-  data: (album) =>
-    ({path: ['media.albumCover', album.directory, album.coverArtFileExtension]}),
+  data: (album) => ({
+    path:
+      ['media.albumCover', album.directory, album.coverArtFileExtension],
+
+    color:
+      album.color,
+  }),
 
   generate: (data, relations) =>
-    relations.coverArtwork.slot('path', data.path),
+    relations.coverArtwork.slots({
+      path: data.path,
+      color: data.color,
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 5fe27caf..90a120ca 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -17,6 +17,7 @@ export default {
     'generateAlbumStyleRules',
     'generateAlbumTrackList',
     'generateChronologyLinks',
+    'generateCommentarySection',
     'generateContentHeading',
     'generatePageLayout',
     'linkAlbum',
@@ -126,13 +127,8 @@ export default {
     // Section: Artist commentary
 
     if (album.commentary) {
-      const artistCommentary = sections.artistCommentary = {};
-
-      artistCommentary.heading =
-        relation('generateContentHeading');
-
-      artistCommentary.content =
-        relation('transformContent', album.commentary);
+      sections.artistCommentary =
+        relation('generateCommentarySection', album.commentary);
     }
 
     return relations;
@@ -235,17 +231,7 @@ export default {
             sec.additionalFiles.additionalFilesList,
           ],
 
-          sec.artistCommentary && [
-            sec.artistCommentary.heading
-              .slots({
-                id: 'artist-commentary',
-                title: language.$('releaseInfo.artistCommentary')
-              }),
-
-            html.tag('blockquote',
-              sec.artistCommentary.content
-                .slot('mode', 'multiline')),
-          ],
+          sec.artistCommentary,
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
index 7eb1dac0..01c88bf7 100644
--- a/src/content/dependencies/generateAlbumNavAccent.js
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -92,7 +92,7 @@ export default {
         html.tag('a',
           {
             href: '#',
-            'data-random': 'track-in-album',
+            'data-random': 'track-in-sidebar',
             id: 'random-button',
           },
           (data.isTrackPage
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index d6405283..dd5baab9 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -94,7 +94,11 @@ export default {
             links:
               language.formatDisjunctionList(
                 relations.externalLinks
-                  .map(link => link.slot('mode', 'album'))),
+                  .map(link =>
+                    link.slots({
+                      context: 'album',
+                      style: 'normal',
+                    }))),
           })),
     ]);
   },
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
index a84f4357..5ef4501b 100644
--- a/src/content/dependencies/generateAlbumSidebar.js
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -30,6 +30,7 @@ export default {
 
   generate(data, relations, {html}) {
     const trackListBox = {
+      class: 'track-list-sidebar-box',
       content:
         html.tags([
           html.tag('h1', relations.albumLink),
@@ -40,8 +41,10 @@ export default {
     if (data.isAlbumPage) {
       const groupBoxes =
         relations.groupBoxes
-          .map(content => content.slot('mode', 'album'))
-          .map(content => ({content}));
+          .map(content => ({
+            class: 'individual-group-sidebar-box',
+            content: content.slot('mode', 'album'),
+          }));
 
       return {
         leftSidebarMultiple: [
@@ -52,6 +55,7 @@ export default {
     }
 
     const conjoinedGroupBox = {
+      class: 'conjoined-group-sidebar-box',
       content:
         relations.groupBoxes
           .flatMap((content, i, {length}) => [
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index 331ddaba..f3705450 100644
--- a/src/content/dependencies/generateAlbumSidebarGroupBox.js
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -66,7 +66,10 @@ export default {
       !empty(relations.externalLinks) &&
         html.tag('p',
           language.$('releaseInfo.visitOn', {
-            links: language.formatDisjunctionList(relations.externalLinks),
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link => link.slot('context', 'group'))),
           })),
 
       slots.mode === 'album' &&
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 03bc0af5..1b85680f 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -161,7 +161,13 @@ export default {
           sec.visit &&
             html.tag('p',
               language.$('releaseInfo.visitOn', {
-                links: language.formatDisjunctionList(sec.visit.externalLinks),
+                links:
+                  language.formatDisjunctionList(
+                    sec.visit.externalLinks.map(link =>
+                      link.slots({
+                        context: 'artist',
+                        style: 'platform',
+                      }))),
               })),
 
           sec.artworks?.artistGalleryLink &&
diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js
index 1b316a3c..3f1d0130 100644
--- a/src/content/dependencies/generateColorStyleRules.js
+++ b/src/content/dependencies/generateColorStyleRules.js
@@ -18,9 +18,12 @@ export default {
       `:root {`,
       ...(
         relations.variables
-          .slot('color', slots.color)
+          .slots({
+            color: slots.color,
+            context: 'page-root',
+            mode: 'property-list',
+          })
           .content
-          .split(';')
           .map(line => line + ';')),
       `}`,
     ].join('\n');
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
index f30d786b..7cd04bd1 100644
--- a/src/content/dependencies/generateColorStyleVariables.js
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -2,7 +2,23 @@ export default {
   extraDependencies: ['html', 'getColors'],
 
   slots: {
-    color: {validate: v => v.isColor},
+    color: {
+      validate: v => v.isColor,
+    },
+
+    context: {
+      validate: v => v.is(
+        'any-content',
+        'page-root',
+        'primary-only'),
+
+      default: 'any-content',
+    },
+
+    mode: {
+      validate: v => v.is('style', 'property-list'),
+      default: 'style',
+    },
   },
 
   generate(slots, {getColors}) {
@@ -18,7 +34,7 @@ export default {
       shadow,
     } = getColors(slots.color);
 
-    return [
+    let anyContent = [
       `--primary-color: ${primary}`,
       `--dark-color: ${dark}`,
       `--dim-color: ${dim}`,
@@ -26,6 +42,35 @@ export default {
       `--bg-color: ${bg}`,
       `--bg-black-color: ${bgBlack}`,
       `--shadow-color: ${shadow}`,
-    ].join('; ');
+    ];
+
+    let selectedProperties;
+
+    switch (slots.context) {
+      case 'any-content':
+        selectedProperties = anyContent;
+        break;
+
+      case 'page-root':
+        selectedProperties = [
+          ...anyContent,
+          `--page-primary-color: ${primary}`,
+        ];
+        break;
+
+      case 'primary-only':
+        selectedProperties = [
+          `--primary-color: ${primary}`,
+        ];
+        break;
+    }
+
+    switch (slots.mode) {
+      case 'style':
+        return selectedProperties.join('; ');
+
+      case 'property-list':
+        return selectedProperties;
+    }
   },
 };
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
new file mode 100644
index 00000000..0b2b2558
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -0,0 +1,99 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleVariables',
+    'linkArtist',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    artistLinks:
+      (!empty(entry.artists) && !entry.artistDisplayText
+        ? entry.artists
+            .map(artist => relation('linkArtist', artist))
+        : null),
+
+    artistsContent:
+      (entry.artistDisplayText
+        ? relation('transformContent', entry.artistDisplayText)
+        : null),
+
+    annotationContent:
+      (entry.annotation
+        ? relation('transformContent', entry.annotation)
+        : null),
+
+    bodyContent:
+      (entry.body
+        ? relation('transformContent', entry.body)
+        : null),
+
+    colorVariables:
+      relation('generateColorStyleVariables'),
+  }),
+
+  data: (entry) => ({
+    date: entry.date,
+  }),
+
+  slots: {
+    color: {validate: v => v.isColor},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const artistsSpan =
+      html.tag('span', {class: 'commentary-entry-artists'},
+        (relations.artistsContent
+          ? relations.artistsContent.slot('mode', 'inline')
+       : relations.artistLinks
+          ? language.formatConjunctionList(relations.artistLinks)
+          : language.$('misc.artistCommentary.entry.title.noArtists')));
+
+    const accentParts = ['misc.artistCommentary.entry.title.accent'];
+    const accentOptions = {};
+
+    if (relations.annotationContent) {
+      accentParts.push('withAnnotation');
+      accentOptions.annotation =
+        relations.annotationContent.slot('mode', 'inline');
+    }
+
+    if (data.date) {
+      accentParts.push('withDate');
+      accentOptions.date =
+        language.formatDate(data.date);
+    }
+
+    const accent =
+      (accentParts.length > 1
+        ? html.tag('span', {class: 'commentary-entry-accent'},
+            language.$(...accentParts, accentOptions))
+        : null);
+
+    const titleParts = ['misc.artistCommentary.entry.title'];
+    const titleOptions = {artists: artistsSpan};
+
+    if (accent) {
+      titleParts.push('withAccent');
+      titleOptions.accent = accent;
+    }
+
+    const style =
+      (slots.color
+        ? relations.colorVariables
+            .slot('color', slots.color)
+            .content
+        : null);
+
+    return html.tags([
+      html.tag('p', {class: 'commentary-entry-heading', style},
+        language.$(...titleParts, titleOptions)),
+
+      html.tag('blockquote', {class: 'commentary-entry-body', style},
+        relations.bodyContent.slot('mode', 'multiline')),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js
index 1d381bff..5d38941a 100644
--- a/src/content/dependencies/generateCommentaryIndexPage.js
+++ b/src/content/dependencies/generateCommentaryIndexPage.js
@@ -19,13 +19,13 @@ export default {
       query.albums.map(album =>
         [album, ...album.tracks]
           .filter(({commentary}) => commentary)
-          .map(({commentary}) => commentary));
+          .flatMap(({commentary}) => commentary));
 
     query.wordCounts =
       entries.map(entries =>
         accumulateSum(
           entries,
-          entry => entry.split(' ').length));
+          entry => entry.body.split(' ').length));
 
     query.entryCounts =
       entries.map(entries => entries.length);
diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js
new file mode 100644
index 00000000..8ae1b2d0
--- /dev/null
+++ b/src/content/dependencies/generateCommentarySection.js
@@ -0,0 +1,29 @@
+export default {
+  contentDependencies: [
+    'transformContent',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entries) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    entries:
+      entries.map(entry =>
+        relation('generateCommentaryEntry', entry)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tags([
+      relations.heading
+        .slots({
+          id: 'artist-commentary',
+          title: language.$('misc.artistCommentary')
+        }),
+
+      relations.entries,
+    ]),
+};
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
index ccaf1076..0343409c 100644
--- a/src/content/dependencies/generateContentHeading.js
+++ b/src/content/dependencies/generateContentHeading.js
@@ -1,19 +1,41 @@
 export default {
   extraDependencies: ['html'],
+  contentDependencies: ['generateColorStyleVariables'],
+
+  relations: (relation) => ({
+    colorVariables: relation('generateColorStyleVariables'),
+  }),
 
   slots: {
     title: {type: 'html'},
+    accent: {type: 'html'},
+
+    color: {validate: v => v.isColor},
+
     id: {type: 'string'},
     tag: {type: 'string', default: 'p'},
   },
 
-  generate(slots, {html}) {
+  generate(relations, slots, {html}) {
     return html.tag(slots.tag,
       {
         class: 'content-heading',
         id: slots.id,
         tabindex: '0',
-      },
-      slots.title);
+
+        style:
+          slots.color &&
+            relations.colorVariables
+              .slot('color', slots.color)
+              .content,
+      }, [
+        html.tag('span',
+          {[html.onlyIfContent]: true, class: 'content-heading-main-title'},
+          slots.title),
+
+        html.tag('span',
+          {[html.onlyIfContent]: true, class: 'content-heading-accent'},
+          slots.accent),
+      ]);
   }
 }
diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js
index 731cfba5..6401e65e 100644
--- a/src/content/dependencies/generateContributionList.js
+++ b/src/content/dependencies/generateContributionList.js
@@ -16,5 +16,6 @@ export default {
               showIcons: true,
               showContribution: true,
               preventWrapping: false,
+              iconMode: 'tooltip',
             })))),
 };
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index aeba97de..e43963fb 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -31,6 +31,10 @@ export default {
       type: 'string',
     },
 
+    color: {
+      validate: v => v.isColor,
+    },
+
     mode: {
       validate: v => v.is('primary', 'thumbnail', 'commentary'),
       default: 'primary',
@@ -45,6 +49,7 @@ export default {
             .slots({
               path: slots.path,
               alt: slots.alt,
+              color: slots.color,
               thumb: 'medium',
               id: 'cover-art',
               reveal: true,
@@ -67,6 +72,7 @@ export default {
           .slots({
             path: slots.path,
             alt: slots.alt,
+            color: slots.color,
             thumb: 'small',
             reveal: false,
             link: false,
@@ -78,6 +84,7 @@ export default {
           .slots({
             path: slots.path,
             alt: slots.alt,
+            color: slots.color,
             thumb: 'medium',
             class: 'commentary-art',
             reveal: true,
diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js
new file mode 100644
index 00000000..bfba647f
--- /dev/null
+++ b/src/content/dependencies/generateDatetimestampTemplate.js
@@ -0,0 +1,28 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    mainContent: {type: 'html'},
+    tooltipContent: {type: 'html'},
+    datetime: {type: 'string'},
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('span', {
+      [html.joinChildren]: '',
+
+      class: [
+        'datetimestamp',
+        slots.tooltipContent && 'has-tooltip',
+      ],
+    }, [
+      html.tag('time',
+        {datetime: slots.datetime},
+        slots.mainContent),
+
+      slots.tooltipContent &&
+        html.tag('span', {class: 'datetimestamp-tooltip'},
+          html.tag('span', {class: 'datetimestamp-tooltip-content'},
+            slots.tooltipContent)),
+    ]),
+};
diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js
index bd6063c9..29379644 100644
--- a/src/content/dependencies/generateFlashActSidebar.js
+++ b/src/content/dependencies/generateFlashActSidebar.js
@@ -1,5 +1,6 @@
 import find from '#find';
 import {stitchArrays} from '#sugar';
+import {filterMultipleArrays} from '#wiki-data';
 
 export default {
   contentDependencies: ['linkFlash', 'linkFlashAct', 'linkFlashIndex'],
@@ -11,10 +12,12 @@ export default {
 
   query(sprawl, act, flash) {
     const findFlashAct = directory =>
-      find.flashAct(directory, sprawl.flashActData, {mode: 'error'});
+      find.flashAct(directory, sprawl.flashActData, {mode: 'quiet'});
+
+    const homestuckSide1 = findFlashAct('flash-act:a1');
 
     const sideFirstActs = [
-      findFlashAct('flash-act:a1'),
+      sprawl.flashActData[0],
       findFlashAct('flash-act:a6a1'),
       findFlashAct('flash-act:hiveswap'),
       findFlashAct('flash-act:cool-and-new-web-comic'),
@@ -22,7 +25,9 @@ export default {
     ];
 
     const sideNames = [
-      `Side 1 (Acts 1-5)`,
+      (homestuckSide1
+        ? `Side 1 (Acts 1-5)`
+        : `All flashes & games`),
       `Side 2 (Acts 6-7)`,
       `Additional Canon`,
       `Fan Adventures`,
@@ -30,13 +35,18 @@ export default {
     ];
 
     const sideColors = [
-      '#4ac925',
+      (homestuckSide1
+        ? '#4ac925'
+        : null),
       '#3796c6',
       '#f2a400',
       '#c466ff',
       '#32c7fe',
     ];
 
+    filterMultipleArrays(sideFirstActs, sideNames, sideColors,
+      firstAct => firstAct);
+
     const sideFirstActIndexes =
       sideFirstActs
         .map(act => sprawl.flashActData.indexOf(act));
@@ -127,7 +137,7 @@ export default {
   }),
 
   generate(data, relations, {getColors, html, language}) {
-    const currentActBox = html.tags([
+    const currentActBoxContent = html.tags([
       html.tag('h1', relations.currentActLink),
 
       html.tag('details',
@@ -150,7 +160,7 @@ export default {
         ]),
     ]);
 
-    const sideMapBox = html.tags([
+    const sideMapBoxContent = html.tags([
       html.tag('h1', relations.flashIndexLink),
 
       stitchArrays({
@@ -178,17 +188,21 @@ export default {
           ])),
     ]);
 
+    const sideMapBox = {
+      class: 'flash-act-map-sidebar-box',
+      content: sideMapBoxContent,
+    };
+
+    const currentActBox = {
+      class: 'flash-current-act-sidebar-box',
+      content: currentActBoxContent,
+    };
+
     return {
       leftSidebarMultiple:
         (data.isFlashActPage
-          ? [
-              {content: sideMapBox},
-              {content: currentActBox},
-            ]
-          : [
-              {content: currentActBox},
-              {content: sideMapBox},
-            ]),
+          ? [sideMapBox, currentActBox]
+          : [currentActBox, sideMapBox]),
     };
   },
 };
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index ad1dab94..5fc62ab3 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -1,4 +1,4 @@
-import {stitchArrays} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -95,23 +95,25 @@ export default {
 
       mainClasses: ['flash-index'],
       mainContent: [
-        html.tag('p',
-          {class: 'quick-info'},
-          language.$('misc.jumpTo')),
-
-        html.tag('ul',
-          {class: 'quick-info'},
-          stitchArrays({
-            colorVariables: relations.jumpLinkColorVariables,
-            anchor: data.jumpLinkAnchors,
-            color: data.jumpLinkColors,
-            label: data.jumpLinkLabels,
-          }).map(({colorVariables, anchor, color, label}) =>
-              html.tag('li',
-                html.tag('a', {
-                  href: '#' + anchor,
-                  style: colorVariables.slot('color', color).content,
-                }, label)))),
+        !empty(data.jumpLinkLabels) && [
+          html.tag('p',
+            {class: 'quick-info'},
+            language.$('misc.jumpTo')),
+
+          html.tag('ul',
+            {class: 'quick-info'},
+            stitchArrays({
+              colorVariables: relations.jumpLinkColorVariables,
+              anchor: data.jumpLinkAnchors,
+              color: data.jumpLinkColors,
+              label: data.jumpLinkLabels,
+            }).map(({colorVariables, anchor, color, label}) =>
+                html.tag('li',
+                  html.tag('a', {
+                    href: '#' + anchor,
+                    style: colorVariables.slot('color', color).content,
+                  }, label)))),
+        ],
 
         stitchArrays({
           colorVariables: relations.actColorVariables,
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 09c6b37c..c60f9696 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -133,7 +133,7 @@ export default {
               links:
                 language.formatDisjunctionList(
                   relations.externalLinks
-                    .map(link => link.slot('mode', 'flash'))),
+                    .map(link => link.slot('context', 'flash'))),
             })),
 
         sec.featuredTracks && [
diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js
index 5df83566..86e6c61a 100644
--- a/src/content/dependencies/generateFooterLocalizationLinks.js
+++ b/src/content/dependencies/generateFooterLocalizationLinks.js
@@ -1,3 +1,6 @@
+import {stitchArrays} from '#sugar';
+import {sortByName} from '#wiki-data';
+
 export default {
   extraDependencies: [
     'defaultLanguage',
@@ -16,25 +19,37 @@ export default {
     pagePath,
     to,
   }) {
-    const links = Object.entries(languages)
-      .filter(([code, language]) => code !== 'default' && !language.hidden)
-      .map(([code, language]) => language)
-      .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0))
-      .map((language) =>
-        html.tag('span',
-          html.tag('a',
-            {
-              href:
-                language === defaultLanguage
-                  ? to(
-                      'localizedDefaultLanguage.' + pagePath[0],
-                      ...pagePath.slice(1))
-                  : to(
-                      'localizedWithBaseDirectory.' + pagePath[0],
-                      language.code,
-                      ...pagePath.slice(1)),
-            },
-            language.name)));
+    const switchableLanguages =
+      Object.entries(languages)
+        .filter(([code, language]) => code !== 'default' && !language.hidden)
+        .map(([code, language]) => language);
+
+    if (switchableLanguages.length <= 1) {
+      return html.blank();
+    }
+
+    sortByName(switchableLanguages);
+
+    const [pagePathSubkey, ...pagePathArgs] = pagePath;
+
+    const linkPaths =
+      switchableLanguages.map(language =>
+        (language === defaultLanguage
+          ? (['localizedDefaultLanguage.' + pagePathSubkey,
+              ...pagePathArgs])
+          : (['localizedWithBaseDirectory.' + pagePathSubkey,
+              language.code,
+              ...pagePathArgs])));
+
+    const links =
+      stitchArrays({
+        language: switchableLanguages,
+        linkPath: linkPaths,
+      }).map(({language, linkPath}) =>
+          html.tag('span',
+            html.tag('a',
+              {href: to(...linkPath)},
+              language.name)));
 
     return html.tag('div', {class: 'footer-localization-links'},
       language.$('misc.uiLanguage', {
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index 0583755e..0e5d645b 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -1,7 +1,9 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateColorStyleVariables',
     'generateContentHeading',
     'generateGroupNavLinks',
     'generateGroupSecondaryNav',
@@ -62,18 +64,27 @@ export default {
       sec.albums.galleryLink =
         relation('linkGroupGallery', group);
 
-      sec.albums.entries =
-        group.albums.map(album => {
-          const links = {};
-          links.albumLink = relation('linkAlbum', album);
+      sec.albums.colorVariables =
+        group.albums
+          .map(() => relation('generateColorStyleVariables'));
 
-          const otherGroup = album.groups.find(g => g !== group);
-          if (otherGroup) {
-            links.groupLink = relation('linkGroup', otherGroup);
-          }
+      sec.albums.albumLinks =
+        group.albums
+          .map(album => relation('linkAlbum', album));
 
-          return links;
-        });
+      sec.albums.groupLinks =
+        group.albums
+          .map(album => album.groups.find(g => g !== group))
+          .map(group =>
+            (group
+              ? relation('linkGroup', group)
+              : null));
+
+      sec.albums.datetimestamps =
+        group.albums.map(album =>
+          (album.date
+            ? relation('generateAbsoluteDatetimestamp', album.date)
+            : null));
     }
 
     return relations;
@@ -85,11 +96,8 @@ export default {
     data.name = group.name;
     data.color = group.color;
 
-    if (!empty(group.albums)) {
-      data.albumYears =
-        group.albums
-          .map(album => album.date?.getFullYear());
-    }
+    data.albumColors =
+      group.albums.map(album => album.color);
 
     return data;
   },
@@ -107,7 +115,10 @@ export default {
           sec.info.visitLinks &&
             html.tag('p',
               language.$('releaseInfo.visitOn', {
-                links: language.formatDisjunctionList(sec.info.visitLinks),
+                links:
+                  language.formatDisjunctionList(
+                    sec.info.visitLinks
+                      .map(link => link.slot('context', 'group'))),
               })),
 
           html.tag('blockquote',
@@ -130,34 +141,50 @@ export default {
               })),
 
             html.tag('ul',
-              sec.albums.entries.map(({albumLink, groupLink}, index) => {
-                // All these strings are really jank, and should probably
-                // be implemented with the same 'const parts = [], opts = {}'
-                // form used elsewhere...
-                const year = data.albumYears[index];
-                const item =
-                  (year
-                    ? language.$('groupInfoPage.albumList.item', {
-                        year,
-                        album: albumLink,
-                      })
-                    : language.$('groupInfoPage.albumList.item.withoutYear', {
-                        album: albumLink,
-                      }));
-
-                return html.tag('li',
-                  (groupLink
-                    ? language.$('groupInfoPage.albumList.item.withAccent', {
-                        item,
-                        accent:
-                          html.tag('span', {class: 'other-group-accent'},
-                            language.$('groupInfoPage.albumList.item.otherGroupAccent', {
-                              group:
-                                groupLink.slot('color', false),
-                            })),
-                      })
-                    : item));
-              })),
+              stitchArrays({
+                albumLink: sec.albums.albumLinks,
+                groupLink: sec.albums.groupLinks,
+                datetimestamp: sec.albums.datetimestamps,
+                colorVariables: sec.albums.colorVariables,
+                albumColor: data.albumColors,
+              }).map(({
+                  albumLink,
+                  groupLink,
+                  datetimestamp,
+                  colorVariables,
+                  albumColor,
+                }) => {
+                  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 (groupLink) {
+                    parts.push('withOtherGroup');
+                    options.otherGroupAccent =
+                      html.tag('span', {class: 'other-group-accent'},
+                        language.$(prefix, 'otherGroupAccent', {
+                          group:
+                            groupLink.slot('color', false),
+                        }));
+                  }
+
+                  return (
+                    html.tag('li',
+                      {style: colorVariables.slot('color', albumColor).content},
+                      language.$(...parts, options)));
+                })),
           ],
         ],
 
diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js
index 6baf37f4..98b288fa 100644
--- a/src/content/dependencies/generateGroupSidebar.js
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -22,6 +22,7 @@ export default {
 
   generate(relations, slots, {html, language}) {
     return {
+      leftSidebarClass: 'category-map-sidebar-box',
       leftSidebarContent: [
         html.tag('h1',
           language.$('groupSidebar.title')),
diff --git a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js
new file mode 100644
index 00000000..b3560aca
--- /dev/null
+++ b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js
@@ -0,0 +1,18 @@
+export default {
+  contentDependencies: ['linkAlbum'],
+
+  data: (album) =>
+    ({directory: album.directory}),
+
+  relations: (relation, album) =>
+    ({albumLink: relation('linkAlbum', album)}),
+
+  generate: (data, relations) =>
+    relations.albumLink.slots({
+      anchor: true,
+      attributes: {
+        'data-random': 'track-in-album',
+        'style': `--album-directory: ${data.directory}`,
+      },
+    }),
+};
diff --git a/src/content/dependencies/generateListRandomPageLinksGroupSection.js b/src/content/dependencies/generateListRandomPageLinksGroupSection.js
deleted file mode 100644
index 2a684b19..00000000
--- a/src/content/dependencies/generateListRandomPageLinksGroupSection.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import {stitchArrays} from '#sugar';
-import {sortChronologically} from '#wiki-data';
-
-export default {
-  contentDependencies: ['generateColorStyleVariables', 'linkGroup'],
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl: ({albumData}) => ({albumData}),
-
-  query: (sprawl, group) => ({
-    albums:
-      sortChronologically(sprawl.albumData.slice())
-        .filter(album => album.groups.includes(group))
-        .filter(album => album.tracks.length > 1),
-  }),
-
-  relations: (relation, query, sprawl, group) => ({
-    groupLink:
-      relation('linkGroup', group),
-
-    albumColorVariables:
-      query.albums
-        .map(() => relation('generateColorStyleVariables')),
-  }),
-
-  data: (query, sprawl, group) => ({
-    groupDirectory:
-      group.directory,
-
-    albumColors:
-      query.albums
-        .map(album => album.color),
-
-    albumDirectories:
-      query.albums
-        .map(album => album.directory),
-
-    albumNames:
-      query.albums
-        .map(album => album.name),
-  }),
-
-  generate: (data, relations, {html, language}) =>
-    html.tags([
-      html.tag('dt',
-        language.$('listingPage.other.randomPages.group', {
-          group: relations.groupLink,
-
-          randomAlbum:
-            html.tag('a',
-              {href: '#', 'data-random': 'album-in-' + data.groupDirectory},
-              language.$('listingPage.other.randomPages.group.randomAlbum')),
-
-          randomTrack:
-            html.tag('a',
-              {href: '#', 'data-random': 'track-in-' + data.groupDirectory},
-              language.$('listingPage.other.randomPages.group.randomTrack')),
-        })),
-
-      html.tag('dd',
-        html.tag('ul',
-          stitchArrays({
-            colorVariables: relations.albumColorVariables,
-            color: data.albumColors,
-            directory: data.albumDirectories,
-            name: data.albumNames,
-          }).map(({colorVariables, color, directory, name}) =>
-              html.tag('li',
-                language.$('listingPage.other.randomPages.album', {
-                  album:
-                    html.tag('a', {
-                      href: '#',
-                      'data-random': 'track-in-album',
-                      style:
-                        colorVariables.slot('color', color).content +
-                        '; ' +
-                        `--album-directory: ${directory}`,
-                    }, name),
-                }))))),
-    ]),
-};
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index 08eb40c6..2050d62d 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {bindOpts, empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -7,6 +7,7 @@ export default {
     'generatePageLayout',
     'linkListing',
     'linkListingIndex',
+    'linkTemplate',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -26,6 +27,9 @@ export default {
     relations.chunkHeading =
       relation('generateContentHeading');
 
+    relations.showSkipToSectionLinkTemplate =
+      relation('linkTemplate');
+
     if (listing.target.listings.length > 1) {
       relations.sameTargetListingLinks =
         listing.target.listings
@@ -58,12 +62,42 @@ export default {
   },
 
   slots: {
-    type: {validate: v => v.is('rows', 'chunks', 'custom')},
+    type: {
+      validate: v => v.is('rows', 'chunks', 'custom'),
+    },
+
+    rows: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
 
-    rows: {validate: v => v.strictArrayOf(v.isObject)},
+    rowAttributes: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject))
+    },
+
+    chunkTitles: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
 
-    chunkTitles: {validate: v => v.strictArrayOf(v.isObject)},
-    chunkRows: {validate: v => v.strictArrayOf(v.isObject)},
+    chunkTitleAccents: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject)),
+    },
+
+    chunkRows: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
+
+    chunkRowAttributes: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject)),
+    },
+
+    showSkipToSection: {
+      type: 'boolean',
+      default: false,
+    },
+
+    chunkIDs: {
+      validate: v => v.strictArrayOf(v.optional(v.isString)),
+    },
 
     listStyle: {
       validate: v => v.is('ordered', 'unordered'),
@@ -74,26 +108,59 @@ export default {
   },
 
   generate(data, relations, slots, {html, language}) {
-    const listTag =
-      (slots.listStyle === 'ordered'
-        ? 'ol'
-        : 'ul');
+    function formatListingString({
+      context,
+      provided = {},
+    }) {
+      const parts = ['listingPage', data.stringsKey];
+
+      if (Array.isArray(context)) {
+        parts.push(...context);
+      } else {
+        parts.push(context);
+      }
 
-    const formatListingString = (contextStringsKey, options = {}) => {
-      const baseStringsKey = `listingPage.${data.stringsKey}`;
+      if (provided.stringsKey) {
+        parts.push(provided.stringsKey);
+      }
 
-      const parts = [baseStringsKey, contextStringsKey];
+      const options = {...provided};
+      delete options.stringsKey;
 
-      if (options.stringsKey) {
-        parts.push(options.stringsKey);
-        delete options.stringsKey;
-      }
+      return language.formatString(...parts, options);
+    }
 
-      return language.formatString(parts.join('.'), options);
-    };
+    const formatRow = ({context, row, attributes}) =>
+      (attributes?.href
+        ? html.tag('li',
+            html.tag('a',
+              attributes,
+              formatListingString({
+                context,
+                provided: row,
+              })))
+        : html.tag('li',
+            attributes,
+            formatListingString({
+              context,
+              provided: row,
+            })));
+
+    const formatRowList = ({context, rows, rowAttributes}) =>
+      html.tag(
+        (slots.listStyle === 'ordered' ? 'ol' : 'ul'),
+        stitchArrays({
+          row: rows,
+          attributes: rowAttributes ?? rows.map(() => null),
+        }).map(
+          bindOpts(formatRow, {
+            [bindOpts.bindIndex]: 0,
+            context,
+          })));
 
     return relations.layout.slots({
-      title: formatListingString('title'),
+      title: formatListingString({context: 'title'}),
+
       headingMode: 'sticky',
 
       mainContent: [
@@ -121,35 +188,78 @@ export default {
               listings: language.formatUnitList(relations.seeAlsoLinks),
             })),
 
+        slots.content,
+
         slots.type === 'rows' &&
-          html.tag(listTag,
-            slots.rows.map(row =>
-              html.tag('li',
-                formatListingString('item', row)))),
+          formatRowList({
+            context: 'item',
+            rows: slots.rows,
+            rowAttributes: slots.rowAttributes,
+          }),
 
         slots.type === 'chunks' &&
-          html.tag('dl',
+          html.tag('dl', [
+            slots.showSkipToSection && [
+              html.tag('dt',
+                language.$('listingPage.skipToSection')),
+
+              html.tag('dd',
+                html.tag('ul',
+                  stitchArrays({
+                    title: slots.chunkTitles,
+                    id: slots.chunkIDs,
+                  }).filter(({id}) => id)
+                    .map(({title, id}) =>
+                      html.tag('li',
+                        relations.showSkipToSectionLinkTemplate
+                          .clone()
+                          .slots({
+                            hash: id,
+                            content:
+                              html.normalize(
+                                formatListingString({
+                                  context: 'chunk.title',
+                                  provided: title,
+                                }).toString()
+                                  .replace(/:$/, '')),
+                          }))))),
+            ],
+
             stitchArrays({
               title: slots.chunkTitles,
+              titleAccent: slots.chunkTitleAccents,
+              id: slots.chunkIDs,
               rows: slots.chunkRows,
-            }).map(({title, rows}) => [
+              rowAttributes: slots.chunkRowAttributes,
+            }).map(({title, titleAccent, id, rows, rowAttributes}) => [
                 relations.chunkHeading
                   .clone()
                   .slots({
                     tag: 'dt',
-                    title: formatListingString('chunk.title', title),
+                    id,
+
+                    title:
+                      formatListingString({
+                        context: 'chunk.title',
+                        provided: title,
+                      }),
+
+                    accent:
+                      titleAccent &&
+                        formatListingString({
+                          context: ['chunk.title', title.stringsKey, 'accent'],
+                          provided: titleAccent,
+                        }),
                   }),
 
                 html.tag('dd',
-                  html.tag(listTag,
-                    rows.map(row =>
-                      html.tag('li',
-                        {class: row.stringsKey === 'rerelease' && 'rerelease'},
-                        formatListingString('chunk.item', row))))),
-              ])),
-
-        slots.type === 'custom' &&
-          slots.content,
+                  formatRowList({
+                    context: 'chunk.item',
+                    rows,
+                    rowAttributes,
+                  })),
+              ]),
+          ]),
       ],
 
       navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js
index fe2a08fa..1cdd236b 100644
--- a/src/content/dependencies/generateListingSidebar.js
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -11,6 +11,7 @@ export default {
 
   generate(relations, {html}) {
     return {
+      leftSidebarClass: 'listing-map-sidebar-box',
       leftSidebarContent: [
         html.tag('h1', relations.listingIndexLink),
         relations.listingIndexList.slot('mode', 'sidebar'),
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 72dfbae5..1591223a 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -6,12 +6,19 @@ function sidebarSlots(side) {
     // if specified.
     [side + 'Content']: {type: 'html'},
 
-    // Multiple is an array of {content: (HTML)} objects. Each of these
-    // will generate one sidebar section.
+    // A single class to apply to the whole sidebar. If specifying multiple
+    // sections, this be added to the containing sidebar-column - specify a
+    // class on each section if that's more suitable.
+    [side + 'Class']: {type: 'string'},
+
+    // Multiple is an array of objects, each specifying content (HTML) and
+    // optionally class (a string). Each of these will generate one sidebar
+    // section.
     [side + 'Multiple']: {
       validate: v =>
         v.sparseArrayOf(
           v.validateProperties({
+            class: v.optional(v.isString),
             content: v.isHTML,
           })),
     },
@@ -27,6 +34,7 @@ function sidebarSlots(side) {
     // the whole section's containing box (or the sidebar column as a whole).
     [side + 'StickyMode']: {
       validate: v => v.is('last', 'column', 'static'),
+      default: 'static',
     },
 
     // Collapsing sidebars disappear when the viewport is sufficiently
@@ -85,8 +93,10 @@ export default {
     relations.stickyHeadingContainer =
       relation('generateStickyHeadingContainer');
 
-    relations.defaultFooterContent =
-      relation('transformContent', sprawl.footerContent);
+    if (sprawl.footerContent) {
+      relations.defaultFooterContent =
+        relation('transformContent', sprawl.footerContent);
+    }
 
     relations.colorStyleRules =
       relation('generateColorStyleRules');
@@ -98,6 +108,8 @@ export default {
     title: {type: 'html'},
     showWikiNameInTitle: {type: 'boolean', default: true},
 
+    additionalNames: {type: 'html'},
+
     cover: {type: 'html'},
 
     socialEmbed: {type: 'html'},
@@ -212,26 +224,29 @@ export default {
     const colors = getColors(slots.color ?? data.wikiColor);
     const hasSocialEmbed = !html.isBlank(slots.socialEmbed);
 
-    let titleHTML = null;
-
-    if (!html.isBlank(slots.title)) {
-      switch (slots.headingMode) {
-        case 'sticky':
-          titleHTML =
-            relations.stickyHeadingContainer.slots({
-              title: slots.title,
-              cover: slots.cover,
-            });
-          break;
-        case 'static':
-          titleHTML = html.tag('h1', slots.title);
-          break;
-      }
-    }
+    const titleContentsHTML =
+      (html.isBlank(slots.title)
+        ? null
+     : html.isBlank(slots.additionalNames)
+        ? language.sanitize(slots.title)
+        : html.tag('a', {
+            href: '#additional-names-box',
+            title: language.$('misc.additionalNames.tooltip').toString(),
+          }, language.sanitize(slots.title)));
+
+    const titleHTML =
+      (html.isBlank(slots.title)
+        ? null
+     : slots.headingMode === 'sticky'
+        ? relations.stickyHeadingContainer.slots({
+            title: titleContentsHTML,
+            cover: slots.cover,
+          })
+        : html.tag('h1', titleContentsHTML));
 
     let footerContent = slots.footerContent;
 
-    if (html.isBlank(footerContent)) {
+    if (html.isBlank(footerContent) && relations.defaultFooterContent) {
       footerContent = relations.defaultFooterContent
         .slot('mode', 'multiline');
     }
@@ -244,6 +259,7 @@ export default {
         titleHTML,
 
         slots.cover,
+        slots.additionalNames,
 
         html.tag('div',
           {
@@ -352,6 +368,7 @@ export default {
 
     const generateSidebarHTML = (side, id) => {
       const content = slots[side + 'Content'];
+      const topClass = slots[side + 'Class'];
       const multiple = slots[side + 'Multiple'];
       const stickyMode = slots[side + 'StickyMode'];
       const wide = slots[side + 'Wide'];
@@ -361,20 +378,18 @@ export default {
       let sidebarContent = html.blank();
 
       if (!html.isBlank(content)) {
-        sidebarClasses = ['sidebar'];
+        sidebarClasses = ['sidebar', topClass];
         sidebarContent = content;
       } else if (multiple) {
-        sidebarClasses = ['sidebar-multiple'];
+        sidebarClasses = ['sidebar-multiple', topClass];
         sidebarContent =
           multiple
             .filter(Boolean)
-            .map(({content}) =>
-              html.tag('div',
-                {
-                  [html.onlyIfContent]: true,
-                  class: 'sidebar',
-                },
-                content));
+            .map(box =>
+              html.tag('div', {
+                [html.onlyIfContent]: true,
+                class: ['sidebar', box.class],
+              }, box.content));
       }
 
       if (html.isBlank(sidebarContent)) {
@@ -609,7 +624,7 @@ export default {
 
             html.tag('link', {
               rel: 'stylesheet',
-              href: to('shared.staticFile', 'site5.css', cachebust),
+              href: to('shared.staticFile', 'site6.css', cachebust),
             }),
 
             html.tag('style', [
@@ -646,7 +661,7 @@ export default {
 
               html.tag('script', {
                 type: 'module',
-                src: to('shared.staticFile', 'client2.js', cachebust),
+                src: to('shared.staticFile', 'client3.js', cachebust),
               }),
             ]),
         ])
diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js
new file mode 100644
index 00000000..bbe33188
--- /dev/null
+++ b/src/content/dependencies/generateRelativeDatetimestamp.js
@@ -0,0 +1,58 @@
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateDatetimestampTemplate',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  data: (currentDate, referenceDate) =>
+    (currentDate.getTime() === referenceDate.getTime()
+      ? {equal: true, date: currentDate}
+      : {equal: false, currentDate, referenceDate}),
+
+  relations: (relation, currentDate) =>
+    ({template: relation('generateDatetimestampTemplate'),
+      fallback: relation('generateAbsoluteDatetimestamp', currentDate)}),
+
+  slots: {
+    style: {
+      validate: v => v.is('full', 'year'),
+      default: 'full',
+    },
+
+    tooltip: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (data.comparison === 'equal') {
+      return relations.fallback.slots({
+        style: slots.style,
+        tooltip: slots.tooltip,
+      });
+    }
+
+    return relations.template.slots({
+      mainContent:
+        (slots.style === 'full'
+          ? language.formatDate(data.currentDate)
+       : slots.style === 'year'
+          ? data.currentDate.getFullYear().toString()
+          : null),
+
+      tooltipContent:
+        slots.tooltip &&
+          language.formatRelativeDate(data.currentDate, data.referenceDate, {
+            considerRoundingDays: true,
+            approximate: true,
+            absolute: slots.style === 'year',
+          }),
+
+      datetime:
+        data.currentDate.toISOString(),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
index 1fa8dcca..2e6c4709 100644
--- a/src/content/dependencies/generateReleaseInfoContributionsLine.js
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -35,6 +35,7 @@ export default {
             link.slots({
               showContribution: slots.showContribution,
               showIcons: slots.showIcons,
+              iconMode: 'tooltip',
             }))),
     });
   },
diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js
index 3e27fd43..226152c7 100644
--- a/src/content/dependencies/generateStaticPage.js
+++ b/src/content/dependencies/generateStaticPage.js
@@ -1,5 +1,6 @@
 export default {
   contentDependencies: ['generatePageLayout', 'transformContent'],
+  extraDependencies: ['html'],
 
   relations(relation, staticPage) {
     return {
@@ -12,10 +13,11 @@ export default {
     return {
       name: staticPage.name,
       stylesheet: staticPage.stylesheet,
+      script: staticPage.script,
     };
   },
 
-  generate(data, relations) {
+  generate(data, relations, {html}) {
     return relations.layout
       .slots({
         title: data.name,
@@ -27,7 +29,12 @@ export default {
             : []),
 
         mainClasses: ['long-content'],
-        mainContent: relations.content,
+        mainContent: [
+          relations.content,
+
+          data.script &&
+            html.tag('script', data.script),
+        ],
 
         navLinkStyle: 'hierarchical',
         navLinks: [
diff --git a/src/content/dependencies/generateTrackAdditionalNamesBox.js b/src/content/dependencies/generateTrackAdditionalNamesBox.js
new file mode 100644
index 00000000..bad04b74
--- /dev/null
+++ b/src/content/dependencies/generateTrackAdditionalNamesBox.js
@@ -0,0 +1,53 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['generateAdditionalNamesBox'],
+  extraDependencies: ['html'],
+
+  query: (track) => {
+    const {
+      additionalNames: own,
+      sharedAdditionalNames: shared,
+      inferredAdditionalNames: inferred,
+    } = track;
+
+    if (empty(own) && empty(shared) && empty(inferred)) {
+      return {combinedList: []};
+    }
+
+    const firstFilter =
+      (empty(own)
+        ? new Set()
+        : new Set(own.map(({name}) => name)));
+
+    const sharedFiltered =
+      shared.filter(({name}) => !firstFilter.has(name))
+
+    const secondFilter =
+      new Set([
+        ...firstFilter,
+        ...sharedFiltered.map(({name}) => name),
+      ]);
+
+    const inferredFiltered =
+      inferred.filter(({name}) => !secondFilter.has(name));
+
+    return {
+      combinedList: [
+        ...own,
+        ...sharedFiltered,
+        ...inferredFiltered,
+      ],
+    };
+  },
+
+  relations: (relation, query) => ({
+    box:
+      (empty(query.combinedList)
+        ? null
+        : relation('generateAdditionalNamesBox', query.combinedList)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.box ?? html.blank(),
+};
diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js
index ec0488e2..6c056c9a 100644
--- a/src/content/dependencies/generateTrackCoverArtwork.js
+++ b/src/content/dependencies/generateTrackCoverArtwork.js
@@ -1,20 +1,28 @@
 export default {
   contentDependencies: ['generateCoverArtwork'],
 
-  relations: (relation, track) =>
-    ({coverArtwork:
-        relation('generateCoverArtwork',
-          (track.hasUniqueCoverArt
-            ? track.artTags
-            : track.album.artTags))}),
-
-  data: (track) =>
-    ({path:
+  relations: (relation, track) => ({
+    coverArtwork:
+      relation('generateCoverArtwork',
         (track.hasUniqueCoverArt
-          ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
-          : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension])}),
+          ? track.artTags
+          : track.album.artTags)),
+  }),
+
+  data: (track) => ({
+    path:
+      (track.hasUniqueCoverArt
+        ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
+        : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension]),
+
+    color:
+      track.color,
+  }),
 
   generate: (data, relations) =>
-    relations.coverArtwork.slot('path', data.path),
+    relations.coverArtwork.slots({
+      path: data.path,
+      color: data.color,
+    }),
 };
 
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 93334948..041f6bbc 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,19 +1,24 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 import {sortAlbumsTracksChronologically, sortFlashesChronologically} from '#wiki-data';
 
 import getChronologyRelations from '../util/getChronologyRelations.js';
 
 export default {
   contentDependencies: [
+    'generateAbsoluteDatetimestamp',
     'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
     'generateAlbumSidebar',
     'generateAlbumStyleRules',
     'generateChronologyLinks',
+    'generateColorStyleVariables',
+    'generateCommentarySection',
     'generateContentHeading',
     'generateContributionList',
     'generatePageLayout',
+    'generateRelativeDatetimestamp',
+    'generateTrackAdditionalNamesBox',
     'generateTrackCoverArtwork',
     'generateTrackList',
     'generateTrackListDividedByGroups',
@@ -106,6 +111,10 @@ export default {
       list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
     });
 
+    // This'll take care of itself being blank if there's nothing to show here.
+    relations.additionalNamesBox =
+      relation('generateTrackAdditionalNamesBox', track);
+
     if (track.hasUniqueCoverArt || album.hasCoverArt) {
       relations.cover =
         relation('generateTrackCoverArtwork', track);
@@ -133,6 +142,29 @@ export default {
       otherReleases.heading =
         relation('generateContentHeading');
 
+      otherReleases.colorVariables =
+        track.otherReleases
+          .map(() => relation('generateColorStyleVariables'));
+
+      otherReleases.trackLinks =
+        track.otherReleases
+          .map(track => relation('linkTrack', track));
+
+      otherReleases.albumLinks =
+        track.otherReleases
+          .map(track => relation('linkAlbum', track.album));
+
+      otherReleases.datetimestamps =
+        track.otherReleases.map(track2 =>
+          (track2.date
+            ? (track.date
+                ? relation('generateRelativeDatetimestamp',
+                    track2.date,
+                    track.date)
+                : relation('generateAbsoluteDatetimestamp',
+                    track2.date))
+            : null));
+
       otherReleases.items =
         track.otherReleases.map(track => ({
           trackLink: relation('linkTrack', track),
@@ -268,13 +300,8 @@ export default {
     // Section: Artist commentary
 
     if (track.commentary) {
-      const artistCommentary = sections.artistCommentary = {};
-
-      artistCommentary.heading =
-        relation('generateContentHeading');
-
-      artistCommentary.content =
-        relation('transformContent', track.commentary);
+      sections.artistCommentary =
+        relation('generateCommentarySection', track.commentary);
     }
 
     return relations;
@@ -288,6 +315,9 @@ export default {
       hasTrackNumbers: track.album.hasTrackNumbers,
       trackNumber: track.album.tracks.indexOf(track) + 1,
 
+      otherReleaseColors:
+        track.otherReleases.map(track => track.color),
+
       numAdditionalFiles: track.additionalFiles.length,
     };
   },
@@ -300,6 +330,8 @@ export default {
         title: language.$('trackPage.title', {track: data.name}),
         headingMode: 'sticky',
 
+        additionalNames: relations.additionalNamesBox,
+
         color: data.color,
         styleRules: [relations.albumStyleRules],
 
@@ -352,12 +384,39 @@ export default {
               }),
 
             html.tag('ul',
-              sec.otherReleases.items.map(({trackLink, albumLink}) =>
-                html.tag('li',
-                  language.$('releaseInfo.alsoReleasedAs.item', {
-                    track: trackLink,
-                    album: albumLink,
-                  })))),
+              stitchArrays({
+                trackLink: sec.otherReleases.trackLinks,
+                albumLink: sec.otherReleases.albumLinks,
+                datetimestamp: sec.otherReleases.datetimestamps,
+                colorVariables: sec.otherReleases.colorVariables,
+                color: data.otherReleaseColors,
+              }).map(({
+                  trackLink,
+                  albumLink,
+                  datetimestamp,
+                  colorVariables,
+                  color,
+                }) => {
+                  const parts = ['releaseInfo.alsoReleasedAs.item'];
+                  const options = {};
+
+                  options.track = trackLink.slot('color', false);
+                  options.album = albumLink;
+
+                  if (datetimestamp) {
+                    parts.push('withYear');
+                    options.year =
+                      datetimestamp.slots({
+                        style: 'year',
+                        tooltip: true,
+                      });
+                  }
+
+                  return (
+                    html.tag('li',
+                      {style: colorVariables.slot('color', color).content},
+                      language.$(...parts, options)));
+                })),
           ],
 
           sec.contributors && [
@@ -491,17 +550,7 @@ export default {
             sec.additionalFiles.list,
           ],
 
-          sec.artistCommentary && [
-            sec.artistCommentary.heading
-              .slots({
-                id: 'artist-commentary',
-                title: language.$('releaseInfo.artistCommentary')
-              }),
-
-            html.tag('blockquote',
-              sec.artistCommentary.content
-                .slot('mode', 'multiline')),
-          ],
+          sec.artistCommentary,
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 9a7478ca..c347dbce 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -77,7 +77,10 @@ export default {
       html.tag('p',
         (relations.externalLinks
           ? language.$('releaseInfo.listenOn', {
-              links: language.formatDisjunctionList(relations.externalLinks),
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'track'))),
             })
           : language.$('releaseInfo.listenOn.noLinks', {
               name: html.tag('i', data.name),
diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js
index cb0860f5..a19f104c 100644
--- a/src/content/dependencies/generateWikiHomeAlbumsRow.js
+++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js
@@ -11,7 +11,7 @@ export default {
     'transformContent',
   ],
 
-  extraDependencies: ['wikiData'],
+  extraDependencies: ['language', 'wikiData'],
 
   sprawl({albumData}, row) {
     const sprawl = {};
@@ -90,12 +90,14 @@ export default {
     data.paths =
       sprawl.albums
         .map(album =>
-          ['media.albumCover', album.directory, album.coverArtFileExtension]);
+          (album.hasCoverArt
+            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
+            : null));
 
     return data;
   },
 
-  generate(data, relations) {
+  generate(data, relations, {language}) {
     // Grids and carousels share some slots! Very convenient.
     const commonSlots = {};
 
@@ -106,8 +108,16 @@ export default {
       stitchArrays({
         image: relations.images,
         path: data.paths,
-      }).map(({image, path}) =>
-          image.slot('path', path));
+        name: data.names ?? data.paths.slice().fill(null),
+      }).map(({image, path, name}) =>
+          image.slots({
+            path,
+            missingSourceContent:
+              name &&
+                language.$('misc.albumGrid.noCoverArt', {
+                  album: name,
+                }),
+            }));
 
     commonSlots.actionLinks =
       (relations.actionLinks
diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js
index 8acd426c..0d8303f1 100644
--- a/src/content/dependencies/generateWikiHomeNewsBox.js
+++ b/src/content/dependencies/generateWikiHomeNewsBox.js
@@ -42,6 +42,7 @@ export default {
     }
 
     return {
+      class: 'latest-news-sidebar-box',
       content: [
         html.tag('h1', language.$('homepage.news.title')),
 
diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js
index 40a6b1c5..36fcc6f2 100644
--- a/src/content/dependencies/generateWikiHomePage.js
+++ b/src/content/dependencies/generateWikiHomePage.js
@@ -75,6 +75,7 @@ export default {
       leftSidebarMultiple: [
         (relations.customSidebarContent
           ? {
+              class: 'custom-content-sidebar-box',
               content:
                 relations.customSidebarContent
                   .slot('mode', 'multiline'),
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 8aa9753b..3c78abe3 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -14,6 +14,12 @@ export default {
     'to',
   ],
 
+  contentDependencies: ['generateColorStyleVariables'],
+
+  relations: (relation) => ({
+    colorVariables: relation('generateColorStyleVariables'),
+  }),
+
   data(artTags) {
     const data = {};
 
@@ -43,6 +49,10 @@ export default {
       default: false,
     },
 
+    color: {
+      validate: v => v.isColor,
+    },
+
     reveal: {type: 'boolean', default: true},
     lazy: {type: 'boolean', default: false},
     square: {type: 'boolean', default: false},
@@ -56,7 +66,7 @@ export default {
     missingSourceContent: {type: 'html'},
   },
 
-  generate(data, slots, {
+  generate(data, relations, slots, {
     checkIfImagePathHasCachedThumbnails,
     getDimensionsOfImagePath,
     getSizeOfImagePath,
@@ -110,6 +120,12 @@ export default {
       !isMissingImageFile &&
       !empty(data.contentWarnings);
 
+    const colorStyle =
+      slots.color &&
+        relations.colorVariables
+          .slot('color', slots.color)
+          .content;
+
     const willSquare = slots.square;
 
     const idOnImg = willLink ? null : slots.id;
@@ -118,6 +134,9 @@ export default {
     const classOnImg = willLink ? null : slots.class;
     const classOnLink = willLink ? slots.class : null;
 
+    const styleOnContainer = willLink ? null : colorStyle;
+    const styleOnLink = willLink ? colorStyle : null;
+
     if (!originalSrc || isMissingImageFile) {
       return prepare(
         html.tag('div', {class: 'image-text-area'},
@@ -191,7 +210,7 @@ export default {
       imgAttributes['data-no-image-preview'] = true;
     }
 
-    // These attributes are only relevant when a thumbnail are available *and*
+    // These attributes are only relevant when a thumbnail is available *and*
     // being used.
     if (hasThumbnails && slots.thumb) {
       if (fileSize) {
@@ -238,9 +257,13 @@ export default {
       let wrapped = content;
 
       wrapped =
-        html.tag('div', {class: ['image-container', !originalSrc && 'placeholder-image']},
+        html.tag('div', {
+          class: ['image-container', !originalSrc && 'placeholder-image'],
+          style: styleOnContainer,
+        }, [
           html.tag('div', {class: 'image-inner-area'},
-            wrapped));
+            wrapped),
+        ]);
 
       if (willReveal) {
         wrapped =
@@ -270,6 +293,7 @@ export default {
         wrapped = html.tag('a',
           {
             id: idOnLink,
+
             class: [
               'box',
               'image-link',
@@ -277,6 +301,8 @@ export default {
               classOnLink,
             ],
 
+            style: styleOnLink,
+
             href:
               (typeof slots.link === 'string'
                 ? slots.link
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index 8e42f247..790afa4f 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -1,15 +1,8 @@
 import {empty} from '#sugar';
 
 export default {
-  contentDependencies: [
-    'linkArtist',
-    'linkExternalAsIcon',
-  ],
-
-  extraDependencies: [
-    'html',
-    'language',
-  ],
+  contentDependencies: ['linkArtist', 'linkExternalAsIcon'],
+  extraDependencies: ['html', 'language'],
 
   relations(relation, contribution) {
     const relations = {};
@@ -20,7 +13,6 @@ export default {
     if (!empty(contribution.who.urls)) {
       relations.artistIcons =
         contribution.who.urls
-          .slice(0, 4)
           .map(url => relation('linkExternalAsIcon', url));
     }
 
@@ -37,37 +29,81 @@ export default {
     showContribution: {type: 'boolean', default: false},
     showIcons: {type: 'boolean', default: false},
     preventWrapping: {type: 'boolean', default: true},
+
+    iconMode: {
+      validate: v => v.is('inline', 'tooltip'),
+      default: 'inline'
+    },
   },
 
   generate(data, relations, slots, {html, language}) {
-    const hasContributionPart = !!(slots.showContribution && data.what);
-    const hasExternalPart = !!(slots.showIcons && relations.artistIcons);
-
-    const externalLinks = hasExternalPart &&
-      html.tag('span',
-        {[html.noEdgeWhitespace]: true, class: 'icons'},
-        language.formatUnitList(relations.artistIcons));
+    const hasContribution = !!(slots.showContribution && data.what);
+    const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
 
     const parts = ['misc.artistLink'];
     const options = {artist: relations.artistLink};
 
-    if (hasContributionPart) {
+    if (hasContribution) {
       parts.push('withContribution');
       options.contrib = data.what;
     }
 
-    if (hasExternalPart) {
+    if (hasExternalIcons && slots.iconMode === 'inline') {
       parts.push('withExternalLinks');
-      options.links = externalLinks;
+      options.links =
+        html.tag('span',
+          {
+            [html.noEdgeWhitespace]: true,
+            class: ['icons', 'icons-inline'],
+          },
+          language.formatUnitList(
+            relations.artistIcons
+              .slice(0, 4)
+              .map(icon => icon.slot('context', 'artist'))));
     }
 
-    const content = language.formatString(parts.join('.'), options);
+    let content = language.formatString(parts.join('.'), options);
 
-    return (
-      (parts.length > 1 && slots.preventWrapping
-        ? html.tag('span',
-            {[html.noEdgeWhitespace]: true, class: 'nowrap'},
-            content)
-        : content));
-    },
+    if (hasExternalIcons && slots.iconMode === 'tooltip') {
+      content = [
+        content,
+        html.tag('span',
+          {
+            [html.noEdgeWhitespace]: true,
+            class: ['icons', 'icons-tooltip'],
+            inert: true,
+          },
+          html.tag('span',
+            {
+              [html.noEdgeWhitespace]: true,
+              [html.joinChildren]: '',
+              class: 'icons-tooltip-content',
+            },
+            relations.artistIcons
+              .map(icon => icon.slots({context: 'artist', withText: true})))),
+      ];
+    }
+
+    if (hasContribution || hasExternalIcons) {
+      content =
+        html.tag('span', {
+          [html.noEdgeWhitespace]: true,
+          [html.joinChildren]: '',
+
+          class: [
+            'contribution',
+
+            hasExternalIcons &&
+            slots.iconMode === 'tooltip' &&
+              'has-tooltip',
+
+            parts.length > 1 &&
+            slots.preventWrapping &&
+              'nowrap',
+          ],
+        }, content);
+    }
+
+    return content;
+  }
 };
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index 5de612e2..70e1ccff 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -1,140 +1,42 @@
-// TODO: Define these as extra dependencies and pass them somewhere
-const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
-const MASTODON_DOMAINS = ['types.pl'];
+import {isExternalLinkContext, isExternalLinkStyle} from '#external-links';
 
 export default {
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl: ({wikiInfo}) => ({wikiInfo}),
-
-  data(sprawl, url) {
-    const data = {url};
-
-    const {canonicalBase} = sprawl.wikiInfo;
-    if (canonicalBase) {
-      const {hostname: canonicalDomain} = new URL(canonicalBase);
-      Object.assign(data, {canonicalDomain});
-    }
-
-    return data;
-  },
+  data: (url) => ({url}),
 
   slots: {
-    mode: {
-      validate: v => v.is('generic', 'album', 'flash'),
-      default: 'generic',
+    style: {
+      // This awkward syntax is because the slot descriptor validator can't
+      // differentiate between a function that returns a validator (the usual
+      // syntax) and a function that is itself a validator.
+      validate: () => isExternalLinkStyle,
+      default: 'normal',
     },
-  },
 
-  generate(data, slots, {html, language}) {
-    let isLocal;
-    let domain;
-    let pathname;
-
-    try {
-      const url = new URL(data.url);
-      domain = url.hostname;
-      pathname = url.pathname;
-    } catch (error) {
-      // No support for relative local URLs yet, sorry! (I.e, local URLs must
-      // be absolute relative to the domain name in order to work.)
-      isLocal = true;
-      domain = null;
-      pathname = null;
-    }
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
 
-    // isLocal also applies for URLs which match the 'Canonical Base' under
-    // wiki-info.yaml, if present.
-    if (data.canonicalDomain && domain === data.canonicalDomain) {
-      isLocal = true;
-    }
+    tab: {
+      validate: v => v.is('default', 'separate'),
+      default: 'default',
+    },
+  },
 
-    const link = html.tag('a',
+  generate: (data, slots, {html, language}) =>
+    html.tag('a',
       {
         href: data.url,
         class: 'nowrap',
+        target:
+          (slots.tab === 'separate'
+            ? '_blank'
+            : null),
       },
-
-      // truly unhinged indentation here
-      isLocal
-        ? language.$('misc.external.local')
-
-    : domain.includes('bandcamp.com')
-        ? language.$('misc.external.bandcamp')
-
-    : BANDCAMP_DOMAINS.includes(domain)
-        ? language.$('misc.external.bandcamp.domain', {domain})
-
-    : MASTODON_DOMAINS.includes(domain)
-        ? language.$('misc.external.mastodon.domain', {domain})
-
-    : domain.includes('youtu')
-        ? slots.mode === 'album'
-          ? data.url.includes('list=')
-            ? language.$('misc.external.youtube.playlist')
-            : language.$('misc.external.youtube.fullAlbum')
-          : language.$('misc.external.youtube')
-
-    : domain.includes('soundcloud')
-        ? language.$('misc.external.soundcloud')
-
-    : domain.includes('tumblr.com')
-        ? language.$('misc.external.tumblr')
-
-    : domain.includes('twitter.com')
-        ? language.$('misc.external.twitter')
-
-    : domain.includes('deviantart.com')
-        ? language.$('misc.external.deviantart')
-
-    : domain.includes('wikipedia.org')
-        ? language.$('misc.external.wikipedia')
-
-    : domain.includes('poetryfoundation.org')
-        ? language.$('misc.external.poetryFoundation')
-
-    : domain.includes('instagram.com')
-        ? language.$('misc.external.instagram')
-
-    : domain.includes('patreon.com')
-        ? language.$('misc.external.patreon')
-
-    : domain.includes('spotify.com')
-        ? language.$('misc.external.spotify')
-
-    : domain.includes('newgrounds.com')
-        ? language.$('misc.external.newgrounds')
-
-        : domain);
-
-    switch (slots.mode) {
-      case 'flash': {
-        const wrap = content =>
-          html.tag('span', {class: 'nowrap'}, content);
-
-        if (domain.includes('homestuck.com')) {
-          const match = pathname.match(/\/story\/(.*)\/?/);
-          if (match) {
-            if (isNaN(Number(match[1]))) {
-              return wrap(language.$('misc.external.flash.homestuck.secret', {link}));
-            } else {
-              return wrap(language.$('misc.external.flash.homestuck.page', {
-                link,
-                page: match[1],
-              }));
-            }
-          }
-        } else if (domain.includes('bgreco.net')) {
-          return wrap(language.$('misc.external.flash.bgreco', {link}));
-        } else if (domain.includes('youtu')) {
-          return wrap(language.$('misc.external.flash.youtube', {link}));
-        }
-
-        return link;
-      }
-
-      default:
-        return link;
-    }
-  }
+      language.formatExternalLink(data.url, {
+        style: slots.style,
+        context: slots.context,
+      })),
 };
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
index cd168992..357c835c 100644
--- a/src/content/dependencies/linkExternalAsIcon.js
+++ b/src/content/dependencies/linkExternalAsIcon.js
@@ -1,46 +1,45 @@
-// TODO: Define these as extra dependencies and pass them somewhere
-const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
-const MASTODON_DOMAINS = ['types.pl'];
+import {isExternalLinkContext} from '#external-links';
 
 export default {
   extraDependencies: ['html', 'language', 'to'],
 
-  data(url) {
-    return {url};
+  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, {html, language, to}) {
-    const domain = new URL(data.url).hostname;
-    const [id, msg] = (
-      domain.includes('bandcamp.com')
-        ? ['bandcamp', language.$('misc.external.bandcamp')]
-      : BANDCAMP_DOMAINS.includes(domain)
-        ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})]
-      : MASTODON_DOMAINS.includes(domain)
-        ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})]
-      : domain.includes('youtu')
-        ? ['youtube', language.$('misc.external.youtube')]
-      : domain.includes('soundcloud')
-        ? ['soundcloud', language.$('misc.external.soundcloud')]
-      : domain.includes('tumblr.com')
-        ? ['tumblr', language.$('misc.external.tumblr')]
-      : domain.includes('twitter.com')
-        ? ['twitter', language.$('misc.external.twitter')]
-      : domain.includes('deviantart.com')
-        ? ['deviantart', language.$('misc.external.deviantart')]
-      : domain.includes('instagram.com')
-        ? ['instagram', language.$('misc.external.bandcamp')]
-      : domain.includes('newgrounds.com')
-        ? ['newgrounds', language.$('misc.external.newgrounds')]
-        : ['globe', language.$('misc.external.domain', {domain})]);
+  generate(data, slots, {html, language, to}) {
+    const format = style =>
+      language.formatExternalLink(data.url, {style, context: slots.context});
+
+    const normalText = format('normal');
+    const compactText = format('compact');
+    const iconId = format('icon-id');
 
     return html.tag('a',
-      {href: data.url, class: 'icon'},
-      html.tag('svg', [
-        html.tag('title', msg),
-        html.tag('use', {
-          href: to('shared.staticIcon', id),
-        }),
-      ]));
+      {href: data.url, class: ['icon', slots.withText && 'has-text']},
+      [
+        html.tag('svg', [
+          !slots.withText &&
+            html.tag('title', normalText),
+
+          html.tag('use', {
+            href: to('shared.staticIcon', iconId),
+          }),
+        ]),
+
+        slots.withText &&
+          html.tag('span', {class: 'icon-text'},
+            compactText ?? normalText),
+      ]);
   },
 };
diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js
deleted file mode 100644
index 65158ff8..00000000
--- a/src/content/dependencies/linkExternalFlash.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// Note: This function is seriously hard-coded for HSMusic, with custom
-// presentation of links to Homestuck flashes hosted various places.
-
-export default {
-  contentDependencies: ['linkExternal'],
-  extraDependencies: ['html', 'language'],
-
-  relations(relation, url) {
-    return {
-      link: relation('linkExternal', url),
-    };
-  },
-
-  data(url, flash) {
-    return {
-      url,
-      page: flash.page,
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    const {link} = relations;
-    const {url, page} = data;
-
-    return html.tag('span',
-      {class: 'nowrap'},
-
-      url.includes('homestuck.com')
-        ? isNaN(Number(page))
-          ? language.$('misc.external.flash.homestuck.secret', {link})
-          : language.$('misc.external.flash.homestuck.page', {link, page})
-
-    : url.includes('bgreco.net')
-        ? language.$('misc.external.flash.bgreco', {link})
-
-    : url.includes('youtu')
-        ? language.$('misc.external.flash.youtube', {link})
-
-        : link);
-  },
-};
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
index d9af726c..a361a4e7 100644
--- a/src/content/dependencies/linkTemplate.js
+++ b/src/content/dependencies/linkTemplate.js
@@ -64,6 +64,14 @@ export default {
       style = `--primary-color: ${primary}; --dim-color: ${dim}`;
     }
 
+    if (slots.attributes?.style) {
+      if (style) {
+        style += '; ' + slots.attributes.style;
+      } else {
+        style = slots.attributes.style;
+      }
+    }
+
     if (slots.tooltip) {
       title = slots.tooltip;
     }
diff --git a/src/content/dependencies/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js
new file mode 100644
index 00000000..242cd4cb
--- /dev/null
+++ b/src/content/dependencies/linkTrackDynamically.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, track) => ({
+    infoLink: relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    trackDirectory:
+      track.directory,
+
+    albumDirectory:
+      track.album.directory,
+
+    trackHasCommentary:
+      !!track.commentary,
+  }),
+
+  generate(data, relations, {pagePath}) {
+    if (
+      pagePath[0] === 'albumCommentary' &&
+      pagePath[1] === data.albumDirectory &&
+      data.trackHasCommentary
+    ) {
+      relations.infoLink.setSlots({
+        anchor: true,
+        hash: data.trackDirectory,
+      });
+    }
+
+    return relations.infoLink;
+  },
+};
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
index 86c8cfa2..58c51a40 100644
--- a/src/content/dependencies/listArtistsByContributions.js
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -1,5 +1,11 @@
-import {stitchArrays, unique} from '#sugar';
-import {filterByCount, sortAlphabetically, sortByCount} from '#wiki-data';
+import {empty, stitchArrays, unique} from '#sugar';
+
+import {
+  filterByCount,
+  filterMultipleArrays,
+  sortAlphabetically,
+  sortByCount,
+} from '#wiki-data';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist'],
@@ -96,68 +102,54 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    const lists = Object.fromEntries(
-      ([
-        ['tracks', [
-          relations.artistLinksByTrackContributions,
-          data.countsByTrackContributions,
-          'countTracks',
-        ]],
-
-        ['artworks', [
-          relations.artistLinksByArtworkContributions,
-          data.countsByArtworkContributions,
-          'countArtworks',
-        ]],
-
-        data.enableFlashesAndGames &&
-          ['flashes', [
-            relations.artistLinksByFlashContributions,
-            data.countsByFlashContributions,
-            'countFlashes',
-          ]],
-      ]).filter(Boolean)
-        .map(([key, [artistLinks, counts, countFunction]]) => [
-          key,
-          html.tag('ul',
-            stitchArrays({
-              artistLink: artistLinks,
-              count: counts,
-            }).map(({artistLink, count}) =>
-                html.tag('li',
-                  language.$('listingPage.listArtists.byContribs.item', {
-                    artist: artistLink,
-                    contributions: language[countFunction](count, {unit: true}),
-                  })))),
-        ]));
+  generate(data, relations, {language}) {
+    const listChunkIDs = ['tracks', 'artworks', 'flashes'];
+    const listTitleStringsKeys = ['trackContributors', 'artContributors', 'flashContributors'];
+    const listCountFunctions = ['countTracks', 'countArtworks', 'countFlashes'];
+
+    const listArtistLinks = [
+      relations.artistLinksByTrackContributions,
+      relations.artistLinksByArtworkContributions,
+      relations.artistLinksByFlashContributions,
+    ];
+
+    const listArtistCounts = [
+      data.countsByTrackContributions,
+      data.countsByArtworkContributions,
+      data.countsByFlashContributions,
+    ];
+
+    filterMultipleArrays(
+      listChunkIDs,
+      listTitleStringsKeys,
+      listCountFunctions,
+      listArtistLinks,
+      listArtistCounts,
+      (_chunkID, _titleStringsKey, _countFunction, artistLinks, _artistCounts) =>
+        !empty(artistLinks));
 
     return relations.page.slots({
-      type: 'custom',
-      content:
-        html.tag('div', {class: 'content-columns'}, [
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$('listingPage.misc.trackContributors')),
-
-            lists.tracks,
-          ]),
-
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$(
-                'listingPage.misc.artContributors')),
-
-            lists.artworks,
-
-            lists.flashes && [
-              html.tag('h2',
-                language.$('listingPage.misc.flashContributors')),
-
-              lists.flashes,
-            ],
-          ]),
-        ]),
+      type: 'chunks',
+
+      showSkipToSection: true,
+      chunkIDs: listChunkIDs,
+
+      chunkTitles:
+        listTitleStringsKeys.map(stringsKey => ({stringsKey})),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: listArtistLinks,
+          artistCounts: listArtistCounts,
+          countFunction: listCountFunctions,
+        }).map(({artistLinks, artistCounts, countFunction}) =>
+            stitchArrays({
+              artistLink: artistLinks,
+              artistCount: artistCounts,
+            }).map(({artistLink, artistCount}) => ({
+                artist: artistLink,
+                contributions: language[countFunction](artistCount, {unit: true}),
+              }))),
     });
   },
 };
diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js
new file mode 100644
index 00000000..3778b9e3
--- /dev/null
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -0,0 +1,133 @@
+import {empty, stitchArrays, unique} from '#sugar';
+
+import {
+  filterMultipleArrays,
+  getArtistNumContributions,
+  sortAlphabetically,
+} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData, wikiInfo}) {
+    return {artistData, wikiInfo};
+  },
+
+  query(sprawl, spec) {
+    const artists = sortAlphabetically(sprawl.artistData.slice());
+    const groups = sprawl.wikiInfo.divideTrackListsByGroups;
+
+    if (empty(groups)) {
+      return {spec, artists};
+    }
+
+    const artistGroups =
+      artists.map(artist =>
+        unique(
+          unique([
+            ...artist.albumsAsAny,
+            ...artist.tracksAsAny.map(track => track.album),
+          ]).flatMap(album => album.groups)))
+
+    const artistsByGroup =
+      groups.map(group =>
+        artists.filter((artist, index) => artistGroups[index].includes(group)));
+
+    filterMultipleArrays(groups, artistsByGroup,
+      (group, artists) => !empty(artists));
+
+    return {spec, groups, artistsByGroup};
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    if (query.artists) {
+      relations.artistLinks =
+        query.artists
+          .map(artist => relation('linkArtist', artist));
+    }
+
+    if (query.artistsByGroup) {
+      relations.groupLinks =
+        query.groups
+          .map(group => relation('linkGroup', group));
+
+      relations.artistLinksByGroup =
+        query.artistsByGroup
+          .map(artists => artists
+            .map(artist => relation('linkArtist', artist)));
+    }
+
+    return relations;
+  },
+
+  data(query) {
+    const data = {};
+
+    if (query.artists) {
+      data.counts =
+        query.artists
+          .map(artist => getArtistNumContributions(artist));
+    }
+
+    if (query.artistsByGroup) {
+      data.groupDirectories =
+        query.groups
+          .map(group => group.directory);
+
+      data.countsByGroup =
+        query.artistsByGroup
+          .map(artists => artists
+            .map(artist => getArtistNumContributions(artist)));
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {language}) {
+    return (
+      (relations.artistLinksByGroup
+        ? relations.page.slots({
+            type: 'chunks',
+
+            showSkipToSection: true,
+            chunkIDs:
+              data.groupDirectories
+                .map(directory => `contributed-to-${directory}`),
+
+            chunkTitles:
+              relations.groupLinks.map(groupLink => ({
+                group: groupLink,
+              })),
+
+            chunkRows:
+              stitchArrays({
+                artistLinks: relations.artistLinksByGroup,
+                counts: data.countsByGroup,
+              }).map(({artistLinks, counts}) =>
+                  stitchArrays({
+                    link: artistLinks,
+                    count: counts,
+                  }).map(({link, count}) => ({
+                      artist: link,
+                      contributions: language.countContributions(count, {unit: true}),
+                    }))),
+          })
+        : relations.page.slots({
+            type: 'rows',
+            rows:
+              stitchArrays({
+                link: relations.artistLinks,
+                count: data.counts,
+              }).map(({link, count}) => ({
+                  artist: link,
+                  contributions: language.countContributions(count, {unit: true}),
+                })),
+          })));
+  },
+};
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 3870afde..45f8390f 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -1,15 +1,16 @@
-import {transposeArrays, empty, stitchArrays} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
+import T from '#things';
 
 import {
   chunkMultipleArrays,
-  compareCaseLessSensitive,
-  compareDates,
-  filterMultipleArrays,
-  reduceMultipleArrays,
   sortAlphabetically,
+  sortAlbumsTracksChronologically,
+  sortFlashesChronologically,
   sortMultipleArrays,
 } from '#wiki-data';
 
+const {Album, Flash} = T;
+
 export default {
   contentDependencies: [
     'generateListingPage',
@@ -20,348 +21,299 @@ export default {
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({artistData, wikiInfo}) {
-    return {
-      artistData,
-      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
-    };
-  },
+  sprawl: ({albumData, artistData, flashData, trackData, wikiInfo}) =>
+    ({albumData, artistData, flashData, trackData,
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames}),
 
   query(sprawl, spec) {
-    const query = {
-      spec,
-      enableFlashesAndGames: sprawl.enableFlashesAndGames,
-    };
-
-    const queryContributionInfo = (
-      artistsKey,
-      chunkThingsKey,
-      datesKey,
-      datelessArtistsKey,
-      fn,
-    ) => {
-      const artists = sortAlphabetically(sprawl.artistData.slice());
-
-      // Each value stored in dateLists, corresponding to each artist,
-      // is going to be a list of dates and nulls. Any nulls represent
-      // a contribution which isn't associated with a particular date.
-      const [chunkThingLists, dateLists] =
-        transposeArrays(artists.map(artist => fn(artist)));
-
-      // Scrap artists who don't even have any relevant contributions.
-      // These artists may still have other contributions across the wiki, but
-      // they weren't returned by the callback and so aren't relevant to this
-      // list.
-      filterMultipleArrays(
-        artists,
-        chunkThingLists,
-        dateLists,
-        (artists, chunkThings, dates) => !empty(dates));
-
-      // Also exclude artists whose remaining contributions are all dateless.
-      // But keep track of the artists removed here, since they'll be displayed
-      // in an additional list in the final listing page.
-      const {removed: [datelessArtists]} =
-        filterMultipleArrays(
-          artists,
-          chunkThingLists,
-          dateLists,
-          (artist, chunkThings, dates) => !empty(dates.filter(Boolean)));
-
-      // Cut out dateless contributions. They're not relevant to finding the
-      // latest date.
-      for (const [chunkThings, dates] of transposeArrays([chunkThingLists, dateLists])) {
-        filterMultipleArrays(chunkThings, dates, (chunkThing, date) => date);
+    //
+    // First main step is to get the latest thing each artist has contributed
+    // to, and the date associated with that contribution! Some notes:
+    //
+    // * Album and track contributions are considered before flashes, so
+    //   they'll take priority if an artist happens to have multiple contribs
+    //   landing on the same date to both an album and a flash.
+    //
+    // * The final (album) contribution list is chunked by album, but also by
+    //   date, because an individual album can cover a variety of dates.
+    //
+    // * If an artist has contributed both artworks and tracks to the album
+    //   containing their latest contribution, then that will be indicated
+    //   in an annotation, but *only if* those contributions were also on
+    //   the same date.
+    //
+    // * If an artist made contributions to multiple albums on the same date,
+    //   then the first of the *albums* sorted chronologically (latest first)
+    //   is the one that will count.
+    //
+    // * Same for artists who've contributed to multiple flashes which were
+    //   released on the same date.
+    //
+    // * The map may exclude artists none of whose contributions were dated.
+    //
+
+    const artistLatestContribMap = new Map();
+
+    const considerDate = (artist, date, thing, contribution) => {
+      if (!date) {
+        return;
       }
 
-      const [chunkThings, dates] =
-        transposeArrays(
-          transposeArrays([chunkThingLists, dateLists])
-            .map(([chunkThings, dates]) =>
-              reduceMultipleArrays(
-                chunkThings, dates,
-                (accChunkThing, accDate, chunkThing, date) =>
-                  (date && date > accDate
-                    ? [chunkThing, date]
-                    : [accChunkThing, accDate]))));
-
-      sortMultipleArrays(artists, dates, chunkThings,
-        (artistA, artistB, dateA, dateB, chunkThingA, chunkThingB) => {
-          const dateComparison = compareDates(dateA, dateB, {latestFirst: true});
-          if (dateComparison !== 0) {
-            return dateComparison;
-          }
-
-          // TODO: Compare alphabetically, not just by directory.
-          return compareCaseLessSensitive(chunkThingA.directory, chunkThingB.directory);
-        });
-
-      const chunks =
-        chunkMultipleArrays(artists, dates, chunkThings,
-          (artist, lastArtist, date, lastDate, chunkThing, lastChunkThing) =>
-            +date !== +lastDate || chunkThing !== lastChunkThing);
+      if (artistLatestContribMap.has(artist)) {
+        const latest = artistLatestContribMap.get(artist);
+        if (latest.date > date) {
+          return;
+        }
 
-      query[chunkThingsKey] =
-        chunks.map(([artists, dates, chunkThings]) => chunkThings[0]);
-
-      query[datesKey] =
-        chunks.map(([artists, dates, chunkThings]) => dates[0]);
+        if (latest.date === date) {
+          if (latest.thing === thing) {
+            // May combine differnt contributions to the same thing and date.
+            latest.contribution.add(contribution);
+          }
 
-      query[artistsKey] =
-        chunks.map(([artists, dates, chunkThings]) => artists);
+          // Earlier-processed things of same date take priority.
+          return;
+        }
+      }
 
-      query[datelessArtistsKey] = datelessArtists;
+      // First entry for artist or more recent contribution than latest date.
+      artistLatestContribMap.set(artist, {
+        date,
+        thing,
+        contribution: new Set([contribution]),
+      });
     };
 
-    queryContributionInfo(
-      'artistsByTrackContributions',
-      'albumsByTrackContributions',
-      'datesByTrackContributions',
-      'datelessArtistsByTrackContributions',
-      artist => {
-        const tracks =
-          [...artist.tracksAsArtist, ...artist.tracksAsContributor]
-            .filter(track => !track.originalReleaseTrack);
-
-        const albums = tracks.map(track => track.album);
-        const dates = tracks.map(track => track.date);
+    const getArtists = (thing, key) => thing[key].map(({who}) => who);
 
-        return [albums, dates];
-      });
+    const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice());
+    const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice());
+    const flashesLatestFirst = sortFlashesChronologically(sprawl.flashData.slice());
 
-    queryContributionInfo(
-      'artistsByArtworkContributions',
-      'albumsByArtworkContributions',
-      'datesByArtworkContributions',
-      'datelessArtistsByArtworkContributions',
-      artist => [
-        [
-          ...artist.tracksAsCoverArtist.map(track => track.album),
-          ...artist.albumsAsCoverArtist,
-          ...artist.albumsAsWallpaperArtist,
-          ...artist.albumsAsBannerArtist,
-        ],
-        [
-          // TODO: Per-artwork dates, see #90.
-          ...artist.tracksAsCoverArtist.map(track => track.coverArtDate ?? track.date),
-          ...artist.albumsAsCoverArtist.map(album => album.coverArtDate ?? album.date),
-          ...artist.albumsAsWallpaperArtist.map(album => album.coverArtDate ?? album.date),
-          ...artist.albumsAsBannerArtist.map(album => album.coverArtDate ?? album.date),
-        ],
-      ]);
-
-    if (sprawl.enableFlashesAndGames) {
-      queryContributionInfo(
-        'artistsByFlashContributions',
-        'flashesByFlashContributions',
-        'datesByFlashContributions',
-        'datelessArtistsByFlashContributions',
-        artist => [
-          [
-            ...artist.flashesAsContributor,
-          ],
-          [
-            ...artist.flashesAsContributor.map(flash => flash.date),
-          ],
-        ]);
+    for (const album of albumsLatestFirst) {
+      for (const artist of new Set([
+        ...getArtists(album, 'coverArtistContribs'),
+        ...getArtists(album, 'wallpaperArtistContribs'),
+        ...getArtists(album, 'bannerArtistContribs'),
+      ])) {
+        // Might combine later with 'track' of the same album and date.
+        considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork');
+      }
     }
 
-    return query;
-  },
-
-  relations(relation, query) {
-    const relations = {};
-
-    relations.page =
-      relation('generateListingPage', query.spec);
-
-    // Track contributors
-
-    relations.albumLinksByTrackContributions =
-      query.albumsByTrackContributions
-        .map(album => relation('linkAlbum', album));
-
-    relations.artistLinksByTrackContributions =
-      query.artistsByTrackContributions
-        .map(artists =>
-          artists.map(artist => relation('linkArtist', artist)));
-
-    relations.datelessArtistLinksByTrackContributions =
-      query.datelessArtistsByTrackContributions
-        .map(artist => relation('linkArtist', artist));
-
-    // Artwork contributors
-
-    relations.albumLinksByArtworkContributions =
-      query.albumsByArtworkContributions
-        .map(album => relation('linkAlbum', album));
-
-    relations.artistLinksByArtworkContributions =
-      query.artistsByArtworkContributions
-        .map(artists =>
-          artists.map(artist => relation('linkArtist', artist)));
+    for (const track of tracksLatestFirst) {
+      for (const artist of getArtists(track, 'coverArtistContribs')) {
+        // No special effect if artist already has 'artwork' for the same album and date.
+        considerDate(artist, track.coverArtDate ?? track.date, track.album, 'artwork');
+      }
 
-    relations.datelessArtistLinksByArtworkContributions =
-      query.datelessArtistsByArtworkContributions
-        .map(artist => relation('linkArtist', artist));
+      for (const artist of new Set([
+        ...getArtists(track, 'artistContribs'),
+        ...getArtists(track, 'contributorContribs'),
+      ])) {
+        // Might be combining with 'artwork' of the same album and date.
+        considerDate(artist, track.date, track.album, 'track');
+      }
+    }
 
-    // Flash contributors
+    for (const flash of flashesLatestFirst) {
+      for (const artist of getArtists(flash, 'contributorContribs')) {
+        // Won't take priority above album contributions of the same date.
+        considerDate(artist, flash.date, flash, 'flash');
+      }
+    }
 
-    if (query.enableFlashesAndGames) {
-      relations.flashLinksByFlashContributions =
-        query.flashesByFlashContributions
-          .map(flash => relation('linkFlash', flash));
+    //
+    // Next up is to sort all the processed artist information!
+    //
+    // Entries with the same album/flash and the same date go together first,
+    // with the following rules for sorting artists therein:
+    //
+    // * If the contributions are different, which can only happen for albums,
+    //   then it's tracks-only first, tracks + artworks next, and artworks-only
+    //   last.
+    //
+    // * If the contributions are the same, then sort alphabetically.
+    //
+    // Entries with different albums/flashes follow another set of rules:
+    //
+    // * Later dates come before earlier dates.
+    //
+    // * On the same date, albums come before flashes.
+    //
+    // * Things of the same type *and* date are sorted alphabetically.
+    //
+
+    const artistsAlphabetically =
+      sortAlphabetically(sprawl.artistData.slice());
+
+    const artists =
+      Array.from(artistLatestContribMap.keys());
+
+    const artistContribEntries =
+      Array.from(artistLatestContribMap.values());
+
+    const artistThings =
+      artistContribEntries.map(({thing}) => thing);
+
+    const artistDates =
+      artistContribEntries.map(({date}) => date);
+
+    const artistContributions =
+      artistContribEntries.map(({contribution}) => contribution);
+
+    sortMultipleArrays(artistThings, artistDates, artistContributions, artists,
+      (thing1, thing2, date1, date2, contrib1, contrib2, artist1, artist2) => {
+        if (date1 === date2 && thing1 === thing2) {
+          // Move artwork-only contribs after contribs with tracks.
+          if (!contrib1.has('track') && contrib2.has('track')) return 1;
+          if (!contrib2.has('track') && contrib1.has('track')) return -1;
+
+          // Move track-only contribs before tracks with tracks and artwork.
+          if (!contrib1.has('artwork') && contrib2.has('artwork')) return -1;
+          if (!contrib2.has('artwork') && contrib1.has('artwork')) return 1;
+
+          // Sort artists of the same type of contribution alphabetically,
+          // referring to a previous sort.
+          const index1 = artistsAlphabetically.indexOf(artist1);
+          const index2 = artistsAlphabetically.indexOf(artist2);
+          return index1 - index2;
+        } else {
+          // Move later dates before earlier ones.
+          if (date1 !== date2) return date2 - date1;
+
+          // Move albums before flashes.
+          if (thing1 instanceof Album && thing2 instanceof Flash) return -1;
+          if (thing1 instanceof Flash && thing2 instanceof Album) return 1;
+
+          // Sort two albums or two flashes alphabetically, referring to a
+          // previous sort (which was chronological but includes the correct
+          // ordering for things released on the same date).
+          const thingsLatestFirst =
+            (thing1 instanceof Album
+              ? albumsLatestFirst
+              : flashesLatestFirst);
+          const index1 = thingsLatestFirst.indexOf(thing1);
+          const index2 = thingsLatestFirst.indexOf(thing2);
+          return index2 - index1;
+        }
+      });
 
-      relations.artistLinksByFlashContributions =
-        query.artistsByFlashContributions
-          .map(artists =>
-            artists.map(artist => relation('linkArtist', artist)));
+    const chunks =
+      chunkMultipleArrays(artistThings, artistDates, artistContributions, artists,
+        (thing, lastThing, date, lastDate) =>
+          thing !== lastThing ||
+          +date !== +lastDate);
 
-      relations.datelessArtistLinksByFlashContributions =
-        query.datelessArtistsByFlashContributions
-          .map(artist => relation('linkArtist', artist));
-    }
+    const chunkThings =
+      chunks.map(([artistThings, , , ]) => artistThings[0]);
 
-    return relations;
-  },
+    const chunkDates =
+      chunks.map(([, artistDates, , ]) => artistDates[0]);
 
-  data(query) {
-    const data = {};
+    const chunkArtistContributions =
+      chunks.map(([, , artistContributions, ]) => artistContributions);
 
-    data.enableFlashesAndGames = query.enableFlashesAndGames;
+    const chunkArtists =
+      chunks.map(([, , , artists]) => artists);
 
-    data.datesByTrackContributions = query.datesByTrackContributions;
-    data.datesByArtworkContributions = query.datesByArtworkContributions;
+    // And one bonus step - keep track of all the artists whose contributions
+    // were all without date.
 
-    if (query.enableFlashesAndGames) {
-      data.datesByFlashContributions = query.datesByFlashContributions;
-    }
+    const datelessArtists =
+      artistsAlphabetically
+        .filter(artist => !artists.includes(artist));
 
-    return data;
+    return {
+      spec,
+      chunkThings,
+      chunkDates,
+      chunkArtistContributions,
+      chunkArtists,
+      datelessArtists,
+    };
   },
 
-  generate(data, relations, {html, language}) {
-    const chunkTitles = Object.fromEntries(
-      ([
-        ['tracks', [
-          'album',
-          relations.albumLinksByTrackContributions,
-          data.datesByTrackContributions,
-        ]],
-
-        ['artworks', [
-          'album',
-          relations.albumLinksByArtworkContributions,
-          data.datesByArtworkContributions,
-        ]],
-
-        data.enableFlashesAndGames &&
-          ['flashes', [
-            'flash',
-            relations.flashLinksByFlashContributions,
-            data.datesByFlashContributions,
-          ]],
-      ]).filter(Boolean)
-        .map(([key, [stringsKey, links, dates]]) => [
-          key,
-          stitchArrays({link: links, date: dates})
-            .map(({link, date}) =>
-              html.tag('dt',
-                language.$(`listingPage.listArtists.byLatest.chunk.title.${stringsKey}`, {
-                  [stringsKey]: link,
-                  date: language.formatDate(date),
-                }))),
-        ]));
-
-    const chunkItems = Object.fromEntries(
-      ([
-        ['tracks', relations.artistLinksByTrackContributions],
-        ['artworks', relations.artistLinksByArtworkContributions],
-        data.enableFlashesAndGames &&
-          ['flashes', relations.artistLinksByFlashContributions],
-      ]).filter(Boolean)
-        .map(([key, artistLinkLists]) => [
-          key,
-          artistLinkLists.map(artistLinks =>
-            html.tag('dd',
-              html.tag('ul',
-                artistLinks.map(artistLink =>
-                  html.tag('li',
-                    language.$('listingPage.listArtists.byLatest.chunk.item', {
-                      artist: artistLink,
-                    })))))),
-        ]));
-
-    const lists = Object.fromEntries(
-      ([
-        ['tracks', [
-          chunkTitles.tracks,
-          chunkItems.tracks,
-          relations.datelessArtistLinksByTrackContributions,
-        ]],
-
-        ['artworks', [
-          chunkTitles.artworks,
-          chunkItems.artworks,
-          relations.datelessArtistLinksByArtworkContributions,
-        ]],
-
-        data.enableFlashesAndGames &&
-          ['flashes', [
-            chunkTitles.flashes,
-            chunkItems.flashes,
-            relations.datelessArtistLinksByFlashContributions,
-          ]],
-      ]).filter(Boolean)
-        .map(([key, [titles, items, datelessArtistLinks]]) => [
-          key,
-          html.tags([
-            html.tag('dl',
-              stitchArrays({
-                title: titles,
-                items: items,
-              }).map(({title, items}) => [title, items])),
-
-            !empty(datelessArtistLinks) && [
-              html.tag('p',
-                language.$('listingPage.listArtists.byLatest.dateless.title')),
-
-              html.tag('ul',
-                datelessArtistLinks.map(artistLink =>
-                  html.tag('li',
-                    language.$('listingPage.listArtists.byLatest.dateless.item', {
-                      artist: artistLink,
-                    })))),
-            ],
-          ]),
-        ]));
-
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    chunkAlbumLinks:
+      query.chunkThings
+        .map(thing =>
+          (thing instanceof Album
+            ? relation('linkAlbum', thing)
+            : null)),
+
+    chunkFlashLinks:
+      query.chunkThings
+        .map(thing =>
+          (thing instanceof Flash
+            ? relation('linkFlash', thing)
+            : null)),
+
+    chunkArtistLinks:
+      query.chunkArtists
+        .map(artists => artists
+          .map(artist => relation('linkArtist', artist))),
+
+    datelessArtistLinks:
+      query.datelessArtists
+        .map(artist => relation('linkArtist', artist)),
+  }),
+
+  data: (query) => ({
+    chunkDates: query.chunkDates,
+    chunkArtistContributions: query.chunkArtistContributions,
+  }),
+
+  generate(data, relations, {language}) {
     return relations.page.slots({
-      type: 'custom',
-      content:
-        html.tag('div', {class: 'content-columns'}, [
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$('listingPage.misc.trackContributors')),
-
-            lists.tracks,
-          ]),
-
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$(
-                'listingPage.misc.artContributors')),
-
-            lists.artworks,
-
-            lists.flashes && [
-              html.tag('h2',
-                language.$('listingPage.misc.flashContributors')),
-
-              lists.flashes,
-            ],
-          ]),
-        ]),
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          albumLink: relations.chunkAlbumLinks,
+          flashLink: relations.chunkFlashLinks,
+          date: data.chunkDates,
+        }).map(({albumLink, flashLink, date}) => ({
+            date: language.formatDate(date),
+            ...(albumLink
+              ? {stringsKey: 'album', album: albumLink}
+              : {stringsKey: 'flash', flash: flashLink}),
+          }))
+          .concat(
+            (empty(relations.datelessArtistLinks)
+              ? []
+              : [{stringsKey: 'dateless'}])),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: relations.chunkArtistLinks,
+          contributions: data.chunkArtistContributions,
+        }).map(({artistLinks, contributions}) =>
+            stitchArrays({
+              artistLink: artistLinks,
+              contribution: contributions,
+            }).map(({artistLink, contribution}) => ({
+                artist: artistLink,
+                stringsKey:
+                  (contribution.has('track') && contribution.has('artwork')
+                    ? 'tracksAndArt'
+                 : contribution.has('track')
+                    ? 'tracks'
+                 : contribution.has('artwork')
+                    ? 'art'
+                    : null),
+              })))
+          .concat(
+            (empty(relations.datelessArtistLinks)
+              ? []
+              : [
+                  relations.datelessArtistLinks.map(artistLink => ({
+                    artist: artistLink,
+                  })),
+                ])),
     });
   },
 };
diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js
index 6c0ad836..554b4587 100644
--- a/src/content/dependencies/listArtistsByName.js
+++ b/src/content/dependencies/listArtistsByName.js
@@ -2,38 +2,33 @@ import {stitchArrays} from '#sugar';
 import {getArtistNumContributions, sortAlphabetically} from '#wiki-data';
 
 export default {
-  contentDependencies: ['generateListingPage', 'linkArtist'],
+  contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
   extraDependencies: ['language', 'wikiData'],
 
-  sprawl({artistData}) {
-    return {artistData};
-  },
+  sprawl: ({artistData, wikiInfo}) =>
+    ({artistData, wikiInfo}),
 
-  query({artistData}, spec) {
-    return {
-      spec,
+  query: (sprawl, spec) => ({
+    spec,
 
-      artists: sortAlphabetically(artistData.slice()),
-    };
-  },
+    artists:
+      sortAlphabetically(sprawl.artistData.slice()),
+  }),
 
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
 
-      artistLinks:
-        query.artists
-          .map(artist => relation('linkArtist', artist)),
-    };
-  },
+    artistLinks:
+      query.artists
+        .map(artist => relation('linkArtist', artist)),
+  }),
 
-  data(query) {
-    return {
-      counts:
-        query.artists
-          .map(artist => getArtistNumContributions(artist)),
-    };
-  },
+  data: (query) => ({
+    counts:
+      query.artists
+        .map(artist => getArtistNumContributions(artist)),
+  }),
 
   generate(data, relations, {language}) {
     return relations.page.slots({
diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js
index 43bf7dd5..18585696 100644
--- a/src/content/dependencies/listRandomPageLinks.js
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -1,90 +1,192 @@
+import {empty} from '#sugar';
+import {sortChronologically} from '#wiki-data';
+
 export default {
   contentDependencies: [
     'generateListingPage',
-    'generateListRandomPageLinksGroupSection',
+    'generateListRandomPageLinksAlbumLink',
+    'linkGroup',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupData}) {
-    return {groupData};
-  },
+  sprawl: ({albumData, wikiInfo}) => ({albumData, wikiInfo}),
 
   query(sprawl, spec) {
-    const group = directory =>
-      sprawl.groupData.find(group => group.directory === directory);
-
-    return {
-      spec,
-      officialGroup: group('official'),
-      fandomGroup: group('fandom'),
-      beyondGroup: group('beyond'),
-    };
+    const query = {spec};
+
+    const groups = sprawl.wikiInfo.divideTrackListsByGroups;
+
+    query.divideByGroups = !empty(groups);
+
+    if (query.divideByGroups) {
+      query.groups = groups;
+
+      query.groupAlbums =
+        groups
+          .map(group =>
+            group.albums.filter(album => album.tracks.length > 1));
+    } else {
+      query.undividedAlbums =
+        sortChronologically(sprawl.albumData.slice())
+          .filter(album => album.tracks.length > 1);
+    }
+
+    return query;
   },
 
   relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    if (query.divideByGroups) {
+      relations.groupLinks =
+        query.groups
+          .map(group => relation('linkGroup', group));
+
+      relations.groupAlbumLinks =
+        query.groupAlbums
+          .map(albums => albums
+            .map(album =>
+              relation('generateListRandomPageLinksAlbumLink', album)));
+    } else {
+      relations.undividedAlbumLinks =
+        query.undividedAlbums
+          .map(album =>
+            relation('generateListRandomPageLinksAlbumLink', album));
+    }
+
+    return relations;
+  },
 
-      officialSection:
-        relation('generateListRandomPageLinksGroupSection', query.officialGroup),
+  data(query) {
+    const data = {};
 
-      fandomSection:
-        relation('generateListRandomPageLinksGroupSection', query.fandomGroup),
+    if (query.divideByGroups) {
+      data.groupDirectories =
+        query.groups
+          .map(group => group.directory);
+    }
 
-      beyondSection:
-        relation('generateListRandomPageLinksGroupSection', query.beyondGroup),
-    };
+    return data;
   },
 
-  generate(relations, {html, language}) {
+  generate(data, relations, {html, language}) {
+    const miscellaneousChunkRows = [
+      {
+        stringsKey: 'randomArtist',
+
+        mainLink:
+          html.tag('a',
+            {href: '#', 'data-random': 'artist'},
+            language.$('listingPage.other.randomPages.chunk.item.randomArtist.mainLink')),
+
+        atLeastTwoContributions:
+          html.tag('a',
+            {href: '#', 'data-random': 'artist-more-than-one-contrib'},
+            language.$('listingPage.other.randomPages.chunk.item.randomArtist.atLeastTwoContributions')),
+      },
+
+      {stringsKey: 'randomAlbumWholeSite'},
+      {stringsKey: 'randomTrackWholeSite'},
+    ];
+
+    const miscellaneousChunkRowAttributes = [
+      null,
+      {href: '#', 'data-random': 'album'},
+      {href: '#','data-random': 'track'},
+    ];
+
     return relations.page.slots({
-      type: 'custom',
+      type: 'chunks',
+
       content: [
         html.tag('p',
-          language.$('listingPage.other.randomPages.chooseLinkLine')),
+          language.$('listingPage.other.randomPages.chooseLinkLine', {
+            fromPart:
+              (relations.groupLinks
+                ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups')
+                : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')),
 
-        html.tag('p',
-          {class: 'js-hide-once-data'},
+            browserSupportPart:
+              language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'),
+          })),
+
+        html.tag('p', {id: 'data-loading-line'},
           language.$('listingPage.other.randomPages.dataLoadingLine')),
 
-        html.tag('p',
-          {class: 'js-show-once-data'},
+        html.tag('p', {id: 'data-loaded-line'},
           language.$('listingPage.other.randomPages.dataLoadedLine')),
 
-        html.tag('dl', [
-          html.tag('dt',
-            language.$('listingPage.other.randomPages.misc')),
-
-          html.tag('dd',
-            html.tag('ul', [
-              html.tag('li', [
-                html.tag('a',
-                  {href: '#', 'data-random': 'artist'},
-                  language.$('listingPage.other.randomPages.misc.randomArtist')),
-
-                '(' +
-                html.tag('a',
-                  {href: '#', 'data-random': 'artist-more-than-one-contrib'},
-                  language.$('listingPage.other.randomPages.misc.atLeastTwoContributions')) +
-                ')',
+        html.tag('p', {id: 'data-error-line'},
+          language.$('listingPage.other.randomPages.dataErrorLine')),
+      ],
+
+      showSkipToSection: true,
+
+      chunkIDs:
+        (data.groupDirectories
+          ? [null, ...data.groupDirectories]
+          : null),
+
+      chunkTitles: [
+        {stringsKey: 'misc'},
+
+        ...
+          (relations.groupLinks
+            ? relations.groupLinks.map(groupLink => ({
+                stringsKey: 'fromGroup',
+                group: groupLink,
+              }))
+            : [{stringsKey: 'fromAlbum'}]),
+      ],
+
+      chunkTitleAccents: [
+        null,
+
+        ...
+          (relations.groupLinks
+            ? relations.groupLinks.map(() => ({
+                randomAlbum:
+                  html.tag('a',
+                    {href: '#', 'data-random': 'album-in-group-dl'},
+                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomAlbum')),
+
+                randomTrack:
+                  html.tag('a',
+                    {href: '#', 'data-random': 'track-in-group-dl'},
+                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomTrack')),
+              }))
+            : [null]),
+      ],
+
+      chunkRows: [
+        miscellaneousChunkRows,
+
+        ...
+          (relations.groupAlbumLinks
+            ? relations.groupAlbumLinks.map(albumLinks =>
+                albumLinks.map(albumLink => ({
+                  stringsKey: 'album',
+                  album: albumLink,
+                })))
+            : [
+                relations.undividedAlbumLinks.map(albumLink => ({
+                  stringsKey: 'album',
+                  album: albumLink,
+                })),
               ]),
+      ],
 
-              html.tag('li',
-                html.tag('a',
-                  {href: '#', 'data-random': 'album'},
-                  language.$('listingPage.other.randomPages.misc.randomAlbumWholeSite'))),
-
-              html.tag('li',
-                html.tag('a',
-                  {href: '#', 'data-random': 'track'},
-                  language.$('listingPage.other.randomPages.misc.randomTrackWholeSite'))),
-            ])),
-
-          relations.officialSection,
-          relations.fandomSection,
-          relations.beyondSection,
-        ]),
+      chunkRowAttributes: [
+        miscellaneousChunkRowAttributes,
+        ...
+          (relations.groupAlbumLinks
+            ? relations.groupAlbumLinks.map(albumLinks =>
+                albumLinks.map(() => null))
+            : [relations.undividedAlbumLinks.map(() => null)]),
       ],
     });
   },
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
index d6546e67..25beb739 100644
--- a/src/content/dependencies/listTracksByDate.js
+++ b/src/content/dependencies/listTracksByDate.js
@@ -71,8 +71,15 @@ export default {
               rerelease: rereleases,
             }).map(({trackLink, rerelease}) =>
                 (rerelease
-                  ? {track: trackLink, stringsKey: 'rerelease'}
+                  ? {stringsKey: 'rerelease', track: trackLink}
                   : {track: trackLink}))),
+
+      chunkRowAttributes:
+        data.rereleases.map(rereleases =>
+          rereleases.map(rerelease =>
+            (rerelease
+              ? {class: 'rerelease'}
+              : null))),
     });
   },
 };
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 3c2c3521..2002ebee 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -1,7 +1,7 @@
 import {bindFind} from '#find';
 import {parseInput} from '#replacer';
 
-import {marked} from 'marked';
+import {Marked} from 'marked';
 
 export const replacerSpec = {
   album: {
@@ -130,7 +130,7 @@ const linkThingRelationMap = {
   newsEntry: 'linkNewsEntry',
   staticPage: 'linkStaticPage',
   tag: 'linkArtTag',
-  track: 'linkTrack',
+  track: 'linkTrackDynamically',
 };
 
 const linkValueRelationMap = {
@@ -147,6 +147,29 @@ const linkIndexRelationMap = {
   newsIndex: 'linkNewsIndex',
 };
 
+const commonMarkedOptions = {
+  headerIds: false,
+  mangle: false,
+};
+
+const multilineMarked = new Marked({
+  ...commonMarkedOptions,
+});
+
+const inlineMarked = new Marked({
+  ...commonMarkedOptions,
+
+  renderer: {
+    paragraph(text) {
+      return text;
+    },
+  },
+});
+
+const lyricsMarked = new Marked({
+  ...commonMarkedOptions,
+});
+
 function getPlaceholder(node, content) {
   return {type: 'text', data: content.slice(node.i, node.iEnd)};
 }
@@ -447,19 +470,9 @@ export default {
       return link.data;
     }
 
-    // In inline mode, no further processing is needed!
-
-    if (slots.mode === 'inline') {
-      return html.tags(contentFromNodes.map(node => node.data));
-    }
-
-    // Multiline mode has a secondary processing stage where it's passed...
-    // through marked! Rolling your own Markdown only gets you so far :D
-
-    const markedOptions = {
-      headerIds: false,
-      mangle: false,
-    };
+    // Content always goes through marked (i.e. parsing as Markdown).
+    // This does require some attention to detail, mostly to do with line
+    // breaks (in multiline mode) and extracting/re-inserting non-text nodes.
 
     // The content of non-text nodes can end up getting mangled by marked.
     // To avoid this, we replace them with mundane placeholders, then
@@ -534,23 +547,36 @@ export default {
       return html.tags(tags, {[html.joinChildren]: ''});
     };
 
+    if (slots.mode === 'inline') {
+      const markedInput =
+        extractNonTextNodes();
+
+      const markedOutput =
+        inlineMarked.parse(markedInput);
+
+      return reinsertNonTextNodes(markedOutput);
+    }
+
     // This is separated into its own function just since we're gonna reuse
     // it in a minute if everything goes to heck in lyrics mode.
     const transformMultiline = () => {
       const markedInput =
         extractNonTextNodes()
-          // Compress multiple line breaks into single line breaks.
-          .replace(/\n{2,}/g, '\n')
+          // Compress multiple line breaks into single line breaks,
+          // except when they're preceding or following indented
+          // text (by at least two spaces).
+          .replace(/(?<!  .*)\n{2,}(?!^  )/gm, '\n') /* eslint-disable-line no-regex-spaces */
           // Expand line breaks which don't follow a list, quote,
-          // or <br> / "  ".
-          .replace(/(?<!^ *-.*|^>.*|  $|<br>$)\n+/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
+          // or <br> / "  ", and which don't precede or follow
+          // indented text (by at least two spaces).
+          .replace(/(?<!^ *-.*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
           // Expand line breaks which are at the end of a list.
           .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n')
           // Expand line breaks which are at the end of a quote.
           .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n');
 
       const markedOutput =
-        marked.parse(markedInput, markedOptions);
+        multilineMarked.parse(markedInput);
 
       return reinsertNonTextNodes(markedOutput);
     }
@@ -600,7 +626,7 @@ export default {
         });
 
       const markedOutput =
-        marked.parse(markedInput, markedOptions);
+        lyricsMarked.parse(markedInput);
 
       return reinsertNonTextNodes(markedOutput);
     }
diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js
index dfc53db7..7fad88b2 100644
--- a/src/data/composite/control-flow/index.js
+++ b/src/data/composite/control-flow/index.js
@@ -1,3 +1,8 @@
+// #composite/control-flow
+//
+// No entries depend on any other entries, except siblings in this directory.
+//
+
 export {default as exitWithoutDependency} from './exitWithoutDependency.js';
 export {default as exitWithoutUpdateValue} from './exitWithoutUpdateValue.js';
 export {default as exposeConstant} from './exposeConstant.js';
diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js
index 718f2294..d798dcdc 100644
--- a/src/data/composite/data/excludeFromList.js
+++ b/src/data/composite/data/excludeFromList.js
@@ -6,10 +6,9 @@
 //  - fillMissingListItems
 //
 // More list utilities:
-//  - withFlattenedList
-//  - withPropertyFromList
-//  - withPropertiesFromList
-//  - withUnflattenedList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/fillMissingListItems.js b/src/data/composite/data/fillMissingListItems.js
index c06eceda..4f818a79 100644
--- a/src/data/composite/data/fillMissingListItems.js
+++ b/src/data/composite/data/fillMissingListItems.js
@@ -5,10 +5,9 @@
 //  - excludeFromList
 //
 // More list utilities:
-//  - withFlattenedList
-//  - withPropertyFromList
-//  - withPropertiesFromList
-//  - withUnflattenedList
+//  - 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 ecd05129..256c0490 100644
--- a/src/data/composite/data/index.js
+++ b/src/data/composite/data/index.js
@@ -1,8 +1,17 @@
+// #composite/data
+//
+// Entries here may depend on entries in #composite/control-flow.
+//
+
 export {default as excludeFromList} from './excludeFromList.js';
 export {default as fillMissingListItems} from './fillMissingListItems.js';
+export {default as withFilteredList} from './withFilteredList.js';
 export {default as withFlattenedList} from './withFlattenedList.js';
+export {default as withMappedList} from './withMappedList.js';
 export {default as withPropertiesFromList} from './withPropertiesFromList.js';
 export {default as withPropertiesFromObject} from './withPropertiesFromObject.js';
 export {default as withPropertyFromList} from './withPropertyFromList.js';
 export {default as withPropertyFromObject} from './withPropertyFromObject.js';
+export {default as withSortedList} from './withSortedList.js';
 export {default as withUnflattenedList} from './withUnflattenedList.js';
+export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js';
diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js
new file mode 100644
index 00000000..82e56903
--- /dev/null
+++ b/src/data/composite/data/withFilteredList.js
@@ -0,0 +1,50 @@
+// Applies a filter - an array of truthy and falsy values - to the index-
+// corresponding items in a list. Items which correspond to a truthy value
+// are kept, and the rest are excluded from the output list.
+//
+// TODO: It would be neat to apply an availability check here, e.g. to allow
+// not providing a filter at all and performing the check on the contents of
+// the list (though on the filter, if present, is fine too). But that's best
+// done by some shmancy-fancy mapping support in composite.js, so a bit out
+// of reach for now (apart from proving uses built on top of a more boring
+// implementation).
+//
+// TODO: There should be two outputs - one for the items included according to
+// the filter, and one for the items excluded.
+//
+// See also:
+//  - withMappedList
+//  - withSortedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withFilteredList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    filter: input({type: 'array'}),
+  },
+
+  outputs: ['#filteredList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('filter')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('filter')]: filter,
+      }) => continuation({
+        '#filteredList':
+          list.filter((item, index) => filter[index]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withFlattenedList.js b/src/data/composite/data/withFlattenedList.js
index b08edb4e..edfa3403 100644
--- a/src/data/composite/data/withFlattenedList.js
+++ b/src/data/composite/data/withFlattenedList.js
@@ -3,13 +3,13 @@
 // successive source array.
 //
 // See also:
-//  - withFlattenedList
+//  - withUnflattenedList
 //
 // More list utilities:
 //  - excludeFromList
 //  - fillMissingListItems
-//  - withPropertyFromList
-//  - withPropertiesFromList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withPropertyFromList, withPropertiesFromList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/withMappedList.js b/src/data/composite/data/withMappedList.js
new file mode 100644
index 00000000..e0a700b2
--- /dev/null
+++ b/src/data/composite/data/withMappedList.js
@@ -0,0 +1,39 @@
+// Applies a map function to each item in a list, just like a normal JavaScript
+// map.
+//
+// See also:
+//  - withFilteredList
+//  - withSortedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withMappedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    map: input({type: 'function'}),
+  },
+
+  outputs: ['#mappedList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('map')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('map')]: mapFn,
+      }) => continuation({
+        ['#mappedList']:
+          list.map(mapFn),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js
index 76ba696c..08907bab 100644
--- a/src/data/composite/data/withPropertiesFromList.js
+++ b/src/data/composite/data/withPropertiesFromList.js
@@ -11,8 +11,8 @@
 // More list utilities:
 //  - excludeFromList
 //  - fillMissingListItems
-//  - withFlattenedList
-//  - withUnflattenedList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js
index 1983ebbc..a2c66d77 100644
--- a/src/data/composite/data/withPropertyFromList.js
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -12,8 +12,8 @@
 // More list utilities:
 //  - excludeFromList
 //  - fillMissingListItems
-//  - withFlattenedList
-//  - withUnflattenedList
+//  - 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
new file mode 100644
index 00000000..882907f5
--- /dev/null
+++ b/src/data/composite/data/withSortedList.js
@@ -0,0 +1,126 @@
+// Applies a sort function across pairs of items in a list, just like a normal
+// JavaScript sort. Alongside the sorted results, so are outputted the indices
+// which each item in the unsorted list corresponds to in the sorted one,
+// allowing for the results of this sort to be composed in some more involved
+// operation. For example, using an alphabetical sort, the list ['banana',
+// 'apple', 'pterodactyl'] will output the expected alphabetical items, as well
+// as the indices list [1, 0, 2].
+//
+// If two items are equal (in the eyes of the sort operation), their placement
+// in the sorted list is arbitrary, though every input index will be present in
+// '#sortIndices' exactly once (and equal items will be bunched together).
+//
+// The '#sortIndices' output refers to the "true" index which each source item
+// occupies in the sorted list. This sacrifices information about equal items,
+// which can be obtained through '#unstableSortIndices' instead: each mapped
+// index may appear more than once, and rather than represent exact positions
+// in the sorted list, they represent relational values: if items A and B are
+// mapped to indices 3 and 5, then A certainly is positioned before B (and vice
+// versa); but there may be more than one item in-between. If items C and D are
+// both mapped to index 4, then their position relative to each other is
+// arbitrary - they are equal - but they both certainly appear after item A and
+// before item B.
+//
+// This implementation is based on the one used for sortMultipleArrays.
+//
+// See also:
+//  - withFilteredList
+//  - withMappedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {empty} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `withSortedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    sort: input({type: 'function'}),
+  },
+
+  outputs: ['#sortedList', '#sortIndices', '#unstableSortIndices'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('sort')],
+      compute(continuation, {
+        [input('list')]: list,
+        [input('sort')]: sortFn,
+      }) {
+        const symbols =
+          Array.from({length: list.length}, () => Symbol());
+
+        const equalSymbols =
+          new Map();
+
+        const indexMap =
+          new Map(Array.from(symbols,
+            (symbol, index) => [symbol, index]));
+
+        symbols.sort((symbol1, symbol2) => {
+          const comparison =
+            sortFn(
+              list[indexMap.get(symbol1)],
+              list[indexMap.get(symbol2)]);
+
+          if (comparison === 0) {
+            if (equalSymbols.has(symbol1)) {
+              equalSymbols.get(symbol1).add(symbol2);
+            } else {
+              equalSymbols.set(symbol1, new Set([symbol2]));
+            }
+
+            if (equalSymbols.has(symbol2)) {
+              equalSymbols.get(symbol2).add(symbol1);
+            } else {
+              equalSymbols.set(symbol2, new Set([symbol1]));
+            }
+          }
+
+          return comparison;
+        });
+
+        const sortIndices =
+          symbols.map(symbol => indexMap.get(symbol));
+
+        const sortedList =
+          sortIndices.map(index => list[index]);
+
+        const stableToUnstable =
+          symbols
+            .map((symbol, index) =>
+              index > 0 &&
+              equalSymbols.get(symbols[index - 1])?.has(symbol))
+            .reduce((accumulator, collapseEqual) => {
+              if (empty(accumulator)) {
+                accumulator.push(0);
+              } else {
+                const last = accumulator[accumulator.length - 1];
+                if (collapseEqual) {
+                  accumulator.push(last);
+                } else {
+                  accumulator.push(last + 1);
+                }
+              }
+              return accumulator;
+            }, []);
+
+        const unstableSortIndices =
+          sortIndices.map(stable => stableToUnstable[stable]);
+
+        return continuation({
+          ['#sortedList']: sortedList,
+          ['#sortIndices']: sortIndices,
+          ['#unstableSortIndices']: unstableSortIndices,
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/data/withUnflattenedList.js b/src/data/composite/data/withUnflattenedList.js
index 3cfc247b..39a666dc 100644
--- a/src/data/composite/data/withUnflattenedList.js
+++ b/src/data/composite/data/withUnflattenedList.js
@@ -3,6 +3,16 @@
 // of filtering them out), this function allows for recombining them. It will
 // filter out null and undefined items by default (pass {filter: false} to
 // disable this).
+//
+// See also:
+//  - withFlattenedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFilteredList, withMappedList, withSortedList
+//  - withPropertyFromList, withPropertiesFromList
+//
 
 import {input, templateCompositeFrom} from '#composite';
 import {isWholeNumber, validateArrayItems} from '#validators';
diff --git a/src/data/composite/data/withUniqueItemsOnly.js b/src/data/composite/data/withUniqueItemsOnly.js
new file mode 100644
index 00000000..7ee08b08
--- /dev/null
+++ b/src/data/composite/data/withUniqueItemsOnly.js
@@ -0,0 +1,40 @@
+// Excludes duplicate items from a list and provides the results, overwriting
+// the list in-place, if possible.
+
+import {input, templateCompositeFrom} from '#composite';
+import {unique} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `withUniqueItemsOnly`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list ?? '#uniqueItems'],
+
+  steps: () => [
+    {
+      dependencies: [input('list')],
+      compute: (continuation, {
+        [input('list')]: list,
+      }) => continuation({
+        ['#values']:
+          unique(list),
+      }),
+    },
+
+    {
+      dependencies: ['#values', input.staticDependency('list')],
+      compute: (continuation, {
+        '#values': values,
+        [input.staticDependency('list')]: list,
+      }) => continuation({
+        [list ?? '#uniqueItems']:
+          values,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/album/withTrackSections.js b/src/data/composite/things/album/withTrackSections.js
index baa3cb4a..679a09fd 100644
--- a/src/data/composite/things/album/withTrackSections.js
+++ b/src/data/composite/things/album/withTrackSections.js
@@ -22,7 +22,7 @@ export default templateCompositeFrom({
 
   steps: () => [
     exitWithoutDependency({
-      dependency: 'trackData',
+      dependency: 'ownTrackData',
       value: input.value([]),
     }),
 
@@ -75,7 +75,7 @@ export default templateCompositeFrom({
 
     withResolvedReferenceList({
       list: '#trackRefs',
-      data: 'trackData',
+      data: 'ownTrackData',
       notFoundMode: input.value('null'),
       find: input.value(find.track),
     }).outputs({
diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js
index dcea6593..fff3d5ae 100644
--- a/src/data/composite/things/album/withTracks.js
+++ b/src/data/composite/things/album/withTracks.js
@@ -12,7 +12,7 @@ export default templateCompositeFrom({
 
   steps: () => [
     exitWithoutDependency({
-      dependency: 'trackData',
+      dependency: 'ownTrackData',
       value: input.value([]),
     }),
 
@@ -35,7 +35,7 @@ export default templateCompositeFrom({
 
     withResolvedReferenceList({
       list: '#trackRefs',
-      data: 'trackData',
+      data: 'ownTrackData',
       find: input.value(find.track),
     }),
 
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
index 3354b1c4..cc723a24 100644
--- a/src/data/composite/things/track/index.js
+++ b/src/data/composite/things/track/index.js
@@ -1,5 +1,7 @@
 export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js';
+export {default as inferredAdditionalNameList} from './inferredAdditionalNameList.js';
 export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js';
+export {default as sharedAdditionalNameList} from './sharedAdditionalNameList.js';
 export {default as trackReverseReferenceList} from './trackReverseReferenceList.js';
 export {default as withAlbum} from './withAlbum.js';
 export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js';
diff --git a/src/data/composite/things/track/inferredAdditionalNameList.js b/src/data/composite/things/track/inferredAdditionalNameList.js
new file mode 100644
index 00000000..9cf158c6
--- /dev/null
+++ b/src/data/composite/things/track/inferredAdditionalNameList.js
@@ -0,0 +1,67 @@
+// Infers additional name entries from other releases that were titled
+// differently; the corresponding releases are stored in eacn entry's "from"
+// array, which will include multiple items, if more than one other release
+// shares the same name differing from this one's.
+
+import {input, templateCompositeFrom} from '#composite';
+import {chunkByProperties} from '#wiki-data';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {withFilteredList, withPropertyFromList} from '#composite/data';
+import {withThingsSortedAlphabetically} from '#composite/wiki-data';
+
+import withOtherReleases from './withOtherReleases.js';
+
+export default templateCompositeFrom({
+  annotation: `inferredAdditionalNameList`,
+
+  compose: false,
+
+  steps: () => [
+    withOtherReleases(),
+
+    exitWithoutDependency({
+      dependency: '#otherReleases',
+      mode: input.value('empty'),
+      value: input.value([]),
+    }),
+
+    withPropertyFromList({
+      list: '#otherReleases',
+      property: input.value('name'),
+    }),
+
+    {
+      dependencies: ['#otherReleases.name', 'name'],
+      compute: (continuation, {
+        ['#otherReleases.name']: releaseNames,
+        ['name']: ownName,
+      }) => continuation({
+        ['#nameFilter']:
+          releaseNames.map(name => name !== ownName),
+      }),
+    },
+
+    withFilteredList({
+      list: '#otherReleases',
+      filter: '#nameFilter',
+    }).outputs({
+      '#filteredList': '#differentlyNamedReleases',
+    }),
+
+    withThingsSortedAlphabetically({
+      things: '#differentlyNamedReleases',
+    }).outputs({
+      '#sortedThings': '#differentlyNamedReleases',
+    }),
+
+    {
+      dependencies: ['#differentlyNamedReleases'],
+      compute: ({
+        ['#differentlyNamedReleases']: releases,
+      }) =>
+        chunkByProperties(releases, ['name'])
+          .map(({name, chunk}) => ({name, from: chunk})),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/sharedAdditionalNameList.js b/src/data/composite/things/track/sharedAdditionalNameList.js
new file mode 100644
index 00000000..1806ec80
--- /dev/null
+++ b/src/data/composite/things/track/sharedAdditionalNameList.js
@@ -0,0 +1,38 @@
+// Compiles additional names directly provided by other releases.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withFlattenedList, withPropertyFromList} from '#composite/data';
+
+import withOtherReleases from './withOtherReleases.js';
+
+export default templateCompositeFrom({
+  annotation: `sharedAdditionalNameList`,
+
+  compose: false,
+
+  steps: () => [
+    withOtherReleases(),
+
+    exitWithoutDependency({
+      dependency: '#otherReleases',
+      mode: input.value('empty'),
+      value: input.value([]),
+    }),
+
+    withPropertyFromList({
+      list: '#otherReleases',
+      property: input.value('additionalNames'),
+    }),
+
+    withFlattenedList({
+      list: '#otherReleases.additionalNames',
+    }),
+
+    exposeDependency({
+      dependency: '#flattenedList',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js
new file mode 100644
index 00000000..65a2263d
--- /dev/null
+++ b/src/data/composite/things/track/trackAdditionalNameList.js
@@ -0,0 +1,38 @@
+// Compiles additional names from various sources.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isAdditionalNameList} from '#validators';
+
+import withInferredAdditionalNames from './withInferredAdditionalNames.js';
+import withSharedAdditionalNames from './withSharedAdditionalNames.js';
+
+export default templateCompositeFrom({
+  annotation: `trackAdditionalNameList`,
+
+  compose: false,
+
+  update: {validate: isAdditionalNameList},
+
+  steps: () => [
+    withInferredAdditionalNames(),
+    withSharedAdditionalNames(),
+
+    {
+      dependencies: [
+        '#inferredAdditionalNames',
+        '#sharedAdditionalNames',
+        input.updateValue(),
+      ],
+
+      compute: ({
+        ['#inferredAdditionalNames']: inferredAdditionalNames,
+        ['#sharedAdditionalNames']: sharedAdditionalNames,
+        [input.updateValue()]: providedAdditionalNames,
+      }) => [
+        ...providedAdditionalNames ?? [],
+        ...sharedAdditionalNames,
+        ...inferredAdditionalNames,
+      ],
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index 1d0400fc..a2ff09d8 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -1,7 +1,15 @@
+// #composite/wiki-data
+//
+// Entries here may depend on entries in #composite/control-flow and in
+// #composite/data.
+//
+
 export {default as exitWithoutContribs} from './exitWithoutContribs.js';
 export {default as inputThingClass} from './inputThingClass.js';
 export {default as inputWikiData} from './inputWikiData.js';
+export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
 export {default as withResolvedContribs} from './withResolvedContribs.js';
 export {default as withResolvedReference} from './withResolvedReference.js';
 export {default as withResolvedReferenceList} from './withResolvedReferenceList.js';
 export {default as withReverseReferenceList} from './withReverseReferenceList.js';
+export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js';
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
new file mode 100644
index 00000000..edfc9e3c
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -0,0 +1,179 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {stitchArrays} from '#sugar';
+import {isCommentary} from '#validators';
+import {commentaryRegex} from '#wiki-data';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
+
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withParsedCommentaryEntries`,
+
+  inputs: {
+    from: input({validate: isCommentary}),
+  },
+
+  outputs: ['#parsedCommentaryEntries'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+      }) => continuation({
+        ['#rawMatches']:
+          Array.from(commentaryText.matchAll(commentaryRegex)),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: '#rawMatches',
+      properties: input.value([
+        '0', // The entire match as a string.
+        'groups',
+        'index',
+      ]),
+    }).outputs({
+      '#rawMatches.0': '#rawMatches.text',
+      '#rawMatches.groups': '#rawMatches.groups',
+      '#rawMatches.index': '#rawMatches.startIndex',
+    }),
+
+    {
+      dependencies: [
+        '#rawMatches.text',
+        '#rawMatches.startIndex',
+      ],
+
+      compute: (continuation, {
+        ['#rawMatches.text']: text,
+        ['#rawMatches.startIndex']: startIndex,
+      }) => continuation({
+        ['#rawMatches.endIndex']:
+          stitchArrays({text, startIndex})
+            .map(({text, startIndex}) => startIndex + text.length),
+      }),
+    },
+
+    {
+      dependencies: [
+        input('from'),
+        '#rawMatches.startIndex',
+        '#rawMatches.endIndex',
+      ],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+        ['#rawMatches.startIndex']: startIndex,
+        ['#rawMatches.endIndex']: endIndex,
+      }) => continuation({
+        ['#entries.body']:
+          stitchArrays({startIndex, endIndex})
+            .map(({endIndex}, index, stitched) =>
+              (index === stitched.length - 1
+                ? commentaryText.slice(endIndex)
+                : commentaryText.slice(
+                    endIndex,
+                    stitched[index + 1].startIndex)))
+            .map(body => body.trim()),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: '#rawMatches.groups',
+      prefix: input.value('#entries'),
+      properties: input.value([
+        'artistReferences',
+        'artistDisplayText',
+        'annotation',
+        'date',
+      ]),
+    }),
+
+    // The artistReferences group will always have a value, since it's required
+    // for the line to match in the first place.
+
+    {
+      dependencies: ['#entries.artistReferences'],
+      compute: (continuation, {
+        ['#entries.artistReferences']: artistReferenceTexts,
+      }) => continuation({
+        ['#entries.artistReferences']:
+          artistReferenceTexts
+            .map(text => text.split(',').map(ref => ref.trim())),
+      }),
+    },
+
+    withFlattenedList({
+      list: '#entries.artistReferences',
+    }),
+
+    withResolvedReferenceList({
+      list: '#flattenedList',
+      data: 'artistData',
+      find: input.value(find.artist),
+      notFoundMode: input.value('null'),
+    }),
+
+    withUnflattenedList({
+      list: '#resolvedReferenceList',
+    }).outputs({
+      '#unflattenedList': '#entries.artists',
+    }),
+
+    fillMissingListItems({
+      list: '#entries.artistDisplayText',
+      fill: input.value(null),
+    }),
+
+    fillMissingListItems({
+      list: '#entries.annotation',
+      fill: input.value(null),
+    }),
+
+    {
+      dependencies: ['#entries.date'],
+      compute: (continuation, {
+        ['#entries.date']: date,
+      }) => continuation({
+        ['#entries.date']:
+          date.map(date => date ? new Date(date) : null),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#entries.artists',
+        '#entries.artistDisplayText',
+        '#entries.annotation',
+        '#entries.date',
+        '#entries.body',
+      ],
+
+      compute: (continuation, {
+        ['#entries.artists']: artists,
+        ['#entries.artistDisplayText']: artistDisplayText,
+        ['#entries.annotation']: annotation,
+        ['#entries.date']: date,
+        ['#entries.body']: body,
+      }) => continuation({
+        ['#parsedCommentaryEntries']:
+          stitchArrays({
+            artists,
+            artistDisplayText,
+            annotation,
+            date,
+            body,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withThingsSortedAlphabetically.js b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
new file mode 100644
index 00000000..d2487e42
--- /dev/null
+++ b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
@@ -0,0 +1,122 @@
+// Sorts a list of live, generic wiki data objects alphabetically.
+// Note that this uses localeCompare but isn't specialized to a particular
+// language; where localization is concerned (in content), a follow-up, locale-
+// specific sort should be performed. But this function does serve to organize
+// a list so same-name entries are beside each other.
+
+import {input, templateCompositeFrom} from '#composite';
+import {validateWikiData} from '#validators';
+import {compareCaseLessSensitive, normalizeName} from '#wiki-data';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withMappedList, withSortedList, withPropertiesFromList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withThingsSortedAlphabetically`,
+
+  inputs: {
+    things: input({validate: validateWikiData}),
+  },
+
+  outputs: ['#sortedThings'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('things'),
+      mode: input.value('empty'),
+      output: input.value({'#sortedThings': []}),
+    }),
+
+    withPropertiesFromList({
+      list: input('things'),
+      properties: input.value(['name', 'directory']),
+    }).outputs({
+      '#list.name': '#names',
+      '#list.directory': '#directories',
+    }),
+
+    withMappedList({
+      list: '#names',
+      map: input.value(normalizeName),
+    }).outputs({
+      '#mappedList': '#normalizedNames',
+    }),
+
+    withSortedList({
+      list: '#normalizedNames',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#normalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#names',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#nonNormalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#directories',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#directorySortIndices',
+    }),
+
+    // TODO: No primitive for the next two-three steps, yet...
+
+    {
+      dependencies: [input('things')],
+      compute: (continuation, {
+        [input('things')]: things,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          Array.from(
+            {length: things.length},
+            (_item, index) => index),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#combinedSortIndices',
+        '#normalizedNameSortIndices',
+        '#nonNormalizedNameSortIndices',
+        '#directorySortIndices',
+      ],
+
+      compute: (continuation, {
+        ['#combinedSortIndices']: combined,
+        ['#normalizedNameSortIndices']: normalized,
+        ['#nonNormalizedNameSortIndices']: nonNormalized,
+        ['#directorySortIndices']: directory,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          combined.sort((index1, index2) => {
+            if (normalized[index1] !== normalized[index2])
+              return normalized[index1] - normalized[index2];
+
+            if (nonNormalized[index1] !== nonNormalized[index2])
+              return nonNormalized[index1] - nonNormalized[index2];
+
+            if (directory[index1] !== directory[index2])
+              return directory[index1] - directory[index2];
+
+            return 0;
+          }),
+      }),
+    },
+
+    {
+      dependencies: [input('things'), '#combinedSortIndices'],
+      compute: (continuation, {
+        [input('things')]: things,
+        ['#combinedSortIndices']: combined,
+      }) => continuation({
+        ['#sortedThings']:
+          combined.map(index => things[index]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js
new file mode 100644
index 00000000..c5971d4a
--- /dev/null
+++ b/src/data/composite/wiki-properties/additionalNameList.js
@@ -0,0 +1,14 @@
+// A list of additional names! These can be used for a variety of purposes,
+// e.g. providing extra searchable titles, localizations, romanizations or
+// original titles, and so on. Each item has a name and, optionally, a
+// descriptive annotation.
+
+import {isAdditionalNameList} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isAdditionalNameList},
+    expose: {transform: value => value ?? []},
+  };
+}
diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js
index fbea9d5c..cd6b7ac4 100644
--- a/src/data/composite/wiki-properties/commentary.js
+++ b/src/data/composite/wiki-properties/commentary.js
@@ -1,12 +1,30 @@
 // Artist commentary! Generally present on tracks and albums.
 
+import {input, templateCompositeFrom} from '#composite';
 import {isCommentary} from '#validators';
 
-// TODO: Not templateCompositeFrom.
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withParsedCommentaryEntries} from '#composite/wiki-data';
 
-export default function() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isCommentary},
-  };
-}
+export default templateCompositeFrom({
+  annotation: `commentary`,
+
+  compose: false,
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input.updateValue({validate: isCommentary}),
+      mode: input.value('falsy'),
+      value: input.value(null),
+    }),
+
+    withParsedCommentaryEntries({
+      from: input.updateValue(),
+    }),
+
+    exposeDependency({
+      dependency: '#parsedCommentaryEntries',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js
index 52aeb868..c5c14769 100644
--- a/src/data/composite/wiki-properties/commentatorArtists.js
+++ b/src/data/composite/wiki-properties/commentatorArtists.js
@@ -1,13 +1,13 @@
-// This one's kinda tricky: it parses artist "references" from the
-// commentary content, and finds the matching artist for each reference.
+// List of artists referenced in commentary entries.
 // This is mostly useful for credits and listings on artist pages.
 
 import {input, templateCompositeFrom} from '#composite';
-import find from '#find';
-import {unique} from '#sugar';
 
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withResolvedReferenceList} from '#composite/wiki-data';
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withFlattenedList, withPropertyFromList, withUniqueItemsOnly}
+  from '#composite/data';
+import {withParsedCommentaryEntries} from '#composite/wiki-data';
 
 export default templateCompositeFrom({
   annotation: `commentatorArtists`,
@@ -21,35 +21,29 @@ export default templateCompositeFrom({
       value: input.value([]),
     }),
 
-    {
-      dependencies: ['commentary'],
-      compute: (continuation, {commentary}) =>
-        continuation({
-          '#artistRefs':
-            Array.from(
-              commentary
-                .replace(/<\/?b>/g, '')
-                .matchAll(/<i>(?<who>.*?):<\/i>/g))
-              .map(({groups: {who}}) => who),
-        }),
-    },
-
-    withResolvedReferenceList({
-      list: '#artistRefs',
-      data: 'artistData',
-      find: input.value(find.artist),
+    withParsedCommentaryEntries({
+      from: 'commentary',
+    }),
+
+    withPropertyFromList({
+      list: '#parsedCommentaryEntries',
+      property: input.value('artists'),
     }).outputs({
-      '#resolvedReferenceList': '#artists',
+      '#parsedCommentaryEntries.artists': '#artistLists',
     }),
 
-    {
-      flags: {expose: true},
+    withFlattenedList({
+      list: '#artistLists',
+    }).outputs({
+      '#flattenedList': '#artists',
+    }),
 
-      expose: {
-        dependencies: ['#artists'],
-        compute: ({'#artists': artists}) =>
-          unique(artists),
-      },
-    },
+    withUniqueItemsOnly({
+      list: '#artists',
+    }),
+
+    exposeDependency({
+      dependency: '#artists',
+    }),
   ],
 });
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index 2462b047..17d51bb8 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -1,4 +1,10 @@
+// #composite/wiki-properties
+//
+// Entries here may depend on entries in #composite/control-flow,
+// #composite/data, and #composite/wiki-data.
+
 export {default as additionalFiles} from './additionalFiles.js';
+export {default as additionalNameList} from './additionalNameList.js';
 export {default as color} from './color.js';
 export {default as commentary} from './commentary.js';
 export {default as commentatorArtists} from './commentatorArtists.js';
diff --git a/src/data/language.js b/src/data/language.js
index 6ffc31e0..6f774f27 100644
--- a/src/data/language.js
+++ b/src/data/language.js
@@ -1,23 +1,28 @@
 import EventEmitter from 'node:events';
 import {readFile} from 'node:fs/promises';
 import path from 'node:path';
+import {fileURLToPath} from 'node:url';
 
 import chokidar from 'chokidar';
 import he from 'he'; // It stands for "HTML Entities", apparently. Cursed.
 import yaml from 'js-yaml';
 
-import T from '#things';
+import {externalLinkSpec} from '#external-links';
 import {colors, logWarn} from '#cli';
-
-import {
-  annotateError,
-  annotateErrorWithFile,
-  showAggregate,
-  withAggregate,
-} from '#sugar';
+import {annotateError, annotateErrorWithFile, showAggregate, withAggregate}
+  from '#sugar';
+import T from '#things';
 
 const {Language} = T;
 
+export const DEFAULT_STRINGS_FILE = 'strings-default.yaml';
+
+export const internalDefaultStringsFile =
+  path.resolve(
+    path.dirname(fileURLToPath(import.meta.url)),
+    '../',
+    DEFAULT_STRINGS_FILE);
+
 export function processLanguageSpec(spec, {existingCode = null} = {}) {
   const {
     'meta.languageCode': code,
@@ -105,6 +110,8 @@ export function initializeLanguageObject() {
   language.escapeHTML = string =>
     he.encode(string, {useNamedReferences: true});
 
+  language.externalLinkSpec = externalLinkSpec;
+
   return language;
 }
 
diff --git a/src/data/serialize.js b/src/data/serialize.js
index 52aacb07..8cac3309 100644
--- a/src/data/serialize.js
+++ b/src/data/serialize.js
@@ -19,6 +19,10 @@ export function toContribRefs(contribs) {
   return contribs?.map(({who, what}) => ({who: toRef(who), what}));
 }
 
+export function toCommentaryRefs(entries) {
+  return entries?.map(({artist, ...props}) => ({artist: toRef(artist), ...props}));
+}
+
 // Interface
 
 export const serializeDescriptors = Symbol();
diff --git a/src/data/things/album.js b/src/data/things/album.js
index af3eb042..a95ba354 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -133,7 +133,10 @@ export class Album extends Thing {
       class: input.value(Group),
     }),
 
-    trackData: wikiData({
+    // Only the tracks which belong to this album.
+    // Necessary for computing the track list, so provide this statically
+    // or keep it updated.
+    ownTrackData: wikiData({
       class: input.value(Track),
     }),
 
@@ -181,7 +184,8 @@ export class Album extends Thing {
     hasTrackArt: S.id,
     isListedOnHomepage: S.id,
 
-    commentary: S.id,
+    commentary: S.toCommentaryRefs,
+
     additionalFiles: S.id,
 
     tracks: S.toRefs,
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index e0350b86..a51723c4 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -107,6 +107,23 @@ export class Artist extends Thing {
     albumsAsBannerArtist:
       Artist.filterByContrib('albumData', 'bannerArtistContribs'),
 
+    albumsAsAny: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['albumData'],
+
+        compute: ({albumData, [Artist.instance]: artist}) =>
+          albumData?.filter((album) =>
+            [
+              ...album.artistContribs,
+              ...album.coverArtistContribs,
+              ...album.wallpaperArtistContribs,
+              ...album.bannerArtistContribs,
+            ].some(({who}) => who === artist)) ?? [],
+      },
+    },
+
     albumsAsCommentator: {
       flags: {expose: true},
 
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 4ea1f007..d1143b0a 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -22,11 +22,6 @@ import * as wikiInfoClasses from './wiki-info.js';
 
 export {default as Thing} from './thing.js';
 
-export {
-  default as CacheableObject,
-  CacheableObjectPropertyValueError,
-} from './cacheable-object.js';
-
 const allClassLists = {
   'album.js': albumClasses,
   'art-tag.js': artTagClasses,
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 0aeb7ad5..c1916313 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,5 +1,15 @@
-import {Tag} from '#html';
+import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
+
 import {isLanguageCode} from '#validators';
+import {Tag} from '#html';
+
+import {
+  getExternalLinkStringOfStyleFromDescriptors,
+  getExternalLinkStringsFromDescriptors,
+  isExternalLinkContext,
+  isExternalLinkSpec,
+  isExternalLinkStyle,
+} from '#external-links';
 
 import {
   externalFunction,
@@ -72,6 +82,13 @@ export class Language extends Thing {
       update: {validate: (t) => typeof t === 'object'},
     },
 
+    // List of descriptors for providing to external link utilities when using
+    // language.formatExternalLink - refer to util/external-links.js for info.
+    externalLinkSpec: {
+      flags: {update: true, expose: true},
+      update: {validate: isExternalLinkSpec},
+    },
+
     // Update only
 
     escapeHTML: externalFunction(),
@@ -269,6 +286,108 @@ export class Language extends Thing {
     return this.intl_date.formatRange(startDate, endDate);
   }
 
+  formatDateDuration({
+    years: numYears = 0,
+    months: numMonths = 0,
+    days: numDays = 0,
+    approximate = false,
+  }) {
+    let basis;
+
+    const years = this.countYears(numYears, {unit: true});
+    const months = this.countMonths(numMonths, {unit: true});
+    const days = this.countDays(numDays, {unit: true});
+
+    if (numYears && numMonths && numDays)
+      basis = this.formatString('count.dateDuration.yearsMonthsDays', {years, months, days});
+    else if (numYears && numMonths)
+      basis = this.formatString('count.dateDuration.yearsMonths', {years, months});
+    else if (numYears && numDays)
+      basis = this.formatString('count.dateDuration.yearsDays', {years, days});
+    else if (numYears)
+      basis = this.formatString('count.dateDuration.years', {years});
+    else if (numMonths && numDays)
+      basis = this.formatString('count.dateDuration.monthsDays', {months, days});
+    else if (numMonths)
+      basis = this.formatzString('count.dateDuration.months', {months});
+    else if (numDays)
+      basis = this.formatString('count.dateDuration.days', {days});
+    else
+      return this.formatString('count.dateDuration.zero');
+
+    if (approximate) {
+      return this.formatString('count.dateDuration.approximate', {
+        duration: basis,
+      });
+    } else {
+      return basis;
+    }
+  }
+
+  formatRelativeDate(currentDate, referenceDate, {
+    considerRoundingDays = false,
+    approximate = true,
+    absolute = true,
+  } = {}) {
+    const currentInstant = toTemporalInstant.apply(currentDate);
+    const referenceInstant = toTemporalInstant.apply(referenceDate);
+
+    const comparison =
+      Temporal.Instant.compare(currentInstant, referenceInstant);
+
+    if (comparison === 0) {
+      return this.formatString('count.dateDuration.same');
+    }
+
+    const currentTDZ = currentInstant.toZonedDateTimeISO('Etc/UTC');
+    const referenceTDZ = referenceInstant.toZonedDateTimeISO('Etc/UTC');
+
+    const earlierTDZ = (comparison === -1 ? currentTDZ : referenceTDZ);
+    const laterTDZ = (comparison === 1 ? currentTDZ : referenceTDZ);
+
+    const {years, months, days} =
+      laterTDZ.since(earlierTDZ, {
+        largestUnit: 'year',
+        smallestUnit:
+          (considerRoundingDays
+            ? (laterTDZ.since(earlierTDZ, {
+                largestUnit: 'year',
+                smallestUnit: 'day',
+              }).years
+                ? 'month'
+                : 'day')
+            : 'day'),
+        roundingMode: 'halfCeil',
+      });
+
+    const duration =
+      this.formatDateDuration({
+        years, months, days,
+        approximate: false,
+      });
+
+    const relative =
+      this.formatString(
+        'count.dateDuration',
+        (approximate && (years || months || days)
+          ? (comparison === -1
+              ? 'approximateEarlier'
+              : 'approximateLater')
+          : (comparison === -1
+              ? 'earlier'
+              : 'later')),
+        {duration});
+
+    if (absolute) {
+      return this.formatString('count.dateDuration.relativeAbsolute', {
+        relative,
+        absolute: this.formatDate(currentDate),
+      });
+    } else {
+      return relative;
+    }
+  }
+
   formatDuration(secTotal, {approximate = false, unit = false} = {}) {
     if (secTotal === 0) {
       return this.formatString('count.duration.missing');
@@ -299,6 +418,31 @@ export class Language extends Thing {
       : duration;
   }
 
+  formatExternalLink(url, {
+    style = 'normal',
+    context = 'generic',
+  } = {}) {
+    if (!this.externalLinkSpec) {
+      throw new TypeError(`externalLinkSpec unavailable`);
+    }
+
+    isExternalLinkContext(context);
+
+    if (style === 'all') {
+      return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, {
+        language: this,
+        context,
+      });
+    }
+
+    isExternalLinkStyle(style);
+
+    return getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+      language: this,
+      context,
+    });
+  }
+
   formatIndex(value) {
     this.assertIntlAvailable('intl_pluralOrdinal');
     return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
@@ -403,7 +547,11 @@ Object.assign(Language.prototype, {
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
   countCoverArts: countHelper('coverArts'),
+  countDays: countHelper('days'),
+  countMonths: countHelper('months'),
   countTimesReferenced: countHelper('timesReferenced'),
   countTimesUsed: countHelper('timesUsed'),
   countTracks: countHelper('tracks'),
+  countWeeks: countHelper('weeks'),
+  countYears: countHelper('years'),
 });
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
index ab9c5f98..8a3fd10e 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -30,5 +30,6 @@ export class StaticPage extends Thing {
     directory: directory(),
     content: simpleString(),
     stylesheet: simpleString(),
+    script: simpleString(),
   });
 }
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 8d310611..e3fe0804 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -24,6 +24,7 @@ import {
 
 import {
   additionalFiles,
+  additionalNameList,
   commentary,
   commentatorArtists,
   contributionList,
@@ -42,7 +43,9 @@ import {
 
 import {
   exitWithoutUniqueCoverArt,
+  inferredAdditionalNameList,
   inheritFromOriginalRelease,
+  sharedAdditionalNameList,
   trackReverseReferenceList,
   withAlbum,
   withAlwaysReferenceByDirectory,
@@ -64,6 +67,10 @@ export class Track extends Thing {
     name: name('Unnamed Track'),
     directory: directory(),
 
+    additionalNames: additionalNameList(),
+    sharedAdditionalNames: sharedAdditionalNameList(),
+    inferredAdditionalNames: inferredAdditionalNameList(),
+
     duration: duration(),
     urls: urls(),
     dateFirstReleased: simpleDate(),
@@ -329,12 +336,21 @@ export class Track extends Thing {
     }
 
     let album;
-    if (depth >= 0 && (album = this.album ?? this.dataSourceAlbum)) {
+
+    if (depth >= 0) {
+      try {
+        album = this.album;
+      } catch (_error) {}
+
+      album ??= this.dataSourceAlbum;
+    }
+
+    if (album) {
       const albumName = album.name;
       const albumIndex = album.tracks.indexOf(this);
       const trackNum =
         (albumIndex === -1
-          ? '#?'
+          ? 'indeterminate position'
           : `#${albumIndex + 1}`);
       parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`);
     }
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
index f60c363c..ac91b456 100644
--- a/src/data/things/validators.js
+++ b/src/data/things/validators.js
@@ -1,7 +1,12 @@
 import {inspect as nodeInspect} from 'node:util';
 
+// Heresy.
+import printable_characters from 'printable-characters';
+const {strlen} = printable_characters;
+
 import {colors, ENABLE_COLOR} from '#cli';
-import {empty, typeAppearance, withAggregate} from '#sugar';
+import {cut, empty, typeAppearance, withAggregate} from '#sugar';
+import {commentaryRegex} from '#wiki-data';
 
 function inspect(value) {
   return nodeInspect(value, {colors: ENABLE_COLOR});
@@ -96,7 +101,10 @@ export function isStringNonEmpty(value) {
 }
 
 export function optional(validator) {
-  return value => value === null || value === undefined || validator(value);
+  return value =>
+    value === null ||
+    value === undefined ||
+    validator(value);
 }
 
 // Complex types (non-primitives)
@@ -166,29 +174,42 @@ export function is(...values) {
 }
 
 function validateArrayItemsHelper(itemValidator) {
-  return (item, index) => {
+  return (item, index, array) => {
     try {
-      const value = itemValidator(item);
+      const value = itemValidator(item, index, array);
 
       if (value !== true) {
         throw new Error(`Expected validator to return true`);
       }
     } catch (error) {
-      error.message = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)}) ${error.message}`;
+      const annotation = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)})`;
+
+      error.message =
+        (error.message.includes('\n') || strlen(annotation) > 20
+          ? annotation + '\n' +
+            error.message
+              .split('\n')
+              .map(line => `  ${line}`)
+              .join('\n')
+          : `${annotation} ${error}`);
+
       error[Symbol.for('hsmusic.decorate.indexInSourceArray')] = index;
+
       throw error;
     }
   };
 }
 
 export function validateArrayItems(itemValidator) {
-  const fn = validateArrayItemsHelper(itemValidator);
+  const helper = validateArrayItemsHelper(itemValidator);
 
   return (array) => {
     isArray(array);
 
-    withAggregate({message: 'Errors validating array items'}, ({wrap}) => {
-      array.forEach(wrap(fn));
+    withAggregate({message: 'Errors validating array items'}, ({call}) => {
+      for (let index = 0; index < array.length; index++) {
+        call(helper, array[index], index, array);
+      }
     });
 
     return true;
@@ -200,12 +221,12 @@ export function strictArrayOf(itemValidator) {
 }
 
 export function sparseArrayOf(itemValidator) {
-  return validateArrayItems(item => {
+  return validateArrayItems((item, index, array) => {
     if (item === false || item === null) {
       return true;
     }
 
-    return itemValidator(item);
+    return itemValidator(item, index, array);
   });
 }
 
@@ -231,18 +252,56 @@ export function isColor(color) {
   throw new TypeError(`Unknown color format`);
 }
 
-export function isCommentary(commentary) {
-  isString(commentary);
+export function isCommentary(commentaryText) {
+  isString(commentaryText);
 
-  const [firstLine] = commentary.match(/.*/);
-  if (!firstLine.replace(/<\/b>/g, '').includes(':</i>')) {
-    throw new TypeError(`Missing commentary citation: "${
-      firstLine.length > 40
-        ? firstLine.slice(0, 40) + '...'
-        : firstLine
-    }"`);
+  const rawMatches =
+    Array.from(commentaryText.matchAll(commentaryRegex));
+
+  if (empty(rawMatches)) {
+    throw new TypeError(`Expected at least one commentary heading`);
   }
 
+  const niceMatches =
+    rawMatches.map(match => ({
+      position: match.index,
+      length: match[0].length,
+    }));
+
+  validateArrayItems(({position, length}, index) => {
+    if (index === 0 && position > 0) {
+      throw new TypeError(`Expected first commentary heading to be at top`);
+    }
+
+    const ownInput = commentaryText.slice(position, position + length);
+    const restOfInput = commentaryText.slice(position + length);
+    const nextLineBreak = restOfInput.indexOf('\n');
+    const upToNextLineBreak = restOfInput.slice(0, nextLineBreak);
+
+    if (/\S/.test(upToNextLineBreak)) {
+      throw new TypeError(
+        `Expected commentary heading to occupy entire line, got extra text:\n` +
+        `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
+        `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
+        `(Check for missing "|-" in YAML, or a misshapen annotation)`);
+    }
+
+    const nextHeading =
+      (index === niceMatches.length - 1
+        ? commentaryText.length
+        : niceMatches[index + 1].position);
+
+    const upToNextHeading =
+      commentaryText.slice(position + length, nextHeading);
+
+    if (!/\S/.test(upToNextHeading)) {
+      throw new TypeError(
+        `Expected commentary entry to have body text, only got a heading`);
+    }
+
+    return true;
+  })(niceMatches);
+
   return true;
 }
 
@@ -285,20 +344,14 @@ export function validateProperties(spec) {
 
 export const isContribution = validateProperties({
   who: isArtistRef,
-  what: (value) =>
-    value === undefined ||
-    value === null ||
-    isStringNonEmpty(value),
+  what: optional(isStringNonEmpty),
 });
 
 export const isContributionList = validateArrayItems(isContribution);
 
 export const isAdditionalFile = validateProperties({
   title: isString,
-  description: (value) =>
-    value === undefined ||
-    value === null ||
-    isString(value),
+  description: optional(isStringNonEmpty),
   files: validateArrayItems(isString),
 });
 
@@ -497,6 +550,24 @@ export function validateWikiData({
   };
 }
 
+export const isAdditionalName = validateProperties({
+  name: isName,
+  annotation: optional(isStringNonEmpty),
+
+  // TODO: This only allows indicating sourcing from a track.
+  // That's okay for the current limited use of "from", but
+  // could be expanded later.
+  from:
+    // Double TODO: Explicitly allowing both references and
+    // live objects to co-exist is definitely weird, and
+    // altogether questions the way we define validators...
+    optional(oneOf(
+      validateReferenceList('track'),
+      validateWikiData({referenceType: 'track'}))),
+});
+
+export const isAdditionalNameList = validateArrayItems(isAdditionalName);
+
 // Compositional utilities
 
 export function oneOf(...checks) {
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 89053d62..3db9727b 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -1,9 +1,8 @@
 import {input} from '#composite';
 import find from '#find';
-import {isLanguageCode, isName, isURL} from '#validators';
+import {isColor, isLanguageCode, isName, isURL} from '#validators';
 
 import {
-  color,
   flag,
   name,
   referenceList,
@@ -32,7 +31,14 @@ export class WikiInfo extends Thing {
       },
     },
 
-    color: color(),
+    color: {
+      flags: {update: true, expose: true},
+      update: {validate: isColor},
+
+      expose: {
+        transform: color => color ?? '#0088ff',
+      },
+    },
 
     // One-line description used for <meta rel="description"> tag.
     description: simpleString(),
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 1d35bae8..82b7faf2 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -7,15 +7,13 @@ import {inspect as nodeInspect} from 'node:util';
 
 import yaml from 'js-yaml';
 
+import CacheableObject, {CacheableObjectPropertyValueError}
+  from '#cacheable-object';
 import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
 import find, {bindFind} from '#find';
 import {traverse} from '#node-utils';
 
-import T, {
-  CacheableObject,
-  CacheableObjectPropertyValueError,
-  Thing,
-} from '#things';
+import T, {Thing} from '#things';
 
 import {
   annotateErrorWithFile,
@@ -23,6 +21,7 @@ import {
   decorateErrorWithIndex,
   decorateErrorWithAnnotation,
   empty,
+  filterAggregate,
   filterProperties,
   openAggregate,
   showAggregate,
@@ -30,6 +29,7 @@ import {
 } from '#sugar';
 
 import {
+  commentaryRegex,
   sortAlbumsTracksChronologically,
   sortAlphabetically,
   sortChronologically,
@@ -38,8 +38,8 @@ import {
 
 // --> General supporting stuff
 
-function inspect(value) {
-  return nodeInspect(value, {colors: ENABLE_COLOR});
+function inspect(value, opts = {}) {
+  return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
 
 // --> YAML data repository structure constants
@@ -308,7 +308,12 @@ export class FieldCombinationError extends Error {
   constructor(fields, message) {
     const fieldNames = Object.keys(fields);
 
-    const mainMessage = `Don't combine ${fieldNames.map(field => colors.red(field)).join(', ')}`;
+    const fieldNamesText =
+      fieldNames
+        .map(field => colors.red(field))
+        .join(', ');
+
+    const mainMessage = `Don't combine ${fieldNamesText}`;
 
     const causeMessage =
       (typeof message === 'function'
@@ -329,8 +334,15 @@ export class FieldCombinationError extends Error {
 }
 
 export class FieldValueAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
+
   constructor(thingConstructor, errors) {
-    super(errors, `Errors processing field values for ${colors.green(thingConstructor.name)}`);
+    const constructorText =
+      colors.green(thingConstructor.name);
+
+    super(
+      errors,
+      `Errors processing field values for ${constructorText}`);
   }
 }
 
@@ -341,8 +353,17 @@ export class FieldValueError extends Error {
         ? caughtError.cause
         : caughtError);
 
+    const fieldText =
+      colors.green(`"${field}"`);
+
+    const propertyText =
+      colors.green(property);
+
+    const valueText =
+      inspect(value, {maxStringLength: 40});
+
     super(
-      `Failed to set ${colors.green(`"${field}"`)} field (${colors.green(property)}) to ${inspect(value)}`,
+      `Failed to set ${fieldText} field (${propertyText}) to ${valueText}`,
       {cause});
   }
 }
@@ -354,13 +375,18 @@ export class SkippedFieldsSummaryError extends Error {
     const lines =
       entries.map(([field, value]) =>
         ` - ${field}: ` +
-        inspect(value)
+        inspect(value, {maxStringLength: 70})
           .split('\n')
           .map((line, index) => index === 0 ? line : `   ${line}`)
           .join('\n'));
 
+    const numFieldsText =
+      (entries.length === 1
+        ? `1 field`
+        : `${entries.length} fields`);
+
     super(
-      colors.bright(colors.yellow(`Altogether, skipped ${entries.length === 1 ? `1 field` : `${entries.length} fields`}:\n`)) +
+      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) +
       lines.join('\n') + '\n' +
       colors.bright(colors.yellow(`See above errors for details.`)));
   }
@@ -436,6 +462,7 @@ export const processTrackSectionDocument = makeProcessDocument(T.TrackSectionHel
 
 export const processTrackDocument = makeProcessDocument(T.Track, {
   fieldTransformations: {
+    'Additional Names': parseAdditionalNames,
     'Duration': parseDuration,
 
     'Date First Released': (value) => new Date(value),
@@ -457,6 +484,7 @@ export const processTrackDocument = makeProcessDocument(T.Track, {
   propertyFieldMapping: {
     name: 'Track',
     directory: 'Directory',
+    additionalNames: 'Additional Names',
     duration: 'Duration',
     color: 'Color',
     urls: 'URLs',
@@ -618,6 +646,7 @@ export const processStaticPageDocument = makeProcessDocument(T.StaticPage, {
     directory: 'Directory',
 
     stylesheet: 'Style',
+    script: 'Script',
     content: 'Content',
   },
 });
@@ -717,26 +746,52 @@ export function parseAdditionalFiles(array) {
   }));
 }
 
-export function parseContributors(contributors) {
+const extractAccentRegex =
+  /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
+
+export function parseContributors(contributionStrings) {
   // If this isn't something we can parse, just return it as-is.
   // The Thing object's validators will handle the data error better
   // than we're able to here.
-  if (!Array.isArray(contributors)) {
-    return contributors;
+  if (!Array.isArray(contributionStrings)) {
+    return contributionStrings;
   }
 
-  contributors = contributors.map((contrib) => {
-    if (typeof contrib !== 'string') return contrib;
+  return contributionStrings.map(item => {
+    if (typeof item === 'object' && item['Who'])
+      return {who: item['Who'], what: item['What'] ?? null};
+
+    if (typeof item !== 'string') return item;
 
-    const match = contrib.match(/^(.*?)( \((.*)\))?$/);
-    if (!match) return contrib;
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
 
-    const who = match[1];
-    const what = match[3] || null;
-    return {who, what};
+    return {
+      who: match.groups.main,
+      what: match.groups.accent ?? null,
+    };
   });
+}
+
+export function parseAdditionalNames(additionalNameStrings) {
+  if (!Array.isArray(additionalNameStrings)) {
+    return additionalNameStrings;
+  }
+
+  return additionalNameStrings.map(item => {
+    if (typeof item === 'object' && item['Name'])
+      return {name: item['Name'], annotation: item['Annotation'] ?? null};
 
-  return contributors;
+    if (typeof item !== 'string') return item;
+
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      name: match.groups.main,
+      annotation: match.groups.accent ?? null,
+    };
+  });
 }
 
 function parseDimensions(string) {
@@ -881,6 +936,7 @@ export const dataSteps = [
         // an individual section before applying it, since those are just
         // generic objects; they aren't Things in and of themselves.)
         const trackSections = [];
+        const ownTrackData = [];
 
         let currentTrackSection = {
           name: `Default Track Section`,
@@ -915,13 +971,16 @@ export const dataSteps = [
 
           entry.dataSourceAlbum = albumRef;
 
+          ownTrackData.push(entry);
           currentTrackSection.tracks.push(Thing.getReference(entry));
         }
 
         closeCurrentTrackSection();
 
-        album.trackSections = trackSections;
         albumData.push(album);
+
+        album.trackSections = trackSections;
+        album.ownTrackData = ownTrackData;
       }
 
       return {albumData, trackData};
@@ -1138,7 +1197,10 @@ export async function loadAndProcessDataDocuments({dataPath}) {
 
   for (const dataStep of dataSteps) {
     await processDataAggregate.nestAsync(
-      {message: `Errors during data step: ${colors.bright(dataStep.title)}`},
+      {
+        message: `Errors during data step: ${colors.bright(dataStep.title)}`,
+        translucent: true,
+      },
       async ({call, callAsync, map, mapAsync, push}) => {
         const {documentMode} = dataStep;
 
@@ -1383,7 +1445,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
 
         switch (documentMode) {
           case documentModes.headerAndEntries:
-            map(yamlResults, {message: `Errors processing documents in data files`},
+            map(yamlResults, {message: `Errors processing documents in data files`, translucent: true},
               decorateErrorWithFile(({documents}) => {
                 const headerDocument = documents[0];
                 const entryDocuments = documents.slice(1).filter(Boolean);
@@ -1493,7 +1555,7 @@ export function linkWikiDataArrays(wikiData, {
 
   assignWikiData([WD.wikiInfo], 'groupData');
 
-  assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData', 'trackData');
+  assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData');
   assignWikiData(WD.trackData, 'albumData', 'artistData', 'artTagData', 'flashData', 'trackData');
   assignWikiData(WD.artistData, 'albumData', 'artistData', 'flashData', 'trackData');
   assignWikiData(WD.groupData, 'albumData', 'groupCategoryData');
@@ -1567,8 +1629,7 @@ export function filterDuplicateDirectories(wikiData) {
         call(() => {
           throw new Error(
             `Duplicate directory ${colors.green(directory)}:\n` +
-              places.map((thing) => ` - ` + inspect(thing)).join('\n')
-          );
+            places.map(thing => ` - ` + inspect(thing)).join('\n'));
         });
       }
 
@@ -1618,6 +1679,7 @@ export function filterReferenceErrors(wikiData) {
       bannerArtistContribs: '_contrib',
       groups: 'group',
       artTags: 'artTag',
+      commentary: '_commentary',
     }],
 
     ['trackData', processTrackDocument, {
@@ -1628,6 +1690,7 @@ export function filterReferenceErrors(wikiData) {
       sampledTracks: '_trackNotRerelease',
       artTags: 'artTag',
       originalReleaseTrack: '_trackNotRerelease',
+      commentary: '_commentary',
     }],
 
     ['groupCategoryData', processGroupCategoryDocument, {
@@ -1677,7 +1740,21 @@ export function filterReferenceErrors(wikiData) {
 
         nest({message: `Reference errors in ${inspect(thing)}`}, ({nest, push, filter}) => {
           for (const [property, findFnKey] of Object.entries(propSpec)) {
-            const value = CacheableObject.getUpdateValue(thing, property);
+            let value = CacheableObject.getUpdateValue(thing, property);
+            let writeProperty = true;
+
+            switch (findFnKey) {
+              case '_commentary':
+                if (value) {
+                  value =
+                    Array.from(value.matchAll(commentaryRegex))
+                      .map(({groups}) => groups.artistReferences)
+                      .map(text => text.split(',').map(text => text.trim()));
+                }
+
+                writeProperty = false;
+                break;
+            }
 
             if (value === undefined) {
               push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`));
@@ -1690,19 +1767,25 @@ export function filterReferenceErrors(wikiData) {
 
             let findFn;
 
+            const findArtistOrAlias = artistRef => {
+              const alias = find.artist(artistRef, wikiData.artistAliasData, {mode: 'quiet'});
+              if (alias) {
+                // No need to check if the original exists here. Aliases are automatically
+                // created from a field on the original, so the original certainly exists.
+                const original = alias.aliasedArtist;
+                throw new Error(`Reference ${colors.red(artistRef)} is to an alias, should be ${colors.green(original.name)}`);
+              }
+
+              return boundFind.artist(artistRef);
+            };
+
             switch (findFnKey) {
-              case '_contrib':
-                findFn = contribRef => {
-                  const alias = find.artist(contribRef.who, wikiData.artistAliasData, {mode: 'quiet'});
-                  if (alias) {
-                    // No need to check if the original exists here. Aliases are automatically
-                    // created from a field on the original, so the original certainly exists.
-                    const original = alias.aliasedArtist;
-                    throw new Error(`Reference ${colors.red(contribRef.who)} is to an alias, should be ${colors.green(original.name)}`);
-                  }
+              case '_commentary':
+                findFn = findArtistOrAlias;
+                break;
 
-                  return boundFind.artist(contribRef.who);
-                };
+              case '_contrib':
+                findFn = contribRef => findArtistOrAlias(contribRef.who);
                 break;
 
               case '_homepageSourceGroup':
@@ -1783,22 +1866,39 @@ export function filterReferenceErrors(wikiData) {
                 ? `Reference errors` + fieldPropertyMessage + findFnMessage
                 : `Reference error` + fieldPropertyMessage + findFnMessage);
 
-            if (Array.isArray(value)) {
-              thing[property] = filter(
-                value,
-                decorateErrorWithIndex(suppress(findFn)),
-                {message: errorMessage});
+            let newPropertyValue = value;
+
+            if (findFnKey === '_commentary') {
+              // Commentary doesn't write a property value, so no need to set.
+              filter(
+                value, {message: errorMessage},
+                decorateErrorWithIndex(refs =>
+                  (refs.length === 1
+                    ? suppress(findFn)(refs[0])
+                    : filterAggregate(
+                        refs, {message: `Errors in entry's artist references`},
+                        decorateErrorWithIndex(suppress(findFn)))
+                          .aggregate
+                          .close())));
+            } else if (Array.isArray(value)) {
+              newPropertyValue = filter(
+                value, {message: errorMessage},
+                decorateErrorWithIndex(suppress(findFn)));
             } else {
               nest({message: errorMessage},
                 suppress(({call}) => {
                   try {
                     call(findFn, value);
                   } catch (error) {
-                    thing[property] = null;
+                    newPropertyValue = null;
                     throw error;
                   }
                 }));
             }
+
+            if (writeProperty) {
+              thing[property] = newPropertyValue;
+            }
           }
         });
       }
diff --git a/src/find.js b/src/find.js
index dfcaa9aa..4d3e996a 100644
--- a/src/find.js
+++ b/src/find.js
@@ -1,8 +1,8 @@
 import {inspect} from 'node:util';
 
+import CacheableObject from '#cacheable-object';
 import {colors, logWarn} from '#cli';
 import {typeAppearance} from '#sugar';
-import {CacheableObject} from '#things';
 
 function warnOrThrow(mode, message) {
   if (mode === 'error') {
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 1bbcb9c1..e6c1f5c2 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -101,8 +101,8 @@ import {
 
 import dimensionsOf from 'image-size';
 
+import CacheableObject from '#cacheable-object';
 import {delay, empty, queue, unique} from '#sugar';
-import {CacheableObject} from '#things';
 import {sortByName} from '#wiki-data';
 
 import {
diff --git a/src/listing-spec.js b/src/listing-spec.js
index 2b33744a..9433ee68 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -44,12 +44,14 @@ listingSpec.push({
   directory: 'artists/by-name',
   stringsKey: 'listArtists.byName',
   contentFunction: 'listArtistsByName',
+  seeAlso: ['artists/by-contribs', 'artists/by-group'],
 });
 
 listingSpec.push({
   directory: 'artists/by-contribs',
   stringsKey: 'listArtists.byContribs',
   contentFunction: 'listArtistsByContributions',
+  seeAlso: ['artists/by-name', 'artists/by-group'],
 });
 
 listingSpec.push({
@@ -64,6 +66,15 @@ listingSpec.push({
   contentFunction: 'listArtistsByDuration',
 });
 
+// TODO: hide if no groups...
+listingSpec.push({
+  directory: 'artists/by-group',
+  stringsKey: 'listArtists.byGroup',
+  contentFunction: 'listArtistsByGroup',
+  featureFlag: 'enableGroupUI',
+  seeAlso: ['artists/by-name', 'artists/by-contribs'],
+});
+
 listingSpec.push({
   directory: 'artists/by-latest',
   stringsKey: 'listArtists.byLatest',
diff --git a/src/repl.js b/src/repl.js
index 7a6f5c45..dd61133c 100644
--- a/src/repl.js
+++ b/src/repl.js
@@ -5,19 +5,18 @@ import {fileURLToPath} from 'node:url';
 
 import {logError, logWarn, parseOptions} from '#cli';
 import {isMain} from '#node-utils';
-import {processLanguageFile} from '#language';
+import {internalDefaultStringsFile, processLanguageFile} from '#language';
 import {bindOpts, showAggregate} from '#sugar';
 import {generateURLs, urlSpec} from '#urls';
 import {quickLoadAllFromYAML} from '#yaml';
 
 import _find, {bindFind} from '#find';
-import thingConstructors, {CacheableObject} from '#things';
+import CacheableObject from '#cacheable-object';
+import thingConstructors from '#things';
 import * as serialize from '#serialize';
 import * as sugar from '#sugar';
 import * as wikiDataUtils from '#wiki-data';
 
-import {DEFAULT_STRINGS_FILE} from './upd8.js';
-
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 export async function getContextAssignments({
@@ -45,10 +44,7 @@ export async function getContextAssignments({
 
   let language;
   try {
-    language = await processLanguageFile(
-      path.join(
-        path.dirname(fileURLToPath(import.meta.url)),
-        DEFAULT_STRINGS_FILE));
+    language = await processLanguageFile(internalDefaultStringsFile);
   } catch (error) {
     console.error(error);
     logWarn`Failed to create Language object`;
diff --git a/src/static/client2.js b/src/static/client3.js
index 523b48d8..ce057712 100644
--- a/src/static/client2.js
+++ b/src/static/client3.js
@@ -7,16 +7,7 @@
 
 import {getColors} from '../util/colors.js';
 import {empty, stitchArrays} from '../util/sugar.js';
-
-import {
-  filterMultipleArrays,
-  getArtistNumContributions,
-} from '../util/wiki-data.js';
-
-let albumData, artistData;
-let officialAlbumData, fandomAlbumData, beyondAlbumData;
-
-let ready = false;
+import {filterMultipleArrays} from '../util/wiki-data.js';
 
 const clientInfo = window.hsmusicClientInfo = Object.create(null);
 
@@ -72,17 +63,25 @@ function pick(array) {
   return array[Math.floor(Math.random() * array.length)];
 }
 
-function cssProp(el, key) {
-  return getComputedStyle(el).getPropertyValue(key).trim();
-}
+function cssProp(el, ...args) {
+  if (typeof args[0] === 'string' && args.length === 1) {
+    return getComputedStyle(el).getPropertyValue(args[0]).trim();
+  }
 
-function getRefDirectory(ref) {
-  return ref.split(':')[1];
-}
+  if (typeof args[0] === 'string' && args.length === 2) {
+    if (args[1] === null) {
+      el.style.removeProperty(args[0]);
+    } else {
+      el.style.setProperty(args[0], args[1]);
+    }
+    return;
+  }
 
-function getAlbum(el) {
-  const directory = cssProp(el, '--album-directory');
-  return albumData.find((album) => album.directory === directory);
+  if (typeof args[0] === 'object') {
+    for (const [property, value] of Object.entries(args[0])) {
+      cssProp(el, property, value);
+    }
+  }
 }
 
 // TODO: These should pro8a8ly access some shared urlSpec path. We'd need to
@@ -99,6 +98,35 @@ function fetchData(type, directory) {
   );
 }
 
+function dispatchInternalEvent(event, eventName, ...args) {
+  const [infoName] =
+    Object.entries(clientInfo)
+      .find(pair => pair[1].event === event);
+
+  if (!infoName) {
+    throw new Error(`Expected event to be stored on clientInfo`);
+  }
+
+  const {[eventName]: listeners} = event;
+
+  if (!listeners) {
+    throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`);
+  }
+
+  let results = [];
+  for (const listener of listeners) {
+    try {
+      results.push(listener(...args));
+    } catch (error) {
+      console.warn(`Uncaught error in listener for ${infoName}.${eventName}`);
+      console.debug(error);
+      results.push(undefined);
+    }
+  }
+
+  return results;
+}
+
 // JS-based links -----------------------------------------
 
 const scriptedLinkInfo = clientInfo.scriptedLinkInfo = {
@@ -108,6 +136,13 @@ const scriptedLinkInfo = clientInfo.scriptedLinkInfo = {
   nextLink: null,
   previousLink: null,
   randomLink: null,
+
+  state: {
+    albumDirectories: null,
+    albumTrackDirectories: null,
+    artistDirectories: null,
+    artistNumContributions: null,
+  },
 };
 
 function getScriptedLinkReferences() {
@@ -129,70 +164,130 @@ function getScriptedLinkReferences() {
 
 function addRandomLinkListeners() {
   for (const a of scriptedLinkInfo.randomLinks ?? []) {
-    a.addEventListener('click', evt => {
-      if (!ready) {
-        evt.preventDefault();
-        return;
-      }
+    a.addEventListener('click', domEvent => {
+      handleRandomLinkClicked(a, domEvent);
+    });
+  }
+}
 
-      const tracks = albumData =>
-        albumData
-          .map(album => album.tracks)
-          .reduce((acc, tracks) => acc.concat(tracks), []);
+function handleRandomLinkClicked(a, domEvent) {
+  const href = determineRandomLinkHref(a);
 
-      setTimeout(() => {
-        a.href = rebase('js-disabled');
-      });
+  if (!href) {
+    domEvent.preventDefault();
+    return;
+  }
 
-      switch (a.dataset.random) {
-        case 'album':
-          a.href = openAlbum(pick(albumData).directory);
-          break;
-
-        case 'album-in-official':
-          a.href = openAlbum(pick(officialAlbumData).directory);
-          break;
-
-        case 'album-in-fandom':
-          a.href = openAlbum(pick(fandomAlbumData).directory);
-          break;
-
-        case 'album-in-beyond':
-          a.href = openAlbum(pick(beyondAlbumData).directory);
-          break;
-
-        case 'track':
-          a.href = openTrack(getRefDirectory(pick(tracks(albumData))));
-          break;
-
-        case 'track-in-album':
-          a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)));
-          break;
-
-        case 'track-in-official':
-          a.href = openTrack(getRefDirectory(pick(tracks(officialAlbumData))));
-          break;
-
-        case 'track-in-fandom':
-          a.href = openTrack(getRefDirectory(pick(tracks(fandomAlbumData))));
-          break;
-
-        case 'track-in-beyond':
-          a.href = openTrack(getRefDirectory(pick(tracks(beyondAlbumData))));
-          break;
-
-        case 'artist':
-          a.href = openArtist(pick(artistData).directory);
-          break;
-
-        case 'artist-more-than-one-contrib':
-          a.href =
-            openArtist(
-              pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1))
-                .directory);
-          break;
-      }
-    });
+  setTimeout(() => {
+    a.href = '#'
+  });
+
+  a.href = href;
+}
+
+function determineRandomLinkHref(a) {
+  const {state} = scriptedLinkInfo;
+
+  const trackDirectoriesFromAlbumDirectories = albumDirectories =>
+    albumDirectories
+      .map(directory => state.albumDirectories.indexOf(directory))
+      .map(index => state.albumTrackDirectories[index])
+      .reduce((acc, trackDirectories) => acc.concat(trackDirectories, []));
+
+  switch (a.dataset.random) {
+    case 'album': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      return openAlbum(pick(albumDirectories));
+    }
+
+    case 'track': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      const trackDirectories =
+        trackDirectoriesFromAlbumDirectories(
+          albumDirectories);
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'album-in-group-dl': {
+      const albumLinks =
+        Array.from(a
+          .closest('dt')
+          .nextElementSibling
+          .querySelectorAll('li a'))
+
+      const listAlbumDirectories =
+        albumLinks
+          .map(a => cssProp(a, '--album-directory'));
+
+      return openAlbum(pick(listAlbumDirectories));
+    }
+
+    case 'track-in-group-dl': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      const albumLinks =
+        Array.from(a
+          .closest('dt')
+          .nextElementSibling
+          .querySelectorAll('li a'))
+
+      const listAlbumDirectories =
+        albumLinks
+          .map(a => cssProp(a, '--album-directory'));
+
+      const trackDirectories =
+        trackDirectoriesFromAlbumDirectories(
+          listAlbumDirectories);
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'track-in-sidebar': {
+      // Note that the container for track links may be <ol> or <ul>, and
+      // they can't be identified by href, since links from one track to
+      // another don't include "track" in the href.
+      const trackLinks =
+        Array.from(document
+          .querySelector('.track-list-sidebar-box')
+          .querySelectorAll('li a'));
+
+      return pick(trackLinks).href;
+    }
+
+    case 'track-in-album': {
+      const {albumDirectories, albumTrackDirectories} = state;
+      if (!albumDirectories || !albumTrackDirectories) return null;
+
+      const albumDirectory = cssProp(a, '--album-directory');
+      const albumIndex = albumDirectories.indexOf(albumDirectory);
+      const trackDirectories = albumTrackDirectories[albumIndex];
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'artist': {
+      const {artistDirectories} = state;
+      if (!artistDirectories) return null;
+
+      return openArtist(pick(artistDirectories));
+    }
+
+    case 'artist-more-than-one-contrib': {
+      const {artistDirectories, artistNumContributions} = state;
+      if (!artistDirectories || !artistNumContributions) return null;
+
+      const filteredArtistDirectories =
+        artistDirectories
+          .filter((_artist, index) => artistNumContributions[index] > 1);
+
+      return openArtist(pick(filteredArtistDirectories));
+    }
   }
 }
 
@@ -216,9 +311,7 @@ function addNavigationKeyPressListeners() {
       } else if (event.charCode === 'P'.charCodeAt(0)) {
         scriptedLinkInfo.previousNavLink?.click();
       } else if (event.charCode === 'R'.charCodeAt(0)) {
-        if (ready) {
-          scriptedLinkInfo.randomNavLink?.click();
-        }
+        scriptedLinkInfo.randomNavLink?.click();
       }
     }
   });
@@ -243,43 +336,649 @@ clientSteps.addPageListeners.push(addNavigationKeyPressListeners);
 clientSteps.addPageListeners.push(addRevealLinkClickListeners);
 clientSteps.mutatePageContent.push(mutateNavigationLinkContent);
 
-const elements1 = document.getElementsByClassName('js-hide-once-data');
-const elements2 = document.getElementsByClassName('js-show-once-data');
+if (
+  document.documentElement.dataset.urlKey === 'localized.listing' &&
+  document.documentElement.dataset.urlValue0 === 'random'
+) {
+  const dataLoadingLine = document.getElementById('data-loading-line');
+  const dataLoadedLine = document.getElementById('data-loaded-line');
+  const dataErrorLine = document.getElementById('data-error-line');
+
+  dataLoadingLine.style.display = 'block';
+
+  fetch(rebase('random-link-data.json', 'rebaseShared'))
+    .then(data => data.json())
+    .then(data => {
+      const {state} = scriptedLinkInfo;
+
+      Object.assign(state, {
+        albumDirectories: data.albumDirectories,
+        albumTrackDirectories: data.albumTrackDirectories,
+        artistDirectories: data.artistDirectories,
+        artistNumContributions: data.artistNumContributions,
+      });
 
-for (const element of elements1) element.style.display = 'block';
+      dataLoadingLine.style.display = 'none';
+      dataLoadedLine.style.display = 'block';
+    }, () => {
+      dataLoadingLine.style.display = 'none';
+      dataErrorLine.style.display = 'block';
+    })
+    .then(() => {
+      const {randomLinks} = scriptedLinkInfo;
+      for (const a of randomLinks) {
+        const href = determineRandomLinkHref(a);
+        if (!href) {
+          a.removeAttribute('href');
+        }
+      }
+    });
+}
 
-fetch(rebase('data.json', 'rebaseShared'))
-  .then((data) => data.json())
-  .then((data) => {
-    albumData = data.albumData;
-    artistData = data.artistData;
+// Tooltip-style hover (infrastructure) -------------------
+
+const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = {
+  settings: {
+    // Hovering has two speed settings. The normal setting is used by default,
+    // and once a tooltip is displayed as a result of hover, the entire tooltip
+    // system will enter a "fast hover mode" - hovering will activate tooltips
+    // sooner. "Fast hover mode" is disabled after a sustained duration of not
+    // hovering over any hoverables; it's meant only to accelerate switching
+    // tooltips while still deciding, or getting a quick overview across more
+    // than one tooltip.
+    normalHoverInfoDelay: 400,
+    fastHoveringInfoDelay: 150,
+    endFastHoveringDelay: 500,
+
+    // Focusing has a single speed setting, which is how long it will take to
+    // enter a functional "focus mode" (though it's not actually implemented
+    // in terms of this state). As soon as "focus mode" is entered, the tooltip
+    // for the current hoverable is displayed, and focusing another hoverable
+    // will cause the current tooltip to be swapped for that one immediately.
+    // "Focus mode" ends as soon as anything apart from a tooltip or hoverable
+    // is focused, and it will be necessary to wait on this delay again.
+    focusInfoDelay: 750,
+
+    hideTooltipDelay: 500,
+
+    // If a tooltip that's transitioning to hidden is hovered during the grace
+    // period (or the corresponding hoverable is hovered at any point in the
+    // transition), it'll cancel out of this animation immediately.
+    transitionHiddenDuration: 300,
+    inertGracePeriod: 100,
+  },
+
+  state: {
+    // These maps store a record for each registered element and related state
+    // and registration info, if applicable.
+    registeredTooltips: new Map(),
+    registeredHoverables: new Map(),
+
+    // These are common across all tooltips, rather than stored individually,
+    // based on the principles that 1) only a single tooltip can be displayed
+    // at once, and 2) likewise, only a single hoverable can be hovered,
+    // focused, or otherwise active at once.
+    hoverTimeout: null,
+    focusTimeout: null,
+    touchTimeout: null,
+    hideTimeout: null,
+    transitionHiddenTimeout: null,
+    inertGracePeriodTimeout: null,
+    currentlyShownTooltip: null,
+    currentlyActiveHoverable: null,
+    currentlyTransitioningHiddenTooltip: null,
+    previouslyActiveHoverable: null,
+    tooltipWasJustHidden: false,
+    hoverableWasRecentlyTouched: false,
+
+    // Fast hovering is a global mode which is activated as soon as any tooltip
+    // is displayed and turns off after a delay of no hoverables being hovered.
+    // Note that fast hovering may be turned off while hovering a tooltip, but
+    // it will never be turned off while idling over a hoverable.
+    fastHovering: false,
+    endFastHoveringTimeout: false,
+
+    // These track the identifiers of current touches and a record of current
+    // identifiers that are "banished" by scrolling - that is, touches which
+    // existed while the page scrolled and were probably responsible for that
+    // scrolling. This is a bit loose (we can't actually tell which touches
+    // caused the page to scroll) but it's intended to keep scrolling the page
+    // from causing the current tooltip to be hidden.
+    currentTouchIdentifiers: new Set(),
+    touchIdentifiersBanishedByScrolling: new Set(),
+  },
+};
+
+// Adds DOM event listeners, so must be called during addPageListeners step.
+function registerTooltipElement(tooltip) {
+  const {state} = hoverableTooltipInfo;
 
-    const albumsInGroup = directory =>
-      albumData
-        .filter(album =>
-          album.groups.includes(`group:${directory}`));
+  if (!tooltip)
+    throw new Error(`Expected tooltip`);
 
-    officialAlbumData = albumsInGroup('official');
-    fandomAlbumData = albumsInGroup('fandom');
-    beyondAlbumData = albumsInGroup('beyond');
+  if (state.registeredTooltips.has(tooltip))
+    throw new Error(`This tooltip is already registered`);
 
-    for (const element of elements1) element.style.display = 'none';
-    for (const element of elements2) element.style.display = 'block';
+  // No state or registration info here.
+  state.registeredTooltips.set(tooltip, {});
 
-    ready = true;
+  tooltip.addEventListener('mouseenter', () => {
+    handleTooltipMouseEntered(tooltip);
   });
 
-// Data & info card ---------------------------------------
+  tooltip.addEventListener('mouseleave', () => {
+    handleTooltipMouseLeft(tooltip);
+  });
 
-/*
-const NORMAL_HOVER_INFO_DELAY = 750;
-const FAST_HOVER_INFO_DELAY = 250;
-const END_FAST_HOVER_DELAY = 500;
-const HIDE_HOVER_DELAY = 250;
+  tooltip.addEventListener('focusin', event => {
+    handleTooltipReceivedFocus(tooltip, event.relatedTarget);
+  });
+
+  tooltip.addEventListener('focusout', event => {
+    // This event gets activated for tabbing *between* links inside the
+    // tooltip, which is no good and certainly doesn't represent the focus
+    // leaving the tooltip.
+    if (currentlyShownTooltipHasFocus(event.relatedTarget)) return;
+
+    handleTooltipLostFocus(tooltip, event.relatedTarget);
+  });
+}
+
+// Adds DOM event listeners, so must be called during addPageListeners step.
+function registerTooltipHoverableElement(hoverable, tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  if (!hoverable || !tooltip)
+    if (hoverable)
+      throw new Error(`Expected hoverable and tooltip, got only hoverable`);
+    else
+      throw new Error(`Expected hoverable and tooltip, got neither`);
+
+  if (!state.registeredTooltips.has(tooltip))
+    throw new Error(`Register tooltip before registering hoverable`);
+
+  if (state.registeredHoverables.has(hoverable))
+    throw new Error(`This hoverable is already registered`);
+
+  state.registeredHoverables.set(hoverable, {tooltip});
+
+  hoverable.addEventListener('mouseenter', () => {
+    handleTooltipHoverableMouseEntered(hoverable);
+  });
+
+  hoverable.addEventListener('mouseleave', () => {
+    handleTooltipHoverableMouseLeft(hoverable);
+  });
+
+  hoverable.addEventListener('focusin', event => {
+    handleTooltipHoverableReceivedFocus(hoverable, event);
+  });
+
+  hoverable.addEventListener('focusout', event => {
+    handleTooltipHoverableLostFocus(hoverable, event);
+  });
+
+  hoverable.addEventListener('touchend', event => {
+    handleTooltipHoverableTouchEnded(hoverable, event);
+  });
+
+  hoverable.addEventListener('click', event => {
+    handleTooltipHoverableClicked(hoverable, event);
+  });
+}
+
+function handleTooltipMouseEntered(tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  if (state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden(true);
+    return;
+  }
+
+  if (state.currentlyShownTooltip !== tooltip) return;
+
+  // Don't time out the current tooltip while hovering it.
+
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipMouseLeft(tooltip) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  if (state.currentlyShownTooltip !== tooltip) return;
+
+  // Start timing out the current tooltip when it's left. This could be
+  // canceled by mousing over a hoverable, or back over the tooltip again.
+  if (!state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
+function handleTooltipReceivedFocus(tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  // Cancel the tooltip-hiding timeout if it exists. The tooltip will never
+  // be hidden while it contains the focus anyway, but this ensures the timeout
+  // will be suitably reset when the tooltip loses focus.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipLostFocus(tooltip) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // Hide the current tooltip right away when it loses focus. Specify intent
+  // to replace - while we don't strictly know if another tooltip is going to
+  // immediately replace it, the mode of navigating with tab focus (once one
+  // tooltip has been activated) is a "switch focus immediately" kind of
+  // interaction in its nature.
+  hideCurrentlyShownTooltip(true);
+}
+
+function handleTooltipHoverableMouseEntered(hoverable) {
+  const {event, settings, state} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // If this tooltip was transitioning to hidden, hovering should cancel that
+  // animation and show it immediately.
+
+  if (tooltip === state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden(true);
+    return;
+  }
+
+  // Start a timer to show the corresponding tooltip, with the delay depending
+  // on whether fast hovering or not. This could be canceled by mousing out of
+  // the hoverable.
+
+  const hoverTimeoutDelay =
+    (state.fastHovering
+      ? settings.fastHoveringInfoDelay
+      : settings.normalHoverInfoDelay);
+
+  state.hoverTimeout =
+    setTimeout(() => {
+      state.hoverTimeout = null;
+      state.fastHovering = true;
+      showTooltipFromHoverable(hoverable);
+    }, hoverTimeoutDelay);
+
+  // Don't stop fast hovering while over any hoverable.
+  if (state.endFastHoveringTimeout) {
+    clearTimeout(state.endFastHoveringTimeout);
+    state.endFastHoveringTimeout = null;
+  }
+
+  // Don't time out the current tooltip while over any hoverable.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipHoverableMouseLeft(hoverable) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // Don't show a tooltip when not over a hoverable!
+  if (state.hoverTimeout) {
+    clearTimeout(state.hoverTimeout);
+    state.hoverTimeout = null;
+  }
+
+  // Start timing out fast hovering (if active) when not over a hoverable.
+  // This will only be canceled by mousing over another hoverable.
+  if (state.fastHovering && !state.endFastHoveringTimeout) {
+    state.endFastHoveringTimeout =
+      setTimeout(() => {
+        state.endFastHoveringTimeout = null;
+        state.fastHovering = false;
+      }, settings.endFastHoveringDelay);
+  }
+
+  // Start timing out the current tooltip when mousing not over a hoverable.
+  // This could be canceled by mousing over another hoverable, or over the
+  // currently shown tooltip.
+  if (state.currentlyShownTooltip && !state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
+function handleTooltipHoverableReceivedFocus(hoverable) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // By default, display the corresponding tooltip after a delay.
+
+  state.focusTimeout =
+    setTimeout(() => {
+      state.focusTimeout = null;
+      showTooltipFromHoverable(hoverable);
+    }, settings.focusInfoDelay);
+
+  // If a tooltip was just hidden - which is almost certainly a result of the
+  // focus changing - then display this tooltip immediately, canceling the
+  // above timeout.
+
+  if (state.tooltipWasJustHidden) {
+    clearTimeout(state.focusTimeout);
+    state.focusTimeout = null;
+
+    showTooltipFromHoverable(hoverable);
+  }
+}
+
+function handleTooltipHoverableLostFocus(hoverable, domEvent) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // Don't show a tooltip from focusing a hoverable if it isn't focused
+  // anymore! If another hoverable is receiving focus, that will be evaluated
+  // and set its own focus timeout after we clear the previous one here.
+  if (state.focusTimeout) {
+    clearTimeout(state.focusTimeout);
+    state.focusTimeout = null;
+  }
+
+  // Unless focus is entering the tooltip itself, hide the tooltip immediately.
+  // This will set the tooltipWasJustHidden flag, which is detected by a newly
+  // focused hoverable, if applicable. Always specify intent to replace when
+  // navigating via tab focus. (Check `handleTooltipLostFocus` for details.)
+  if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) {
+    hideCurrentlyShownTooltip(true);
+  }
+}
+
+function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
+  const {settings, state} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // Don't proceed if this hoverable's tooltip is already visible - in that
+  // case touching the hoverable again should behave just like a normal click.
+  if (state.currentlyShownTooltip === tooltip) return;
+
+  const touches = Array.from(domEvent.changedTouches);
+  const identifiers = touches.map(touch => touch.identifier);
+
+  // Don't process touch events that were "banished" because the page was
+  // scrolled while those touches were active, and most likely as a result of
+  // them.
+  filterMultipleArrays(touches, identifiers,
+    (_touch, identifier) =>
+      !state.touchIdentifiersBanishedByScrolling.has(identifier));
+
+  if (empty(touches)) return;
+
+  // Don't proceed if none of the (just-ended) touches ended over the
+  // hoverable.
+  const anyTouchEndedOverHoverable =
+    touches.some(touch =>
+      hoverable.contains(
+        document.elementFromPoint(touch.clientX, touch.clientY)));
+
+  if (!anyTouchEndedOverHoverable) {
+    return;
+  }
+
+  if (state.touchTimeout) {
+    clearTimeout(state.touchTimeout);
+    state.touchTimeout = null;
+  }
+
+  // Show the tooltip right away.
+  showTooltipFromHoverable(hoverable);
+
+  // Set a state, for a brief but not instantaneous period, indicating that a
+  // hoverable was recently touched. The touchend event may precede the click
+  // event by some time, and we don't want to navigate away from the page as
+  // a result of the click event which this touch precipitated.
+  state.hoverableWasRecentlyTouched = true;
+  state.touchTimeout =
+    setTimeout(() => {
+      state.hoverableWasRecentlyTouched = false;
+    }, 250);
+}
+
+function handleTooltipHoverableClicked(hoverable, domEvent) {
+  const {state} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // Don't navigate away from the page if the this hoverable was recently
+  // touched (and had its tooltip activated). That flag won't be set if its
+  // tooltip was already open before the touch.
+  if (
+    state.currentlyActiveHoverable === hoverable &&
+    state.hoverableWasRecentlyTouched
+  ) {
+    event.preventDefault();
+  }
+}
+
+function currentlyShownTooltipHasFocus(focusElement = document.activeElement) {
+  const {state} = hoverableTooltipInfo;
+
+  const {
+    currentlyShownTooltip: tooltip,
+    currentlyActiveHoverable: hoverable,
+  } = state;
+
+  // If there's no tooltip, it can't possibly have focus.
+  if (!tooltip) return false;
+
+  // If the tooltip literally contains (or is) the focused element, then that's
+  // the principle condition we're looking for.
+  if (tooltip.contains(focusElement)) return true;
+
+  // If the hoverable *which opened the tooltip* is focused, then that also
+  // represents the tooltip being focused (in its currently shown state).
+  if (hoverable.contains(focusElement)) return true;
+
+  return false;
+}
+
+function beginTransitioningTooltipHidden(tooltip) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  if (state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden();
+  }
+
+  cssProp(tooltip, {
+    'display': 'block',
+    'opacity': '0',
+
+    'transition-property': 'opacity',
+    'transition-timing-function':
+      `steps(${Math.ceil(settings.transitionHiddenDuration / 60)}, end)`,
+    'transition-duration':
+      `${settings.transitionHiddenDuration / 1000}s`,
+  });
+
+  state.currentlyTransitioningHiddenTooltip = tooltip;
+  state.transitionHiddenTimeout =
+    setTimeout(() => {
+      endTransitioningTooltipHidden();
+    }, settings.transitionHiddenDuration);
+}
+
+function cancelTransitioningTooltipHidden(andShow = false) {
+  const {state} = hoverableTooltipInfo;
+
+  endTransitioningTooltipHidden();
+
+  if (andShow) {
+    showTooltipFromHoverable(state.previouslyActiveHoverable);
+  }
+}
+
+function endTransitioningTooltipHidden() {
+  const {state} = hoverableTooltipInfo;
+  const {currentlyTransitioningHiddenTooltip: tooltip} = state;
+
+  if (!tooltip) return;
+
+  cssProp(tooltip, {
+    'display': null,
+    'opacity': null,
+    'transition-property': null,
+    'transition-timing-function': null,
+    'transition-duration': null,
+  });
+
+  state.currentlyTransitioningHiddenTooltip = null;
+
+  if (state.inertGracePeriodTimeout) {
+    clearTimeout(state.inertGracePeriodTimeout);
+    state.inertGracePeriodTimeout = null;
+  }
+
+  if (state.transitionHiddenTimeout) {
+    clearTimeout(state.transitionHiddenTimeout);
+    state.transitionHiddenTimeout = null;
+  }
+}
+
+function hideCurrentlyShownTooltip(intendingToReplace = false) {
+  const {event, settings, state} = hoverableTooltipInfo;
+  const {currentlyShownTooltip: tooltip} = state;
+
+  // If there was no tooltip to begin with, we're functionally in the desired
+  // state already, so return true.
+  if (!tooltip) return true;
+
+  // Never hide the tooltip if it's focused.
+  if (currentlyShownTooltipHasFocus()) return false;
+
+  state.currentlyActiveHoverable.classList.remove('has-visible-tooltip');
+
+  // If there's no intent to replace this tooltip, it's the last one currently
+  // apparent in the interaction, and should be hidden with a transition.
+  if (intendingToReplace) {
+    cssProp(tooltip, 'display', 'none');
+  } else {
+    beginTransitioningTooltipHidden(state.currentlyShownTooltip);
+  }
+
+  // Wait just a moment before making the tooltip inert. You might react
+  // (to the ghosting, or just to time passing) and realize you wanted
+  // to look at the tooltip after all - this delay gives a little buffer
+  // to second guess letting it disappear.
+  state.inertGracePeriodTimeout =
+    setTimeout(() => {
+      tooltip.inert = true;
+    }, settings.inertGracePeriod);
+
+  state.previouslyActiveHoverable = state.currentlyActiveHoverable;
+
+  state.currentlyShownTooltip = null;
+  state.currentlyActiveHoverable = null;
+
+  // Set this for one tick of the event cycle.
+  state.tooltipWasJustHidden = true;
+  setTimeout(() => {
+    state.tooltipWasJustHidden = false;
+  });
+
+  return true;
+}
+
+function showTooltipFromHoverable(hoverable) {
+  const {event, state} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
 
-let fastHover = false;
-let endFastHoverTimeout = null;
+  if (!hideCurrentlyShownTooltip(true)) return false;
 
+  // Cancel out another tooltip that's transitioning hidden, if that's going
+  // on - it's a distraction that this tooltip is now replacing.
+  cancelTransitioningTooltipHidden();
+
+  hoverable.classList.add('has-visible-tooltip');
+
+  cssProp(tooltip, 'display', 'block');
+  tooltip.inert = false;
+
+  state.currentlyShownTooltip = tooltip;
+  state.currentlyActiveHoverable = hoverable;
+
+  state.tooltipWasJustHidden = false;
+
+  return true;
+}
+
+function addHoverableTooltipPageListeners() {
+  const {state} = hoverableTooltipInfo;
+
+  const getTouchIdentifiers = domEvent =>
+    Array.from(domEvent.changedTouches)
+      .map(touch => touch.identifier)
+      .filter(identifier => typeof identifier !== 'undefined');
+
+  document.body.addEventListener('touchstart', domEvent => {
+    for (const identifier of getTouchIdentifiers(domEvent)) {
+      state.currentTouchIdentifiers.add(identifier);
+    }
+  });
+
+  window.addEventListener('scroll', () => {
+    for (const identifier of state.currentTouchIdentifiers) {
+      state.touchIdentifiersBanishedByScrolling.add(identifier);
+    }
+  });
+
+  document.body.addEventListener('touchend', domEvent => {
+    setTimeout(() => {
+      for (const identifier of getTouchIdentifiers(domEvent)) {
+        state.currentTouchIdentifiers.delete(identifier);
+        state.touchIdentifiersBanishedByScrolling.delete(identifier);
+      }
+    });
+  });
+
+  document.body.addEventListener('touchend', domEvent => {
+    const hoverables = Array.from(state.registeredHoverables.keys());
+    const tooltips = Array.from(state.registeredTooltips.keys());
+
+    const touches = Array.from(domEvent.changedTouches);
+    const identifiers = touches.map(touch => touch.identifier);
+
+    // Don't process touch events that were "banished" because the page was
+    // scrolled while those touches were active, and most likely as a result of
+    // them.
+    filterMultipleArrays(touches, identifiers,
+      (_touch, identifier) =>
+        !state.touchIdentifiersBanishedByScrolling.has(identifier));
+
+    if (empty(touches)) return;
+
+    const anyTouchOverAnyHoverableOrTooltip =
+      touches.some(({clientX, clientY}) => {
+        const element = document.elementFromPoint(clientX, clientY);
+        if (hoverables.some(el => el.contains(element))) return true;
+        if (tooltips.some(el => el.contains(element))) return true;
+        return false;
+      });
+
+    if (!anyTouchOverAnyHoverableOrTooltip) {
+      hideCurrentlyShownTooltip();
+    }
+  });
+}
+
+clientSteps.addPageListeners.push(addHoverableTooltipPageListeners);
+
+// Data & info card ---------------------------------------
+
+/*
 function colorLink(a, color) {
   console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet');
   return;
@@ -445,53 +1144,6 @@ const infoCard = (() => {
   };
 })();
 
-function makeInfoCardLinkHandlers(type) {
-  let hoverTimeout = null;
-
-  return {
-    mouseenter(evt) {
-      hoverTimeout = setTimeout(
-        () => {
-          fastHover = true;
-          infoCard.show(type, evt.target);
-        },
-        fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY);
-
-      clearTimeout(endFastHoverTimeout);
-      endFastHoverTimeout = null;
-
-      infoCard.cancelHide();
-    },
-
-    mouseleave() {
-      clearTimeout(hoverTimeout);
-
-      if (fastHover && !endFastHoverTimeout) {
-        endFastHoverTimeout = setTimeout(() => {
-          endFastHoverTimeout = null;
-          fastHover = false;
-        }, END_FAST_HOVER_DELAY);
-      }
-
-      infoCard.readyHide();
-    },
-  };
-}
-
-const infoCardLinkHandlers = {
-  track: makeInfoCardLinkHandlers('track'),
-};
-
-function addInfoCardLinkHandlers(type) {
-  for (const a of document.querySelectorAll(`a[data-${type}]`)) {
-    for (const [eventName, handler] of Object.entries(
-      infoCardLinkHandlers[type]
-    )) {
-      a.addEventListener(eventName, handler);
-    }
-  }
-}
-
 // Info cards are disa8led for now since they aren't quite ready for release,
 // 8ut you can try 'em out 8y setting this localStorage flag!
 //
@@ -516,6 +1168,7 @@ const hashLinkInfo = clientInfo.hashLinkInfo = {
   },
 
   event: {
+    beforeHashLinkScrolls: [],
     whenHashLinkClicked: [],
   },
 };
@@ -578,6 +1231,22 @@ function addHashLinkListeners() {
         return;
       }
 
+      // Don't do anything if the target element isn't actually visible!
+      if (target.offsetParent === null) {
+        return;
+      }
+
+      // Allow event handlers to prevent scrolling.
+      const listenerResults =
+        dispatchInternalEvent(event, 'beforeHashLinkScrolls', {
+          link: hashLink,
+          target,
+        });
+
+      if (listenerResults.includes(false)) {
+        return;
+      }
+
       // Hide skipper box right away, so the layout is updated on time for the
       // math operations coming up next.
       const skipper = document.getElementById('skippers');
@@ -612,11 +1281,10 @@ function addHashLinkListeners() {
 
       processScrollingAfterHashLinkClicked();
 
-      for (const handler of event.whenHashLinkClicked) {
-        handler({
-          link: hashLink,
-        });
-      }
+      dispatchInternalEvent(event, 'whenHashLinkClicked', {
+        link: hashLink,
+        target,
+      });
     });
   }
 
@@ -839,7 +1507,12 @@ function updateStickySubheadingContent(index) {
       child.remove();
     }
 
-    for (const child of closestHeading.childNodes) {
+    const textContainer =
+      closestHeading.querySelector('.content-heading-main-title')
+        // Just for compatibility with older builds of the site.
+        ?? closestHeading;
+
+    for (const child of textContainer.childNodes) {
       if (child.tagName === 'A') {
         for (const grandchild of child.childNodes) {
           stickySubheading.appendChild(grandchild.cloneNode(true));
@@ -858,12 +1531,10 @@ function updateStickySubheadingContent(index) {
 
   state.displayedHeading = closestHeading;
 
-  for (const handler of event.whenDisplayedHeadingChanges) {
-    handler(index, {
-      oldHeading: oldDisplayedHeading,
-      newHeading: closestHeading,
-    });
-  }
+  dispatchInternalEvent(event, 'whenDisplayedHeadingChanges', index, {
+    oldHeading: oldDisplayedHeading,
+    newHeading: closestHeading,
+  });
 }
 
 function updateStickyHeadings(index) {
@@ -894,6 +1565,8 @@ clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings);
 
 // Image overlay ------------------------------------------
 
+// TODO: Update to clientSteps style.
+
 function addImageOverlayClickHandlers() {
   const container = document.getElementById('image-overlay-container');
 
@@ -1179,8 +1852,100 @@ function loadImage(imageUrl, onprogress) {
   });
 }
 
+// "Additional names" box ---------------------------------
+
+const additionalNamesBoxInfo = clientInfo.additionalNamesBox = {
+  box: null,
+  links: null,
+  mainContentContainer: null,
+
+  state: {
+    visible: false,
+  },
+};
+
+function getAdditionalNamesBoxReferences() {
+  const info = additionalNamesBoxInfo;
+
+  info.box =
+    document.getElementById('additional-names-box');
+
+  info.links =
+    document.querySelectorAll('a[href="#additional-names-box"]');
+
+  info.mainContentContainer =
+    document.querySelector('#content .main-content-container');
+}
+
+function addAdditionalNamesBoxInternalListeners() {
+  const info = additionalNamesBoxInfo;
+
+  hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => {
+    if (target === info.box) {
+      return false;
+    }
+  });
+}
+
+function addAdditionalNamesBoxListeners() {
+  const info = additionalNamesBoxInfo;
+
+  for (const link of info.links) {
+    link.addEventListener('click', domEvent => {
+      handleAdditionalNamesBoxLinkClicked(domEvent);
+    });
+  }
+}
+
+function handleAdditionalNamesBoxLinkClicked(domEvent) {
+  const info = additionalNamesBoxInfo;
+  const {state} = info;
+
+  domEvent.preventDefault();
+
+  if (!info.box || !info.mainContentContainer) return;
+
+  const margin =
+    +(cssProp(info.box, 'scroll-margin-top').replace('px', ''));
+
+  const {top} =
+    (state.visible
+      ? info.box.getBoundingClientRect()
+      : info.mainContentContainer.getBoundingClientRect());
+
+  if (top + 20 < margin || top > 0.4 * window.innerHeight) {
+    if (!state.visible) {
+      toggleAdditionalNamesBox();
+    }
+
+    window.scrollTo({
+      top: window.scrollY + top - margin,
+      behavior: 'smooth',
+    });
+  } else {
+    toggleAdditionalNamesBox();
+  }
+}
+
+function toggleAdditionalNamesBox() {
+  const info = additionalNamesBoxInfo;
+  const {state} = info;
+
+  state.visible = !state.visible;
+  info.box.style.display =
+    (state.visible
+      ? 'block'
+      : 'none');
+}
+
+clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences);
+clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners);
+clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners);
+
 // Group contributions table ------------------------------
 
+// TODO: Update to clientSteps style.
+
 const groupContributionsTableInfo =
   Array.from(document.querySelectorAll('#content dl'))
     .filter(dl => dl.querySelector('a.group-contributions-sort-button'))
@@ -1213,6 +1978,76 @@ for (const info of groupContributionsTableInfo) {
   });
 }
 
+// Artist link icon tooltips ------------------------------
+
+const externalIconTooltipInfo = clientInfo.externalIconTooltipInfo = {
+  hoverables: null,
+  tooltips: null,
+};
+
+function getExternalIconTooltipReferences() {
+  const info = externalIconTooltipInfo;
+
+  const spans =
+    Array.from(document.querySelectorAll('span.contribution.has-tooltip'));
+
+  info.hoverables =
+    spans.map(span => span.querySelector('a'));
+
+  info.tooltips =
+    spans.map(span => span.querySelector('span.icons-tooltip'));
+}
+
+function addExternalIconTooltipPageListeners() {
+  const info = externalIconTooltipInfo;
+
+  for (const {hoverable, tooltip} of stitchArrays({
+    hoverable: info.hoverables,
+    tooltip: info.tooltips,
+  })) {
+    registerTooltipElement(tooltip);
+    registerTooltipHoverableElement(hoverable, tooltip);
+  }
+}
+
+clientSteps.getPageReferences.push(getExternalIconTooltipReferences);
+clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners);
+
+// Datetimestamp tooltips ---------------------------------
+
+const datetimestampTooltipInfo = clientInfo.datetimestampTooltipInfo = {
+  hoverables: null,
+  tooltips: null,
+};
+
+function getDatestampTooltipReferences() {
+  const info = datetimestampTooltipInfo;
+
+  const spans =
+    Array.from(document.querySelectorAll('span.datetimestamp.has-tooltip'));
+
+  info.hoverables =
+    spans.map(span => span.querySelector('time'));
+
+  info.tooltips =
+    spans.map(span => span.querySelector('span.datetimestamp-tooltip'));
+}
+
+function addDatestampTooltipPageListeners() {
+  const info = datetimestampTooltipInfo;
+
+  for (const {hoverable, tooltip} of stitchArrays({
+    hoverable: info.hoverables,
+    tooltip: info.tooltips,
+  })) {
+    registerTooltipElement(tooltip);
+    registerTooltipHoverableElement(hoverable, tooltip);
+  }
+}
+
+clientSteps.getPageReferences.push(getDatestampTooltipReferences);
+clientSteps.addPageListeners.push(addDatestampTooltipPageListeners);
+
 // Sticky commentary sidebar ------------------------------
 
 const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = {
diff --git a/src/static/site5.css b/src/static/site6.css
index d9b354d9..3534e50d 100644
--- a/src/static/site5.css
+++ b/src/static/site6.css
@@ -3,13 +3,7 @@
  * no need to re-run upd8.js when tweaking values here. Handy!
  */
 
-:root {
-  --primary-color: #0088ff;
-}
-
-/* Layout - Common
- *
- */
+/* Layout - Common */
 
 body {
   margin: 10px;
@@ -433,6 +427,7 @@ a {
 
 a:hover {
   text-decoration: underline;
+  text-decoration-style: solid !important;
 }
 
 a.current {
@@ -478,11 +473,98 @@ a:not([href]):hover {
   white-space: nowrap;
 }
 
+.contribution.has-tooltip,
+.datetimestamp.has-tooltip {
+  position: relative;
+}
+
+.contribution.has-tooltip > a,
+.datetimestamp.has-tooltip > time {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+.datetimestamp.has-tooltip > time {
+  cursor: default;
+}
+
+.contribution.has-tooltip > a:hover,
+.contribution.has-tooltip > a.has-visible-tooltip,
+.datetimestamp.has-tooltip > time:hover,
+.datetimestamp.has-tooltip > time.has-visible-tooltip {
+  text-decoration-style: wavy !important;
+}
+
+.icons-tooltip,
+.datetimestamp-tooltip {
+  position: absolute;
+  z-index: 3;
+  left: -34px;
+  top: calc(1em + 1px);
+  display: none;
+}
+
+.icons-tooltip {
+  padding: 3px 6px 6px 6px;
+  left: -34px;
+}
+
+.datetimestamp-tooltip {
+  padding: 3px 4px 2px 2px;
+  left: -10px;
+}
+
+li:not(:first-child:last-child) .datetimestamp-tooltip {
+  left: 14px;
+}
+
+.icons-tooltip-content,
+.datetimestamp-tooltip-content {
+  display: block;
+
+  background: var(--bg-black-color);
+  border: 1px dotted var(--primary-color);
+  border-radius: 6px;
+
+  -webkit-backdrop-filter:
+    brightness(1.5) saturate(1.4) blur(4px);
+
+          backdrop-filter:
+    brightness(1.5) saturate(1.4) blur(4px);
+
+  box-shadow:
+    0 3px 4px 4px #000000aa,
+    0 -2px 4px -2px var(--primary-color) inset;
+}
+
+.icons-tooltip-content {
+  padding: 6px 2px 2px 2px;
+
+  -webkit-user-select: none;
+          user-select: none;
+
+  cursor: default;
+}
+
+.datetimestamp-tooltip-content {
+  padding: 5px 6px;
+  white-space: nowrap;
+  font-size: 0.9em;
+}
+
 .icons {
   font-style: normal;
   white-space: nowrap;
 }
 
+.icons a:hover {
+  filter: brightness(1.4);
+}
+
+.icons a {
+  padding: 0 3px;
+}
+
 .icon {
   display: inline-block;
   width: 24px;
@@ -498,6 +580,23 @@ a:not([href]):hover {
   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;
@@ -508,6 +607,10 @@ a:not([href]):hover {
   white-space: nowrap;
 }
 
+.other-group-accent a {
+  color: var(--page-primary-color);
+}
+
 .content-columns {
   columns: 2;
 }
@@ -543,11 +646,24 @@ p .current {
   margin-top: 5px;
 }
 
+.commentary-entry-heading {
+  margin-left: 15px;
+  padding-left: 5px;
+  max-width: 625px;
+  padding-bottom: 0.2em;
+  border-bottom: 1px dotted var(--primary-color);
+}
+
+.commentary-entry-accent {
+  font-style: oblique;
+}
+
 .commentary-art {
   float: right;
   width: 30%;
   max-width: 250px;
   margin: 15px 0 10px 20px;
+  box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.35);
 }
 
 .js-hide,
@@ -698,7 +814,7 @@ p code {
 }
 
 main.long-content {
-  --long-content-padding-ratio: 0.12;
+  --long-content-padding-ratio: 0.10;
 }
 
 main.long-content .main-content-container,
@@ -798,10 +914,94 @@ html[data-url-key="localized.albumCommentary"] li.no-commentary {
   opacity: 0.7;
 }
 
+html[data-url-key="localized.albumCommentary"] .content-heading-main-title {
+  margin-right: 0.25em;
+}
+
+html[data-url-key="localized.albumCommentary"] .content-heading-accent {
+  font-weight: normal;
+  font-style: oblique;
+  font-size: 0.9rem;
+  display: inline-block;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line,
+html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line,
+html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line {
+  display: none;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not([href]) {
+  opacity: 0.7;
+}
+
 html[data-url-key="localized.newsEntry"] .read-another-links {
   font-style: oblique;
 }
 
+/* Additional names (heading and box) */
+
+h1 a[href="#additional-names-box"] {
+  color: inherit;
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+h1 a[href="#additional-names-box"]:hover {
+  text-decoration-style: solid;
+}
+
+#additional-names-box {
+  --custom-scroll-offset: calc(0.5em - 2px);
+
+  margin: 1em 0 1em -10px;
+  padding: 15px 20px 10px 20px;
+  width: max-content;
+  max-width: min(60vw, 600px);
+
+  border: 1px dotted var(--primary-color);
+  border-radius: 6px;
+
+  background:
+    linear-gradient(var(--bg-color), var(--bg-color)),
+    linear-gradient(#000000bb, #000000bb),
+    var(--primary-color);
+
+  box-shadow: 0 -2px 6px -1px var(--dim-color) inset;
+
+  display: none;
+}
+
+#additional-names-box > :first-child { margin-top: 0; }
+#additional-names-box > :last-child { margin-bottom: 0; }
+
+#additional-names-box p {
+  padding-left: 10px;
+  padding-right: 10px;
+  margin-bottom: 0;
+  font-style: oblique;
+}
+
+#additional-names-box ul {
+  padding-left: 10px;
+  margin-top: 0.5em;
+}
+
+#additional-names-box li .additional-name {
+  margin-right: 0.25em;
+}
+
+#additional-names-box li .additional-name .content-image {
+  margin-bottom: 0.25em;
+  margin-top: 0.5em;
+}
+
+#additional-names-box li .accent {
+  opacity: 0.8;
+  display: inline-block;
+}
+
+>>>>>>> preview:src/static/site6.css
 /* Images */
 
 .image-container {
@@ -1304,6 +1504,10 @@ h3.content-heading {
 
 /* Sticky heading */
 
+[id] {
+  --custom-scroll-offset: 0px;
+}
+
 #content [id] {
   /* Adjust scroll margin. */
   scroll-margin-top: calc(
@@ -1311,6 +1515,7 @@ h3.content-heading {
     + 33px /* Sticky subheading */
     - 1em  /* One line of text (align bottom) */
     - 12px /* Padding for hanging letters & focus ring */
+    + var(--custom-scroll-offset) /* Customizable offset */
   );
 }
 
@@ -1660,6 +1865,13 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
  */
 
 @media (min-width: 600px) and (max-width: 899.98px) {
+  /* Medim layout is mainly defined (to the user) by hiding the sidebar, so
+   * don't apply the similar layout change of widening the long-content area
+   * if this page doesn't have a sidebar to hide in the first place.
+   */
+  #page-container:not(.has-zero-sidebars) main.long-content {
+    --long-content-padding-ratio: 0.06;
+  }
 }
 
 /* Layout - Wide or Medium */
@@ -1683,7 +1895,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     float: right;
     width: 40%;
     max-width: 400px;
-    margin: -60px 0 10px 10px;
+    margin: -60px 0 10px 20px;
 
     position: relative;
     z-index: 2;
@@ -1743,18 +1955,33 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     columns: 1;
   }
 
+  main.long-content {
+    --long-content-padding-ratio: 0.02;
+  }
+
   #cover-art-container {
     margin: 25px 0 5px 0;
     width: 100%;
     max-width: unset;
   }
 
+  #additional-names-box {
+    max-width: unset;
+  }
+
   /* Show sticky heading above cover art */
 
   .content-sticky-heading-container {
     z-index: 2;
   }
 
+  /* Let sticky heading text span past lower-index cover art */
+
+  .content-sticky-heading-container.has-cover .content-sticky-heading-row,
+  .content-sticky-heading-container.has-cover .content-sticky-subheading-row {
+    grid-template-columns: 1fr 90px;
+  }
+
   /* Disable grid features, just line header children up vertically */
 
   #header {
@@ -1764,8 +1991,4 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
   #header > div:not(:first-child) {
     margin-top: 0.5em;
   }
-
-  main.long-content {
-    --long-content-padding-ratio: 0.04;
-  }
 }
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 8b97eaf5..c812b330 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -129,6 +129,16 @@ count:
       many: ""
       other: "{DAYS} days"
 
+  months:
+    _: "{MONTHS}"
+    withUnit:
+      zero: ""
+      one: "{MONTHS} month"
+      two: ""
+      few: ""
+      many: ""
+      other: "{MONTHS} months"
+
   timesReferenced:
     _: "{TIMES_REFERENCED}"
     withUnit:
@@ -149,6 +159,16 @@ count:
       many: ""
       other: "used {TIMES_USED} times"
 
+  weeks:
+    _: "{WEEKS}"
+    withUnit:
+      zero: ""
+      one: "{WEEKS} week"
+      two: ""
+      few: ""
+      many: ""
+      other: "{WEEKS} weeks"
+
   words:
     _: "{WORDS}"
     thousand: "{WORDS}k"
@@ -160,6 +180,16 @@ count:
       many: ""
       other: "{WORDS} words"
 
+  years:
+    _: "{YEARS}"
+    withUnit:
+      zero: ""
+      one: "{YEARS} year"
+      two: ""
+      few: ""
+      many: ""
+      other: "{YEARS} years"
+
   # Numerical things that aren't exactly counting, per se
 
   duration:
@@ -172,6 +202,24 @@ count:
       _:        "{MINUTES}:{SECONDS}"
       withUnit: "{MINUTES}:{SECONDS} minutes"
 
+  dateDuration:
+    earlier: "{DURATION} earlier"
+    later: "{DURATION} later"
+    same: "on the same date"
+    zero: "at most one day"
+    approximate: "about {DURATION}"
+    approximateEarlier: "about {DURATION} earlier"
+    approximateLater: "about {DURATION} later"
+    relativeAbsolute: "{ABSOLUTE}; {RELATIVE}"
+
+    years: "{YEARS}"
+    months: "{MONTHS}"
+    days: "{DAYS}"
+    yearsMonthsDays: "{YEARS}, {MONTHS}, {DAYS}"
+    yearsMonths: "{YEARS}, {MONTHS}"
+    yearsDays: "{YEARS}, {DAYS}"
+    monthsDays: "{MONTHS}, {DAYS}"
+
   fileSize:
     terabytes: "{TERABYTES} TB"
     gigabytes: "{GIGABYTES} GB"
@@ -220,7 +268,10 @@ releaseInfo:
 
   alsoReleasedAs:
     _: "Also released as:"
-    item: "{TRACK} (on {ALBUM})"
+
+    item:
+      _: "{TRACK} ({ALBUM})"
+      withYear: "({YEAR}) {TRACK} ({ALBUM})"
 
   tracksReferenced: "Tracks that {TRACK} references:"
   tracksThatReference: "Tracks that reference {TRACK}:"
@@ -273,10 +324,6 @@ releaseInfo:
     _: "Read {LINK}."
     link: "artist commentary"
 
-  artistCommentary:
-    _: "Artist commentary:"
-    seeOriginalRelease: "See {ORIGINAL}!"
-
   additionalFiles:
     heading: "View or download {ADDITIONAL_FILES}:"
 
@@ -338,6 +385,25 @@ trackList:
 #
 misc:
 
+  # additionalNames:
+  #   "Drop"-styled box that catalogues a variety of additional or
+  #   alternate names for the current thing; toggled by clicking on the
+  #   thing's title, which is styled interactively and gets a tooltip
+  #   (hover text), since it isn't usually an interactive element.
+
+  additionalNames:
+    title: "Additional or alternate names:"
+    tooltip: "Click to view additional or alternate names"
+
+    item:
+      _: "{NAME}"
+      withAccent: "{NAME} {ACCENT}"
+
+      accent:
+        withAnnotation: "({ANNOTATION})"
+        withAlbums: "(on {ALBUMS})"
+        withAnnotations.withAlbums: "({ANNOTATION}; on {ALBUMS})"
+
   # alt:
   #   Fallback text for the alt text of images and artworks - these
   #   are read aloud by screen readers.
@@ -349,6 +415,23 @@ misc:
     artistAvatar: "artist avatar"
     flashArt: "flash art"
 
+  # artistCommentary:
+
+  artistCommentary:
+    _: "Artist commentary:"
+
+    entry:
+      title:
+        _: "{ARTISTS}:"
+        noArtists: "Unknown artist"
+        withAccent: "{ARTISTS}: {ACCENT}"
+        accent:
+          withAnnotation: "({ANNOTATION})"
+          withDate: ({DATE})"
+          withAnnotation.withDate: "({ANNOTATION}, {DATE})"
+
+      seeOriginalRelease: "See {ORIGINAL}!"
+
   # artistLink:
   #   Artist links have special accents which are made conditionally
   #   present in a variety of places across the wiki.
@@ -404,21 +487,31 @@ misc:
   #   wiki - sorry!
 
   external:
+    external: "External"
 
-    # domain:
-    #   General domain when one the URL doesn't match one of the
-    #   sites below.
-
-    domain: "External ({DOMAIN})"
+    withDomain:
+      "{PLATFORM} ({DOMAIN})"
 
-    # local:
-    #   Files which are locally available on the wiki (under its media
-    #   directory).
+    withHandle:
+      "{PLATFORM} ({HANDLE})"
 
     local: "Wiki Archive (local upload)"
 
+    bandcamp: "Bandcamp"
+
+    bgreco:
+      _: "bgreco.net"
+      flash: "bgreco.net (high quality audio)"
+
     deviantart: "DeviantArt"
+
+    homestuck:
+      _: "Homestuck"
+      page: "Homestuck (page {PAGE})"
+      secretPage: "Homestuck (secret page)"
+
     instagram: "Instagram"
+    mastodon: "Mastodon"
     newgrounds: "Newgrounds"
     patreon: "Patreon"
     poetryFoundation: "Poetry Foundation"
@@ -428,26 +521,12 @@ misc:
     twitter: "Twitter"
     wikipedia: "Wikipedia"
 
-    bandcamp:
-      _: "Bandcamp"
-      domain: "Bandcamp ({DOMAIN})"
-
-    mastodon:
-      _: "Mastodon"
-      domain: "Mastodon ({DOMAIN})"
-
     youtube:
       _: "YouTube"
+      flash: "YouTube (on any device)"
       playlist: "YouTube (playlist)"
       fullAlbum: "YouTube (full album)"
 
-    flash:
-      bgreco: "{LINK} (HQ Audio)"
-      youtube: "{LINK} (on any device)"
-      homestuck:
-        page: "{LINK} (page {PAGE})"
-        secret: "{LINK} (secret page)"
-
   # missingImage:
   #   Fallback text displayed in an image when it's sourced to a file
   #   that isn't available under the wiki's media directory. While it
@@ -740,8 +819,13 @@ albumCommentaryPage:
 
   entry:
     title:
-      albumCommentary: "Album commentary"
-      trackCommentary: "{TRACK}"
+      albumCommentary:
+        _: "{ALBUM}"
+        accent: "Listen on: {LISTENING_LINKS}"
+
+      trackCommentary:
+        _: "{TRACK}"
+        accent: "Listen on: {LISTENING_LINKS}"
 
 #
 # artistInfoPage:
@@ -994,10 +1078,20 @@ groupInfoPage:
     title: "Albums"
 
     item:
-      _: "({YEAR}) {ALBUM}"
-      withoutYear: "{ALBUM}"
-      withAccent: "{ITEM} {ACCENT}"
-      otherGroupAccent: "(from {GROUP})"
+      _: >-
+        {ALBUM}
+
+      withYear: >-
+        {YEAR_ACCENT} {ALBUM}
+
+      withOtherGroup: >-
+        {ALBUM} {OTHER_GROUP_ACCENT}
+
+      withYear.withOtherGroup: >-
+        {YEAR_ACCENT} {ALBUM} {OTHER_GROUP_ACCENT}
+
+      yearAccent: "({YEAR})"
+      otherGroupAccent:  "(from {GROUP})"
 
 #
 # groupGalleryPage:
@@ -1075,6 +1169,13 @@ listingPage:
 
   seeAlso: "Also check out: {LISTINGS}"
 
+  # skipToSection:
+  #   Some listings which use a chunked-list layout also show links
+  #   to scroll down to each of these sections - this is the title
+  #   for the list of those links.
+
+  skipToSection: "Skip to a section:"
+
   listAlbums:
 
     # listAlbums.byName:
@@ -1155,7 +1256,12 @@ listingPage:
     byContribs:
       title: "Artists - by Contributions"
       title.short: "...by Contributions"
-      item: "{ARTIST} ({CONTRIBUTIONS})"
+      chunk:
+        item: "{ARTIST} ({CONTRIBUTIONS})"
+        title:
+          trackContributors: "Contributed tracks:"
+          artContributors: "Contributed artworks:"
+          flashContributors: "Contributed to flashes & games:"
 
     # listArtists.byCommentary:
     #   Lists artists by number of commentary entries, most to least,
@@ -1180,31 +1286,44 @@ listingPage:
       title.short: "...by Duration"
       item: "{ARTIST} ({DURATION})"
 
+    # listArtists.byGroup:
+    #   Lists artists who have contributed to each of the main groups
+    #   of a wiki (its "Divide Track Lists By Groups" field), sorted
+    #   alphabetically. Artists who aren't credited for contributions
+    #   under each of the groups are exlcuded from the respective
+    #   list.
+
+    byGroup:
+      title: "Artists - by Group"
+      title.short: "...by Group"
+      item: "{ARTIST} ({CONTRIBUTIONS})"
+      chunk:
+        title: "Contributed to {GROUP}:"
+        item: "{ARTIST} ({CONTRIBUTIONS})"
+
     # listArtists.byLatest:
-    #   Lists artists by the date of their latest musical, artwork,
-    #   or flash contributions (with a separate section for each),
-    #   latest to longest ago, and chunks artists together by the
-    #   album/flash which their contribution was to. If two albums
-    #   (or flashes) released on the same date, they're sorted by
-    #   name, and artists within each album/flash are also sorted
-    #   alphabetically. If an artist has contributions of a given
-    #   kind, but those contributions aren't dated at all, they're
-    #   listed at the bottom; artists who aren't credited for any
-    #   contributions to each category are totally excluded from the
-    #   respective lists.
+    #   Lists artists by the date of their latest contribution
+    #   overall, and chunks artists together by the album or flash
+    #   which that contribution belongs to. Within albums, each
+    #   artist is accented with the kind of contribution they made -
+    #   tracks, artworks, or both - and sorted so those of the same
+    #   sort of contribution are bunched together, then by name.
+    #   Artists who aren't credited for any dated contributions are
+    #   included at the bottom under a separate chunk.
 
     byLatest:
       title: "Artists - by Latest Contribution"
       title.short: "...by Latest Contribution"
-
       chunk:
-        title.album: "{ALBUM} ({DATE})"
-        title.flash: "{FLASH} ({DATE})"
-        item: "{ARTIST}"
-
-      dateless:
-        title: "These artists' contributions aren't dated:"
-        item: "{ARTIST}"
+        title:
+          album: "{ALBUM} ({DATE})"
+          flash: "{FLASH} ({DATE})"
+          dateless: "These artists' contributions aren't dated:"
+        item:
+          _: "{ARTIST}"
+          tracks: "{ARTIST} (tracks)"
+          tracksAndArt: "{ARTIST} (tracks, art)"
+          art: "{ARTIST} (art)"
 
   listGroups:
 
@@ -1518,12 +1637,20 @@ listingPage:
       # chooseLinkLine:
       #   Introductory line explaining the links on this listing.
 
-      chooseLinkLine: >-
-        Choose a link to go to a random page in that category or album!
-        If your browser doesn't support relatively modern JavaScript
-        or you've disabled it, these links won't work - sorry.
+      chooseLinkLine:
+        _: "{FROM_PART} {BROWSER_SUPPORT_PART}"
+
+        fromPart:
+          dividedByGroups: >-
+            Choose a link to go to a random page in that group or album!
+          notDividedByGroups: >-
+            Choose a link to go to a random page in that album!
 
-      # dataLoadingLine, dataLoadedLine:
+        browserSupportPart: >-
+          If your browser doesn't support relatively modern JavaScript
+          or you've disabled it, these links won't work - sorry.
+
+      # dataLoadingLine, dataLoadedLine, dataErrorLine:
       #   Since the links on this page depend on access to a fairly
       #   large data file that is downloaded separately and in the
       #   background, these messages indicate the status of that
@@ -1535,32 +1662,45 @@ listingPage:
       dataLoadedLine: >-
         (Data files have finished being downloaded. The links should work!)
 
-      # misc:
-      #   The first chunk in the list includes general links which
-      #   bring you to some random page across the whole site!
+      dataErrorLine: >-
+        (Data files failed to download. Sorry, some of these links won't work right now!)
+
+      chunk:
 
-      misc:
-        _: "Miscellaneous:"
-        randomArtist: "Random Artist"
-        atLeastTwoContributions: "at least 2 contributions"
-        randomAlbumWholeSite: "Random Album (whole site)"
-        randomTrackWholeSite: "Random Track (whole site)"
+        title:
+          misc: "Miscellaneous:"
 
-      # group:
-      #   The remaining chunks are one for each of the main groups on
-      #   the site, and each includes a list of all the albums from
-      #   that group - clicking one brings to a random track from the
-      #   album.
+          # fromAlbum:
+          #   If the wiki hasn't got "Divide Track Lists By Groups"
+          #   set, all albums across the wiki are grouped in one
+          #   long chunk.
 
-      group:
-        _: "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})"
-        randomAlbum: "Random Album"
-        randomTrack: "Random Track"
+          fromAlbum: "From an album:"
 
-      # album:
-      #   Album entries under each group.
+          # fromGroup:
+          #   If the wiki does have "Divide Track Lists By Groups"
+          #   set, there's one chunk past Miscellaneous for each of
+          #   those groups, listing all the albums from that group,
+          #   each of which links to a random track from that album.
+
+          fromGroup:
+            _: "From {GROUP}:"
+
+            accent:
+              _: "({RANDOM_ALBUM}, {RANDOM_TRACK})"
+              randomAlbum: "Random Album"
+              randomTrack: "Random Track"
+
+        item:
+          album: "{ALBUM}"
+
+          randomArtist:
+            _: "{MAIN_LINK} ({AT_LEAST_TWO_CONTRIBUTIONS})"
+            mainLink: "Random Artist"
+            atLeastTwoContributions: "at least 2 contributions"
 
-      album: "{ALBUM}"
+          randomAlbumWholeSite: "Random Album (whole site)"
+          randomTrackWholeSite: "Random Track (whole site)"
 
 #
 # newsIndex:
diff --git a/src/upd8.js b/src/upd8.js
index 24d0b92b..ebb278b2 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -38,12 +38,13 @@ import {fileURLToPath} from 'node:url';
 
 import wrap from 'word-wrap';
 
+import CacheableObject from '#cacheable-object';
 import {displayCompositeCacheAnalysis} from '#composite';
-import {processLanguageFile, watchLanguageFile} from '#language';
+import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile}
+  from '#language';
 import {isMain, traverse} from '#node-utils';
 import bootRepl from '#repl';
 import {empty, showAggregate, withEntries} from '#sugar';
-import {CacheableObject} from '#things';
 import {generateURLs, urlSpec} from '#urls';
 import {sortByName} from '#wiki-data';
 
@@ -93,8 +94,6 @@ try {
 
 const BUILD_TIME = new Date();
 
-export const DEFAULT_STRINGS_FILE = 'strings-default.yaml';
-
 const STATUS_NOT_STARTED       = `not started`;
 const STATUS_NOT_APPLICABLE    = `not applicable`;
 const STATUS_STARTED_NOT_DONE  = `started but not yet done`;
@@ -1104,8 +1103,6 @@ async function main() {
   let internalDefaultLanguage;
   let internalDefaultLanguageWatcher;
 
-  const internalDefaultStringsFile = path.join(__dirname, DEFAULT_STRINGS_FILE);
-
   let errorLoadingInternalDefaultLanguage = false;
 
   if (noLanguageReloading) {
@@ -1182,7 +1179,9 @@ async function main() {
     });
 
     const languageDataFiles = await traverse(langPath, {
-      filterFile: name => path.extname(name) === '.json',
+      filterFile: name =>
+        path.extname(name) === '.json' ||
+        path.extname(name) === '.yaml',
       pathStyle: 'device',
     });
 
diff --git a/src/util/external-links.js b/src/util/external-links.js
new file mode 100644
index 00000000..0a4a77cf
--- /dev/null
+++ b/src/util/external-links.js
@@ -0,0 +1,679 @@
+import {empty, stitchArrays} from '#sugar';
+
+import {
+  is,
+  isObject,
+  isStringNonEmpty,
+  oneOf,
+  optional,
+  validateArrayItems,
+  validateInstanceOf,
+  validateProperties,
+} from '#validators';
+
+export const externalLinkStyles = [
+  'normal',
+  'compact',
+  'platform',
+  'icon-id',
+];
+
+export const isExternalLinkStyle = is(...externalLinkStyles);
+
+export const externalLinkContexts = [
+  'album',
+  'artist',
+  'flash',
+  'generic',
+  'group',
+  'track',
+];
+
+export const isExternalLinkContext = is(...externalLinkContexts);
+
+// This might need to be adjusted for YAML importing...
+const isRegExp =
+  validateInstanceOf(RegExp);
+
+export const isExternalLinkExtractSpec =
+  validateProperties({
+    prefix: optional(isStringNonEmpty),
+
+    url: optional(isRegExp),
+    domain: optional(isRegExp),
+    pathname: optional(isRegExp),
+    query: optional(isRegExp),
+  });
+
+export const isExternalLinkSpec =
+  validateArrayItems(
+    validateProperties({
+      match: validateProperties({
+        // TODO: Don't allow providing both of these, and require providing one
+        domain: optional(isStringNonEmpty),
+        domains: optional(validateArrayItems(isStringNonEmpty)),
+
+        // TODO: Don't allow providing both of these
+        pathname: optional(isRegExp),
+        pathnames: optional(validateArrayItems(isRegExp)),
+
+        // TODO: Don't allow providing both of these
+        query: optional(isRegExp),
+        queries: optional(validateArrayItems(isRegExp)),
+
+        context:
+          optional(oneOf(
+            isExternalLinkContext,
+            validateArrayItems(isExternalLinkContext))),
+      }),
+
+      platform: isStringNonEmpty,
+      substring: optional(isStringNonEmpty),
+
+      // TODO: Don't allow 'handle' or 'custom' options if the corresponding
+      // properties aren't provided
+      normal: optional(is('domain', 'handle', 'custom')),
+      compact: optional(is('domain', 'handle', 'custom')),
+      icon: optional(isStringNonEmpty),
+
+      handle: optional(isExternalLinkExtractSpec),
+
+      // TODO: This should validate each value with isExternalLinkExtractSpec.
+      custom: optional(isObject),
+    }));
+
+export const fallbackDescriptor = {
+  platform: 'external',
+
+  normal: 'domain',
+  compact: 'domain',
+  icon: 'globe',
+};
+
+// TODO: Define all this stuff in data as YAML!
+export const externalLinkSpec = [
+  // Special handling for album links
+
+  {
+    match: {
+      context: 'album',
+      domain: 'youtube.com',
+      pathname: /^playlist/,
+    },
+
+    platform: 'youtube',
+    substring: 'playlist',
+
+    icon: 'youtube',
+  },
+
+  {
+    match: {
+      context: 'album',
+      domain: 'youtube.com',
+      pathname: /^watch/,
+    },
+
+    platform: 'youtube',
+    substring: 'fullAlbum',
+
+    icon: 'youtube',
+  },
+
+  {
+    match: {
+      context: 'album',
+      domain: 'youtu.be',
+    },
+
+    platform: 'youtube',
+    substring: 'fullAlbum',
+
+    icon: 'youtube',
+  },
+
+  // Special handling for artist links
+
+  {
+    match: {
+      domain: 'patreon.com',
+      context: 'artist',
+    },
+
+    platform: 'patreon',
+
+    normal: 'handle',
+    compact: 'handle',
+    icon: 'globe',
+
+    handle: /([^/]*)\/?$/,
+  },
+
+  {
+    match: {
+      context: 'artist',
+      domain: 'youtube.com',
+    },
+
+    platform: 'youtube',
+
+    normal: 'handle',
+    compact: 'handle',
+    icon: 'youtube',
+
+    handle: {
+      pathname: /^(@.*?)\/?$/,
+    },
+  },
+
+  // Special handling for flash links
+
+  {
+    match: {
+      context: 'flash',
+      domain: 'bgreco.net',
+    },
+
+    platform: 'bgreco',
+    substring: 'flash',
+
+    icon: 'globe',
+  },
+
+  // This takes precedence over the secretPage match below.
+  {
+    match: {
+      context: 'flash',
+      domain: 'homestuck.com',
+      pathname: /^story\/[0-9]+\/?$/,
+    },
+
+    platform: 'homestuck',
+    substring: 'page',
+
+    normal: 'custom',
+    icon: 'globe',
+
+    custom: {
+      page: {
+        pathname: /[0-9]+/,
+      },
+    },
+  },
+
+  {
+    match: {
+      context: 'flash',
+      domain: 'homestuck.com',
+      pathname: /^story\/.+\/?$/,
+    },
+
+    platform: 'homestuck',
+    substring: 'secretPage',
+
+    icon: 'globe',
+  },
+
+  {
+    match: {
+      context: 'flash',
+      domains: ['youtube.com', 'youtu.be'],
+    },
+
+    platform: 'youtube',
+    substring: 'flash',
+
+    icon: 'youtube',
+  },
+
+  // Generic domains, sorted alphabetically (by string)
+
+  {
+    match: {domains: ['bc.s3m.us', 'music.solatrus.com']},
+
+    platform: 'bandcamp',
+
+    normal: 'domain',
+    compact: 'domain',
+    icon: 'bandcamp',
+  },
+
+  {
+    match: {domain: '.bandcamp.com'},
+
+    platform: 'bandcamp',
+
+    compact: 'handle',
+    icon: 'bandcamp',
+
+    handle: {domain: /^[^.]*/},
+  },
+
+  {
+    match: {domain: 'deviantart.com'},
+    platform: 'deviantart',
+    icon: 'deviantart',
+  },
+
+  {
+    match: {domain: 'homestuck.com'},
+    platform: 'homestuck',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'hsmusic.wiki'},
+    platform: 'local',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'instagram.com'},
+    platform: 'instagram',
+    icon: 'instagram',
+  },
+
+  {
+    match: {domains: ['types.pl']},
+
+    platform: 'mastodon',
+
+    normal: 'domain',
+    compact: 'domain',
+    icon: 'mastodon',
+  },
+
+  {
+    match: {domain: 'newgrounds.com'},
+    platform: 'newgrounds',
+    icon: 'newgrounds',
+  },
+
+  {
+    match: {domain: 'patreon.com'},
+    platform: 'patreon',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'poetryfoundation.org'},
+    platform: 'poetryFoundation',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'soundcloud.com'},
+
+    platform: 'soundcloud',
+
+    compact: 'handle',
+    icon: 'soundcloud',
+
+    handle: /([^/]*)\/?$/,
+  },
+
+  {
+    match: {domain: 'spotify.com'},
+    platform: 'spotify',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: '.tumblr.com'},
+
+    platform: 'tumblr',
+
+    compact: 'handle',
+    icon: 'tumblr',
+
+    handle: {domain: /^[^.]*/},
+  },
+
+  {
+    match: {domain: 'twitter.com'},
+
+    platform: 'twitter',
+
+    compact: 'handle',
+    icon: 'twitter',
+
+    handle: {
+      prefix: '@',
+      pathname: /^@?([a-zA-Z0-9_]*)\/?$/,
+    },
+  },
+
+  {
+    match: {domain: 'wikipedia.org'},
+    platform: 'wikipedia',
+    icon: 'misc',
+  },
+
+  {
+    match: {domains: ['youtube.com', 'youtu.be']},
+    platform: 'youtube',
+    icon: 'youtube',
+  },
+];
+
+function urlParts(url) {
+  const {
+    hostname: domain,
+    pathname,
+    search: query,
+  } = new URL(url);
+
+  return {domain, pathname, query};
+}
+
+function createEmptyResults() {
+  return Object.fromEntries(externalLinkStyles.map(style => [style, null]));
+}
+
+export function getMatchingDescriptorsForExternalLink(url, descriptors, {
+  context = 'generic',
+} = {}) {
+  const {domain, pathname, query} = urlParts(url);
+
+  const compareDomain = string => domain.includes(string);
+  const comparePathname = regex => regex.test(pathname.slice(1));
+  const compareQuery = regex => regex.test(query.slice(1));
+
+  const matchingDescriptors =
+    descriptors
+      .filter(({match}) => {
+        if (match.domain) return compareDomain(match.domain);
+        if (match.domains) return match.domains.some(compareDomain);
+        return false;
+      })
+      .filter(({match}) => {
+        if (Array.isArray(match.context)) return match.context.includes(context);
+        if (match.context) return context === match.context;
+        return true;
+      })
+      .filter(({match}) => {
+        if (match.pathname) return comparePathname(match.pathname);
+        if (match.pathnames) return match.pathnames.some(comparePathname);
+        return true;
+      })
+      .filter(({match}) => {
+        if (match.query) return compareQuery(match.query);
+        if (match.queries) return match.quieries.some(compareQuery);
+        return true;
+      });
+
+  return [...matchingDescriptors, fallbackDescriptor];
+}
+
+export function extractPartFromExternalLink(url, extract) {
+  const {domain, pathname, query} = urlParts(url);
+
+  let regexen = [];
+  let tests = [];
+  let prefix = '';
+
+  if (extract instanceof RegExp) {
+    regexen.push(extract);
+    tests.push(url);
+  } else {
+    for (const [key, value] of Object.entries(extract)) {
+      switch (key) {
+        case 'prefix':
+          prefix = value;
+          continue;
+
+        case 'url':
+          tests.push(url);
+          break;
+
+        case 'domain':
+          tests.push(domain);
+          break;
+
+        case 'pathname':
+          tests.push(pathname.slice(1));
+          break;
+
+        case 'query':
+          tests.push(query.slice(1));
+          break;
+
+        default:
+          tests.push('');
+          break;
+      }
+
+      regexen.push(value);
+    }
+  }
+
+  for (const {regex, test} of stitchArrays({
+    regex: regexen,
+    test: tests,
+  })) {
+    const match = test.match(regex);
+    if (match) {
+      return prefix + (match[1] ?? match[0]);
+    }
+  }
+
+  return null;
+}
+
+export function extractAllCustomPartsFromExternalLink(url, custom) {
+  const customParts = {};
+
+  // All or nothing: if one part doesn't match, all results are scrapped.
+  for (const [key, value] of Object.entries(custom)) {
+    customParts[key] = extractPartFromExternalLink(url, value);
+    if (!customParts[key]) return null;
+  }
+
+  return customParts;
+}
+
+export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) {
+  const prefix = 'misc.external';
+
+  function getPlatform() {
+    return language.$(prefix, descriptor.platform);
+  }
+
+  function getDomain() {
+    return urlParts(url).domain;
+  }
+
+  function getCustom() {
+    if (!descriptor.custom) {
+      return null;
+    }
+
+    const customParts =
+      extractAllCustomPartsFromExternalLink(url, descriptor.custom);
+
+    if (!customParts) {
+      return null;
+    }
+
+    return language.$(prefix, descriptor.platform, descriptor.substring, customParts);
+  }
+
+  function getHandle() {
+    if (!descriptor.handle) {
+      return null;
+    }
+
+    return extractPartFromExternalLink(url, descriptor.handle);
+  }
+
+  function getNormal() {
+    if (descriptor.custom) {
+      if (descriptor.normal === 'custom') {
+        return getCustom();
+      } else {
+        return null;
+      }
+    }
+
+    if (descriptor.normal === 'domain') {
+      const platform = getPlatform();
+      const domain = getDomain();
+
+      if (!platform || !domain) {
+        return null;
+      }
+
+      return language.$(prefix, 'withDomain', {platform, domain});
+    }
+
+    if (descriptor.normal === 'handle') {
+      const platform = getPlatform();
+      const handle = getHandle();
+
+      if (!platform || !handle) {
+        return null;
+      }
+
+      return language.$(prefix, 'withHandle', {platform, handle});
+    }
+
+    return language.$(prefix, descriptor.platform, descriptor.substring);
+  }
+
+  function getCompact() {
+    if (descriptor.custom) {
+      if (descriptor.compact === 'custom') {
+        return getCustom();
+      } else {
+        return null;
+      }
+    }
+
+    if (descriptor.compact === 'domain') {
+      const domain = getDomain();
+
+      if (!domain) {
+        return null;
+      }
+
+      return language.sanitize(domain.replace(/^www\./, ''));
+    }
+
+    if (descriptor.compact === 'handle') {
+      const handle = getHandle();
+
+      if (!handle) {
+        return null;
+      }
+
+      return language.sanitize(handle);
+    }
+  }
+
+  function getIconId() {
+    return descriptor.icon ?? null;
+  }
+
+  switch (style) {
+    case 'normal': return getNormal();
+    case 'compact': return getCompact();
+    case 'platform': return getPlatform();
+    case 'icon-id': return getIconId();
+  }
+}
+
+export function couldDescriptorSupportStyle(descriptor, style) {
+  if (style === 'normal') {
+    if (descriptor.custom) {
+      return descriptor.normal === 'custom';
+    } else {
+      return true;
+    }
+  }
+
+  if (style === 'compact') {
+    if (descriptor.custom) {
+      return descriptor.compact === 'custom';
+    } else {
+      return !!descriptor.compact;
+    }
+  }
+
+  if (style === 'platform') {
+    return true;
+  }
+
+  if (style === 'icon-id') {
+    return !!descriptor.icon;
+  }
+}
+
+export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, {
+  language,
+  context = 'generic',
+}) {
+  const matchingDescriptors =
+    getMatchingDescriptorsForExternalLink(url, descriptors, {context});
+
+  const styleFilteredDescriptors =
+    matchingDescriptors.filter(descriptor =>
+      couldDescriptorSupportStyle(descriptor, style));
+
+  for (const descriptor of styleFilteredDescriptors) {
+    const descriptorResult =
+      getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language});
+
+    if (descriptorResult) {
+      return descriptorResult;
+    }
+  }
+
+  return null;
+}
+
+export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) {
+  const getStyle = style =>
+    getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language});
+
+  return {
+    'normal': getStyle('normal'),
+    'compact': getStyle('compact'),
+    'platform': getStyle('platform'),
+    'icon-id': getStyle('icon-id'),
+  };
+}
+
+export function getExternalLinkStringsFromDescriptors(url, descriptors, {
+  language,
+  context = 'generic',
+}) {
+  const results = createEmptyResults();
+  const remainingKeys = new Set(Object.keys(results));
+
+  const matchingDescriptors =
+    getMatchingDescriptorsForExternalLink(url, descriptors, {context});
+
+  for (const descriptor of matchingDescriptors) {
+    const descriptorResults =
+      getExternalLinkStringsFromDescriptor(url, descriptor, {language});
+
+    const descriptorKeys =
+      new Set(
+        Object.entries(descriptorResults)
+          .filter(entry => entry[1])
+          .map(entry => entry[0]));
+
+    for (const key of remainingKeys) {
+      if (descriptorKeys.has(key)) {
+        results[key] = descriptorResults[key];
+        remainingKeys.delete(key);
+      }
+    }
+
+    if (empty(remainingKeys)) {
+      return results;
+    }
+  }
+
+  return results;
+}
diff --git a/src/util/html.js b/src/util/html.js
index 282a52da..5b6743e0 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -181,6 +181,10 @@ export function tags(content, attributes = null) {
   return new Tag(null, attributes, content);
 }
 
+export function normalize(content) {
+  return Tag.normalize(content);
+}
+
 export class Tag {
   #tagName = '';
   #content = null;
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 9646be37..cee3df12 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -250,6 +250,16 @@ export function typeAppearance(value) {
   return typeof value;
 }
 
+// Limits a string to the desired length, filling in an ellipsis at the end
+// if it cuts any text off.
+export function cut(text, length = 40) {
+  if (text.length >= length) {
+    return text.slice(0, Math.max(1, length - 3)) + '...';
+  } else {
+    return text;
+  }
+}
+
 // Binds default values for arguments in a {key: value} type function argument
 // (typically the second argument, but may be overridden by providing a
 // [bindOpts.bindIndex] argument). Typically useful for preparing a function for
@@ -315,6 +325,12 @@ export function openAggregate({
   // constructed.
   message = '',
 
+  // Optional flag to indicate that this layer of the aggregate error isn't
+  // generally useful outside of developer debugging purposes - it will be
+  // skipped by default when using showAggregate, showing contained errors
+  // inline with other children of this aggregate's parent.
+  translucent = false,
+
   // Value to return when a provided function throws an error. If this is a
   // function, it will be called with the arguments given to the function.
   // (This is primarily useful when wrapping a function and then providing it
@@ -397,7 +413,13 @@ export function openAggregate({
 
   aggregate.close = () => {
     if (errors.length) {
-      throw Reflect.construct(errorClass, [errors, message]);
+      const error = Reflect.construct(errorClass, [errors, message]);
+
+      if (translucent) {
+        error[Symbol.for(`hsmusic.aggregate.translucent`)] = true;
+      }
+
+      throw error;
     }
   };
 
@@ -567,37 +589,123 @@ export function _withAggregate(mode, aggregateOpts, fn) {
   }
 }
 
+export const unhelpfulStackLines = [
+  /sugar/,
+  /node:/,
+  /<anonymous>/,
+];
+
+export function getUsefulStackLine(stack) {
+  if (!stack) return '';
+
+  function isUseful(stackLine) {
+    const trimmed = stackLine.trim();
+
+    if (!trimmed.startsWith('at'))
+      return false;
+
+    if (unhelpfulStackLines.some(regex => regex.test(trimmed)))
+      return false;
+
+    return true;
+  }
+
+  const stackLines = stack.split('\n');
+  const usefulStackLine = stackLines.find(isUseful);
+  return usefulStackLine ?? '';
+}
+
 export function showAggregate(topError, {
   pathToFileURL = f => f,
   showTraces = true,
+  showTranslucent = showTraces,
   print = true,
 } = {}) {
-  const recursive = (error, {level}) => {
-    let headerPart = showTraces
-      ? `[${error.constructor.name || 'unnamed'}] ${
-          error.message || '(no message)'
-        }`
-      : error instanceof AggregateError
-      ? `[${error.message || '(no message)'}]`
-      : error.message || '(no message)';
+  const translucentSymbol = Symbol.for('hsmusic.aggregate.translucent');
+
+  const determineCause = error => {
+    let cause = error.cause;
+    if (showTranslucent) return cause ?? null;
+
+    while (cause) {
+      if (!cause[translucentSymbol]) return cause;
+      cause = cause.cause;
+    }
+
+    return null;
+  };
+
+  const determineErrors = parentError => {
+    if (!parentError.errors) return null;
+    if (showTranslucent) return parentError.errors;
+
+    const errors = [];
+    for (const error of parentError.errors) {
+      if (!error[translucentSymbol]) {
+        errors.push(error);
+        continue;
+      }
+
+      if (error.cause) {
+        errors.push(determineCause(error));
+      }
+
+      if (error.errors) {
+        errors.push(...determineErrors(error));
+      }
+    }
+
+    return errors;
+  };
+
+  const flattenErrorStructure = (error, level = 0) => {
+    const cause = determineCause(error);
+    const errors = determineErrors(error);
+
+    return {
+      level,
+
+      kind: error.constructor.name,
+      message: error.message,
+      stack: error.stack,
+
+      cause:
+        (cause
+          ? flattenErrorStructure(cause, level + 1)
+          : null),
+
+      errors:
+        (errors
+          ? errors.map(error => flattenErrorStructure(error, level + 1))
+          : null),
+    };
+  };
+
+  const recursive = ({level, kind, message, stack, cause, errors}) => {
+    const messagePart =
+      message || `(no message)`;
+
+    const kindPart =
+      kind || `unnamed kind`;
+
+    let headerPart =
+      (showTraces
+        ? `[${kindPart}] ${messagePart}`
+     : errors
+        ? `[${messagePart}]`
+        : messagePart);
 
     if (showTraces) {
-      const stackLines = error.stack?.split('\n');
-
-      const stackLine = stackLines?.find(
-        (line) =>
-          line.trim().startsWith('at') &&
-          !line.includes('sugar') &&
-          !line.includes('node:') &&
-          !line.includes('<anonymous>')
-      );
+      const stackLine =
+        getUsefulStackLine(stack);
 
-      const tracePart = stackLine
-        ? '- ' +
-          stackLine
-            .trim()
-            .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match))
-        : '(no stack trace)';
+      const tracePart =
+        (stackLine
+          ? '- ' +
+            stackLine
+              .trim()
+              .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match))
+          : '(no stack trace)');
 
       headerPart += ` ${colors.dim(tracePart)}`;
     }
@@ -606,8 +714,8 @@ export function showAggregate(topError, {
     const bar1 = ' ';
 
     const causePart =
-      (error.cause
-        ? recursive(error.cause, {level: level + 1})
+      (cause
+        ? recursive(cause)
             .split('\n')
             .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`)
             .join('\n')
@@ -616,19 +724,20 @@ export function showAggregate(topError, {
     const head2 = level % 2 === 0 ? '\u257f' : colors.dim('\u257f');
     const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e');
 
-    const aggregatePart =
-      (error instanceof AggregateError
-        ? error.errors
-            .map(error => recursive(error, {level: level + 1}))
+    const errorsPart =
+      (errors
+        ? errors
+            .map(error => recursive(error))
             .flatMap(str => str.split('\n'))
             .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`)
             .join('\n')
         : '');
 
-    return [headerPart, causePart, aggregatePart].filter(Boolean).join('\n');
+    return [headerPart, causePart, errorsPart].filter(Boolean).join('\n');
   };
 
-  const message = recursive(topError, {level: 0});
+  const structure = flattenErrorStructure(topError);
+  const message = recursive(structure);
 
   if (print) {
     console.error(message);
@@ -685,7 +794,7 @@ export function asyncAdaptiveDecorateError(fn, callback) {
     try {
       return await fn(...args);
     } catch (caughtError) {
-      throw callback(caughtError);
+      throw callback(caughtError, ...args);
     }
   };
 
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
index 0790ae91..b5813c7a 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -629,6 +629,41 @@ export function sortFlashesChronologically(data, {
 
 // Specific data utilities
 
+// Matches heading details from commentary data in roughly the formats:
+//
+//    <i>artistReference:</i> (annotation, date)
+//    <i>artistReference|artistDisplayText:</i> (annotation, date)
+//
+// where capturing group "annotation" can be any text at all, except that the
+// last entry (past a comma or the only content within parentheses), if parsed
+// as a date, is the capturing group "date". "Parsing as a date" means matching
+// one of these formats:
+//
+//   * "25 December 2019" - one or two number digits, followed by any text,
+//     followed by four number digits
+//   * "December 25, 2019" - one all-letters word, a space, one or two number
+//     digits, a comma, and four number digits
+//   * "12/25/2019" etc - three sets of one to four number digits, separated
+//     by slashes or dashes (only valid orders are MM/DD/YYYY and YYYY/MM/DD)
+//
+// Note that the annotation and date are always wrapped by one opening and one
+// closing parentheses. The whole heading does NOT need to match the entire
+// line it occupies (though it does always start at the first position on that
+// line), and if there is more than one closing parenthesis on the line, the
+// annotation will always cut off only at the last parenthesis, or a comma
+// preceding a date and then the last parenthesis. This is to ensure that
+// parentheses can be part of the actual annotation content.
+//
+// Capturing group "artistReference" is all the characters between <i> and </i>
+// (apart from the pipe and "artistDisplayText" text, if present), and is either
+// the name of an artist or an "artist:directory"-style reference.
+//
+// This regular expression *doesn't* match bodies, which will need to be parsed
+// out of the original string based on the indices matched using this.
+//
+export const commentaryRegex =
+  /^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?<date>[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4}))?\))?/gm;
+
 export function filterAlbumsByCommentary(albums) {
   return albums
     .filter((album) => [album, ...album.tracks].some((x) => x.commentary));
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index ab6ceecb..8828a5bd 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -16,7 +16,7 @@ import {
 } from '#urls';
 
 import {bindUtilities} from '../bind-utilities.js';
-import {generateGlobalWikiDataJSON, generateRedirectHTML} from '../common-templates.js';
+import {generateRandomLinkDataJSON, generateRedirectHTML} from '../common-templates.js';
 
 const defaultHost = '0.0.0.0';
 const defaultPort = 8002;
@@ -157,19 +157,20 @@ export async function go({
 
     // Specialized routes
 
-    if (pathname === '/data.json') {
+    if (pathname === '/random-link-data.json') {
       try {
-        const json = generateGlobalWikiDataJSON({
+        const json = generateRandomLinkDataJSON({
           serializeThings,
           wikiData,
         });
+
         response.writeHead(200, contentTypeJSON);
         response.end(json);
-        if (loudResponses) console.log(`${requestHead} [200] /data.json`);
+        if (loudResponses) console.log(`${requestHead} [200] ${pathname}`);
       } catch (error) {
         response.writeHead(500, contentTypeJSON);
         response.end(`Internal error serializing wiki JSON`);
-        console.error(`${requestHead} [500] /data.json`);
+        console.error(`${requestHead} [500] ${pathname}`);
         showError(error);
       }
       return;
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index b6dc9643..a8e0eb23 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -31,7 +31,7 @@ import {
 } from '#urls';
 
 import {bindUtilities} from '../bind-utilities.js';
-import {generateRedirectHTML, generateGlobalWikiDataJSON} from '../common-templates.js';
+import {generateRedirectHTML, generateRandomLinkDataJSON} from '../common-templates.js';
 
 const pageFlags = Object.keys(pageSpecs);
 
@@ -145,14 +145,8 @@ export async function go({
   });
 
   await writeSharedFilesAndPages({
-    language: defaultLanguage,
     outputPath,
-    urls,
-    wikiData,
-    wikiDataJSON: generateGlobalWikiDataJSON({
-      serializeThings,
-      wikiData,
-    }),
+    randomLinkDataJSON: generateRandomLinkDataJSON({wikiData}),
   });
 
   const buildSteps = writeAll
@@ -477,12 +471,12 @@ async function writeFavicon({
 
 async function writeSharedFilesAndPages({
   outputPath,
-  wikiDataJSON,
+  randomLinkDataJSON,
 }) {
   return progressPromiseAll(`Writing files & pages shared across languages.`, [
-    wikiDataJSON &&
+    randomLinkDataJSON &&
       writeFile(
-        path.join(outputPath, 'data.json'),
-        wikiDataJSON),
+        path.join(outputPath, 'random-link-data.json'),
+        randomLinkDataJSON),
   ].filter(Boolean));
 }
diff --git a/src/write/common-templates.js b/src/write/common-templates.js
index 2dd4c924..d897a73b 100644
--- a/src/write/common-templates.js
+++ b/src/write/common-templates.js
@@ -1,4 +1,5 @@
 import * as html from '#html';
+import {getArtistNumContributions} from '#wiki-data';
 
 export function generateRedirectHTML(title, target, {language}) {
   return `<!DOCTYPE html>\n` + html.tag('html', [
@@ -30,22 +31,25 @@ export function generateRedirectHTML(title, target, {language}) {
   ]);
 }
 
-export function generateGlobalWikiDataJSON({
-  serializeThings,
-  wikiData,
-}) {
-  const stringifyThings = thingData =>
-    JSON.stringify(serializeThings(thingData));
-
-  return '{\n' +
-    ([
-      `"albumData": ${stringifyThings(wikiData.albumData)},`,
-      wikiData.wikiInfo.enableFlashesAndGames &&
-        `"flashData": ${stringifyThings(wikiData.flashData)},`,
-      `"artistData": ${stringifyThings(wikiData.artistData)}`,
-    ]
-      .filter(Boolean)
-      .map(line => '  ' + line)
-      .join('\n')) +
-    '\n}';
+export function generateRandomLinkDataJSON({wikiData}) {
+  const {albumData, artistData} = wikiData;
+
+  return JSON.stringify({
+    albumDirectories:
+      albumData
+        .map(album => album.directory),
+
+    albumTrackDirectories:
+      albumData
+        .map(album => album.tracks
+          .map(track => track.directory)),
+
+    artistDirectories:
+      artistData
+        .map(artist => artist.directory),
+
+    artistNumContributions:
+      artistData
+        .map(artist => getArtistNumContributions(artist)),
+  });
 }
diff --git a/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs
index 2c679fc8..26d9be49 100644
--- a/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs
@@ -16,7 +16,7 @@ exports[`test/snapshot/generateAlbumCoverArtwork.js > TAP > generateAlbumCoverAr
          { name: 'creepy crawlies', isContentWarning: true }
        ]
      ]
-     slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]
+     slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]
     <p>Tags: <a href="tag/damara/">Damara</a>, <a href="tag/cronus/">Cronus</a>, <a href="tag/bees/">Bees</a></p>
 </div>
 `
@@ -31,5 +31,5 @@ exports[`test/snapshot/generateAlbumCoverArtwork.js > TAP > generateAlbumCoverAr
      { name: 'creepy crawlies', isContentWarning: true }
    ]
  ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'small', reveal: false, link: false, square: true }]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'small', reveal: false, link: false, square: true }]
 `
diff --git a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
index 9702cad8..e9dbfea2 100644
--- a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
@@ -7,18 +7,16 @@
 'use strict'
 exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > basic behavior 1`] = `
 <p>
-    By <span class="nowrap"><a href="artist/toby-fox/">Toby Fox</a> (music probably)</span> and <span class="nowrap"><a href="artist/tensei/">Tensei</a> (hot jams) (<span class="icons"><a href="https://tenseimusic.bandcamp.com/" class="icon">
-                <svg>
-                    <title>Bandcamp</title>
-                    <use href="static/icons.svg#icon-bandcamp"></use>
-                </svg>
-            </a></span>)</span>.
+    By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (music probably)</span> and <span class="contribution has-tooltip nowrap"><a href="artist/tensei/">Tensei</a> (hot jams)<span class="icons icons-tooltip" inert><span class="icons-tooltip-content"><a href="https://tenseimusic.bandcamp.com/" class="icon has-text">
+                    <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
+                    <span class="icon-text">tenseimusic</span>
+                </a></span></span></span>.
     <br>
     Cover art by <a href="artist/hb/">Hanni Brosh</a>.
     <br>
-    Wallpaper art by <a href="artist/hb/">Hanni Brosh</a> and <span class="nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
+    Wallpaper art by <a href="artist/hb/">Hanni Brosh</a> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
     <br>
-    Banner art by <a href="artist/hb/">Hanni Brosh</a> and <span class="nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
+    Banner art by <a href="artist/hb/">Hanni Brosh</a> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
     <br>
     Released 3/14/2011.
     <br>
diff --git a/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
index 304717d1..d3702e65 100644
--- a/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
@@ -16,7 +16,7 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
 
 exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > basic behavior, with track sections 1`] = `
 <dl class="album-group-list">
-    <dt class="content-heading" tabindex="0">First section (~1:30):</dt>
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">First section (~1:30):</span></dt>
     <dd>
         <ul>
             <li>(0:20) <a href="track/t1/">Track 1</a></li>
@@ -24,7 +24,7 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
             <li>(0:40) <a href="track/t3/">Track 3</a></li>
         </ul>
     </dd>
-    <dt class="content-heading" tabindex="0">Second section (0:05):</dt>
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section (0:05):</span></dt>
     <dd><ul><li style="--primary-color: #ea2e83">(0:05) <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
 </dl>
 `
diff --git a/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs
new file mode 100644
index 00000000..3a22266e
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs
@@ -0,0 +1,99 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > inferred additional names only 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Baz Baz', from: [ { directory: 'the-pyrenees' } ] }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > multiple own 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Apple Time!' },
+     { name: 'Pterodactyl Time!' },
+     { name: 'Banana Time!' }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > no additional names 1`] = `
+
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own additional names only 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [ [ { name: 'Foo Bar', annotation: 'the Alps' } ] ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and inferred, some overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Ke$halo Strike Back', annotation: 'own annotation' },
+     { name: 'Ironic Mania', annotation: 'own annotation' },
+     {
+       name: 'ANARCHY::MEGASTRIFE',
+       from: [ { directory: 'inferred-from' } ]
+     }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and shared and inferred, various overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Own!', annotation: 'own annotation' },
+     { name: 'Own! Shared!', annotation: 'own annotation' },
+     { name: 'Own! Inferred!', annotation: 'own annotation' },
+     { name: 'Own! Shared! Inferred!', annotation: 'own annotation' },
+     { name: 'Shared!', annotation: 'shared annotation' },
+     { name: 'Shared! Inferred!', annotation: 'shared annotation' },
+     { name: 'Inferred!', from: [ { directory: 'inferred-from' } ] }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and shared, some overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'weed dreams..', annotation: 'own annotation' },
+     { name: '夜間のMOON汗', annotation: 'own annotation' },
+     { name: 'GAMINGブラザー96', annotation: 'shared annotation' }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > shared additional names only 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [ [ { name: 'Bar Foo', annotation: 'the Rockies' } ] ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > shared and inferred, some overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Coruscate', annotation: 'shared annotation' },
+     { name: 'Arbroath', annotation: 'shared annotation' },
+     { name: 'Prana Ferox', from: [ { directory: 'inferred-from' } ] }
+   ]
+ ]
+ slots: {}]
+`
diff --git a/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs
index 78063c48..0e4a15f4 100644
--- a/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs
@@ -16,7 +16,7 @@ exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverAr
          { name: 'creepy crawlies', isContentWarning: true }
        ]
      ]
-     slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]
+     slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]
     <p>Tags: <a href="tag/damara/">Damara</a>, <a href="tag/cronus/">Cronus</a>, <a href="tag/bees/">Bees</a></p>
 </div>
 `
@@ -25,7 +25,7 @@ exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverAr
 <div id="cover-art-container">
     [mocked: image
      args: [ [ { name: 'Bees', directory: 'bees', isContentWarning: false } ] ]
-     slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]
+     slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], color: '#f28514', thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]
     <p>Tags: <a href="tag/bees/">Bees</a></p>
 </div>
 `
@@ -40,11 +40,11 @@ exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverAr
      { name: 'creepy crawlies', isContentWarning: true }
    ]
  ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'small', reveal: false, link: false, square: true }]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'small', reveal: false, link: false, square: true }]
 `
 
 exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverArtwork (snapshot) > display: thumbnail - unique art 1`] = `
 [mocked: image
  args: [ [ { name: 'Bees', directory: 'bees', isContentWarning: false } ] ]
- slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], thumb: 'small', reveal: false, link: false, square: true }]
+ slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], color: '#f28514', thumb: 'small', reveal: false, link: false, square: true }]
 `
diff --git a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
index 2add28ed..3d988dce 100644
--- a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
@@ -25,7 +25,7 @@ exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseI
 <p>
     By <a href="artist/toby-fox/">Toby Fox</a>.
     <br>
-    Cover art by <span class="nowrap"><a href="artist/alpaca/">Alpaca</a> (&#x1F525;)</span>.
+    Cover art by <span class="contribution nowrap"><a href="artist/alpaca/">Alpaca</a> (&#x1F525;)</span>.
 </p>
 <p>This wiki doesn't have any listening links for <i>Suspicious Track</i>.</p>
 `
diff --git a/tap-snapshots/test/snapshot/image.js.test.cjs b/tap-snapshots/test/snapshot/image.js.test.cjs
index f88141d5..5119eb03 100644
--- a/tap-snapshots/test/snapshot/image.js.test.cjs
+++ b/tap-snapshots/test/snapshot/image.js.test.cjs
@@ -68,7 +68,7 @@ exports[`test/snapshot/image.js > TAP > image (snapshot) > thumb requested but s
 `
 
 exports[`test/snapshot/image.js > TAP > image (snapshot) > thumbnail details 1`] = `
-<div class="image-container"><div class="image-inner-area"><img data-original-length="1200" data-thumbs="voluminous:1200 middling:900 petite:20" src="media/album-art/beyond-canon/cover.voluminous.jpg"></div></div>
+<div class="image-container"><div class="image-inner-area"><img data-original-length="1200" data-thumbs="voluminous:1200 middling:900 petite:20" src="thumb/album-art/beyond-canon/cover.voluminous.jpg"></div></div>
 `
 
 exports[`test/snapshot/image.js > TAP > image (snapshot) > width & height 1`] = `
diff --git a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
index 75b9d273..acb8faf4 100644
--- a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
+++ b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
@@ -5,8 +5,8 @@
  * Make sure to inspect the output below.  Do not ignore changes!
  */
 'use strict'
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links 1`] = `
-<span class="nowrap"><a href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a> (<span class="icons"><a href="https://loremipsum.io" class="icon">
+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 href="https://loremipsum.io" class="icon">
             <svg>
                 <title>External (loremipsum.io)</title>
                 <use href="static/icons.svg#icon-globe"></use>
@@ -29,6 +29,34 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) >
         </a></span>)</span>
 `
 
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (tooltip) 1`] = `
+<span class="contribution has-tooltip"><a href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a><span class="icons icons-tooltip" inert><span class="icons-tooltip-content"><a href="https://loremipsum.io" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                <span class="icon-text">loremipsum.io</span>
+            </a><a href="https://loremipsum.io/generator/" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                <span class="icon-text">loremipsum.io</span>
+            </a><a href="https://loremipsum.io/#meaning" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                <span class="icon-text">loremipsum.io</span>
+            </a><a href="https://loremipsum.io/#usage-and-examples" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                <span class="icon-text">loremipsum.io</span>
+            </a><a href="https://loremipsum.io/#controversy" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                <span class="icon-text">loremipsum.io</span>
+            </a><a href="https://loremipsum.io/#when-to-use-lorem-ipsum" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                <span class="icon-text">loremipsum.io</span>
+            </a><a href="https://loremipsum.io/#lorem-ipsum-all-the-things" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                <span class="icon-text">loremipsum.io</span>
+            </a><a href="https://loremipsum.io/#original-source" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                <span class="icon-text">loremipsum.io</span>
+            </a></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>
@@ -36,41 +64,41 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) >
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no preventWrapping 1`] = `
-<a href="artist/clark-powell/">Clark Powell</a> (<span class="icons"><a href="https://soundcloud.com/plazmataz" class="icon">
-        <svg>
-            <title>SoundCloud</title>
-            <use href="static/icons.svg#icon-soundcloud"></use>
-        </svg>
-    </a></span>)
-<a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)
-<a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons"><a href="https://tobyfox.bandcamp.com/" class="icon">
-        <svg>
-            <title>Bandcamp</title>
-            <use href="static/icons.svg#icon-bandcamp"></use>
-        </svg>
-    </a>, <a href="https://toby.fox/" class="icon">
-        <svg>
-            <title>External (toby.fox)</title>
-            <use href="static/icons.svg#icon-globe"></use>
-        </svg>
-    </a></span>)
+<span class="contribution"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a href="https://soundcloud.com/plazmataz" class="icon">
+            <svg>
+                <title>SoundCloud</title>
+                <use href="static/icons.svg#icon-soundcloud"></use>
+            </svg>
+        </a></span>)</span>
+<span class="contribution"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
+<span class="contribution"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons icons-inline"><a href="https://tobyfox.bandcamp.com/" class="icon">
+            <svg>
+                <title>Bandcamp</title>
+                <use href="static/icons.svg#icon-bandcamp"></use>
+            </svg>
+        </a>, <a href="https://toby.fox/" class="icon">
+            <svg>
+                <title>External (toby.fox)</title>
+                <use href="static/icons.svg#icon-globe"></use>
+            </svg>
+        </a></span>)</span>
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showContribution 1`] = `
 <a href="artist/clark-powell/">Clark Powell</a>
-<span class="nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
-<span class="nowrap"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement)</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 1`] = `
-<span class="nowrap"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons"><a href="https://soundcloud.com/plazmataz" class="icon">
+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 href="https://soundcloud.com/plazmataz" class="icon">
             <svg>
                 <title>SoundCloud</title>
                 <use href="static/icons.svg#icon-soundcloud"></use>
             </svg>
         </a></span>)</span>
 <a href="artist/the-big-baddies/">Grounder &amp; Scratch</a>
-<span class="nowrap"><a href="artist/toby-fox/">Toby Fox</a> (<span class="icons"><a href="https://tobyfox.bandcamp.com/" class="icon">
+<span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (<span class="icons icons-inline"><a href="https://tobyfox.bandcamp.com/" class="icon">
             <svg>
                 <title>Bandcamp</title>
                 <use href="static/icons.svg#icon-bandcamp"></use>
@@ -83,15 +111,30 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) >
         </a></span>)</span>
 `
 
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons 1`] = `
-<span class="nowrap"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons"><a href="https://soundcloud.com/plazmataz" class="icon">
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (tooltip) 1`] = `
+<span class="contribution has-tooltip"><a href="artist/clark-powell/">Clark Powell</a><span class="icons icons-tooltip" inert><span class="icons-tooltip-content"><a href="https://soundcloud.com/plazmataz" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-soundcloud"></use></svg>
+                <span class="icon-text">plazmataz</span>
+            </a></span></span></span>
+<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
+<span class="contribution has-tooltip nowrap"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement)<span class="icons icons-tooltip" inert><span class="icons-tooltip-content"><a href="https://tobyfox.bandcamp.com/" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
+                <span class="icon-text">tobyfox</span>
+            </a><a href="https://toby.fox/" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                <span class="icon-text">toby.fox</span>
+            </a></span></span></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 href="https://soundcloud.com/plazmataz" class="icon">
             <svg>
                 <title>SoundCloud</title>
                 <use href="static/icons.svg#icon-soundcloud"></use>
             </svg>
         </a></span>)</span>
-<span class="nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
-<span class="nowrap"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons"><a href="https://tobyfox.bandcamp.com/" class="icon">
+<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 href="https://tobyfox.bandcamp.com/" class="icon">
             <svg>
                 <title>Bandcamp</title>
                 <use href="static/icons.svg#icon-bandcamp"></use>
@@ -103,3 +146,18 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) >
             </svg>
         </a></span>)</span>
 `
+
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (tooltip) 1`] = `
+<span class="contribution has-tooltip"><a href="artist/clark-powell/">Clark Powell</a><span class="icons icons-tooltip" inert><span class="icons-tooltip-content"><a href="https://soundcloud.com/plazmataz" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-soundcloud"></use></svg>
+                <span class="icon-text">plazmataz</span>
+            </a></span></span></span>
+<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
+<span class="contribution has-tooltip nowrap"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement)<span class="icons icons-tooltip" inert><span class="icons-tooltip-content"><a href="https://tobyfox.bandcamp.com/" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
+                <span class="icon-text">tobyfox</span>
+            </a><a href="https://toby.fox/" class="icon has-text">
+                <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                <span class="icon-text">toby.fox</span>
+            </a></span></span></span>
+`
diff --git a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs
index cd6dca76..a9116be4 100644
--- a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs
+++ b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs
@@ -5,7 +5,68 @@
  * Make sure to inspect the output below.  Do not ignore changes!
  */
 'use strict'
-exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > basic domain matches 1`] = `
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: album, style: compact 1`] = `
+<a href="https://youtu.be/abc" class="nowrap">youtu.be</a>
+<a href="https://youtube.com/watch?v=abc" class="nowrap">youtube.com</a>
+<a href="https://youtube.com/Playlist?list=kweh" class="nowrap">youtube.com</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: album, style: normal 1`] = `
+<a href="https://youtu.be/abc" class="nowrap">YouTube (full album)</a>
+<a href="https://youtube.com/watch?v=abc" class="nowrap">YouTube (full album)</a>
+<a href="https://youtube.com/Playlist?list=kweh" class="nowrap">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: album, style: platform 1`] = `
+<a href="https://youtu.be/abc" class="nowrap">YouTube</a>
+<a href="https://youtube.com/watch?v=abc" class="nowrap">YouTube</a>
+<a href="https://youtube.com/Playlist?list=kweh" class="nowrap">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: flash, style: compact 1`] = `
+<a href="https://www.bgreco.net/hsflash/002238.html" class="nowrap">bgreco.net</a>
+<a href="https://homestuck.com/story/1234" class="nowrap">homestuck.com</a>
+<a href="https://homestuck.com/story/pony" class="nowrap">homestuck.com</a>
+<a href="https://www.youtube.com/watch?v=wKgOp3Kg2wI" class="nowrap">youtube.com</a>
+<a href="https://youtu.be/IOcvkkklWmY" class="nowrap">youtu.be</a>
+<a href="https://some.external.site/foo/bar/" class="nowrap">some.external.site</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: flash, style: normal 1`] = `
+<a href="https://www.bgreco.net/hsflash/002238.html" class="nowrap">bgreco.net (high quality audio)</a>
+<a href="https://homestuck.com/story/1234" class="nowrap">Homestuck (page 1234)</a>
+<a href="https://homestuck.com/story/pony" class="nowrap">Homestuck (secret page)</a>
+<a href="https://www.youtube.com/watch?v=wKgOp3Kg2wI" class="nowrap">YouTube (on any device)</a>
+<a href="https://youtu.be/IOcvkkklWmY" class="nowrap">YouTube (on any device)</a>
+<a href="https://some.external.site/foo/bar/" class="nowrap">External (some.external.site)</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: flash, style: platform 1`] = `
+<a href="https://www.bgreco.net/hsflash/002238.html" class="nowrap">bgreco.net</a>
+<a href="https://homestuck.com/story/1234" class="nowrap">Homestuck</a>
+<a href="https://homestuck.com/story/pony" class="nowrap">Homestuck</a>
+<a href="https://www.youtube.com/watch?v=wKgOp3Kg2wI" class="nowrap">YouTube</a>
+<a href="https://youtu.be/IOcvkkklWmY" class="nowrap">YouTube</a>
+<a href="https://some.external.site/foo/bar/" class="nowrap">External</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: compact 1`] = `
+<a href="https://homestuck.bandcamp.com/" class="nowrap">homestuck</a>
+<a href="https://soundcloud.com/plazmataz" class="nowrap">plazmataz</a>
+<a href="https://aeritus.tumblr.com/" class="nowrap">aeritus</a>
+<a href="https://twitter.com/awkwarddoesart" class="nowrap">@awkwarddoesart</a>
+<a href="https://www.deviantart.com/chesswanderlust-sama" class="nowrap">deviantart.com</a>
+<a href="https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)" class="nowrap">en.wikipedia.org</a>
+<a href="https://www.poetryfoundation.org/poets/christina-rossetti" class="nowrap">poetryfoundation.org</a>
+<a href="https://www.instagram.com/levc_egm/" class="nowrap">instagram.com</a>
+<a href="https://www.patreon.com/CecilyRenns" class="nowrap">patreon.com</a>
+<a href="https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3" class="nowrap">open.spotify.com</a>
+<a href="https://buzinkai.newgrounds.com/" class="nowrap">buzinkai.newgrounds.com</a>
+<a href="https://music.solatrus.com/" class="nowrap">music.solatrus.com</a>
+<a href="https://types.pl/" class="nowrap">types.pl</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: normal 1`] = `
 <a href="https://homestuck.bandcamp.com/" class="nowrap">Bandcamp</a>
 <a href="https://soundcloud.com/plazmataz" class="nowrap">SoundCloud</a>
 <a href="https://aeritus.tumblr.com/" class="nowrap">Tumblr</a>
@@ -17,23 +78,26 @@ exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > basic d
 <a href="https://www.patreon.com/CecilyRenns" class="nowrap">Patreon</a>
 <a href="https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3" class="nowrap">Spotify</a>
 <a href="https://buzinkai.newgrounds.com/" class="nowrap">Newgrounds</a>
-`
-
-exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > custom domains for common platforms 1`] = `
-<a href="https://music.solatrus.com/" class="nowrap">music.solatrus.com</a>
+<a href="https://music.solatrus.com/" class="nowrap">Bandcamp (music.solatrus.com)</a>
 <a href="https://types.pl/" class="nowrap">Mastodon (types.pl)</a>
 `
 
-exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > custom matches - album 1`] = `
-<a href="https://youtu.be/abc" class="nowrap">YouTube (full album)</a>
-<a href="https://youtube.com/watch?v=abc" class="nowrap">YouTube (full album)</a>
-<a href="https://youtube.com/Playlist?list=kweh" class="nowrap">YouTube (playlist)</a>
-`
-
-exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > missing domain (arbitrary local path) 1`] = `
-<a href="/foo/bar/baz.mp3" class="nowrap">Wiki Archive (local upload)</a>
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: platform 1`] = `
+<a href="https://homestuck.bandcamp.com/" class="nowrap">Bandcamp</a>
+<a href="https://soundcloud.com/plazmataz" class="nowrap">SoundCloud</a>
+<a href="https://aeritus.tumblr.com/" class="nowrap">Tumblr</a>
+<a href="https://twitter.com/awkwarddoesart" class="nowrap">Twitter</a>
+<a href="https://www.deviantart.com/chesswanderlust-sama" class="nowrap">DeviantArt</a>
+<a href="https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)" class="nowrap">Wikipedia</a>
+<a href="https://www.poetryfoundation.org/poets/christina-rossetti" class="nowrap">Poetry Foundation</a>
+<a href="https://www.instagram.com/levc_egm/" class="nowrap">Instagram</a>
+<a href="https://www.patreon.com/CecilyRenns" class="nowrap">Patreon</a>
+<a href="https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3" class="nowrap">Spotify</a>
+<a href="https://buzinkai.newgrounds.com/" class="nowrap">Newgrounds</a>
+<a href="https://music.solatrus.com/" class="nowrap">Bandcamp</a>
+<a href="https://types.pl/" class="nowrap">Mastodon</a>
 `
 
 exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > unknown domain (arbitrary world wide web path) 1`] = `
-<a href="https://snoo.ping.as/usual/i/see/" class="nowrap">snoo.ping.as</a>
+<a href="https://snoo.ping.as/usual/i/see/" class="nowrap">External (snoo.ping.as)</a>
 `
diff --git a/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs b/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs
deleted file mode 100644
index d29d0dde..00000000
--- a/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs
+++ /dev/null
@@ -1,18 +0,0 @@
-/* IMPORTANT
- * This snapshot file is auto-generated, but designed for humans.
- * It should be checked into source control and tracked carefully.
- * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
- * Make sure to inspect the output below.  Do not ignore changes!
- */
-'use strict'
-exports[`test/snapshot/linkExternalFlash.js > TAP > linkExternalFlash (snapshot) > basic behavior 1`] = `
-<span class="nowrap"><a href="https://homestuck.com/story/4109/" class="nowrap">homestuck.com</a> (page 4109)</span>
-<span class="nowrap"><a href="https://youtu.be/FDt-SLyEcjI" class="nowrap">YouTube</a> (on any device)</span>
-<span class="nowrap"><a href="https://www.bgreco.net/hsflash/006009.html" class="nowrap">www.bgreco.net</a> (HQ Audio)</span>
-<span class="nowrap"><a href="https://www.newgrounds.com/portal/view/582345" class="nowrap">Newgrounds</a></span>
-`
-
-exports[`test/snapshot/linkExternalFlash.js > TAP > linkExternalFlash (snapshot) > secret page 1`] = `
-<span class="nowrap"><a href="https://homestuck.com/story/pony/" class="nowrap">homestuck.com</a> (secret page)</span>
-<span class="nowrap"><a href="https://youtu.be/USB1pj6hAjU" class="nowrap">YouTube</a> (on any device)</span>
-`
diff --git a/tap-snapshots/test/snapshot/transformContent.js.test.cjs b/tap-snapshots/test/snapshot/transformContent.js.test.cjs
index 85ee740f..9ab299e6 100644
--- a/tap-snapshots/test/snapshot/transformContent.js.test.cjs
+++ b/tap-snapshots/test/snapshot/transformContent.js.test.cjs
@@ -10,6 +10,45 @@ exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) >
 <p>Very nice: <time datetime="Fri, 25 Oct 2413 03:00:00 GMT">10/25/2413</time></p>
 `
 
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > hanging indent list 1`] = `
+<p>Hello!</p>
+<ul>
+<li><p>I am a list item and I
+go on and on and on
+and on and on and on.</p>
+</li>
+<li><p>I am another list item.
+Yeah.</p>
+</li>
+</ul>
+<p>In-between!</p>
+<ul>
+<li>Spooky,
+spooky, I say!</li>
+<li>Following list item.
+No empty line around me.</li>
+<li>Very cool.
+So, so cool.</li>
+</ul>
+<p>Goodbye!</p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > indent on a directly following line 1`] = `
+<div>
+    <span>Wow!</span>
+</div>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > indent on an indierctly following line 1`] = `
+<p>Some text.</p>
+<p>Yes, some more text.</p>
+<pre><code>I am hax0rz!!
+All yor base r blong 2 us.
+</code></pre>
+<p>Aye.</p>
+<p>Aye aye aye.</p>
+`
+
 exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > inline images 1`] = `
 <p><img src="snooping.png"> as USUAL...</p>
 <p>What do you know? <img src="cowabunga.png" width="24" height="32"></p>
diff --git a/test/lib/content-function.js b/test/lib/content-function.js
index 5cb499b1..a4c5dac1 100644
--- a/test/lib/content-function.js
+++ b/test/lib/content-function.js
@@ -8,7 +8,7 @@ import {getColors} from '#colors';
 import {quickLoadContentDependencies} from '#content-dependencies';
 import {quickEvaluate} from '#content-function';
 import * as html from '#html';
-import {processLanguageFile} from '#language';
+import {internalDefaultStringsFile, processLanguageFile} from '#language';
 import {empty, showAggregate} from '#sugar';
 import {generateURLs, thumb, urlSpec} from '#urls';
 
@@ -22,7 +22,7 @@ export function testContentFunctions(t, message, fn) {
   t.test(message, async t => {
     let loadedContentDependencies;
 
-    const language = await processLanguageFile('./src/strings-default.json');
+    const language = await processLanguageFile(internalDefaultStringsFile);
     const mocks = [];
 
     const evaluate = ({
@@ -50,8 +50,15 @@ export function testContentFunctions(t, message, fn) {
             thumb,
             to,
             urls,
+
+            pagePath: ['home'],
             appendIndexHTML: false,
             getColors: c => getColors(c, {chroma}),
+
+            wikiData: {
+              wikiInfo: {},
+            },
+
             ...extraDependencies,
           },
         });
diff --git a/test/lib/wiki-data.js b/test/lib/wiki-data.js
index c4083a56..5433de29 100644
--- a/test/lib/wiki-data.js
+++ b/test/lib/wiki-data.js
@@ -1,7 +1,32 @@
+import CacheableObject from '#cacheable-object';
+import find from '#find';
 import {linkWikiDataArrays} from '#yaml';
 
-export function linkAndBindWikiData(wikiData) {
-  linkWikiDataArrays(wikiData);
+export function linkAndBindWikiData(wikiData, {
+  inferAlbumsOwnTrackData = true,
+} = {}) {
+  function customLinkWikiDataArrays(...args) {
+    linkWikiDataArrays(...args);
+
+    // If albumData is present, automatically set albums' ownTrackData values
+    // by resolving track sections' references against the full array. This is
+    // just a nicety for working with albums throughout tests.
+    if (inferAlbumsOwnTrackData && wikiData.albumData && wikiData.trackData) {
+      for (const album of wikiData.albumData) {
+        const trackSections =
+          CacheableObject.getUpdateValue(album, 'trackSections');
+
+        const trackRefs =
+          trackSections.flatMap(section => section.tracks);
+
+        album.ownTrackData =
+          trackRefs.map(ref =>
+            find.track(ref, wikiData.trackData, {mode: 'error'}));
+      }
+    }
+  }
+
+  customLinkWikiDataArrays(wikiData);
 
   return {
     // Mutate to make the below functions aware of new data objects, or of
@@ -13,12 +38,14 @@ export function linkAndBindWikiData(wikiData) {
     // It'll automatically relink everything on wikiData so all the objects
     // are caught up to date.
     linkWikiDataArrays:
-      linkWikiDataArrays.bind(null, wikiData),
+      customLinkWikiDataArrays
+        .bind(null, wikiData),
 
     // Use this if you HAVEN'T mutated wikiData and just need to decache
     // indirect dependencies on exposed properties of other data objects.
     // See documentation on linkWikiDataArarys (in yaml.js) for more info.
     XXX_decacheWikiData:
-      linkWikiDataArrays.bind(null, wikiData, {XXX_decacheWikiData: true}),
+      customLinkWikiDataArrays
+        .bind(null, wikiData, {XXX_decacheWikiData: true}),
   };
 }
diff --git a/test/snapshot/generateAlbumCoverArtwork.js b/test/snapshot/generateAlbumCoverArtwork.js
index b1c7885f..9244c034 100644
--- a/test/snapshot/generateAlbumCoverArtwork.js
+++ b/test/snapshot/generateAlbumCoverArtwork.js
@@ -13,6 +13,7 @@ testContentFunctions(t, 'generateAlbumCoverArtwork (snapshot)', async (t, evalua
   const album = {
     directory: 'bee-forus-seatbelt-safebee',
     coverArtFileExtension: 'png',
+    color: '#f28514',
     artTags: [
       {name: 'Damara', directory: 'damara', isContentWarning: false},
       {name: 'Cronus', directory: 'cronus', isContentWarning: false},
diff --git a/test/snapshot/generateTrackAdditionalNamesBox.js b/test/snapshot/generateTrackAdditionalNamesBox.js
new file mode 100644
index 00000000..9c1e3598
--- /dev/null
+++ b/test/snapshot/generateTrackAdditionalNamesBox.js
@@ -0,0 +1,107 @@
+import t from 'tap';
+
+import contentFunction from '#content-function';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateTrackAdditionalNamesBox (snapshot)', async (t, evaluate) => {
+  await evaluate.load({
+    mock: {
+      generateAdditionalNamesBox:
+        evaluate.stubContentFunction('generateAdditionalNamesBox'),
+    },
+  });
+
+  const stubTrack = {
+    additionalNames: [],
+    sharedAdditionalNames: [],
+    inferredAdditionalNames: [],
+  };
+
+  const quickSnapshot = (message, trackProperties) =>
+    evaluate.snapshot(message, {
+      name: 'generateTrackAdditionalNamesBox',
+      args: [{...stubTrack, ...trackProperties}],
+    });
+
+  quickSnapshot(`no additional names`, {});
+
+  quickSnapshot(`own additional names only`, {
+    additionalNames: [
+      {name: `Foo Bar`, annotation: `the Alps`},
+    ],
+  });
+
+  quickSnapshot(`shared additional names only`, {
+    sharedAdditionalNames: [
+      {name: `Bar Foo`, annotation: `the Rockies`},
+    ],
+  });
+
+  quickSnapshot(`inferred additional names only`, {
+    inferredAdditionalNames: [
+      {name: `Baz Baz`, from: [{directory: `the-pyrenees`}]},
+    ],
+  });
+
+  quickSnapshot(`multiple own`, {
+    additionalNames: [
+      {name: `Apple Time!`},
+      {name: `Pterodactyl Time!`},
+      {name: `Banana Time!`},
+    ],
+  });
+
+  quickSnapshot(`own and shared, some overlap`, {
+    additionalNames: [
+      {name: `weed dreams..`, annotation: `own annotation`},
+      {name: `夜間のMOON汗`, annotation: `own annotation`},
+    ],
+    sharedAdditionalNames: [
+      {name: `weed dreams..`, annotation: `shared annotation`},
+      {name: `GAMINGブラザー96`, annotation: `shared annotation`},
+    ],
+  });
+
+  quickSnapshot(`shared and inferred, some overlap`, {
+    sharedAdditionalNames: [
+      {name: `Coruscate`, annotation: `shared annotation`},
+      {name: `Arbroath`, annotation: `shared annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Arbroath`, from: [{directory: `inferred-from`}]},
+      {name: `Prana Ferox`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+
+  quickSnapshot(`own and inferred, some overlap`, {
+    additionalNames: [
+      {name: `Ke$halo Strike Back`, annotation: `own annotation`},
+      {name: `Ironic Mania`, annotation: `own annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Ironic Mania`, from: [{directory: `inferred-from`}]},
+      {name: `ANARCHY::MEGASTRIFE`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+
+  quickSnapshot(`own and shared and inferred, various overlap`, {
+    additionalNames: [
+      {name: `Own!`, annotation: `own annotation`},
+      {name: `Own! Shared!`, annotation: `own annotation`},
+      {name: `Own! Inferred!`, annotation: `own annotation`},
+      {name: `Own! Shared! Inferred!`, annotation: `own annotation`},
+    ],
+    sharedAdditionalNames: [
+      {name: `Shared!`, annotation: `shared annotation`},
+      {name: `Own! Shared!`, annotation: `shared annotation`},
+      {name: `Shared! Inferred!`, annotation: `shared annotation`},
+      {name: `Own! Shared! Inferred!`, annotation: `shared annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Own! Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Shared! Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Own! Shared! Inferred!`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+});
diff --git a/test/snapshot/generateTrackCoverArtwork.js b/test/snapshot/generateTrackCoverArtwork.js
index 03a181e7..1e651eb1 100644
--- a/test/snapshot/generateTrackCoverArtwork.js
+++ b/test/snapshot/generateTrackCoverArtwork.js
@@ -23,6 +23,7 @@ testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evalua
     directory: 'beesmp3',
     hasUniqueCoverArt: true,
     coverArtFileExtension: 'jpg',
+    color: '#f28514',
     artTags: [{name: 'Bees', directory: 'bees', isContentWarning: false}],
     album,
   };
@@ -30,6 +31,7 @@ testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evalua
   const track2 = {
     directory: 'fake-bonus-track',
     hasUniqueCoverArt: false,
+    color: '#abcdef',
     album,
   };
 
diff --git a/test/snapshot/linkContribution.js b/test/snapshot/linkContribution.js
index ad5fb416..ebd3be58 100644
--- a/test/snapshot/linkContribution.js
+++ b/test/snapshot/linkContribution.js
@@ -33,22 +33,36 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
       slots,
     });
 
-  quickSnapshot('showContribution & showIcons', {
+  quickSnapshot('showContribution & showIcons (inline)', {
     showContribution: true,
     showIcons: true,
+    iconMode: 'inline',
+  });
+
+  quickSnapshot('showContribution & showIcons (tooltip)', {
+    showContribution: true,
+    showIcons: true,
+    iconMode: 'tooltip',
   });
 
   quickSnapshot('only showContribution', {
     showContribution: true,
   });
 
-  quickSnapshot('only showIcons', {
+  quickSnapshot('only showIcons (inline)', {
+    showIcons: true,
+    iconMode: 'inline',
+  });
+
+  quickSnapshot('only showIcons (tooltip)', {
+    showContribution: true,
     showIcons: true,
+    iconMode: 'tooltip',
   });
 
   quickSnapshot('no accents', {});
 
-  evaluate.snapshot('loads of links', {
+  evaluate.snapshot('loads of links (inline)', {
     name: 'linkContribution',
     args: [
       {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
@@ -65,6 +79,23 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
     slots: {showIcons: true},
   });
 
+  evaluate.snapshot('loads of links (tooltip)', {
+    name: 'linkContribution',
+    args: [
+      {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
+        'https://loremipsum.io',
+        'https://loremipsum.io/generator/',
+        'https://loremipsum.io/#meaning',
+        'https://loremipsum.io/#usage-and-examples',
+        'https://loremipsum.io/#controversy',
+        'https://loremipsum.io/#when-to-use-lorem-ipsum',
+        'https://loremipsum.io/#lorem-ipsum-all-the-things',
+        'https://loremipsum.io/#original-source',
+      ]}, what: null},
+    ],
+    slots: {showIcons: true, iconMode: 'tooltip'},
+  });
+
   quickSnapshot('no preventWrapping', {
     showContribution: true,
     showIcons: true,
diff --git a/test/snapshot/linkExternal.js b/test/snapshot/linkExternal.js
index 3e8aee0d..434372a9 100644
--- a/test/snapshot/linkExternal.js
+++ b/test/snapshot/linkExternal.js
@@ -4,51 +4,58 @@ import {testContentFunctions} from '#test-lib';
 testContentFunctions(t, 'linkExternal (snapshot)', async (t, evaluate) => {
   await evaluate.load();
 
-  evaluate.snapshot('missing domain (arbitrary local path)', {
-    name: 'linkExternal',
-    args: ['/foo/bar/baz.mp3']
-  });
-
   evaluate.snapshot('unknown domain (arbitrary world wide web path)', {
     name: 'linkExternal',
     args: ['https://snoo.ping.as/usual/i/see/'],
   });
 
-  evaluate.snapshot('basic domain matches', {
-    name: 'linkExternal',
-    multiple: [
-      {args: ['https://homestuck.bandcamp.com/']},
-      {args: ['https://soundcloud.com/plazmataz']},
-      {args: ['https://aeritus.tumblr.com/']},
-      {args: ['https://twitter.com/awkwarddoesart']},
-      {args: ['https://www.deviantart.com/chesswanderlust-sama']},
-      {args: ['https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)']},
-      {args: ['https://www.poetryfoundation.org/poets/christina-rossetti']},
-      {args: ['https://www.instagram.com/levc_egm/']},
-      {args: ['https://www.patreon.com/CecilyRenns']},
-      {args: ['https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3']},
-      {args: ['https://buzinkai.newgrounds.com/']},
-    ],
-  });
+  const urlsToArgs = urls =>
+    urls.map(url => ({args: [url]}));
 
-  evaluate.snapshot('custom matches - album', {
-    name: 'linkExternal',
-    multiple: [
-      {args: ['https://youtu.be/abc']},
-      {args: ['https://youtube.com/watch?v=abc']},
-      {args: ['https://youtube.com/Playlist?list=kweh']},
-    ],
-    slots: {
-      mode: 'album',
-    },
-  });
+  const quickSnapshot = (message, urls, slots) =>
+    evaluate.snapshot(message, {
+      name: 'linkExternal',
+      slots,
+      multiple: urlsToArgs(urls),
+    });
 
-  evaluate.snapshot('custom domains for common platforms', {
-    name: 'linkExternal',
-    multiple: [
-      // Just one domain of each platform is OK here
-      {args: ['https://music.solatrus.com/']},
-      {args: ['https://types.pl/']},
-    ],
-  });
+  const quickSnapshotAllStyles = (context, urls) => {
+    for (const style of ['platform', 'normal', 'compact']) {
+      const message = `context: ${context}, style: ${style}`;
+      quickSnapshot(message, urls, {context, style});
+    }
+  };
+
+  quickSnapshotAllStyles('generic', [
+    'https://homestuck.bandcamp.com/',
+    'https://soundcloud.com/plazmataz',
+    'https://aeritus.tumblr.com/',
+    'https://twitter.com/awkwarddoesart',
+    'https://www.deviantart.com/chesswanderlust-sama',
+    'https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)',
+    'https://www.poetryfoundation.org/poets/christina-rossetti',
+    'https://www.instagram.com/levc_egm/',
+    'https://www.patreon.com/CecilyRenns',
+    'https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3',
+    'https://buzinkai.newgrounds.com/',
+
+    // Just one custom domain of each platform is OK here
+    'https://music.solatrus.com/',
+    'https://types.pl/',
+  ]);
+
+  quickSnapshotAllStyles('album', [
+    'https://youtu.be/abc',
+    'https://youtube.com/watch?v=abc',
+    'https://youtube.com/Playlist?list=kweh',
+  ]);
+
+  quickSnapshotAllStyles('flash', [
+    'https://www.bgreco.net/hsflash/002238.html',
+    'https://homestuck.com/story/1234',
+    'https://homestuck.com/story/pony',
+    'https://www.youtube.com/watch?v=wKgOp3Kg2wI',
+    'https://youtu.be/IOcvkkklWmY',
+    'https://some.external.site/foo/bar/',
+  ]);
 });
diff --git a/test/snapshot/linkExternalFlash.js b/test/snapshot/linkExternalFlash.js
deleted file mode 100644
index a4d44aff..00000000
--- a/test/snapshot/linkExternalFlash.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import t from 'tap';
-import {testContentFunctions} from '#test-lib';
-
-testContentFunctions(t, 'linkExternalFlash (snapshot)', async (t, evaluate) => {
-  await evaluate.load();
-
-  evaluate.snapshot('basic behavior', {
-    name: 'linkExternalFlash',
-    multiple: [
-      {args: ['https://homestuck.com/story/4109/', {page: '4109'}]},
-      {args: ['https://youtu.be/FDt-SLyEcjI', {page: '4109'}]},
-      {args: ['https://www.bgreco.net/hsflash/006009.html', {page: '4109'}]},
-      {args: ['https://www.newgrounds.com/portal/view/582345', {page: '4109'}]},
-    ],
-  });
-
-  evaluate.snapshot('secret page', {
-    name: 'linkExternalFlash',
-    multiple: [
-      {args: ['https://homestuck.com/story/pony/', {page: 'pony'}]},
-      {args: ['https://youtu.be/USB1pj6hAjU', {page: 'pony'}]},
-    ],
-  });
-});
diff --git a/test/snapshot/transformContent.js b/test/snapshot/transformContent.js
index b05beac1..740d94df 100644
--- a/test/snapshot/transformContent.js
+++ b/test/snapshot/transformContent.js
@@ -37,6 +37,45 @@ testContentFunctions(t, 'transformContent (snapshot)', async (t, evaluate) => {
       `That's right, [[album:cool-album]]!`);
 
   quickSnapshot(
+    'indent on a directly following line',
+      `<div>\n` +
+      `    <span>Wow!</span>\n` +
+      `</div>`);
+
+  quickSnapshot(
+    'indent on an indierctly following line',
+      `Some text.\n` +
+      `Yes, some more text.\n` +
+      `\n` +
+      `    I am hax0rz!!\n` +
+      `    All yor base r blong 2 us.\n` +
+      `\n` +
+      `Aye.\n` +
+      `Aye aye aye.`);
+
+  quickSnapshot(
+    'hanging indent list',
+      `Hello!\n` +
+      `\n` +
+      `* I am a list item and I\n` +
+      `  go on and on and on\n` +
+      `  and on and on and on.\n` +
+      `\n` +
+      `* I am another list item.\n` +
+      `  Yeah.\n` +
+      `\n` +
+      `In-between!\n` +
+      `\n` +
+      `* Spooky,\n` +
+      `  spooky, I say!\n` +
+      `* Following list item.\n` +
+      `  No empty line around me.\n` +
+      `* Very cool.\n` +
+      `  So, so cool.\n` +
+      `\n` +
+      `Goodbye!`);
+
+  quickSnapshot(
     'inline images',
       `<img src="snooping.png"> as USUAL...\n` +
       `What do you know? <img src="cowabunga.png" width="24" height="32">\n` +
diff --git a/test/unit/data/cacheable-object.js b/test/unit/data/cacheable-object.js
index 57e562d8..5ed9a4a9 100644
--- a/test/unit/data/cacheable-object.js
+++ b/test/unit/data/cacheable-object.js
@@ -1,6 +1,6 @@
 import t from 'tap';
 
-import {CacheableObject} from '#things';
+import CacheableObject from '#cacheable-object';
 
 function newCacheableObject(PD) {
   return new (class extends CacheableObject {
diff --git a/test/unit/data/composite/data/withUniqueItemsOnly.js b/test/unit/data/composite/data/withUniqueItemsOnly.js
new file mode 100644
index 00000000..965b14b5
--- /dev/null
+++ b/test/unit/data/composite/data/withUniqueItemsOnly.js
@@ -0,0 +1,84 @@
+import t from 'tap';
+
+import {compositeFrom, input} from '#composite';
+import {exposeDependency} from '#composite/control-flow';
+import {withUniqueItemsOnly} from '#composite/data';
+
+t.test(`withUniqueItemsOnly: basic behavior`, t => {
+  t.plan(3);
+
+  const composite = compositeFrom({
+    compose: false,
+
+    steps: [
+      withUniqueItemsOnly({
+        list: 'list',
+      }),
+
+      exposeDependency({dependency: '#list'}),
+    ],
+  });
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['list'],
+    },
+  });
+
+  t.same(composite.expose.compute({
+    list: ['apple', 'banana', 'banana', 'banana', 'apple', 'watermelon'],
+  }), ['apple', 'banana', 'watermelon']);
+
+  t.same(composite.expose.compute({
+    list: [],
+  }), []);
+});
+
+t.test(`withUniqueItemsOnly: output shapes & values`, t => {
+  t.plan(2 * 3 ** 1);
+
+  const dependencies = {
+    ['list_dependency']:
+      [1, 1, 2, 3, 3, 4, 'foo', false, false, 4],
+    [input('list_neither')]:
+      [8, 8, 7, 6, 6, 5, 'bar', true, true, 5],
+  };
+
+  const mapLevel1 = [
+    ['list_dependency', {
+      '#list_dependency': [1, 2, 3, 4, 'foo', false],
+    }],
+    [input.value([-1, -1, 'interesting', 'very', 'interesting']), {
+      '#uniqueItems': [-1, 'interesting', 'very'],
+    }],
+    [input('list_neither'), {
+      '#uniqueItems': [8, 7, 6, 5, 'bar', true],
+    }],
+  ];
+
+  for (const [listInput, outputDict] of mapLevel1) {
+    const step = withUniqueItemsOnly({
+      list: listInput,
+    });
+
+    quickCheckOutputs(step, outputDict);
+  }
+
+  function quickCheckOutputs(step, outputDict) {
+    t.same(
+      Object.keys(step.toDescription().outputs),
+      Object.keys(outputDict));
+
+    const composite = compositeFrom({
+      compose: false,
+      steps: [step, {
+        dependencies: Object.keys(outputDict),
+        compute: dependencies => dependencies,
+      }],
+    });
+
+    t.same(
+      composite.expose.compute(dependencies),
+      outputDict);
+  }
+});
diff --git a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
new file mode 100644
index 00000000..babe4fae
--- /dev/null
+++ b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -0,0 +1,102 @@
+import t from 'tap';
+
+import {compositeFrom, input} from '#composite';
+import thingConstructors from '#things';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withParsedCommentaryEntries} from '#composite/wiki-data';
+
+const {Artist} = thingConstructors;
+
+const composite = compositeFrom({
+  compose: false,
+
+  steps: [
+    withParsedCommentaryEntries({
+      from: 'from',
+    }),
+
+    exposeDependency({dependency: '#parsedCommentaryEntries'}),
+  ],
+});
+
+function stubArtist(artistName = `Test Artist`) {
+  const artist = new Artist();
+  artist.name = artistName;
+
+  return artist;
+}
+
+t.test(`withParsedCommentaryEntries: basic behavior`, t => {
+  t.plan(3);
+
+  const artist1 = stubArtist(`Mobius Trip`);
+  const artist2 = stubArtist(`Hadron Kaleido`);
+
+  const artistData = [artist1, artist2];
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['from', 'artistData'],
+    },
+  });
+
+  t.same(composite.expose.compute({
+    artistData,
+    from:
+      `<i>Mobius Trip:</i>\n` +
+      `Some commentary.\n` +
+      `Very cool.\n`,
+  }), [
+    {
+      artists: [artist1],
+      artistDisplayText: null,
+      annotation: null,
+      date: null,
+      body: `Some commentary.\nVery cool.`,
+    },
+  ]);
+
+  t.same(composite.expose.compute({
+    artistData,
+    from:
+      `<i>Mobius Trip|Moo-bius Trip:</i> (music, art, 12 January 2015)\n` +
+      `First commentary entry.\n` +
+      `Very cool.\n` +
+      `<i>Hadron Kaleido|<b>[[artist:hadron-kaleido|The Ol' Hadron]]</b>:</i> (moral support, 4/4/2022)\n` +
+      `Second commentary entry. Yes. So cool.\n` +
+      `<i>Mystery Artist:</i> (pingas, August 25, 2023)\n` +
+      `Oh no.. Oh dear...\n` +
+      `<i>Mobius Trip, Hadron Kaleido:</i>\n` +
+      `And back around we go.`,
+  }), [
+    {
+      artists: [artist1],
+      artistDisplayText: `Moo-bius Trip`,
+      annotation: `music, art`,
+      date: new Date('12 January 2015'),
+      body: `First commentary entry.\nVery cool.`,
+    },
+    {
+      artists: [artist2],
+      artistDisplayText: `<b>[[artist:hadron-kaleido|The Ol' Hadron]]</b>`,
+      annotation: `moral support`,
+      date: new Date('4 April 2022'),
+      body: `Second commentary entry. Yes. So cool.`,
+    },
+    {
+      artists: [],
+      artistDisplayText: null,
+      annotation: `pingas`,
+      date: new Date('25 August 2023'),
+      body: `Oh no.. Oh dear...`,
+    },
+    {
+      artists: [artist1, artist2],
+      artistDisplayText: null,
+      annotation: null,
+      date: null,
+      body: `And back around we go.`,
+    },
+  ]);
+});
diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js
index 76a2b90f..5a1261a4 100644
--- a/test/unit/data/things/album.js
+++ b/test/unit/data/things/album.js
@@ -204,11 +204,13 @@ t.test(`Album.tracks`, t => {
   const track1 = stubTrack('track1');
   const track2 = stubTrack('track2');
   const track3 = stubTrack('track3');
+  const tracks = [track1, track2, track3];
 
-  linkAndBindWikiData({
-    albumData: [album],
-    trackData: [track1, track2, track3],
-  });
+  album.ownTrackData = tracks;
+
+  for (const track of tracks) {
+    track.albumData = [album];
+  }
 
   t.same(album.tracks, [],
     `Album.tracks #1: defaults to empty array`);
@@ -259,11 +261,13 @@ t.test(`Album.trackSections`, t => {
   const track2 = stubTrack('track2');
   const track3 = stubTrack('track3');
   const track4 = stubTrack('track4');
+  const tracks = [track1, track2, track3, track4];
 
-  linkAndBindWikiData({
-    albumData: [album],
-    trackData: [track1, track2, track3, track4],
-  });
+  album.ownTrackData = tracks;
+
+  for (const track of tracks) {
+    track.albumData = [album];
+  }
 
   album.trackSections = [
     {tracks: ['track:track1', 'track:track2']},
diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js
index 806efbf1..57bd4253 100644
--- a/test/unit/data/things/track.js
+++ b/test/unit/data/things/track.js
@@ -80,8 +80,8 @@ t.test(`Track.album`, t => {
 
   track1.albumData = [album1, album2];
   track2.albumData = [album1, album2];
-  album1.trackData = [track1, track2];
-  album2.trackData = [track1, track2];
+  album1.ownTrackData = [track1, track2];
+  album2.ownTrackData = [track1, track2];
   album1.trackSections = [{tracks: ['track:track1']}];
   album2.trackSections = [{tracks: ['track:track2']}];
 
@@ -98,13 +98,13 @@ t.test(`Track.album`, t => {
   t.equal(track1.album, null,
     `album #4: is null when track missing albumData`);
 
-  album1.trackData = [];
+  album1.ownTrackData = [];
   track1.albumData = [album1, album2];
 
   t.equal(track1.album, null,
-    `album #5: is null when album missing trackData`);
+    `album #5: is null when album missing ownTrackData`);
 
-  album1.trackData = [track1, track2];
+  album1.ownTrackData = [track1, track2];
   album1.trackSections = [{tracks: ['track:track2']}];
 
   // XXX_decacheWikiData
@@ -165,7 +165,7 @@ t.test(`Track.color`, t => {
 
   const {track, album} = stubTrackAndAlbum();
 
-  const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
     albumData: [album],
     trackData: [track],
   });
@@ -188,18 +188,17 @@ t.test(`Track.color`, t => {
   // track's album will always have a corresponding track section. But if that
   // connection breaks for some future reason (with the album still present),
   // Track.color should still inherit directly from the album.
-  wikiData.albumData = [
-    new Proxy({
+  track.albumData = [
+    {
+      constructor: {[Thing.referenceType]: 'album'},
       color: '#abcdef',
       tracks: [track],
       trackSections: [
         {color: '#baaaad', tracks: []},
       ],
-    }, {getPrototypeOf: () => Album.prototype}),
+    },
   ];
 
-  linkWikiDataArrays();
-
   t.equal(track.color, '#abcdef',
     `color #3: inherits from album without matching track section`);
 
@@ -214,7 +213,7 @@ t.test(`Track.color`, t => {
 });
 
 t.test(`Track.commentatorArtists`, t => {
-  t.plan(6);
+  t.plan(8);
 
   const track = new Track();
   const artist1 = stubArtist(`SnooPING`);
@@ -226,47 +225,67 @@ t.test(`Track.commentatorArtists`, t => {
     artistData: [artist1, artist2, artist3],
   });
 
-  track.commentary =
+  // Keep track of the last commentary string in a separate value, since
+  // the track.commentary property exposes as a completely different format
+  // (i.e. an array of objects, one for each entry), and so isn't compatible
+  // with the += operator on its own.
+  let commentary;
+
+  track.commentary = commentary =
     `<i>SnooPING:</i>\n` +
     `Wow.\n`;
 
   t.same(track.commentatorArtists, [artist1],
     `Track.commentatorArtists #1: works with one commentator`);
 
-  track.commentary +=
+  track.commentary = commentary +=
     `<i>ASUsual:</i>\n` +
     `Yes!\n`;
 
   t.same(track.commentatorArtists, [artist1, artist2],
     `Track.commentatorArtists #2: works with two commentators`);
 
-  track.commentary +=
-    `<i><b>Icy:</b></i>\n` +
+  track.commentary = commentary +=
+    `<i>Icy|<b>Icy What You Did There</b>:</i>\n` +
     `Incredible.\n`;
 
   t.same(track.commentatorArtists, [artist1, artist2, artist3],
-    `Track.commentatorArtists #3: works with boldface name`);
+    `Track.commentatorArtists #3: works with custom artist text`);
 
-  track.commentary =
+  track.commentary = commentary =
     `<i>Icy:</i> (project manager)\n` +
     `Very good track.\n`;
 
   t.same(track.commentatorArtists, [artist3],
-    `Track.commentatorArtists #4: works with parenthical accent`);
+    `Track.commentatorArtists #4: works with annotation`);
 
-  track.commentary +=
-    `<i>SNooPING ASUsual Icy:</i>\n` +
-    `WITH ALL THREE POWERS COMBINED...`;
+  track.commentary = commentary =
+    `<i>Icy:</i> (project manager, 08/15/2023)\n` +
+    `Very very good track.\n`;
+
+  t.same(track.commentatorArtists, [artist3],
+    `Track.commentatorArtists #5: works with date`);
+
+  track.commentary = commentary +=
+    `<i>Ohohohoho:</i>\n` +
+    `OHOHOHOHOHOHO...\n`;
 
   t.same(track.commentatorArtists, [artist3],
-    `Track.commentatorArtists #5: ignores artist names not found`);
+    `Track.commentatorArtists #6: ignores artist names not found`);
 
-  track.commentary +=
+  track.commentary = commentary +=
     `<i>Icy:</i>\n` +
     `I'm back!\n`;
 
   t.same(track.commentatorArtists, [artist3],
-    `Track.commentatorArtists #6: ignores duplicate artist`);
+    `Track.commentatorArtists #7: ignores duplicate artist`);
+
+  track.commentary = commentary +=
+    `<i>SNooPING, ASUsual, Icy:</i>\n` +
+    `WITH ALL THREE POWERS COMBINED...`;
+
+  t.same(track.commentatorArtists, [artist3, artist1, artist2],
+    `Track.commentatorArtists #8: works with more than one artist in one entry`);
 });
 
 t.test(`Track.coverArtistContribs`, t => {
diff --git a/test/unit/data/things/validators.js b/test/unit/data/things/validators.js
index bb33bf86..aa56a10e 100644
--- a/test/unit/data/things/validators.js
+++ b/test/unit/data/things/validators.js
@@ -149,13 +149,18 @@ t.test('isColor', t => {
 });
 
 t.test('isCommentary', t => {
-  t.plan(6);
+  t.plan(9);
+
+  // TODO: Test specific error messages.
   t.ok(isCommentary(`<i>Toby Fox:</i>\ndogsong.mp3`));
-  t.ok(isCommentary(`Technically, this works:</i>`));
-  t.ok(isCommentary(`<i><b>Whodunnit:</b></i>`));
-  t.throws(() => isCommentary(123), TypeError);
-  t.throws(() => isCommentary(``), TypeError);
-  t.throws(() => isCommentary(`<i><u>Toby Fox:</u></i>`));
+  t.ok(isCommentary(`<i>Toby Fox:</i> (music)\ndogsong.mp3`));
+  t.throws(() => isCommentary(`dogsong.mp3\n<i>Toby Fox:</i>\ndogsong.mp3`));
+  t.throws(() => isCommentary(`<i>Toby Fox:</i> dogsong.mp3`));
+  t.throws(() => isCommentary(`<i>Toby Fox:</i> (music) dogsong.mp3`));
+  t.throws(() => isCommentary(`<i>I Have Nothing To Say:</i>`));
+  t.throws(() => isCommentary(123));
+  t.throws(() => isCommentary(``));
+  t.throws(() => isCommentary(`Technically, ah, er:</i>\nCorrect`));
 });
 
 t.test('isContribution', t => {