« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/content-function.js22
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js101
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js53
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunkItem.js30
-rw-r--r--src/content/dependencies/generateAdditionalFilesShortcut.js27
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js127
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js24
-rw-r--r--src/content/dependencies/generateAlbumCommentarySidebar.js47
-rw-r--r--src/content/dependencies/generateAlbumCoverArtwork.js4
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js22
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js15
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js19
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNav.js11
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js104
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js72
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackListBox.js31
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js12
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js13
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js15
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js45
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js7
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js271
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js5
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js6
-rw-r--r--src/content/dependencies/generateCoverArtwork.js20
-rw-r--r--src/content/dependencies/generateFlashActGalleryPage.js2
-rw-r--r--src/content/dependencies/generateFlashActSidebar.js224
-rw-r--r--src/content/dependencies/generateFlashActSidebarCurrentActBox.js63
-rw-r--r--src/content/dependencies/generateFlashActSidebarSideMapBox.js85
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js6
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js25
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js10
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js6
-rw-r--r--src/content/dependencies/generateGroupSecondaryNav.js18
-rw-r--r--src/content/dependencies/generateGroupSidebar.js54
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesChunk.js6
-rw-r--r--src/content/dependencies/generateListingPage.js2
-rw-r--r--src/content/dependencies/generateListingSidebar.js46
-rw-r--r--src/content/dependencies/generateListingsIndexPage.js2
-rw-r--r--src/content/dependencies/generatePageLayout.js172
-rw-r--r--src/content/dependencies/generatePageSidebar.js77
-rw-r--r--src/content/dependencies/generatePageSidebarBox.js28
-rw-r--r--src/content/dependencies/generatePageSidebarConjoinedBox.js42
-rw-r--r--src/content/dependencies/generateTrackCoverArtwork.js6
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js26
-rw-r--r--src/content/dependencies/generateTrackList.js19
-rw-r--r--src/content/dependencies/generateWikiHomeNewsBox.js67
-rw-r--r--src/content/dependencies/generateWikiHomePage.js37
-rw-r--r--src/content/dependencies/image.js39
-rw-r--r--src/content/dependencies/linkContribution.js47
-rw-r--r--src/content/dependencies/linkExternal.js118
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js10
-rw-r--r--src/content/dependencies/listAllAdditionalFilesTemplate.js4
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js3
-rw-r--r--src/content/dependencies/transformContent.js168
-rw-r--r--src/content/util/getChronologyRelations.js12
-rw-r--r--src/data/cacheable-object.js46
-rw-r--r--src/data/checks.js70
-rw-r--r--src/data/composite.js47
-rw-r--r--src/data/composite/control-flow/exposeConstant.js2
-rw-r--r--src/data/composite/things/album/index.js2
-rw-r--r--src/data/composite/things/album/withTrackSections.js116
-rw-r--r--src/data/composite/things/album/withTracks.js46
-rw-r--r--src/data/composite/things/flash-act/index.js1
-rw-r--r--src/data/composite/things/flash-act/withFlashSide.js22
-rw-r--r--src/data/composite/things/flash/withFlashAct.js100
-rw-r--r--src/data/composite/things/track-section/index.js1
-rw-r--r--src/data/composite/things/track-section/withAlbum.js22
-rw-r--r--src/data/composite/things/track/index.js1
-rw-r--r--src/data/composite/things/track/inheritFromOriginalRelease.js51
-rw-r--r--src/data/composite/things/track/withAlbum.js100
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js31
-rw-r--r--src/data/composite/things/track/withContainingTrackSection.js47
-rw-r--r--src/data/composite/things/track/withPropertyFromAlbum.js13
-rw-r--r--src/data/composite/things/track/withPropertyFromOriginalRelease.js86
-rw-r--r--src/data/composite/wiki-data/index.js3
-rw-r--r--src/data/composite/wiki-data/withDirectory.js55
-rw-r--r--src/data/composite/wiki-data/withDirectoryFromName.js42
-rw-r--r--src/data/composite/wiki-data/withResolvedContribs.js22
-rw-r--r--src/data/composite/wiki-data/withReverseContributionList.js14
-rw-r--r--src/data/composite/wiki-data/withReverseReferenceList.js10
-rw-r--r--src/data/composite/wiki-data/withUniqueReferencingThing.js51
-rw-r--r--src/data/composite/wiki-properties/contributionList.js10
-rw-r--r--src/data/composite/wiki-properties/directory.js48
-rw-r--r--src/data/composite/wiki-properties/referenceList.js20
-rw-r--r--src/data/serialize.js5
-rw-r--r--src/data/thing.js43
-rw-r--r--src/data/things/album.js300
-rw-r--r--src/data/things/artist.js5
-rw-r--r--src/data/things/flash.js183
-rw-r--r--src/data/things/index.js7
-rw-r--r--src/data/things/language.js66
-rw-r--r--src/data/things/track.js21
-rw-r--r--src/data/validators.js126
-rw-r--r--src/data/yaml.js823
-rw-r--r--src/gen-thumbs.js139
-rw-r--r--src/import-heck.js9
-rw-r--r--src/static/client4.js (renamed from src/static/client3.js)454
-rw-r--r--src/static/icons.svg52
-rw-r--r--src/static/site7.css (renamed from src/static/site6.css)136
-rw-r--r--src/static/xhr-util.js64
-rw-r--r--src/strings-default.yaml52
-rwxr-xr-xsrc/upd8.js418
-rw-r--r--src/url-spec.js31
-rw-r--r--src/util/aggregate.js108
-rw-r--r--src/util/cli.js49
-rw-r--r--src/util/external-links.js681
-rw-r--r--src/util/html.js100
-rw-r--r--src/util/replacer.js87
-rw-r--r--src/util/serialize.js6
-rw-r--r--src/web-routes.js62
-rw-r--r--src/write/build-modes/live-dev-server.js107
-rw-r--r--src/write/build-modes/repl.js2
-rw-r--r--src/write/build-modes/static-build.js81
116 files changed, 5251 insertions, 2580 deletions
diff --git a/src/content-function.js b/src/content-function.js
index 16b1864..44f8b84 100644
--- a/src/content-function.js
+++ b/src/content-function.js
@@ -1,7 +1,7 @@
 import {inspect as nodeInspect} from 'node:util';
 
 import {decorateError} from '#aggregate';
-import {colors, ENABLE_COLOR} from '#cli';
+import {colors, decorateTime, ENABLE_COLOR} from '#cli';
 import {Template} from '#html';
 import {annotateFunction, empty, setIntersection} from '#sugar';
 
@@ -9,6 +9,8 @@ function inspect(value, opts = {}) {
   return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
 
+const DECORATE_TIME = process.env.HSMUSIC_DEBUG_CONTENT_PERF === '1';
+
 export class ContentFunctionSpecError extends Error {}
 
 export default function contentFunction({
@@ -104,6 +106,11 @@ export function expectDependencies({
 
   let wrappedGenerate;
 
+  const optionalDecorateTime = (prefix, fn) =>
+    (DECORATE_TIME
+      ? decorateTime(`${prefix}/${generate.name}`, fn)
+      : fn);
+
   if (isInvalidated) {
     wrappedGenerate = function() {
       throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${[...invalidatingDependencyKeys].join(', ')}`);
@@ -119,7 +126,7 @@ export function expectDependencies({
     annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'});
     wrappedGenerate.fulfilled = false;
   } else {
-    const callUnderlyingGenerate = ([arg1, arg2], ...extraArgs) => {
+    let callUnderlyingGenerate = ([arg1, arg2], ...extraArgs) => {
       if (hasDataFunction && !arg1) {
         throw new Error(`Expected data`);
       }
@@ -161,6 +168,9 @@ export function expectDependencies({
       }
     };
 
+    callUnderlyingGenerate =
+      optionalDecorateTime(`generate`, callUnderlyingGenerate);
+
     if (hasSlotsDescription) {
       const stationery = fulfilledDependencies.html.stationery({
         annotation: generate.name,
@@ -204,19 +214,19 @@ export function expectDependencies({
   wrappedGenerate[contentFunction.identifyingSymbol] = true;
 
   if (hasSprawlFunction) {
-    wrappedGenerate.sprawl = sprawl;
+    wrappedGenerate.sprawl = optionalDecorateTime(`sprawl`, sprawl);
   }
 
   if (hasQueryFunction) {
-    wrappedGenerate.query = query;
+    wrappedGenerate.query = optionalDecorateTime(`query`, query);
   }
 
   if (hasRelationsFunction) {
-    wrappedGenerate.relations = relations;
+    wrappedGenerate.relations = optionalDecorateTime(`relations`, relations);
   }
 
   if (hasDataFunction) {
-    wrappedGenerate.data = data;
+    wrappedGenerate.data = optionalDecorateTime(`data`, data);
   }
 
   wrappedGenerate.fulfill ??= function fulfill(dependencies) {
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
index 92948c7..f504cf8 100644
--- a/src/content/dependencies/generateAdditionalFilesList.js
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -1,97 +1,24 @@
-import {empty} from '#sugar';
-
-function validateFileMapping(v, validateValue) {
-  return value => {
-    v.isObject(value);
-
-    const valueErrors = [];
-    for (const [fileKey, fileValue] of Object.entries(value)) {
-      if (fileValue === null) {
-        continue;
-      }
-
-      try {
-        validateValue(fileValue);
-      } catch (error) {
-        error.message = `(${fileKey}) ` + error.message;
-        valueErrors.push(error);
-      }
-    }
-
-    if (!empty(valueErrors)) {
-      throw new AggregateError(valueErrors, `Errors validating values`);
-    }
-  };
-}
+import {stitchArrays} from '#sugar';
 
 export default {
-  extraDependencies: ['html', 'language'],
-
-  data(additionalFiles) {
-    return {
-      // Additional files are already a serializable format.
-      additionalFiles,
-    };
-  },
+  extraDependencies: ['html'],
 
   slots: {
-    fileLinks: {
-      validate: v => validateFileMapping(v, v.isHTML),
+    chunks: {
+      validate: v => v.strictArrayOf(v.isHTML),
     },
 
-    fileSizes: {
-      validate: v => validateFileMapping(v, v.isWholeNumber),
+    chunkItems: {
+      validate: v => v.strictArrayOf(v.isHTML),
     },
   },
 
-  generate(data, slots, {html, language}) {
-    if (!slots.fileLinks) {
-      return html.blank();
-    }
-
-    const filesWithLinks = new Set(
-      Object.entries(slots.fileLinks)
-        .filter(([key, value]) => value)
-        .map(([key]) => key));
-
-    if (empty(filesWithLinks)) {
-      return html.blank();
-    }
-
-    const filteredFileGroups = data.additionalFiles
-      .map(({title, description, files}) => ({
-        title,
-        description,
-        files: files.filter(f => filesWithLinks.has(f)),
-      }))
-      .filter(({files}) => !empty(files));
-
-    if (empty(filteredFileGroups)) {
-      return html.blank();
-    }
-
-    return html.tag('dl',
-      filteredFileGroups.flatMap(({title, description, files}) => [
-        html.tag('dt',
-          (description
-            ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
-                title,
-                description,
-              })
-            : language.$('releaseInfo.additionalFiles.entry', {title}))),
-
-        html.tag('dd',
-          html.tag('ul',
-            files.map(file =>
-              html.tag('li',
-                (slots.fileSizes?.[file]
-                  ? language.$('releaseInfo.additionalFiles.file.withSize', {
-                      file: slots.fileLinks[file],
-                      size: language.formatFileSize(slots.fileSizes[file]),
-                    })
-                  : language.$('releaseInfo.additionalFiles.file', {
-                      file: slots.fileLinks[file],
-                    })))))),
-      ]));
-  },
+  generate: (slots, {html}) =>
+    html.tag('ul', {class: 'additional-files-list'},
+      stitchArrays({
+        chunk: slots.chunks,
+        items: slots.chunkItems,
+      }).map(({chunk, items}) =>
+          chunk.clone()
+            .slot('items', items))),
 };
diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js
new file mode 100644
index 0000000..5804115
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -0,0 +1,53 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    description: {
+      type: 'html',
+      mutable: false,
+    },
+
+    items: {
+      validate: v => v.looseArrayOf(v.isHTML),
+    },
+  },
+
+  generate(slots, {html, language}) {
+    const summary =
+      html.tag('summary',
+        html.tag('span',
+          language.$('releaseInfo.additionalFiles.entry', {
+            title:
+              html.tag('span', {class: 'group-name'},
+                slots.title),
+          })));
+
+    const description =
+      html.tag('li', {class: 'entry-description'},
+        {[html.onlyIfContent]: true},
+        slots.description);
+
+    const items =
+      (html.isBlank(slots.items)
+        ? html.tag('li',
+            language.$('releaseInfo.additionalFiles.entry.noFilesAvailable'))
+        : slots.items);
+
+    const content =
+      html.tag('ul', [description, items]);
+
+    const details =
+      html.tag('details',
+        html.isBlank(slots.items) &&
+          {open: true},
+
+        [summary, content]);
+
+    return html.tag('li', details);
+  },
+};
diff --git a/src/content/dependencies/generateAdditionalFilesListChunkItem.js b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
new file mode 100644
index 0000000..c37d6bb
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
@@ -0,0 +1,30 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    fileLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    fileSize: {
+      validate: v => v.isWholeNumber,
+    },
+  },
+
+  generate(slots, {html, language}) {
+    const itemParts = ['releaseInfo.additionalFiles.file'];
+    const itemOptions = {file: slots.fileLink};
+
+    if (slots.fileSize) {
+      itemParts.push('withSize');
+      itemOptions.size = language.formatFileSize(slots.fileSize);
+    }
+
+    const li =
+      html.tag('li',
+        language.$(...itemParts, itemOptions));
+
+    return li;
+  },
+};
diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js
deleted file mode 100644
index 9e119bc..0000000
--- a/src/content/dependencies/generateAdditionalFilesShortcut.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import {empty} from '#sugar';
-
-export default {
-  extraDependencies: ['html', 'language'],
-
-  data(additionalFiles) {
-    return {
-      titles: additionalFiles.map(fileGroup => fileGroup.title),
-    };
-  },
-
-  generate(data, {html, language}) {
-    if (empty(data.titles)) {
-      return html.blank();
-    }
-
-    return language.$('releaseInfo.additionalFiles.shortcut', {
-      anchorLink:
-        html.tag('a',
-          {href: '#additional-files'},
-          language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
-
-      titles:
-        language.formatUnitList(data.titles),
-    });
-  },
-}
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
index 23f32bf..9818a43 100644
--- a/src/content/dependencies/generateAlbumAdditionalFilesList.js
+++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js
@@ -1,59 +1,96 @@
+import {stitchArrays} from '#sugar';
+
 export default {
   contentDependencies: [
     'generateAdditionalFilesList',
+    'generateAdditionalFilesListChunk',
+    'generateAdditionalFilesListChunkItem',
     'linkAlbumAdditionalFile',
+    'transformContent',
   ],
 
-  extraDependencies: [
-    'getSizeOfAdditionalFile',
-    'html',
-    'urls',
-  ],
+  extraDependencies: ['getSizeOfAdditionalFile', 'html', 'urls'],
 
-  data(album, additionalFiles) {
-    return {
-      albumDirectory: album.directory,
-      fileLocations: additionalFiles.flatMap(({files}) => files),
-    };
-  },
+  relations: (relation, album, additionalFiles) => ({
+    list:
+      relation('generateAdditionalFilesList', additionalFiles),
 
-  relations(relation, album, additionalFiles) {
-    return {
-      additionalFilesList:
-        relation('generateAdditionalFilesList', additionalFiles),
-
-      additionalFileLinks:
-        Object.fromEntries(
-          additionalFiles
-            .flatMap(({files}) => files)
-            .map(file => [
-              file,
-              relation('linkAlbumAdditionalFile', album, file),
-            ])),
-    };
-  },
+    chunks:
+      additionalFiles
+        .map(() => relation('generateAdditionalFilesListChunk')),
+
+    chunkDescriptions:
+      additionalFiles
+        .map(({description}) =>
+          (description
+            ? relation('transformContent', description)
+            : null)),
+
+    chunkItems:
+      additionalFiles
+        .map(({files}) =>
+          (files ?? [])
+            .map(() => relation('generateAdditionalFilesListChunkItem'))),
+
+    chunkItemFileLinks:
+      additionalFiles
+        .map(({files}) =>
+          (files ?? [])
+            .map(file => relation('linkAlbumAdditionalFile', album, file))),
+  }),
+
+  data: (album, additionalFiles) => ({
+    albumDirectory: album.directory,
+
+    chunkTitles:
+      additionalFiles
+        .map(({title}) => title),
+
+    chunkItemLocations:
+      additionalFiles
+        .map(({files}) => files ?? []),
+  }),
 
   slots: {
     showFileSizes: {type: 'boolean', default: true},
   },
 
-  generate(data, relations, slots, {
-    getSizeOfAdditionalFile,
-    urls,
-  }) {
-    return relations.additionalFilesList
-      .slots({
-        fileLinks: relations.additionalFileLinks,
-        fileSizes:
-          Object.fromEntries(data.fileLocations.map(file => [
-            file,
-            (slots.showFileSizes
-              ? getSizeOfAdditionalFile(
-                  urls
-                    .from('media.root')
-                    .to('media.albumAdditionalFile', data.albumDirectory, file))
-              : 0),
-          ])),
-      });
-  },
+  generate: (data, relations, slots, {getSizeOfAdditionalFile, urls}) =>
+    relations.list.slots({
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          description: relations.chunkDescriptions,
+          title: data.chunkTitles,
+        }).map(({chunk, title, description}) =>
+            chunk.slots({
+              title,
+              description:
+                (description
+                  ? description.slot('mode', 'inline')
+                  : null),
+            })),
+
+      chunkItems:
+        stitchArrays({
+          items: relations.chunkItems,
+          fileLinks: relations.chunkItemFileLinks,
+          locations: data.chunkItemLocations,
+        }).map(({items, fileLinks, locations}) =>
+            stitchArrays({
+              item: items,
+              fileLink: fileLinks,
+              location: locations,
+            }).map(({item, fileLink, location}) =>
+                item.slots({
+                  fileLink: fileLink,
+                  fileSize:
+                    (slots.showFileSizes
+                      ? getSizeOfAdditionalFile(
+                          urls
+                            .from('media.root')
+                            .to('media.albumAdditionalFile', data.albumDirectory, location))
+                      : 0),
+                }))),
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 5a7142e..7879269 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -2,9 +2,9 @@ import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAlbumCommentarySidebar',
     'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
-    'generateAlbumSidebarTrackSection',
     'generateAlbumStyleRules',
     'generateCommentaryEntry',
     'generateContentHeading',
@@ -23,6 +23,9 @@ export default {
     relations.layout =
       relation('generatePageLayout');
 
+    relations.sidebar =
+      relation('generateAlbumCommentarySidebar', album);
+
     relations.albumStyleRules =
       relation('generateAlbumStyleRules', album, null);
 
@@ -82,13 +85,6 @@ export default {
           track.commentary
             .map(entry => relation('generateCommentaryEntry', entry)));
 
-    relations.sidebarAlbumLink =
-      relation('linkAlbum', album);
-
-    relations.sidebarTrackSections =
-      album.trackSections.map(trackSection =>
-        relation('generateAlbumSidebarTrackSection', album, null, trackSection));
-
     return relations;
   },
 
@@ -249,17 +245,7 @@ export default {
           },
         ],
 
-        leftSidebarStickyMode: 'column',
-        leftSidebarClass: 'commentary-track-list-sidebar-box',
-        leftSidebarContent: [
-          html.tag('h1', relations.sidebarAlbumLink),
-          relations.sidebarTrackSections.map(section =>
-            section.slots({
-              anchor: true,
-              open: true,
-              mode: 'commentary',
-            })),
-        ],
+        leftSidebar: relations.sidebar,
       });
   },
 };
diff --git a/src/content/dependencies/generateAlbumCommentarySidebar.js b/src/content/dependencies/generateAlbumCommentarySidebar.js
new file mode 100644
index 0000000..435860c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCommentarySidebar.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarTrackSection',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackSections:
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection',
+          album,
+          null,
+          trackSection)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.sidebar.slots({
+      stickyMode: 'column',
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'commentary-track-list-sidebar-box'},
+          content: [
+            html.tag('h1', relations.albumLink),
+            relations.trackSections.map(section =>
+              section.slots({
+                anchor: true,
+                open: true,
+                mode: 'commentary',
+              })),
+          ],
+        }),
+      ]
+    }),
+}
diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js
index ce8cde2..dbb22fe 100644
--- a/src/content/dependencies/generateAlbumCoverArtwork.js
+++ b/src/content/dependencies/generateAlbumCoverArtwork.js
@@ -12,11 +12,15 @@ export default {
 
     color:
       album.color,
+
+    dimensions:
+      album.coverArtDimensions,
   }),
 
   generate: (data, relations) =>
     relations.coverArtwork.slots({
       path: data.path,
       color: data.color,
+      dimensions: data.dimensions,
     }),
 };
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index f61b198..aa02568 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -30,7 +30,7 @@ export default {
       const allCoverArtistArrays =
         tracksWithUniqueCoverArt
           .map(track => track.coverArtistContribs)
-          .map(contribs => contribs.map(contrib => contrib.who));
+          .map(contribs => contribs.map(contrib => contrib.artist));
 
       const allSameCoverArtists =
         allCoverArtistArrays
@@ -116,7 +116,7 @@ export default {
 
     data.coverArtists = [
       (album.hasCoverArt
-        ? album.coverArtistContribs.map(({who: artist}) => artist.name)
+        ? album.coverArtistContribs.map(({artist}) => artist.name)
         : null),
 
       ...
@@ -126,7 +126,7 @@ export default {
           }
 
           if (track.hasUniqueCoverArt) {
-            return track.coverArtistContribs.map(({who: artist}) => artist.name);
+            return track.coverArtistContribs.map(({artist}) => artist.name);
           }
 
           return null;
@@ -145,6 +145,18 @@ export default {
             : null)),
     ];
 
+    data.dimensions = [
+      (album.hasCoverArt
+        ? album.coverArtDimensions
+        : null),
+
+      ...
+        album.tracks.map(track =>
+          (track.hasUniqueCoverArt
+            ? track.coverArtDimensions
+            : null)),
+    ];
+
     return data;
   },
 
@@ -175,10 +187,12 @@ export default {
                 stitchArrays({
                   image: relations.images,
                   path: data.paths,
+                  dimensions: data.dimensions,
                   name: data.names,
-                }).map(({image, path, name}) =>
+                }).map(({image, path, dimensions, name}) =>
                     image.slots({
                       path,
+                      dimensions,
                       missingSourceContent:
                         language.$('misc.albumGalleryGrid.noCoverArt', {name}),
                     })),
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 5853f11..739a666 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -5,7 +5,6 @@ import getChronologyRelations from '../util/getChronologyRelations.js';
 
 export default {
   contentDependencies: [
-    'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumBanner',
     'generateAlbumCoverArtwork',
@@ -107,11 +106,6 @@ export default {
         relation('linkAlbumCommentary', album);
     }
 
-    if (!empty(album.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', album.additionalFiles);
-    }
-
     // Section: Track list
 
     relations.trackList =
@@ -180,7 +174,12 @@ export default {
             {[html.joinChildren]: html.tag('br')},
 
             [
-              sec.extra.additionalFilesShortcut,
+              sec.additionalFiles &&
+                language.$('releaseInfo.additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#additional-files'},
+                    language.$('releaseInfo.additionalFiles.shortcut.link')),
+                }),
 
               sec.extra.galleryLink && sec.extra.commentaryLink &&
                 language.$('releaseInfo.viewGalleryOrCommentary', {
@@ -265,7 +264,7 @@ export default {
 
         secondaryNav: relations.secondaryNav,
 
-        ...relations.sidebar,
+        leftSidebar: relations.sidebar,
 
         socialEmbed: relations.socialEmbed,
       });
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 5128fba..6fc1375 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -96,17 +96,14 @@ export default {
               language.formatDisjunctionList(
                 relations.externalLinks
                   .map(link =>
-                    link.slots({
-                      context: [
-                        'album',
-                        (data.numTracks === 0
-                          ? 'albumNoTracks'
-                       : data.numTracks === 1
-                          ? 'albumOneTrack'
-                          : 'albumMultipleTracks'),
-                      ],
-                      style: 'normal',
-                    }))),
+                    link.slot('context', [
+                      'album',
+                      (data.numTracks === 0
+                        ? 'albumNoTracks'
+                     : data.numTracks === 1
+                        ? 'albumOneTrack'
+                        : 'albumMultipleTracks'),
+                    ]))),
           })),
     ]);
   },
diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js
index 400420b..d6ff8a0 100644
--- a/src/content/dependencies/generateAlbumSecondaryNav.js
+++ b/src/content/dependencies/generateAlbumSecondaryNav.js
@@ -59,11 +59,11 @@ export default {
       relation('generateSecondaryNav');
 
     relations.groupLinks =
-      album.groups
+      query.groups
         .map(group => relation('linkGroup', group));
 
     relations.colorStyles =
-      album.groups
+      query.groups
         .map(group => relation('generateColorStyleAttribute', group.color));
 
     if (album.date) {
@@ -102,7 +102,7 @@ export default {
   generate(relations, slots, {html, language}) {
     const navLinksShouldShowPreviousNext =
       (slots.mode === 'track'
-        ? Array.from(relations.previousNextLinks, () => false)
+        ? Array.from(relations.previousNextLinks ?? [], () => false)
         : stitchArrays({
             previousAlbumLink: relations.previousAlbumLinks ?? null,
             nextAlbumLink: relations.nextAlbumLinks ?? null,
@@ -151,11 +151,8 @@ export default {
       stitchArrays({
         content: navLinkContents,
         colorStyle: relations.colorStyles,
-      }).map(({content, colorStyle}, index) =>
+      }).map(({content, colorStyle}) =>
           html.tag('span', {class: 'nav-link'},
-            index > 0 &&
-              {class: 'has-divider'},
-
             colorStyle.slot('context', 'primary-only'),
 
             content));
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
index 5ef4501..355a9a9 100644
--- a/src/content/dependencies/generateAlbumSidebar.js
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -1,79 +1,47 @@
 export default {
   contentDependencies: [
     'generateAlbumSidebarGroupBox',
-    'generateAlbumSidebarTrackSection',
-    'linkAlbum',
+    'generateAlbumSidebarTrackListBox',
+    'generatePageSidebar',
+    'generatePageSidebarConjoinedBox',
   ],
 
-  extraDependencies: ['html'],
+  relations: (relation, album, track) => ({
+    sidebar:
+      relation('generatePageSidebar'),
 
-  relations(relation, album, track) {
-    const relations = {};
+    conjoinedBox:
+      relation('generatePageSidebarConjoinedBox'),
 
-    relations.albumLink =
-      relation('linkAlbum', album);
+    trackListBox:
+      relation('generateAlbumSidebarTrackListBox', album, track),
 
-    relations.groupBoxes =
+    groupBoxes:
       album.groups.map(group =>
-        relation('generateAlbumSidebarGroupBox', album, group));
-
-    relations.trackSections =
-      album.trackSections.map(trackSection =>
-        relation('generateAlbumSidebarTrackSection', album, track, trackSection));
-
-    return relations;
-  },
-
-  data(album, track) {
-    return {isAlbumPage: !track};
-  },
-
-  generate(data, relations, {html}) {
-    const trackListBox = {
-      class: 'track-list-sidebar-box',
-      content:
-        html.tags([
-          html.tag('h1', relations.albumLink),
-          relations.trackSections,
-        ]),
-    };
-
-    if (data.isAlbumPage) {
-      const groupBoxes =
-        relations.groupBoxes
-          .map(content => ({
-            class: 'individual-group-sidebar-box',
-            content: content.slot('mode', 'album'),
-          }));
-
-      return {
-        leftSidebarMultiple: [
-          ...groupBoxes,
-          trackListBox,
-        ],
-      };
-    }
-
-    const conjoinedGroupBox = {
-      class: 'conjoined-group-sidebar-box',
-      content:
-        relations.groupBoxes
-          .flatMap((content, i, {length}) => [
-            content.slot('mode', 'track'),
-            i < length - 1 &&
-              html.tag('hr', {
-                style: `border-color: var(--primary-color); border-style: none none dotted none`
-              }),
-          ])
-          .filter(Boolean),
-    };
-
-    return {
-      // leftSidebarStickyMode: 'column',
-      leftSidebarMultiple: [
-        trackListBox,
-        conjoinedGroupBox,
+        relation('generateAlbumSidebarGroupBox', album, group)),
+  }),
+
+  data: (album, track) => ({
+    isAlbumPage: !track,
+  }),
+
+  generate: (data, relations) =>
+    relations.sidebar.slots({
+      boxes: [
+        data.isAlbumPage &&
+          relations.groupBoxes
+            .map(box => box.slot('mode', 'album')),
+
+        relations.trackListBox,
+
+        !data.isAlbumPage &&
+          relations.conjoinedBox.slots({
+            attributes: {class: 'conjoined-group-sidebar-box'},
+            boxes:
+              relations.groupBoxes
+                .map(box => box.slot('mode', 'track'))
+                .map(box => box.content), /* TODO: Kludge. */
+          }),
       ],
-    };
-  },
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index 93ebf5d..00a96c3 100644
--- a/src/content/dependencies/generateAlbumSidebarGroupBox.js
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -3,6 +3,7 @@ import {atOffset, empty} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generatePageSidebarBox',
     'linkAlbum',
     'linkExternal',
     'linkGroup',
@@ -40,6 +41,9 @@ export default {
   relations(relation, query, album, group) {
     const relations = {};
 
+    relations.box =
+      relation('generatePageSidebarBox');
+
     relations.groupLink =
       relation('linkGroup', group);
 
@@ -72,39 +76,41 @@ export default {
     },
   },
 
-  generate(relations, slots, {html, language}) {
-    return html.tags([
-      html.tag('h1',
-        language.$('albumSidebar.groupBox.title', {
-          group: relations.groupLink,
-        })),
-
-      slots.mode === 'album' &&
-        relations.description
-          ?.slot('mode', 'multiline'),
-
-      !empty(relations.externalLinks) &&
-        html.tag('p',
-          language.$('releaseInfo.visitOn', {
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link => link.slot('context', 'group'))),
-          })),
-
-      slots.mode === 'album' &&
-      relations.nextAlbumLink &&
-        html.tag('p', {class: 'group-chronology-link'},
-          language.$('albumSidebar.groupBox.next', {
-            album: relations.nextAlbumLink,
+  generate: (relations, slots, {html, language}) =>
+    relations.box.slots({
+      attributes: {class: 'individual-group-sidebar-box'},
+      content: [
+        html.tag('h1',
+          language.$('albumSidebar.groupBox.title', {
+            group: relations.groupLink,
           })),
 
-      slots.mode === 'album' &&
-      relations.previousAlbumLink &&
-        html.tag('p', {class: 'group-chronology-link'},
-          language.$('albumSidebar.groupBox.previous', {
-            album: relations.previousAlbumLink,
-          })),
-    ]);
-  },
+        slots.mode === 'album' &&
+          relations.description
+            ?.slot('mode', 'multiline'),
+
+        !empty(relations.externalLinks) &&
+          html.tag('p',
+            language.$('releaseInfo.visitOn', {
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
+
+        slots.mode === 'album' &&
+        relations.nextAlbumLink &&
+          html.tag('p', {class: 'group-chronology-link'},
+            language.$('albumSidebar.groupBox.next', {
+              album: relations.nextAlbumLink,
+            })),
+
+        slots.mode === 'album' &&
+        relations.previousAlbumLink &&
+          html.tag('p', {class: 'group-chronology-link'},
+            language.$('albumSidebar.groupBox.previous', {
+              album: relations.previousAlbumLink,
+            })),
+      ],
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
new file mode 100644
index 0000000..3a244e3
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
@@ -0,0 +1,31 @@
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarTrackSection',
+    'generatePageSidebarBox',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album, track) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackSections:
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection', album, track, trackSection)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.box.slots({
+      attributes: {class: 'track-list-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.albumLink),
+        relations.trackSections,
+      ],
+    })
+};
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index 11b6a1b..7190fb4 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -64,8 +64,8 @@ export default {
       !empty(track.artistContribs) &&
        (empty(album.artistContribs) ||
         !compareArrays(
-          track.artistContribs.map(c => c.who),
-          album.artistContribs.map(c => c.who),
+          track.artistContribs.map(contrib => contrib.artist),
+          album.artistContribs.map(contrib => contrib.artist),
           {checkOrder: false}));
 
     return data;
@@ -119,9 +119,11 @@ export default {
       parts.push('withArtists');
       options.by =
         html.tag('span', {class: 'by'},
-          language.$('trackList.item.withArtists.by', {
-            artists: language.formatConjunctionList(relations.contributionLinks),
-          }));
+          html.metatag('chunkwrap', {split: ','},
+            html.resolve(
+              language.$('trackList.item.withArtists.by', {
+                artists: language.formatConjunctionList(relations.contributionLinks),
+              }))));
     }
 
     return html.tag('li',
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index 962f1b7..eae48f0 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -74,10 +74,13 @@ export default {
           ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
           : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
 
+    data.dimensions =
+      query.things.map(thing => thing.coverArtDimensions);
+
     data.coverArtists =
       query.things.map(thing =>
         thing.coverArtistContribs
-          .map(({who: artist}) => artist.name));
+          .map(({artist}) => artist.name));
 
     return data;
   },
@@ -111,8 +114,12 @@ export default {
                 stitchArrays({
                   image: relations.images,
                   path: data.paths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
+                  dimensions: data.dimensions,
+                }).map(({image, path, dimensions}) =>
+                    image.slots({
+                      path,
+                      dimensions,
+                    })),
 
               info:
                 data.coverArtists.map(names =>
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index 1377915..db8f123 100644
--- a/src/content/dependencies/generateArtistGalleryPage.js
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -68,12 +68,15 @@ export default {
           ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
           : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
 
+    data.dimensions =
+      query.things.map(thing => thing.coverArtDimensions);
+
     data.otherCoverArtists =
       query.things.map(thing =>
         (thing.coverArtistContribs.length > 1
           ? thing.coverArtistContribs
-              .filter(({who}) => who !== artist)
-              .map(({who}) => who.name)
+              .filter(({artist: otherArtist}) => otherArtist !== artist)
+              .map(({artist: otherArtist}) => otherArtist.name)
           : null));
 
     return data;
@@ -107,8 +110,12 @@ export default {
                 stitchArrays({
                   image: relations.images,
                   path: data.paths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
+                  dimensions: data.dimensions,
+                }).map(({image, path, dimensions}) =>
+                    image.slots({
+                      path,
+                      dimensions,
+                    })),
 
               info:
                 data.otherCoverArtists.map(names =>
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index a51f516..1725d4b 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -45,6 +45,7 @@ export default {
 
     const groupsSortedByCount =
       allGroupsOrdered
+        .slice()
         .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a));
 
     // The filter here ensures all displayed groups have at least some duration
@@ -188,16 +189,16 @@ export default {
                     data.groupDurationsSortedByCount,
                     data.groupDurationsApproximateSortedByCount),
               }).map(({group, count, duration}) =>
-                html.tag('li',
-                  html.tag('div', {class: 'group-contributions-row'}, [
-                    group,
-                    html.tag('span', {class: 'group-contributions-metrics'},
-                      // When sorting by count, duration details aren't necessarily
-                      // available for all items.
-                      (slots.showBothColumns && duration
-                        ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration})
-                        : language.$('artistPage.groupContributions.item.countAccent', {count}))),
-                  ])))
+                  html.tag('li',
+                    html.tag('div', {class: 'group-contributions-row'}, [
+                      group,
+                      html.tag('span', {class: 'group-contributions-metrics'},
+                        // When sorting by count, duration details aren't necessarily
+                        // available for all items.
+                        (slots.showBothColumns && duration
+                          ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration})
+                          : language.$('artistPage.groupContributions.item.countAccent', {count}))),
+                    ])))
 
             : stitchArrays({
                 group: relations.groupLinksSortedByDuration,
@@ -205,19 +206,19 @@ export default {
                 duration:
                   getDurations(
                     data.groupDurationsSortedByDuration,
-                    data.groupDurationsApproximateSortedByCount),
+                    data.groupDurationsApproximateSortedByDuration),
               }).map(({group, count, duration}) =>
-                html.tag('li',
-                  html.tag('div', {class: 'group-contributions-row'}, [
-                    group,
-                    html.tag('span', {class: 'group-contributions-metrics'},
-                      // Count details are always available, since they're just the
-                      // number of contributions directly. And duration details are
-                      // guaranteed for every item when sorting by duration.
-                      (slots.showBothColumns
-                        ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count})
-                        : language.$('artistPage.groupContributions.item.durationAccent', {duration}))),
-                  ])))))),
+                  html.tag('li',
+                    html.tag('div', {class: 'group-contributions-row'}, [
+                      group,
+                      html.tag('span', {class: 'group-contributions-metrics'},
+                        // Count details are always available, since they're just the
+                        // number of contributions directly. And duration details are
+                        // guaranteed for every item when sorting by duration.
+                        (slots.showBothColumns
+                          ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count})
+                          : language.$('artistPage.groupContributions.item.durationAccent', {duration}))),
+                    ])))))),
     ]);
   },
 };
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 1b85680..ac9209a 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -163,11 +163,8 @@ export default {
               language.$('releaseInfo.visitOn', {
                 links:
                   language.formatDisjunctionList(
-                    sec.visit.externalLinks.map(link =>
-                      link.slots({
-                        context: 'artist',
-                        style: 'platform',
-                      }))),
+                    sec.visit.externalLinks
+                      .map(link => link.slot('context', 'artist'))),
               })),
 
           sec.artworks?.artistGalleryLink &&
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
index 0beeb27..44fb42f 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -171,8 +171,8 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk.map(({contribs}) =>
             contribs
-              .find(({who}) => who === artist)
-              .what)),
+              .find(contrib => contrib.artist === artist)
+              .annotation)),
     };
   },
 
diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
index 0bcadc7..133095e 100644
--- a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -1,12 +1,19 @@
-import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort';
 import {chunkByProperties, stitchArrays} from '#sugar';
 
+import {
+  sortAlbumsTracksChronologically,
+  sortByDate,
+  sortEntryThingPairs,
+} from '#sort';
+
 export default {
   contentDependencies: [
     'generateArtistInfoPageChunk',
     'generateArtistInfoPageChunkItem',
     'generateArtistInfoPageOtherArtistLinks',
     'linkAlbum',
+    'linkFlash',
+    'linkFlashAct',
     'linkTrack',
     'transformContent',
   ],
@@ -14,34 +21,68 @@ export default {
   extraDependencies: ['html', 'language'],
 
   query(artist) {
-    const processEntry = ({thing, entry, type, track, album}) => ({
+    const processEntry = ({
+      thing,
+      entry,
+
+      chunkType,
+      itemType,
+
+      album = null,
+      track = null,
+      flashAct = null,
+      flash = null,
+    }) => ({
       thing: thing,
       entry: {
-        type: type,
-        track: track,
-        album: album,
+        chunkType,
+        itemType,
+
+        album,
+        track,
+        flashAct,
+        flash,
+
         annotation: entry.annotation,
       },
     });
 
-    const processAlbumEntry = ({type, album, entry}) =>
+    const processAlbumEntry = ({thing: album, entry}) =>
       processEntry({
         thing: album,
         entry: entry,
-        type: type,
+
+        chunkType: 'album',
+        itemType: 'album',
+
         album: album,
         track: null,
       });
 
-    const processTrackEntry = ({type, track, entry}) =>
+    const processTrackEntry = ({thing: track, entry}) =>
       processEntry({
         thing: track,
         entry: entry,
-        type: type,
+
+        chunkType: 'album',
+        itemType: 'track',
+
         album: track.album,
         track: track,
       });
 
+    const processFlashEntry = ({thing: flash, entry}) =>
+      processEntry({
+        thing: flash,
+        entry: entry,
+
+        chunkType: 'flash-act',
+        itemType: 'flash',
+
+        flashAct: flash.act,
+        flash: flash,
+      });
+
     const processEntries = ({things, processEntry}) =>
       things
         .flatMap(thing =>
@@ -49,136 +90,180 @@ export default {
             .filter(entry => entry.artists.includes(artist))
             .map(entry => processEntry({thing, entry})));
 
-    const processAlbumEntries = ({type, albums}) =>
+    const processAlbumEntries = ({albums}) =>
       processEntries({
         things: albums,
-        processEntry: ({thing, entry}) =>
-          processAlbumEntry({
-            type: type,
-            album: thing,
-            entry: entry,
-          }),
+        processEntry: processAlbumEntry,
       });
 
-    const processTrackEntries = ({type, tracks}) =>
+    const processTrackEntries = ({tracks}) =>
       processEntries({
         things: tracks,
-        processEntry: ({thing, entry}) =>
-          processTrackEntry({
-            type: type,
-            track: thing,
-            entry: entry,
-          }),
+        processEntry: processTrackEntry,
       });
 
-    const {albumsAsCommentator, tracksAsCommentator} = artist;
-
-    const trackEntries =
-      processTrackEntries({
-        type: 'track',
-        tracks: tracksAsCommentator,
+    const processFlashEntries = ({flashes}) =>
+      processEntries({
+        things: flashes,
+        processEntry: processFlashEntry,
       });
 
+    const {
+      albumsAsCommentator,
+      tracksAsCommentator,
+      flashesAsCommentator,
+    } = artist;
+
     const albumEntries =
       processAlbumEntries({
-        type: 'album',
         albums: albumsAsCommentator,
       });
 
-    const entries = [
-      ...albumEntries,
-      ...trackEntries,
-    ];
+    const trackEntries =
+      processTrackEntries({
+        tracks: tracksAsCommentator,
+      });
 
-    sortEntryThingPairs(entries, sortAlbumsTracksChronologically);
+    const flashEntries =
+      processFlashEntries({
+        flashes: flashesAsCommentator,
+      })
+
+    const albumTrackEntries =
+      sortEntryThingPairs(
+        [...albumEntries, ...trackEntries],
+        sortAlbumsTracksChronologically);
+
+    const allEntries =
+      sortEntryThingPairs(
+        [...albumTrackEntries, ...flashEntries],
+        sortByDate);
 
     const chunks =
       chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['album']);
+        allEntries.map(({entry}) => entry),
+        ['chunkType', 'album', 'flashAct']);
 
     return {chunks};
   },
 
-  relations(relation, query) {
-    return {
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+  relations: (relation, query) => ({
+    chunks:
+      query.chunks
+        .map(() => relation('generateArtistInfoPageChunk')),
 
-      albumLinks:
-        query.chunks.map(({album}) => relation('linkAlbum', album)),
+    chunkLinks:
+      query.chunks
+        .map(({chunkType, album, flashAct}) =>
+          (chunkType === 'album'
+            ? relation('linkAlbum', album)
+         : chunkType === 'flash-act'
+            ? relation('linkFlashAct', flashAct)
+            : null)),
 
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+    items:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(() => relation('generateArtistInfoPageChunkItem'))),
 
-      itemTrackLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) =>
+    itemLinks:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({track, flash}) =>
             (track
               ? relation('linkTrack', track)
+           : flash
+              ? relation('linkFlash', flash)
               : null))),
 
-      itemAnnotations:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({annotation}) =>
+    itemAnnotations:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({annotation}) =>
             (annotation
               ? relation('transformContent', annotation)
               : null))),
-    };
-  },
+  }),
 
-  data(query) {
-    return {
-      itemTypes:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({type}) => type)),
-    };
-  },
+  data: (query) => ({
+    chunkTypes:
+      query.chunks
+        .map(({chunkType}) => chunkType),
 
-  generate(data, relations, {html, language}) {
-    return html.tag('dl',
+    itemTypes:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({itemType}) => itemType)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('dl',
       stitchArrays({
         chunk: relations.chunks,
-        albumLink: relations.albumLinks,
+        chunkLink: relations.chunkLinks,
+        chunkType: data.chunkTypes,
 
         items: relations.items,
-        itemTrackLinks: relations.itemTrackLinks,
+        itemLinks: relations.itemLinks,
         itemAnnotations: relations.itemAnnotations,
         itemTypes: data.itemTypes,
       }).map(({
           chunk,
-          albumLink,
+          chunkLink,
+          chunkType,
 
           items,
-          itemTrackLinks,
+          itemLinks,
           itemAnnotations,
           itemTypes,
         }) =>
-          chunk.slots({
-            mode: 'album',
-            albumLink,
-            items:
-              stitchArrays({
-                item: items,
-                trackLink: itemTrackLinks,
-                annotation: itemAnnotations,
-                type: itemTypes,
-              }).map(({item, trackLink, annotation, type}) =>
-                item.slots({
-                  annotation:
-                    (annotation
-                      ? annotation.slot('mode', 'inline')
-                      : null),
-
-                  content:
-                    (type === 'album'
-                      ? html.tag('i',
-                          language.$('artistPage.creditList.entry.album.commentary'))
-                      : language.$('artistPage.creditList.entry.track', {
-                          track: trackLink,
-                        })),
-                })),
-          })));
-  },
+          (chunkType === 'album'
+            ? chunk.slots({
+                mode: 'album',
+                albumLink: chunkLink,
+                items:
+                  stitchArrays({
+                    item: items,
+                    link: itemLinks,
+                    annotation: itemAnnotations,
+                    type: itemTypes,
+                  }).map(({item, link, annotation, type}) =>
+                    item.slots({
+                      annotation:
+                        (annotation
+                          ? annotation.slot('mode', 'inline')
+                          : null),
+
+                      content:
+                        (type === 'album'
+                          ? html.tag('i',
+                              language.$('artistPage.creditList.entry.album.commentary'))
+                          : language.$('artistPage.creditList.entry.track', {
+                              track: link,
+                            })),
+                    })),
+              })
+         : chunkType === 'flash-act'
+            ? chunk.slots({
+                mode: 'flash',
+                flashActLink: chunkLink,
+                items:
+                  stitchArrays({
+                    item: items,
+                    link: itemLinks,
+                    annotation: itemAnnotations,
+                  }).map(({item, link, annotation}) =>
+                    item.slots({
+                      annotation:
+                        (annotation
+                          ? annotation.slot('mode', 'inline')
+                          : null),
+
+                      content:
+                        language.$('artistPage.creditList.entry.flash', {
+                          flash: link,
+                        }),
+                    })),
+              })
+            : null))),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
index 88a97af..447e697 100644
--- a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
@@ -92,8 +92,8 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk.map(({contribs}) =>
             contribs
-              .find(({who}) => who === artist)
-              .what)),
+              .find(contrib => contrib.artist === artist)
+              .annotation)),
     };
   },
 
diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
index dea7742..471ee26 100644
--- a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
+++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
@@ -4,7 +4,8 @@ export default {
   contentDependencies: ['linkArtist'],
 
   relations(relation, contribs, artist) {
-    const otherArtistContribs = contribs.filter(({who}) => who !== artist);
+    const otherArtistContribs =
+      contribs.filter(contrib => contrib.artist !== artist);
 
     if (empty(otherArtistContribs)) {
       return {};
@@ -12,7 +13,7 @@ export default {
 
     const otherArtistLinks =
       otherArtistContribs
-        .map(({who}) => relation('linkArtist', who));
+        .map(contrib => relation('linkArtist', contrib.artist));
 
     return {otherArtistLinks};
   },
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
index f003779..bce6ced 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -150,7 +150,7 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk
             .map(({contribs}) =>
-              contribs.filter(({who}) => who === artist))
+              contribs.filter(contrib => contrib.artist === artist))
             .map(ownContribs => ({
               creditedAsArtist:
                 ownContribs
@@ -162,7 +162,7 @@ export default {
 
               annotatedContribs:
                 ownContribs
-                  .filter(({what}) => what),
+                  .filter(({annotation}) => annotation),
             }))
             .map(({annotatedContribs, ...rest}) => ({
               ...rest,
@@ -203,7 +203,7 @@ export default {
               ];
             })
             .map(contribs =>
-              contribs.map(({what}) => what))
+              contribs.map(({annotation}) => annotation))
             .map(contributions =>
               (empty(contributions)
                 ? null
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index d0941d2..90c9db9 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -59,9 +59,23 @@ export default {
       validate: v => v.is('primary', 'thumbnail', 'commentary'),
       default: 'primary',
     },
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
   },
 
   generate(data, relations, slots, {html}) {
+    const square =
+      (slots.dimensions
+        ? slots.dimensions[0] === slots.dimensions[1]
+        : true);
+
+    const sizeSlots =
+      (square
+        ? {square: true}
+        : {dimensions: slots.dimensions});
+
     switch (slots.mode) {
       case 'primary':
         return html.tags([
@@ -72,7 +86,7 @@ export default {
             thumb: 'medium',
             reveal: true,
             link: true,
-            square: true,
+            ...sizeSlots,
           }),
 
           !empty(relations.tagLinks) &&
@@ -93,7 +107,7 @@ export default {
           thumb: 'small',
           reveal: false,
           link: false,
-          square: true,
+          ...sizeSlots,
         });
 
       case 'commentary':
@@ -104,8 +118,8 @@ export default {
           thumb: 'medium',
           reveal: true,
           link: true,
-          square: true,
           lazy: true,
+          ...sizeSlots,
 
           attributes:
             {class: 'commentary-art'},
diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js
index 8eea58b..1707812 100644
--- a/src/content/dependencies/generateFlashActGalleryPage.js
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -85,7 +85,7 @@ export default {
 
       navBottomRowContent: relations.flashActNavAccent,
 
-      ...relations.sidebar,
+      leftSidebar: relations.sidebar,
     });
   },
 };
diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js
index 0bbfa1f..1421dde 100644
--- a/src/content/dependencies/generateFlashActSidebar.js
+++ b/src/content/dependencies/generateFlashActSidebar.js
@@ -1,216 +1,30 @@
-import find from '#find';
-import {filterMultipleArrays, stitchArrays} from '#sugar';
-
 export default {
-  contentDependencies: ['linkFlash', 'linkFlashAct', 'linkFlashIndex'],
-  extraDependencies: ['getColors', 'html', 'language', 'wikiData'],
-
-  // So help me Gog, the flash sidebar is heavily hard-coded.
-
-  sprawl: ({flashActData}) => ({flashActData}),
-
-  query(sprawl, act, flash) {
-    const findFlashAct = directory =>
-      find.flashAct(directory, sprawl.flashActData, {mode: 'quiet'});
-
-    const homestuckSide1 = findFlashAct('flash-act:a1');
-
-    const sideFirstActs = [
-      sprawl.flashActData[0],
-      findFlashAct('flash-act:a6a1'),
-      findFlashAct('flash-act:hiveswap'),
-      findFlashAct('flash-act:cool-and-new-web-comic'),
-      findFlashAct('flash-act:sunday-night-strifin'),
-    ];
-
-    const sideNames = [
-      (homestuckSide1
-        ? `Side 1 (Acts 1-5)`
-        : `All flashes & games`),
-      `Side 2 (Acts 6-7)`,
-      `Additional Canon`,
-      `Fan Adventures`,
-      `Fan Games & More`,
-    ];
-
-    const sideColors = [
-      (homestuckSide1
-        ? '#4ac925'
-        : null),
-      '#3796c6',
-      '#f2a400',
-      '#c466ff',
-      '#32c7fe',
-    ];
-
-    filterMultipleArrays(sideFirstActs, sideNames, sideColors,
-      firstAct => firstAct);
-
-    const sideFirstActIndexes =
-      sideFirstActs
-        .map(act => sprawl.flashActData.indexOf(act));
-
-    const actSideIndexes =
-      sprawl.flashActData
-        .map((act, actIndex) => actIndex)
-        .map(actIndex =>
-          sideFirstActIndexes
-            .findIndex((firstActIndex, i) =>
-              i === sideFirstActs.length - 1 ||
-                firstActIndex <= actIndex &&
-                sideFirstActIndexes[i + 1] > actIndex));
-
-    const sideActs =
-      sideNames
-        .map((name, sideIndex) =>
-          stitchArrays({
-            act: sprawl.flashActData,
-            actSideIndex: actSideIndexes,
-          }).filter(({actSideIndex}) => actSideIndex === sideIndex)
-            .map(({act}) => act));
-
-    const currentActFlashes =
-      act.flashes;
-
-    const currentFlashIndex =
-      currentActFlashes.indexOf(flash);
-
-    const currentSideIndex =
-      actSideIndexes[sprawl.flashActData.indexOf(act)];
-
-    const currentSideActs =
-      sideActs[currentSideIndex];
-
-    const currentActIndex =
-      currentSideActs.indexOf(act);
-
-    const fallbackListTerminology =
-      (currentSideIndex <= 1
-        ? 'flashesInThisAct'
-        : 'entriesInThisSection');
-
-    return {
-      sideNames,
-      sideColors,
-      sideActs,
-
-      currentSideIndex,
-      currentSideActs,
-      currentActIndex,
-      currentActFlashes,
-      currentFlashIndex,
+  contentDependencies: [
+    'generateFlashActSidebarCurrentActBox',
+    'generateFlashActSidebarSideMapBox',
+    'generatePageSidebar',
+  ],
 
-      fallbackListTerminology,
-    };
-  },
+  relations: (relation, act, flash) => ({
+    sidebar:
+      relation('generatePageSidebar'),
 
-  relations: (relation, query, sprawl, act, _flash) => ({
-    currentActLink:
-      relation('linkFlashAct', act),
+    currentActBox:
+      relation('generateFlashActSidebarCurrentActBox', act, flash),
 
-    flashIndexLink:
-      relation('linkFlashIndex'),
-
-    sideActLinks:
-      query.sideActs
-        .map(acts => acts
-          .map(act => relation('linkFlashAct', act))),
-
-    currentActFlashLinks:
-      act.flashes
-        .map(flash => relation('linkFlash', flash)),
+    sideMapBox:
+      relation('generateFlashActSidebarSideMapBox', act, flash),
   }),
 
-  data: (query, sprawl, act, flash) => ({
+  data: (_act, flash) => ({
     isFlashActPage: !flash,
-
-    sideColors: query.sideColors,
-    sideNames: query.sideNames,
-
-    currentSideIndex: query.currentSideIndex,
-    currentActIndex: query.currentActIndex,
-    currentFlashIndex: query.currentFlashIndex,
-
-    customListTerminology: act.listTerminology,
-    fallbackListTerminology: query.fallbackListTerminology,
   }),
 
-  generate(data, relations, {getColors, html, language}) {
-    const currentActBoxContent = html.tags([
-      html.tag('h1', relations.currentActLink),
-
-      html.tag('details',
-        (data.isFlashActPage
-          ? {}
-          : {class: 'current', open: true}),
-        [
-          html.tag('summary',
-            html.tag('span', {class: 'group-name'},
-              (data.customListTerminology
-                ? language.sanitize(data.customListTerminology)
-                : language.$('flashSidebar.flashList', data.fallbackListTerminology)))),
-
-          html.tag('ul',
-            relations.currentActFlashLinks
-              .map((flashLink, index) =>
-                html.tag('li',
-                  index === data.currentFlashIndex &&
-                    {class: 'current'},
-
-                  flashLink))),
-        ]),
-    ]);
-
-    const sideMapBoxContent = html.tags([
-      html.tag('h1', relations.flashIndexLink),
-
-      stitchArrays({
-        sideName: data.sideNames,
-        sideColor: data.sideColors,
-        actLinks: relations.sideActLinks,
-      }).map(({sideName, sideColor, actLinks}, sideIndex) =>
-          html.tag('details',
-            sideIndex === data.currentSideIndex &&
-              {class: 'current'},
-
-            data.isFlashActPage &&
-            sideIndex === data.currentSideIndex &&
-              {open: true},
-
-            sideColor &&
-              {style: `--primary-color: ${getColors(sideColor).primary}`},
-
-            [
-              html.tag('summary',
-                html.tag('span', {class: 'group-name'},
-                  sideName)),
-
-              html.tag('ul',
-                actLinks.map((actLink, actIndex) =>
-                  html.tag('li',
-                    sideIndex === data.currentSideIndex &&
-                    actIndex === data.currentActIndex &&
-                      {class: 'current'},
-
-                    actLink))),
-            ])),
-    ]);
-
-    const sideMapBox = {
-      class: 'flash-act-map-sidebar-box',
-      content: sideMapBoxContent,
-    };
-
-    const currentActBox = {
-      class: 'flash-current-act-sidebar-box',
-      content: currentActBoxContent,
-    };
-
-    return {
-      leftSidebarMultiple:
+  generate: (data, relations) =>
+    relations.sidebar.slots({
+      boxes:
         (data.isFlashActPage
-          ? [sideMapBox, currentActBox]
-          : [currentActBox, sideMapBox]),
-    };
-  },
+          ? [relations.sideMapBox, relations.currentActBox]
+          : [relations.currentActBox, relations.sideMapBox]),
+    }),
 };
diff --git a/src/content/dependencies/generateFlashActSidebarCurrentActBox.js b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
new file mode 100644
index 0000000..c5426a4
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
@@ -0,0 +1,63 @@
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkFlash',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, act, _flash) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    actLink:
+      relation('linkFlashAct', act),
+
+    flashLinks:
+      act.flashes
+        .map(flash => relation('linkFlash', flash)),
+  }),
+
+  data: (act, flash) => ({
+    isFlashActPage:
+      !flash,
+
+    currentFlashIndex:
+      act.flashes.indexOf(flash),
+
+    customListTerminology:
+      act.listTerminology,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.box.slots({
+      attributes: {class: 'flash-act-map-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.actLink),
+
+        html.tag('details',
+          (data.isFlashActPage
+            ? {}
+            : {class: 'current', open: true}),
+
+          [
+            html.tag('summary',
+              html.tag('span', {class: 'group-name'},
+                (data.customListTerminology
+                  ? language.sanitize(data.customListTerminology)
+                  : language.$('flashSidebar.flashList.entriesInThisSection')))),
+
+            html.tag('ul',
+              relations.flashLinks
+                .map((flashLink, index) =>
+                  html.tag('li',
+                    index === data.currentFlashIndex &&
+                      {class: 'current'},
+
+                    flashLink))),
+          ]),
+        ],
+    }),
+};
diff --git a/src/content/dependencies/generateFlashActSidebarSideMapBox.js b/src/content/dependencies/generateFlashActSidebarSideMapBox.js
new file mode 100644
index 0000000..3d261ec
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebarSideMapBox.js
@@ -0,0 +1,85 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generatePageSidebarBox',
+    'linkFlashAct',
+    'linkFlashIndex',
+  ],
+
+  extraDependencies: ['html', 'wikiData'],
+
+  sprawl: ({flashSideData}) => ({flashSideData}),
+
+  relations: (relation, sprawl, _act, _flash) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    flashIndexLink:
+      relation('linkFlashIndex'),
+
+    sideColorStyles:
+      sprawl.flashSideData
+        .map(side => relation('generateColorStyleAttribute', side.color)),
+
+    sideActLinks:
+      sprawl.flashSideData
+        .map(side => side.acts
+          .map(act => relation('linkFlashAct', act))),
+  }),
+
+  data: (sprawl, act, flash) => ({
+    isFlashActPage:
+      !flash,
+
+    sideNames:
+      sprawl.flashSideData
+        .map(side => side.name),
+
+    currentSideIndex:
+      sprawl.flashSideData.indexOf(act.side),
+
+    currentActIndex:
+      act.side.acts.indexOf(act),
+  }),
+
+  generate: (data, relations, {html}) =>
+    relations.box.slots({
+      attributes: {class: 'flash-act-map-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.flashIndexLink),
+
+        stitchArrays({
+          sideName: data.sideNames,
+          sideColorStyle: relations.sideColorStyles,
+          actLinks: relations.sideActLinks,
+        }).map(({sideName, sideColorStyle, actLinks}, sideIndex) =>
+            html.tag('details',
+              sideIndex === data.currentSideIndex &&
+                {class: 'current'},
+
+              data.isFlashActPage &&
+              sideIndex === data.currentSideIndex &&
+                {open: true},
+
+              sideColorStyle.slot('context', 'primary-only'),
+
+              [
+                html.tag('summary',
+                  html.tag('span', {class: 'group-name'},
+                    sideName)),
+
+                html.tag('ul',
+                  actLinks.map((actLink, actIndex) =>
+                    html.tag('li',
+                      sideIndex === data.currentSideIndex &&
+                      actIndex === data.currentActIndex &&
+                        {class: 'current'},
+
+                      actLink))),
+              ])),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index 57072a1..36bfaba 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -20,7 +20,7 @@ export default {
 
     const jumpActs =
       flashActs
-        .filter(act => act.jump);
+        .filter(act => act.side.acts.indexOf(act) === 0);
 
     return {flashActs, jumpActs};
   },
@@ -31,7 +31,7 @@ export default {
 
     jumpLinkColorStyles:
       query.jumpActs
-        .map(act => relation('generateColorStyleAttribute', act.jumpColor)),
+        .map(act => relation('generateColorStyleAttribute', act.side.color)),
 
     actColorStyles:
       query.flashActs
@@ -63,7 +63,7 @@ export default {
 
     jumpLinkLabels:
       query.jumpActs
-        .map(act => act.jump),
+        .map(act => act.side.name),
 
     actAnchors:
       query.flashActs
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index c60f969..0596493 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -2,6 +2,7 @@ import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateCommentarySection',
     'generateContentHeading',
     'generateContributionList',
     'generateFlashActSidebar',
@@ -89,6 +90,13 @@ export default {
         relation('generateContributionList', flash.contributorContribs);
     }
 
+    // Section: Artist commentary
+
+    if (flash.commentary) {
+      sections.artistCommentary =
+        relation('generateCommentarySection', flash.commentary);
+    }
+
     return relations;
   },
 
@@ -136,6 +144,19 @@ export default {
                     .map(link => link.slot('context', 'flash'))),
             })),
 
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            sec.artistCommentary &&
+              language.$('releaseInfo.readCommentary', {
+                link: html.tag('a',
+                  {href: '#artist-commentary'},
+                  language.$('releaseInfo.readCommentary.link')),
+              }),
+          ]),
+
         sec.featuredTracks && [
           sec.featuredTracks.heading
             .slots({
@@ -158,6 +179,8 @@ export default {
 
           sec.contributors.list,
         ],
+
+        sec.artistCommentary,
       ],
 
       navLinkStyle: 'hierarchical',
@@ -169,7 +192,7 @@ export default {
 
       navBottomRowContent: sec.nav.flashNavAccent,
 
-      ...relations.sidebar,
+      leftSidebar: relations.sidebar,
     });
   },
 };
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index b29c586..d07847c 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -178,10 +178,12 @@ export default {
             }),
         ],
 
-        ...
-          relations.sidebar
-            ?.slot('currentExtra', 'gallery')
-            ?.content,
+        leftSidebar:
+          (relations.sidebar
+            ? relations.sidebar
+                .slot('currentExtra', 'gallery')
+                .content /* TODO: Kludge. */
+            : null),
 
         navLinkStyle: 'hierarchical',
         navLinks:
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index 2e1d168..b5b456a 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -207,7 +207,11 @@ export default {
           ],
         ],
 
-        ...relations.sidebar?.content ?? {},
+        leftSidebar:
+          (relations.sidebar
+            ? relations.sidebar
+                .content /* TODO: Kludge. */
+            : null),
 
         navLinkStyle: 'hierarchical',
         navLinks: relations.navLinks.content,
diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js
index 17eb508..a4f8131 100644
--- a/src/content/dependencies/generateGroupSecondaryNav.js
+++ b/src/content/dependencies/generateGroupSecondaryNav.js
@@ -69,12 +69,16 @@ export default {
   }),
 
   generate(data, relations, {html, language}) {
-    const {content: previousNextPart} =
-      relations.previousNextLinks.slots({
-        previousLink: relations.previousGroupLink,
-        nextLink: relations.nextGroupLink,
-        id: true,
-      });
+    const previousNextPart =
+      (relations.previousNextLinks
+        ? relations.previousNextLinks
+            .slots({
+              previousLink: relations.previousGroupLink,
+              nextLink: relations.nextGroupLink,
+              id: true,
+            })
+            .content /* TODO: Kludge. */
+        : null);
 
     const {categoryLink} = relations;
 
@@ -83,7 +87,7 @@ export default {
     return relations.secondaryNav.slots({
       class: 'nav-links-groups',
       content:
-        (relations.previousGroupLink || relations.nextGroupLink
+        (previousNextPart
           ? html.tag('span', {class: 'nav-link'},
               relations.colorStyle.slot('context', 'primary-only'),
 
diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js
index 98b288f..0888cbb 100644
--- a/src/content/dependencies/generateGroupSidebar.js
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -1,18 +1,25 @@
 export default {
-  contentDependencies: ['generateGroupSidebarCategoryDetails'],
+  contentDependencies: [
+    'generateGroupSidebarCategoryDetails',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+  ],
+
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupCategoryData}) {
-    return {groupCategoryData};
-  },
+  sprawl: ({groupCategoryData}) => ({groupCategoryData}),
 
-  relations(relation, sprawl, group) {
-    return {
-      categoryDetails:
-        sprawl.groupCategoryData.map(category =>
-          relation('generateGroupSidebarCategoryDetails', category, group)),
-    };
-  },
+  relations: (relation, sprawl, group) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    categoryDetails:
+      sprawl.groupCategoryData.map(category =>
+        relation('generateGroupSidebarCategoryDetails', category, group)),
+  }),
 
   slots: {
     currentExtra: {
@@ -20,17 +27,20 @@ export default {
     },
   },
 
-  generate(relations, slots, {html, language}) {
-    return {
-      leftSidebarClass: 'category-map-sidebar-box',
-      leftSidebarContent: [
-        html.tag('h1',
-          language.$('groupSidebar.title')),
+  generate: (relations, slots, {html, language}) =>
+    relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'category-map-sidebar-box'},
+          content: [
+            html.tag('h1',
+              language.$('groupSidebar.title')),
 
-        relations.categoryDetails
-          .map(details =>
-            details.slot('currentExtra', slots.currentExtra)),
+            relations.categoryDetails
+              .map(details =>
+                details.slot('currentExtra', slots.currentExtra)),
+          ],
+        }),
       ],
-    };
-  },
+    }),
 };
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
index b046cca..43a78cb 100644
--- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -51,6 +51,12 @@ export default {
                         }),
                     }))
 
+             : additionalFileLinks.length === 0
+                ? html.tag('li',
+                    language.$('listingPage', slots.stringsKey, 'file.withNoFiles', {
+                      title: additionalFileTitle,
+                    }))
+
                 : html.tag('li', {class: 'has-details'},
                     html.tag('details', [
                       html.tag('summary',
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index aa661ab..23377af 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -276,7 +276,7 @@ export default {
         {auto: 'current'},
       ],
 
-      ...relations.sidebar,
+      leftSidebar: relations.sidebar,
     });
   },
 };
diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js
index 1cdd236..aeac05c 100644
--- a/src/content/dependencies/generateListingSidebar.js
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -1,21 +1,37 @@
 export default {
-  contentDependencies: ['generateListingIndexList', 'linkListingIndex'],
+  contentDependencies: [
+    'generateListingIndexList',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkListingIndex',
+  ],
+
   extraDependencies: ['html'],
 
-  relations(relation, currentListing) {
-    return {
-      listingIndexLink: relation('linkListingIndex'),
-      listingIndexList: relation('generateListingIndexList', currentListing),
-    };
-  },
+  relations: (relation, currentListing) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    listingIndexLink:
+      relation('linkListingIndex'),
+
+    listingIndexList:
+      relation('generateListingIndexList', currentListing),
+  }),
 
-  generate(relations, {html}) {
-    return {
-      leftSidebarClass: 'listing-map-sidebar-box',
-      leftSidebarContent: [
-        html.tag('h1', relations.listingIndexLink),
-        relations.listingIndexList.slot('mode', 'sidebar'),
+  generate: (relations, {html}) =>
+    relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'listing-map-sidebar-box'},
+          content: [
+            html.tag('h1', relations.listingIndexLink),
+            relations.listingIndexList.slot('mode', 'sidebar'),
+          ],
+        }),
       ],
-    };
-  },
+    }),
 };
diff --git a/src/content/dependencies/generateListingsIndexPage.js b/src/content/dependencies/generateListingsIndexPage.js
index 1b1c855..b57ebe1 100644
--- a/src/content/dependencies/generateListingsIndexPage.js
+++ b/src/content/dependencies/generateListingsIndexPage.js
@@ -83,7 +83,7 @@ export default {
         {auto: 'current'},
       ],
 
-      ...relations.sidebar,
+      leftSidebar: relations.sidebar,
     });
   },
 };
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 9e9b461..51f9057 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -1,60 +1,6 @@
 import {openAggregate} from '#aggregate';
 import {empty} from '#sugar';
 
-function sidebarSlots(side) {
-  return {
-    // Content is a flat HTML array. It'll generate one sidebar section
-    // if specified.
-    [side + 'Content']: {
-      type: 'html',
-      mutable: false,
-    },
-
-    // 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,
-          })),
-    },
-
-    // Sticky mode controls which sidebar section(s), if any, follow the
-    // scroll position, "sticking" to the top of the browser viewport.
-    //
-    // 'last' - last or only sidebar box is sticky
-    // 'column' - entire column, incl. multiple boxes from top, is sticky
-    // 'none' - sidebar not sticky at all, stays at top of page
-    //
-    // Note: This doesn't affect the content of any sidebar section, only
-    // the whole section's containing box (or the sidebar column as a whole).
-    [side + 'StickyMode']: {
-      validate: v => v.is('last', 'column', 'static'),
-      default: 'static',
-    },
-
-    // Collapsing sidebars disappear when the viewport is sufficiently
-    // thin. (This is the default.) Override as false to make the sidebar
-    // stay visible in thinner viewports, where the page layout will be
-    // reflowed so the sidebar is as wide as the screen and appears below
-    // nav, above the main content.
-    [side + 'Collapse']: {type: 'boolean', default: true},
-
-    // Wide sidebars generally take up more horizontal space in the normal
-    // page layout, and should be used if the content of the sidebar has
-    // a greater than typical focus compared to main content.
-    [side + 'Wide']: {type: 'boolean', defualt: false},
-  };
-}
-
 export default {
   contentDependencies: [
     'generateColorStyleRules',
@@ -162,8 +108,15 @@ export default {
 
     // Sidebars
 
-    ...sidebarSlots('leftSidebar'),
-    ...sidebarSlots('rightSidebar'),
+    leftSidebar: {
+      type: 'html',
+      mutable: true,
+    },
+
+    rightSidebar: {
+      type: 'html',
+      mutable: true,
+    },
 
     // Banner
 
@@ -266,6 +219,14 @@ export default {
     const colors = getColors(slots.color ?? data.wikiColor);
     const hasSocialEmbed = !html.isBlank(slots.socialEmbed);
 
+    // Hilariously jank. Sorry! We're going to need this content later ANYWAY,
+    // so it's "fine" to stringify it here, but this DOES mean that we're
+    // stringifying (and resolving) the content without the context that it's
+    // e.g. going to end up in a page HTML hierarchy. Might have implications
+    // later, mainly for: https://github.com/hsmusic/hsmusic-wiki/issues/434
+    const mainContentHTML = html.tags([slots.mainContent]).toString();
+    const hasID = id => mainContentHTML.includes(`id="${id}"`);
+
     const titleContentsHTML =
       (html.isBlank(slots.title)
         ? null
@@ -289,8 +250,11 @@ export default {
     let footerContent = slots.footerContent;
 
     if (html.isBlank(footerContent) && relations.defaultFooterContent) {
-      footerContent = relations.defaultFooterContent
-        .slot('mode', 'multiline');
+      footerContent =
+        relations.defaultFooterContent.slots({
+          mode: 'multiline',
+          indicateExternalLinks: false,
+        });
     }
 
     const mainHTML =
@@ -308,7 +272,7 @@ export default {
 
           html.tag('div', {class: 'main-content-container'},
             {[html.onlyIfContent]: true},
-            slots.mainContent),
+            mainContentHTML),
         ]);
 
     const footerHTML =
@@ -384,9 +348,6 @@ export default {
                     showAsCurrent &&
                       {class: 'current'},
 
-                    i > 0 &&
-                      {class: 'has-divider'},
-
                     [
                       html.tag('span', {class: 'nav-link-content'},
                         // Use inline-block styling on the content span,
@@ -413,64 +374,21 @@ export default {
             slots.navContent),
         ]);
 
-    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'];
-      const collapse = slots[side + 'Collapse'];
-
-      let sidebarClasses = [];
-      let sidebarContent = html.blank();
-
-      if (!html.isBlank(content)) {
-        sidebarClasses = ['sidebar', topClass];
-        sidebarContent = content;
-      } else if (multiple) {
-        sidebarClasses = ['sidebar-multiple', topClass];
-        sidebarContent =
-          multiple
-            .filter(Boolean)
-            .map(box =>
-              html.tag('div', {class: 'sidebar'},
-                {[html.onlyIfContent]: true},
-                {class: box.class},
-                box.content));
-      }
-
-      if (html.isBlank(sidebarContent)) {
-        return html.blank();
-      }
-
-      return html.tag('div', {class: 'sidebar-column'},
-        {id, class: sidebarClasses},
-
-        wide &&
-          {class: 'wide'},
-
-        !collapse &&
-          {class: 'no-hide'},
-
-        stickyMode !== 'static' &&
-          {class: `sticky-${stickyMode}`},
-
-        sidebarContent);
-    }
-
-    const sidebarLeftHTML = generateSidebarHTML('leftSidebar', 'sidebar-left');
-    const sidebarRightHTML = generateSidebarHTML('rightSidebar', 'sidebar-right');
-
-    const hasSidebarLeft = !html.isBlank(sidebarLeftHTML);
-    const hasSidebarRight = !html.isBlank(sidebarRightHTML);
+    const getSidebar = (side, id) =>
+      (html.isBlank(slots[side])
+        ? html.blank()
+        : slots[side].slots({
+            attributes:
+              slots[side]
+                .getSlotValue('attributes')
+                .with({id}),
+          }));
 
-    const collapseSidebars = slots.leftSidebarCollapse && slots.rightSidebarCollapse;
+    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left');
+    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right');
 
-    const hasID = (() => {
-      // Hilariously jank. Sorry!
-      const mainContentHTML = slots.mainContent.toString();
-      return id => mainContentHTML.includes(`id="${id}"`);
-    })();
+    const hasSidebarLeft = !html.isBlank(html.resolve(leftSidebar));
+    const hasSidebarRight = !html.isBlank(html.resolve(rightSidebar));
 
     const processSkippers = skipperList =>
       skipperList
@@ -583,15 +501,11 @@ export default {
 
       slots.secondaryNav,
 
-      html.tag('div', {class: 'layout-columns'},
-        !collapseSidebars &&
-          {class: 'vertical-when-thin'},
-
-        [
-          sidebarLeftHTML,
-          mainHTML,
-          sidebarRightHTML,
-        ]),
+      html.tag('div', {class: 'layout-columns'}, [
+        leftSidebar,
+        mainHTML,
+        rightSidebar,
+      ]),
 
       slots.bannerPosition === 'bottom' &&
         slots.banner,
@@ -684,7 +598,7 @@ export default {
 
             html.tag('link', {
               rel: 'stylesheet',
-              href: to('shared.staticFile', 'site6.css', cachebust),
+              href: to('shared.staticFile', 'site7.css', cachebust),
             }),
 
             html.tag('style', [
@@ -724,7 +638,7 @@ export default {
 
               html.tag('script', {
                 type: 'module',
-                src: to('shared.staticFile', 'client3.js', cachebust),
+                src: to('shared.staticFile', 'client4.js', cachebust),
               }),
             ]),
         ])
diff --git a/src/content/dependencies/generatePageSidebar.js b/src/content/dependencies/generatePageSidebar.js
new file mode 100644
index 0000000..43015aa
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebar.js
@@ -0,0 +1,77 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    // Attributes to apply to the whole sidebar. This be added to the
+    // containing sidebar-column, arr - specify attributes on each section if
+    // that's more suitable.
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    // Content boxes to line up vertically in the sidebar.
+    boxes: {
+      type: 'html',
+      mutable: false,
+    },
+
+    // Sticky mode controls which sidebar sections, if any, follow the
+    // scroll position, "sticking" to the top of the browser viewport.
+    //
+    // 'last' - last or only sidebar box is sticky
+    // 'column' - entire column, incl. multiple boxes from top, is sticky
+    // 'static' - sidebar not sticky at all, stays at top of page
+    //
+    // Note: This doesn't affect the content of any sidebar section, only
+    // the whole section's containing box (or the sidebar column as a whole).
+    stickyMode: {
+      validate: v => v.is('last', 'column', 'static'),
+      default: 'static',
+    },
+
+    // Wide sidebars generally take up more horizontal space in the normal
+    // page layout, and should be used if the content of the sidebar has
+    // a greater than typical focus compared to main content.
+    wide: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate(slots, {html}) {
+    const attributes =
+      html.attributes({class: [
+        'sidebar-column',
+        'sidebar-multiple',
+      ]});
+
+    attributes.add(slots.attributes);
+
+    if (slots.wide) {
+      attributes.add('class', 'wide');
+    }
+
+    if (slots.stickyMode !== 'static') {
+      attributes.add('class', `sticky-${slots.stickyMode}`);
+    }
+
+    const {content: boxes} = html.smooth(slots.boxes);
+
+    const allBoxesCollapsible =
+      boxes.every(box =>
+        html.resolve(box)
+          .attributes
+          .has('class', 'collapsible'));
+
+    if (allBoxesCollapsible) {
+      attributes.add('class', 'all-boxes-collapsible');
+    }
+
+    if (html.isBlank(slots.boxes)) {
+      return html.blank();
+    } else {
+      return html.tag('div', attributes, slots.boxes);
+    }
+  },
+};
diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js
new file mode 100644
index 0000000..e11efc3
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebarBox.js
@@ -0,0 +1,28 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    collapsible: {
+      type: 'boolean',
+      default: true,
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('div', {class: 'sidebar'},
+      slots.collapsible &&
+        {class: 'collapsible'},
+
+      slots.attributes,
+      slots.content),
+};
diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js
new file mode 100644
index 0000000..05b1d46
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js
@@ -0,0 +1,42 @@
+// This component is kind of unfortunately magical. It reads the content of
+// various boxes and joins them together, discarding the boxes' attributes.
+// Since it requires access to the actual box *templates* (rather than those
+// templates' resolved content), take care when slotting into this.
+
+export default {
+  contentDependencies: ['generatePageSidebarBox'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    box:
+      relation('generatePageSidebarBox'),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    boxes: {
+      validate: v => v.looseArrayOf(v.isTemplate),
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.box.slots({
+      attributes: slots.attributes,
+      content:
+        slots.boxes.slice()
+          .map(box => box.getSlotValue('content'))
+          .map((content, index, {length}) => [
+            content,
+            index < length - 1 &&
+              html.tag('hr', {
+                style:
+                  `border-color: var(--primary-color); ` +
+                  `border-style: none none dotted none`,
+              }),
+          ]),
+    }),
+};
diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js
index 6c056c9..a241eaf 100644
--- a/src/content/dependencies/generateTrackCoverArtwork.js
+++ b/src/content/dependencies/generateTrackCoverArtwork.js
@@ -17,12 +17,18 @@ export default {
 
     color:
       track.color,
+
+    dimensions:
+      (track.hasUniqueCoverArt
+        ? track.coverArtDimensions
+        : track.album.coverArtDimensions),
   }),
 
   generate: (data, relations) =>
     relations.coverArtwork.slots({
       path: data.path,
       color: data.color,
+      dimensions: data.dimensions,
     }),
 };
 
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 7b70d4f..f532451 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -7,9 +7,9 @@ import getChronologyRelations from '../util/getChronologyRelations.js';
 export default {
   contentDependencies: [
     'generateAbsoluteDatetimestamp',
-    'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
     'generateAlbumStyleRules',
     'generateChronologyLinks',
@@ -112,6 +112,9 @@ export default {
     relations.chronologyLinks =
       relation('generateChronologyLinks');
 
+    relations.secondaryNav =
+      relation('generateAlbumSecondaryNav', track.album);
+
     relations.sidebar =
       relation('generateAlbumSidebar', track.album, track);
 
@@ -134,15 +137,6 @@ export default {
     relations.releaseInfo =
       relation('generateTrackReleaseInfo', track);
 
-    // Section: Extra links
-
-    const extra = sections.extra = {};
-
-    if (!empty(track.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', track.additionalFiles);
-    }
-
     // Section: Other releases
 
     if (!empty(track.otherReleases)) {
@@ -371,7 +365,11 @@ export default {
                 }),
 
               sec.additionalFiles &&
-                sec.extra.additionalFilesShortcut,
+                language.$('releaseInfo.additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#midi-project-files'},
+                    language.$('releaseInfo.additionalFiles.shortcut.link')),
+                }),
 
               sec.artistCommentary &&
                 language.$('releaseInfo.readCommentary', {
@@ -595,7 +593,11 @@ export default {
             ],
           }),
 
-        ...relations.sidebar,
+        secondaryNav:
+          relations.secondaryNav
+            .slot('mode', 'track'),
+
+        leftSidebar: relations.sidebar,
 
         socialEmbed: relations.socialEmbed,
       });
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index 65f5552..3c36d24 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -44,15 +44,16 @@ export default {
                     track: trackLink,
                     by:
                       html.tag('span', {class: 'by'},
-                        language.$('trackList.item.withArtists.by', {
-                          artists:
-                            language.formatConjunctionList(
-                              contributionLinks.map(link =>
-                                link.slots({
-                                  showContribution: slots.showContribution,
-                                  showIcons: slots.showIcons,
-                                }))),
-                        })),
+                        html.metatag('chunkwrap', {split: ','},
+                          language.$('trackList.item.withArtists.by', {
+                            artists:
+                              language.formatConjunctionList(
+                                contributionLinks.map(link =>
+                                  link.slots({
+                                    showContribution: slots.showContribution,
+                                    showIcons: slots.showIcons,
+                                  }))),
+                          }))),
                   }))))));
   },
 };
diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js
index f592ab9..bd0e479 100644
--- a/src/content/dependencies/generateWikiHomeNewsBox.js
+++ b/src/content/dependencies/generateWikiHomeNewsBox.js
@@ -1,48 +1,53 @@
 import {empty, stitchArrays} from '#sugar';
 
 export default {
-  contentDependencies: ['linkNewsEntry', 'transformContent'],
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkNewsEntry',
+    'transformContent',
+  ],
+
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({newsData}) {
-    return {
-      entries: newsData.slice(0, 3),
-    };
-  },
+  sprawl: ({newsData}) => ({
+    entries:
+      newsData.slice(0, 3),
+  }),
 
-  relations(relation, sprawl) {
-    return {
-      entryContents:
-        sprawl.entries
-          .map(entry => relation('transformContent', entry.contentShort)),
+  relations: (relation, sprawl) => ({
+    box:
+      relation('generatePageSidebarBox'),
 
-      entryMainLinks:
-        sprawl.entries
-          .map(entry => relation('linkNewsEntry', entry)),
+    entryContents:
+      sprawl.entries
+        .map(entry => relation('transformContent', entry.contentShort)),
 
-      entryReadMoreLinks:
-        sprawl.entries
-          .map(entry =>
-            entry.contentShort !== entry.content &&
-              relation('linkNewsEntry', entry)),
-    };
-  },
+    entryMainLinks:
+      sprawl.entries
+        .map(entry => relation('linkNewsEntry', entry)),
 
-  data(sprawl) {
-    return {
-      entryDates:
-        sprawl.entries
-          .map(entry => entry.date),
-    }
-  },
+    entryReadMoreLinks:
+      sprawl.entries
+        .map(entry =>
+          entry.contentShort !== entry.content &&
+            relation('linkNewsEntry', entry)),
+  }),
+
+  data: (sprawl) => ({
+    entryDates:
+      sprawl.entries
+        .map(entry => entry.date),
+  }),
 
   generate(data, relations, {html, language}) {
     if (empty(relations.entryContents)) {
       return html.blank();
     }
 
-    return {
-      class: 'latest-news-sidebar-box',
+    return relations.box.slots({
+      attributes: {class: 'latest-news-sidebar-box'},
+      collapsible: false,
+
       content: [
         html.tag('h1', language.$('homepage.news.title')),
 
@@ -77,6 +82,6 @@ export default {
                     })),
               ])),
       ],
-    };
+    });
   },
 };
diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js
index 36fcc6f..ee14a58 100644
--- a/src/content/dependencies/generateWikiHomePage.js
+++ b/src/content/dependencies/generateWikiHomePage.js
@@ -1,6 +1,8 @@
 export default {
   contentDependencies: [
     'generatePageLayout',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
     'generateWikiHomeAlbumsRow',
     'generateWikiHomeNewsBox',
     'transformContent',
@@ -22,7 +24,13 @@ export default {
     relations.layout =
       relation('generatePageLayout');
 
+    relations.sidebar =
+      relation('generatePageSidebar');
+
     if (homepageLayout.sidebarContent) {
+      relations.customSidebarBox =
+        relation('generatePageSidebarBox');
+
       relations.customSidebarContent =
         relation('transformContent', homepageLayout.sidebarContent);
     }
@@ -69,21 +77,24 @@ export default {
         relations.contentRows,
       ],
 
-      leftSidebarCollapse: false,
-      leftSidebarWide: true,
+      leftSidebar:
+        relations.sidebar.slots({
+          wide: true,
 
-      leftSidebarMultiple: [
-        (relations.customSidebarContent
-          ? {
-              class: 'custom-content-sidebar-box',
-              content:
-                relations.customSidebarContent
-                  .slot('mode', 'multiline'),
-            }
-          : null),
+          boxes: [
+            relations.customSidebarContent &&
+              relations.customSidebarBox.slots({
+                attributes: {class: 'custom-content-sidebar-box'},
+                collapsible: false,
 
-        relations.newsSidebarBox ?? null,
-      ],
+                content:
+                  relations.customSidebarContent
+                    .slot('mode', 'multiline'),
+              }),
+
+            relations.newsSidebarBox,
+          ],
+        }),
 
       navLinkStyle: 'index',
       navLinks: [
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index db307a6..822efe3 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -1,4 +1,4 @@
-import {logInfo, logWarn} from '#cli';
+import {logWarn} from '#cli';
 import {empty} from '#sugar';
 
 export default {
@@ -61,11 +61,14 @@ export default {
 
     reveal: {type: 'boolean', default: true},
     lazy: {type: 'boolean', default: false},
+
     square: {type: 'boolean', default: false},
 
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
+
     alt: {type: 'string'},
-    width: {type: 'number'},
-    height: {type: 'number'},
 
     attributes: {
       type: 'attributes',
@@ -116,10 +119,6 @@ export default {
     const isMissingImageFile =
       missingImagePaths.includes(mediaSrc);
 
-    if (isMissingImageFile) {
-      logInfo`No image file for ${mediaSrc} - build again for list of missing images.`;
-    }
-
     const willLink =
       !isMissingImageFile &&
       (typeof slots.link === 'string' || slots.link);
@@ -134,14 +133,26 @@ export default {
       !isMissingImageFile &&
       !empty(contentWarnings);
 
-    const willSquare = slots.square;
+    const hasBothDimensions =
+      !!(slots.dimensions &&
+         slots.dimensions[0] !== null &&
+         slots.dimensions[1] !== null);
+
+    const willSquare =
+      (hasBothDimensions
+        ? slots.dimensions[0] === slots.dimensions[1]
+        : slots.square);
 
     const imgAttributes = html.attributes([
       {class: 'image'},
 
       slots.alt && {alt: slots.alt},
-      slots.width && {width: slots.width},
-      slots.height && {height: slots.height},
+
+      slots.dimensions?.[0] &&
+        {width: slots.dimensions[0]},
+
+      slots.dimensions?.[1] &&
+        {width: slots.dimensions[1]},
     ]);
 
     const isPlaceholder =
@@ -220,11 +231,9 @@ export default {
           to('thumb.path', mediaSrcJpeg);
       }
 
-      const dimensions = getDimensionsOfImagePath(mediaSrc);
-      const availableThumbs = getThumbnailsAvailableForDimensions(dimensions);
-
-      const [width, height] = dimensions;
-      const originalLength = Math.max(width, height)
+      const originalDimensions = getDimensionsOfImagePath(mediaSrc);
+      const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions);
+      const originalLength = Math.max(originalDimensions[0], originalDimensions[1]);
 
       const fileSize =
         (willLink && mediaSrc
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index cb57aa4..1a51c38 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -1,4 +1,4 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -14,7 +14,7 @@ export default {
     const relations = {};
 
     relations.artistLink =
-      relation('linkArtist', contribution.who);
+      relation('linkArtist', contribution.artist);
 
     relations.textWithTooltip =
       relation('generateTextWithTooltip');
@@ -22,9 +22,9 @@ export default {
     relations.tooltip =
       relation('generateTooltip');
 
-    if (!empty(contribution.who.urls)) {
+    if (!empty(contribution.artist.urls)) {
       relations.artistIcons =
-        contribution.who.urls
+        contribution.artist.urls
           .map(url => relation('linkExternalAsIcon', url));
     }
 
@@ -33,7 +33,8 @@ export default {
 
   data(contribution) {
     return {
-      what: contribution.what,
+      contribution: contribution.annotation,
+      urls: contribution.artist.urls,
     };
   },
 
@@ -49,7 +50,7 @@ export default {
   },
 
   generate(data, relations, slots, {html, language}) {
-    const hasContribution = !!(slots.showContribution && data.what);
+    const hasContribution = !!(slots.showContribution && data.contribution);
     const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
 
     const parts = ['misc.artistLink'];
@@ -74,19 +75,43 @@ export default {
                   {[html.joinChildren]: ''},
 
                 content:
-                  relations.artistIcons
-                    .map(icon =>
-                      icon.slots({
+                  stitchArrays({
+                    icon: relations.artistIcons,
+                    url: data.urls,
+                  }).map(({icon, url}) => {
+                      icon.setSlots({
                         context: 'artist',
                         withText: true,
-                      })),
+                      });
+
+                      let platformText =
+                        language.formatExternalLink(url, {
+                          context: 'artist',
+                          style: 'platform',
+                        });
+
+                      // This is a pretty ridiculous hack, but we currently
+                      // don't have a way of telling formatExternalLink to *not*
+                      // use the fallback string, which just formats the URL as
+                      // its host/domain... so is technically detectable.
+                      if (platformText.toString() === (new URL(url)).host) {
+                        platformText =
+                          language.$('misc.artistLink.noExternalLinkPlatformName');
+                      }
+
+                      const platformSpan =
+                        html.tag('span', {class: 'icon-platform'},
+                          platformText);
+
+                      return [icon, platformSpan];
+                    }),
               }),
           })
         : relations.artistLink);
 
     if (hasContribution) {
       parts.push('withContribution');
-      options.contrib = data.what;
+      options.contrib = data.contribution;
     }
 
     if (hasExternalIcons && slots.iconMode === 'inline') {
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index ba2dbf2..f6b47db 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -6,12 +6,17 @@ export default {
   data: (url) => ({url}),
 
   slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
     style: {
       // This awkward syntax is because the slot descriptor validator can't
       // differentiate between a function that returns a validator (the usual
       // syntax) and a function that is itself a validator.
       validate: () => isExternalLinkStyle,
-      default: 'normal',
+      default: 'platform',
     },
 
     context: {
@@ -19,22 +24,113 @@ export default {
       default: 'generic',
     },
 
+    fromContent: {
+      type: 'boolean',
+      default: false,
+    },
+
+    indicateExternal: {
+      type: 'boolean',
+      default: false,
+    },
+
     tab: {
       validate: v => v.is('default', 'separate'),
       default: 'default',
     },
   },
 
-  generate: (data, slots, {html, language}) =>
-    html.tag('a',
-      {href: data.url},
-      {class: 'nowrap'},
+  generate(data, slots, {html, language}) {
+    let urlIsValid;
+    try {
+      new URL(data.url);
+      urlIsValid = true;
+    } catch (error) {
+      urlIsValid = false;
+    }
+
+    let formattedLink;
+    if (urlIsValid) {
+      formattedLink =
+        language.formatExternalLink(data.url, {
+          style: slots.style,
+          context: slots.context,
+        });
+
+      // Fall back to platform if nothing matched the desired style.
+      if (html.isBlank(formattedLink) && slots.style !== 'platform') {
+        formattedLink =
+          language.formatExternalLink(data.url, {
+            style: 'platform',
+            context: slots.context,
+          });
+      }
+    } else {
+      formattedLink = null;
+    }
 
-      slots.tab === 'separate' &&
-        {target: '_blank'},
+    const linkAttributes = html.attributes({
+      class: 'external-link',
+    });
 
-      language.formatExternalLink(data.url, {
-        style: slots.style,
-        context: slots.context,
-      })),
+    let linkContent;
+    if (urlIsValid) {
+      linkAttributes.set('href', data.url);
+
+      if (html.isBlank(slots.content)) {
+        linkContent = formattedLink;
+      } else {
+        linkContent = slots.content;
+      }
+    } else {
+      if (html.isBlank(slots.content)) {
+        linkContent =
+          html.tag('i',
+            language.$('misc.external.invalidURL.annotation'));
+      } else {
+        linkContent =
+          language.$('misc.external.invalidURL', {
+            link: slots.content,
+            annotation:
+              html.tag('i',
+                language.$('misc.external.invalidURL.annotation')),
+          });
+      }
+    }
+
+    if (slots.fromContent) {
+      linkAttributes.add('class', 'from-content');
+    }
+
+    if (urlIsValid && slots.indicateExternal) {
+      linkAttributes.add('class', 'indicate-external');
+
+      let titleText;
+      if (slots.tab === 'separate') {
+        if (html.isBlank(slots.content)) {
+          titleText =
+            language.$('misc.external.opensInNewTab.annotation');
+        } else {
+          titleText =
+            language.$('misc.external.opensInNewTab', {
+              link: formattedLink,
+              annotation:
+                language.$('misc.external.opensInNewTab.annotation'),
+            });
+        }
+      } else if (!html.isBlank(slots.content)) {
+        titleText = formattedLink;
+      }
+
+      if (titleText) {
+        linkAttributes.set('title', titleText.toString());
+      }
+    }
+
+    if (urlIsValid && slots.tab === 'separate') {
+      linkAttributes.set('target', '_blank');
+    }
+
+    return html.tag('a', linkAttributes, linkContent);
+  },
 };
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
index 3eb355a..6f37529 100644
--- a/src/content/dependencies/linkExternalAsIcon.js
+++ b/src/content/dependencies/linkExternalAsIcon.js
@@ -21,8 +21,8 @@ export default {
     const format = style =>
       language.formatExternalLink(data.url, {style, context: slots.context});
 
-    const normalText = format('normal');
-    const compactText = format('compact');
+    const platformText = format('platform');
+    const handleText = format('handle');
     const iconId = format('icon-id');
 
     return html.tag('a', {class: 'icon'},
@@ -34,7 +34,7 @@ export default {
       [
         html.tag('svg', [
           !slots.withText &&
-            html.tag('title', normalText),
+            html.tag('title', platformText),
 
           html.tag('use', {
             href: to('shared.staticIcon', iconId),
@@ -43,7 +43,9 @@ export default {
 
         slots.withText &&
           html.tag('span', {class: 'icon-text'},
-            compactText ?? normalText),
+            (html.isBlank(handleText)
+              ? platformText
+              : handleText)),
       ]);
   },
 };
diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js
index bf48c96..e33ad7b 100644
--- a/src/content/dependencies/listAllAdditionalFilesTemplate.js
+++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js
@@ -63,7 +63,7 @@ export default {
     const albumAdditionalFileFiles =
       albumAdditionalFileObjects
         .map(byAlbum => byAlbum
-          .map(({files}) => files));
+          .map(({files}) => files ?? []));
 
     const trackAdditionalFileTitles =
       trackAdditionalFileObjects
@@ -75,7 +75,7 @@ export default {
       trackAdditionalFileObjects
         .map(byAlbum => byAlbum
           .map(byTrack => byTrack
-            .map(({files}) => files)));
+            .map(({files}) => files ?? [])));
 
     return {
       spec,
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 0f70957..27a2faa 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -83,7 +83,8 @@ export default {
       });
     };
 
-    const getArtists = (thing, key) => thing[key].map(({who}) => who);
+    const getArtists = (thing, key) =>
+      thing[key].map(({artist}) => artist);
 
     const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice());
     const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice());
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index faae35a..0904cde 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -37,6 +37,7 @@ export default {
         .map(description => description.link)
         .filter(Boolean)),
     'image',
+    'linkExternal',
   ],
 
   extraDependencies: ['html', 'language', 'to', 'wikiData'],
@@ -114,7 +115,7 @@ export default {
 
             data.hash = enteredHash ?? null;
 
-            return {i: node.i, iEnd: node.iEnd, type: 'link', data};
+            return {i: node.i, iEnd: node.iEnd, type: 'internal-link', data};
           }
 
           // This will be another {type: 'tag'} node which gets processed in
@@ -140,10 +141,15 @@ export default {
         sprawl.nodes
           .map(node => {
             switch (node.type) {
-              // Replace link nodes with a stub. It'll be replaced (by position)
-              // with an item from relations.
-              case 'link':
-                return {type: 'link'};
+              // Replace internal link nodes with a stub. It'll be replaced
+              // (by position) with an item from relations.
+              //
+              // TODO: This should be where label and hash get passed through,
+              // rather than in relations... (in which case there's no need to
+              // handle it specially here, and we can really just return
+              // data.nodes = sprawl.nodes)
+              case 'internal-link':
+                return {type: 'internal-link'};
 
               // Other nodes will get processed in generate.
               default:
@@ -167,9 +173,9 @@ export default {
           : getPlaceholder(node, content));
 
     return {
-      links:
+      internalLinks:
         nodes
-          .filter(({type}) => type === 'link')
+          .filter(({type}) => type === 'internal-link')
           .map(node => {
             const {link, thing, value} = node.data;
 
@@ -182,6 +188,15 @@ export default {
             }
           }),
 
+      externalLinks:
+        nodes
+          .filter(({type}) => type === 'external-link')
+          .map(node => {
+            const {href} = node.data;
+
+            return relation('linkExternal', href);
+          }),
+
       images:
         nodes
           .filter(({type}) => type === 'image')
@@ -201,6 +216,11 @@ export default {
       default: false,
     },
 
+    indicateExternalLinks: {
+      type: 'boolean',
+      default: true,
+    },
+
     thumb: {
       validate: v => v.is('small', 'medium', 'large'),
       default: 'large',
@@ -208,12 +228,10 @@ export default {
   },
 
   generate(data, relations, slots, {html, language, to}) {
-    let linkIndex = 0;
     let imageIndex = 0;
+    let internalLinkIndex = 0;
+    let externalLinkIndex = 0;
 
-    // This array contains only straight text and link nodes, which are directly
-    // representable in html (so no further processing is needed on the level of
-    // individual nodes).
     const contentFromNodes =
       data.nodes.map(node => {
         switch (node.type) {
@@ -237,57 +255,83 @@ export default {
             } = node;
 
             if (node.inline) {
+              let content =
+                html.tag('img',
+                  src && {src},
+                  width && {width},
+                  height && {height},
+                  style && {style},
+
+                  pixelate &&
+                    {class: 'pixelate'});
+
+              if (link) {
+                content =
+                  html.tag('a',
+                    {href: link},
+                    {target: '_blank'},
+
+                    {title:
+                      language.$('misc.external.opensInNewTab', {
+                        link:
+                          language.formatExternalLink(link, {
+                            style: 'platform',
+                          }),
+
+                        annotation:
+                          language.$('misc.external.opensInNewTab.annotation'),
+                      }).toString()},
+
+                    content);
+              }
+
               return {
-                type: 'image',
+                type: 'processed-image',
                 inline: true,
-                data:
-                  html.tag('img',
-                    src && {src},
-                    width && {width},
-                    height && {height},
-                    style && {style},
-
-                    pixelate &&
-                      {class: 'pixelate'}),
+                data: content,
               };
             }
 
             const image = relations.images[imageIndex++];
 
+            image.setSlots({
+              src,
+
+              link: link ?? true,
+              warnings: warnings ?? null,
+              thumb: slots.thumb,
+            });
+
+            if (width || height) {
+              image.setSlot('dimensions', [width ?? null, height ?? null]);
+            }
+
+            image.setSlot('attributes', [
+              {class: 'content-image'},
+
+              pixelate &&
+                {class: 'pixelate'},
+            ]);
+
             return {
-              type: 'image',
+              type: 'processed-image',
               inline: false,
               data:
                 html.tag('div', {class: 'content-image-container'},
                   align === 'center' &&
                     {class: 'align-center'},
 
-                  image.slots({
-                    src,
-
-                    link: link ?? true,
-                    width: width ?? null,
-                    height: height ?? null,
-                    warnings: warnings ?? null,
-                    thumb: slots.thumb,
-
-                    attributes: [
-                      {class: 'content-image'},
-
-                      pixelate &&
-                        {class: 'pixelate'},
-                    ],
-                  })),
+                  image),
             };
           }
 
-          case 'link': {
-            const linkNode = relations.links[linkIndex++];
-            if (linkNode.type === 'text') {
-              return {type: 'text', data: linkNode.data};
+          case 'internal-link': {
+            const nodeFromRelations = relations.internalLinks[internalLinkIndex++];
+            if (nodeFromRelations.type === 'text') {
+              return {type: 'text', data: nodeFromRelations.data};
             }
 
-            const {link, label, hash} = linkNode;
+            const {link, label, hash} = nodeFromRelations;
 
             // These are removed from the typical combined slots({})-style
             // because we don't want to override slots that were already set
@@ -322,7 +366,27 @@ export default {
               link.setSlot('tooltipStyle', 'none');
             }
 
-            return {type: 'link', data: link};
+            return {type: 'processed-internal-link', data: link};
+          }
+
+          case 'external-link': {
+            const {label} = node.data;
+            const externalLink = relations.externalLinks[externalLinkIndex++];
+
+            externalLink.setSlots({
+              content: label,
+              fromContent: true,
+            });
+
+            if (slots.indicateExternalLinks) {
+              externalLink.setSlots({
+                indicateExternal: true,
+                tab: 'separate',
+                style: 'platform',
+              });
+            }
+
+            return {type: 'processed-external-link', data: externalLink};
           }
 
           case 'tag': {
@@ -358,7 +422,10 @@ export default {
     // access to its slots.
 
     if (slots.mode === 'single-link') {
-      const link = contentFromNodes.find(node => node.type === 'link');
+      const link =
+        contentFromNodes.find(node =>
+          node.type === 'processed-internal-link' ||
+          node.type === 'processed-external-link');
 
       if (!link) {
         return html.blank();
@@ -385,13 +452,10 @@ export default {
             return getTextNodeContents(node, index);
           }
 
-          const attributes = html.attributes({
-            class: 'INSERT-NON-TEXT',
-            'data-type': node.type,
-          });
+          let attributes = `class="INSERT-NON-TEXT" data-type="${node.type}"`;
 
-          if (node.type === 'image') {
-            attributes.set('data-inline', node.inline);
+          if (node.type === 'processed-image' && node.inline) {
+            attributes += ` data-inline`;
           }
 
           return `<span ${attributes}>${index}</span>`;
@@ -426,7 +490,7 @@ export default {
         // the surrounding <p> tag that marked generates. The HTML parser
         // treats a <div> that starts inside a <p> as a Crocker-class
         // misgiving, and will treat you very badly if you feed it that.
-        if (attributes.get('data-type') === 'image') {
+        if (attributes.get('data-type') === 'processed-image') {
           if (!attributes.get('data-inline')) {
             tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, '');
             deleteParagraph = true;
diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js
index 67d6d5f..c4a62da 100644
--- a/src/content/util/getChronologyRelations.js
+++ b/src/content/util/getChronologyRelations.js
@@ -18,17 +18,17 @@ export default function getChronologyRelations(thing, {
 
   const artistsSoFar = new Set();
 
-  contributions = contributions.filter(({who}) => {
-    if (artistsSoFar.has(who)) {
+  contributions = contributions.filter(({artist}) => {
+    if (artistsSoFar.has(artist)) {
       return false;
     } else {
-      artistsSoFar.add(who);
+      artistsSoFar.add(artist);
       return true;
     }
   });
 
-  return contributions.map(({who}) => {
-    const things = Array.from(new Set(getThings(who)));
+  return contributions.map(({artist}) => {
+    const things = Array.from(new Set(getThings(artist)));
 
     // Don't show a line if this contribution isn't part of the artist's
     // chronology at all (usually because this thing isn't dated).
@@ -47,7 +47,7 @@ export default function getChronologyRelations(thing, {
 
     return {
       index: index + 1,
-      artistLink: linkArtist(who),
+      artistLink: linkArtist(artist),
       previousLink: previous ? linkThing(previous) : null,
       nextLink: next ? linkThing(next) : null,
     };
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index 1e7c7aa..4a89b77 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -83,6 +83,8 @@ function inspect(value) {
 }
 
 export default class CacheableObject {
+  static propertyDescriptors = Symbol.for('CacheableObject.propertyDescriptors');
+
   #propertyUpdateValues = Object.create(null);
   #propertyUpdateCacheInvalidators = Object.create(null);
 
@@ -113,28 +115,37 @@ export default class CacheableObject {
     }
   }
 
+  #withEachPropertyDescriptor(callback) {
+    const {[CacheableObject.propertyDescriptors]: propertyDescriptors} =
+      this.constructor;
+
+    for (const property of Reflect.ownKeys(propertyDescriptors)) {
+      callback(property, propertyDescriptors[property]);
+    }
+  }
+
   #initializeUpdatingPropertyValues() {
-    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
+    this.#withEachPropertyDescriptor((property, descriptor) => {
       const {flags, update} = descriptor;
 
       if (!flags.update) {
-        continue;
+        return;
       }
 
-      if (update?.default) {
+      if ('default' in update) {
         this[property] = update?.default;
       } else {
         this[property] = null;
       }
-    }
+    });
   }
 
   #defineProperties() {
-    if (!this.constructor.propertyDescriptors) {
-      throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`);
+    if (!this.constructor[CacheableObject.propertyDescriptors]) {
+      throw new Error(`Expected constructor ${this.constructor.name} to provide CacheableObject.propertyDescriptors`);
     }
 
-    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
+    this.#withEachPropertyDescriptor((property, descriptor) => {
       const {flags} = descriptor;
 
       const definition = {
@@ -151,7 +162,7 @@ export default class CacheableObject {
       }
 
       Object.defineProperty(this, property, definition);
-    }
+    });
 
     Object.seal(this);
   }
@@ -191,7 +202,7 @@ export default class CacheableObject {
   }
 
   #getPropertyDescriptor(property) {
-    return this.constructor.propertyDescriptors[property];
+    return this.constructor[CacheableObject.propertyDescriptors][property];
   }
 
   #invalidateCachesDependentUpon(property) {
@@ -244,7 +255,8 @@ export default class CacheableObject {
 
     if (expose.dependencies?.length > 0) {
       const dependencyKeys = expose.dependencies.slice();
-      const shouldReflect = dependencyKeys.includes('this');
+      const shouldReflectObject = dependencyKeys.includes('this');
+      const shouldReflectProperty = dependencyKeys.includes('thisProperty');
 
       getAllDependencies = () => {
         const dependencies = Object.create(null);
@@ -253,10 +265,14 @@ export default class CacheableObject {
           dependencies[key] = this.#propertyUpdateValues[key];
         }
 
-        if (shouldReflect) {
+        if (shouldReflectObject) {
           dependencies.this = this;
         }
 
+        if (shouldReflectProperty) {
+          dependencies.thisProperty = property;
+        }
+
         return dependencies;
       };
     } else {
@@ -311,16 +327,16 @@ export default class CacheableObject {
       return;
     }
 
-    const {propertyDescriptors} = obj.constructor;
+    const {[CacheableObject.propertyDescriptors]: propertyDescriptors} =
+      obj.constructor;
 
     if (!propertyDescriptors) {
       console.warn('Missing property descriptors:', obj);
       return;
     }
 
-    for (const [property, descriptor] of Object.entries(propertyDescriptors)) {
-      const {flags} = descriptor;
-
+    for (const property of Reflect.ownKeys(propertyDescriptors)) {
+      const {flags} = propertyDescriptors[property];
       if (!flags.expose) {
         continue;
       }
diff --git a/src/data/checks.js b/src/data/checks.js
index 79a8d4c..f3741a1 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -24,20 +24,30 @@ function inspect(value, opts = {}) {
   return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
 
-// Warn about directories which are reused across more than one of the same type
-// of Thing. Directories are the unique identifier for most data objects across
-// the wiki, so we have to make sure they aren't duplicated!
-export function reportDuplicateDirectories(wikiData, {
+// Warn about problems to do with directories.
+//
+// * Duplicate directories: these are the unique identifier for referencable
+//   data objects across the wiki, so duplicates introduce ambiguity where it
+//   can't fit.
+//
+// * Missing directories: in almost all cases directories can be computed,
+//   but in particularly brutal internal cases, it might not be possible, and
+//   a thing's directory is just null. This leaves it unable to be referenced.
+//
+export function reportDirectoryErrors(wikiData, {
   getAllFindSpecs,
 }) {
   const duplicateSets = [];
+  const missingDirectoryThings = new Set();
 
   for (const findSpec of Object.values(getAllFindSpecs())) {
     if (!findSpec.bindTo) continue;
 
     const directoryPlaces = Object.create(null);
     const duplicateDirectories = new Set();
+
     const thingData = wikiData[findSpec.bindTo];
+    if (!thingData) continue;
 
     for (const thing of thingData) {
       if (findSpec.include && !findSpec.include(thing)) {
@@ -50,6 +60,11 @@ export function reportDuplicateDirectories(wikiData, {
           : [thing.directory]);
 
       for (const directory of directories) {
+        if (directory === null || directory === undefined) {
+          missingDirectoryThings.add(thing);
+          continue;
+        }
+
         if (directory in directoryPlaces) {
           directoryPlaces[directory].push(thing);
           duplicateDirectories.add(directory);
@@ -59,8 +74,6 @@ export function reportDuplicateDirectories(wikiData, {
       }
     }
 
-    if (empty(duplicateDirectories)) continue;
-
     const sortedDuplicateDirectories =
       Array.from(duplicateDirectories)
         .sort((a, b) => {
@@ -75,8 +88,6 @@ export function reportDuplicateDirectories(wikiData, {
     }
   }
 
-  if (empty(duplicateSets)) return;
-
   // Multiple find functions may effectively have duplicates across the same
   // things. These only need to be reported once, because resolving one of them
   // will resolve the rest, so cut out duplicate sets before reporting.
@@ -107,12 +118,20 @@ export function reportDuplicateDirectories(wikiData, {
     deduplicateDuplicateSets.push(set);
   }
 
-  withAggregate({message: `Duplicate directories found`}, ({push}) => {
+  withAggregate({message: `Directory errors detected`}, ({push}) => {
     for (const {directory, places} of deduplicateDuplicateSets) {
       push(new Error(
         `Duplicate directory ${colors.green(`"${directory}"`)}:\n` +
         places.map(thing => ` - ` + inspect(thing)).join('\n')));
     }
+
+    if (!empty(missingDirectoryThings)) {
+      push(new Error(
+        `Couldn't figure out an implicit directory for:\n` +
+        Array.from(missingDirectoryThings)
+          .map(thing => `- ` + inspect(thing))
+          .join('\n')));
+    }
   });
 }
 
@@ -166,6 +185,10 @@ export function filterReferenceErrors(wikiData, {
       commentary: '_commentary',
     }],
 
+    ['flashData', {
+      commentary: '_commentary',
+    }],
+
     ['groupCategoryData', {
       groups: 'group',
     }],
@@ -257,7 +280,7 @@ export function filterReferenceErrors(wikiData, {
                 break;
 
               case '_contrib':
-                findFn = contribRef => findArtistOrAlias(contribRef.who);
+                findFn = contribRef => findArtistOrAlias(contribRef.artist);
                 break;
 
               case '_homepageSourceGroup':
@@ -469,6 +492,10 @@ export class ContentNodeError extends Error {
 export function reportContentTextErrors(wikiData, {
   bindFind,
 }) {
+  const additionalFileShape = {
+    description: 'description',
+  };
+
   const commentaryShape = {
     body: 'commentary body',
     artistDisplayText: 'commentary artist display text',
@@ -477,6 +504,7 @@ export function reportContentTextErrors(wikiData, {
 
   const contentTextSpec = [
     ['albumData', {
+      additionalFiles: additionalFileShape,
       commentary: commentaryShape,
     }],
 
@@ -484,8 +512,15 @@ export function reportContentTextErrors(wikiData, {
       contextNotes: '_content',
     }],
 
+    ['flashData', {
+      commentary: commentaryShape,
+    }],
+
     ['flashActData', {
-      jump: '_content',
+      listTerminology: '_content',
+    }],
+
+    ['flashSideData', {
       listTerminology: '_content',
     }],
 
@@ -506,8 +541,11 @@ export function reportContentTextErrors(wikiData, {
     }],
 
     ['trackData', {
+      additionalFiles: additionalFileShape,
       commentary: commentaryShape,
       lyrics: '_content',
+      midiProjectFiles: additionalFileShape,
+      sheetMusicFiles: additionalFileShape,
     }],
 
     ['wikiInfo', {
@@ -574,6 +612,16 @@ export function reportContentTextErrors(wikiData, {
             continue;
           }
         }
+      } else if (node.type === 'external-link') {
+        try {
+          new URL(node.data.href);
+        } catch (error) {
+          yield {
+            index, length,
+            message:
+              `Invalid URL ${colors.red(`"${node.data.href}"`)}`,
+          };
+        }
       }
     }
   }
diff --git a/src/data/composite.js b/src/data/composite.js
index 7a98c42..33d69a6 100644
--- a/src/data/composite.js
+++ b/src/data/composite.js
@@ -29,6 +29,7 @@ input.value = _valueIntoToken('input.value');
 input.dependency = _valueIntoToken('input.dependency');
 
 input.myself = () => Symbol.for(`hsmusic.composite.input.myself`);
+input.thisProperty = () => Symbol.for('hsmusic.composite.input.thisProperty');
 
 input.updateValue = _valueIntoToken('input.updateValue');
 
@@ -284,6 +285,7 @@ export function templateCompositeFrom(description) {
               'input.value',
               'input.dependency',
               'input.myself',
+              'input.thisProperty',
               'input.updateValue',
             ].includes(tokenShape)) {
               expectedValueProvidingTokenInputNames.push(name);
@@ -567,6 +569,8 @@ export function compositeFrom(description) {
             return token;
           case 'input.myself':
             return 'this';
+          case 'input.thisProperty':
+            return 'thisProperty';
           default:
             return null;
         }
@@ -721,6 +725,8 @@ export function compositeFrom(description) {
               return (tokenValue.startsWith('#') ? null : tokenValue);
             case 'input.myself':
               return 'this';
+            case 'input.thisProperty':
+              return 'thisProperty';
             default:
               return null;
           }
@@ -774,16 +780,9 @@ export function compositeFrom(description) {
       (step.annotation ? ` (${step.annotation})` : ``);
 
     aggregate.nest({message}, ({push}) => {
-      if (isBase && stepComposes !== compositionNests) {
+      if (!isBase && !stepComposes) {
         return push(new TypeError(
-          (compositionNests
-            ? `Base must compose, this composition is nestable`
-            : `Base must not compose, this composition isn't nestable`)));
-      } else if (!isBase && !stepComposes) {
-        return push(new TypeError(
-          (compositionNests
-            ? `All steps must compose`
-            : `All steps (except base) must compose`)));
+          `All steps leading up to base must compose`));
       }
 
       if (
@@ -877,6 +876,8 @@ export function compositeFrom(description) {
               return valueSoFar;
             case 'input.myself':
               return initialDependencies['this'];
+            case 'input.thisProperty':
+              return initialDependencies['thisProperty'];
             case 'input':
               return initialDependencies[token];
             default:
@@ -907,8 +908,16 @@ export function compositeFrom(description) {
       debug(() => colors.bright(`begin composition - not transforming`));
     }
 
-    for (let i = 0; i < steps.length; i++) {
-      const step = steps[i];
+    for (
+      const [i, {
+        step,
+        stepComposes,
+      }] of
+        stitchArrays({
+          step: steps,
+          stepComposes: stepsCompose,
+        }).entries()
+    ) {
       const isBase = i === steps.length - 1;
 
       debug(() => [
@@ -969,6 +978,7 @@ export function compositeFrom(description) {
             ? {[input.updateValue()]: valueSoFar}
             : {}),
         [input.myself()]: initialDependencies?.['this'] ?? null,
+        [input.thisProperty()]: initialDependencies?.['thisProperty'] ?? null,
       };
 
       const selectDependencies =
@@ -983,6 +993,8 @@ export function compositeFrom(description) {
               return dependency;
             case 'input.myself':
               return input.myself();
+            case 'input.thisProperty':
+              return input.thisProperty();
             case 'input.dependency':
               return tokenValue;
             case 'input.updateValue':
@@ -1018,10 +1030,7 @@ export function compositeFrom(description) {
 
         let args;
 
-        if (isBase && !compositionNests) {
-          args =
-            argsLayout.filter(arg => arg !== continuationSymbol);
-        } else {
+        if (stepComposes) {
           let continuation;
 
           ({continuation, continuationStorage} =
@@ -1032,6 +1041,9 @@ export function compositeFrom(description) {
               (arg === continuationSymbol
                 ? continuation
                 : arg));
+        } else {
+          args =
+            argsLayout.filter(arg => arg !== continuationSymbol);
         }
 
         return expose[name](...args);
@@ -1091,11 +1103,6 @@ export function compositeFrom(description) {
 
       if (result !== continuationSymbol) {
         debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]);
-
-        if (compositionNests) {
-          throw new TypeError(`Inferred early-exit is disallowed in nested compositions`);
-        }
-
         debug(() => colors.bright(`end composition - exit (inferred)`));
 
         return result;
diff --git a/src/data/composite/control-flow/exposeConstant.js b/src/data/composite/control-flow/exposeConstant.js
index e043547..e76699c 100644
--- a/src/data/composite/control-flow/exposeConstant.js
+++ b/src/data/composite/control-flow/exposeConstant.js
@@ -12,7 +12,7 @@ export default templateCompositeFrom({
   compose: false,
 
   inputs: {
-    value: input.staticValue(),
+    value: input.staticValue({acceptsNull: true}),
   },
 
   steps: () => [
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
index 8139f10..0ef91b8 100644
--- a/src/data/composite/things/album/index.js
+++ b/src/data/composite/things/album/index.js
@@ -1,2 +1,2 @@
-export {default as withTracks} from './withTracks.js';
 export {default as withTrackSections} from './withTrackSections.js';
+export {default as withTracks} from './withTracks.js';
diff --git a/src/data/composite/things/album/withTrackSections.js b/src/data/composite/things/album/withTrackSections.js
index 0a1ebeb..a56bda3 100644
--- a/src/data/composite/things/album/withTrackSections.js
+++ b/src/data/composite/things/album/withTrackSections.js
@@ -1,127 +1,21 @@
 import {input, templateCompositeFrom} from '#composite';
+
 import find from '#find';
-import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
-import {isTrackSectionList} from '#validators';
 
-import {exitWithoutDependency, exitWithoutUpdateValue}
-  from '#composite/control-flow';
 import {withResolvedReferenceList} from '#composite/wiki-data';
 
-import {
-  fillMissingListItems,
-  withFlattenedList,
-  withPropertiesFromList,
-  withUnflattenedList,
-} from '#composite/data';
-
 export default templateCompositeFrom({
   annotation: `withTrackSections`,
 
   outputs: ['#trackSections'],
 
   steps: () => [
-    exitWithoutDependency({
-      dependency: 'ownTrackData',
-      value: input.value([]),
-    }),
-
-    exitWithoutUpdateValue({
-      mode: input.value('empty'),
-      value: input.value([]),
-    }),
-
-    // TODO: input.updateValue description down here is a kludge.
-    withPropertiesFromList({
-      list: input.updateValue({
-        validate: isTrackSectionList,
-      }),
-      prefix: input.value('#sections'),
-      properties: input.value([
-        'tracks',
-        'dateOriginallyReleased',
-        'isDefaultTrackSection',
-        'name',
-        'color',
-      ]),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.tracks',
-      fill: input.value([]),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.isDefaultTrackSection',
-      fill: input.value(false),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.name',
-      fill: input.value('Unnamed Track Section'),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.color',
-      fill: input.dependency('color'),
-    }),
-
-    withFlattenedList({
-      list: '#sections.tracks',
-    }).outputs({
-      ['#flattenedList']: '#trackRefs',
-      ['#flattenedIndices']: '#sections.startIndex',
-    }),
-
     withResolvedReferenceList({
-      list: '#trackRefs',
-      data: 'ownTrackData',
-      notFoundMode: input.value('null'),
-      find: input.value(find.track),
+      list: 'trackSections',
+      data: 'ownTrackSectionData',
+      find: input.value(find.unqualifiedTrackSection),
     }).outputs({
-      ['#resolvedReferenceList']: '#tracks',
+      ['#resolvedReferenceList']: '#trackSections',
     }),
-
-    withUnflattenedList({
-      list: '#tracks',
-      indices: '#sections.startIndex',
-    }).outputs({
-      ['#unflattenedList']: '#sections.tracks',
-    }),
-
-    {
-      dependencies: [
-        '#sections.tracks',
-        '#sections.name',
-        '#sections.color',
-        '#sections.dateOriginallyReleased',
-        '#sections.isDefaultTrackSection',
-        '#sections.startIndex',
-      ],
-
-      compute: (continuation, {
-        '#sections.tracks': tracks,
-        '#sections.name': name,
-        '#sections.color': color,
-        '#sections.dateOriginallyReleased': dateOriginallyReleased,
-        '#sections.isDefaultTrackSection': isDefaultTrackSection,
-        '#sections.startIndex': startIndex,
-      }) => {
-        filterMultipleArrays(
-          tracks, name, color, dateOriginallyReleased, isDefaultTrackSection, startIndex,
-          tracks => !empty(tracks));
-
-        return continuation({
-          ['#trackSections']:
-            stitchArrays({
-              tracks,
-              name,
-              color,
-              dateOriginallyReleased,
-              isDefaultTrackSection,
-              startIndex,
-            }),
-        });
-      },
-    },
   ],
 });
diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js
index fff3d5a..c8d27c4 100644
--- a/src/data/composite/things/album/withTracks.js
+++ b/src/data/composite/things/album/withTracks.js
@@ -1,51 +1,27 @@
 import {input, templateCompositeFrom} from '#composite';
-import find from '#find';
 
-import {exitWithoutDependency, raiseOutputWithoutDependency}
-  from '#composite/control-flow';
+import {withFlattenedList, withPropertyFromList} from '#composite/data';
 import {withResolvedReferenceList} from '#composite/wiki-data';
 
+import withTrackSections from './withTrackSections.js';
+
 export default templateCompositeFrom({
   annotation: `withTracks`,
 
   outputs: ['#tracks'],
 
   steps: () => [
-    exitWithoutDependency({
-      dependency: 'ownTrackData',
-      value: input.value([]),
-    }),
+    withTrackSections(),
 
-    raiseOutputWithoutDependency({
-      dependency: 'trackSections',
-      mode: input.value('empty'),
-      output: input.value({
-        ['#tracks']: [],
-      }),
+    withPropertyFromList({
+      list: '#trackSections',
+      property: input.value('tracks'),
     }),
 
-    {
-      dependencies: ['trackSections'],
-      compute: (continuation, {trackSections}) =>
-        continuation({
-          '#trackRefs': trackSections
-            .flatMap(section => section.tracks ?? []),
-        }),
-    },
-
-    withResolvedReferenceList({
-      list: '#trackRefs',
-      data: 'ownTrackData',
-      find: input.value(find.track),
+    withFlattenedList({
+      list: '#trackSections.tracks',
+    }).outputs({
+      ['#flattenedList']: '#tracks',
     }),
-
-    {
-      dependencies: ['#resolvedReferenceList'],
-      compute: (continuation, {
-        ['#resolvedReferenceList']: resolvedReferenceList,
-      }) => continuation({
-        ['#tracks']: resolvedReferenceList,
-      })
-    },
   ],
 });
diff --git a/src/data/composite/things/flash-act/index.js b/src/data/composite/things/flash-act/index.js
new file mode 100644
index 0000000..40fecd2
--- /dev/null
+++ b/src/data/composite/things/flash-act/index.js
@@ -0,0 +1 @@
+export {default as withFlashSide} from './withFlashSide.js';
diff --git a/src/data/composite/things/flash-act/withFlashSide.js b/src/data/composite/things/flash-act/withFlashSide.js
new file mode 100644
index 0000000..64daa1f
--- /dev/null
+++ b/src/data/composite/things/flash-act/withFlashSide.js
@@ -0,0 +1,22 @@
+// Gets the flash act's side. This will early exit if flashSideData is missing.
+// If there's no side whose list of flash acts includes this act, the output
+// dependency will be null.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withFlashSide`,
+
+  outputs: ['#flashSide'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      data: 'flashSideData',
+      list: input.value('acts'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#flashSide',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/flash/withFlashAct.js b/src/data/composite/things/flash/withFlashAct.js
index ada2dcf..652b8bf 100644
--- a/src/data/composite/things/flash/withFlashAct.js
+++ b/src/data/composite/things/flash/withFlashAct.js
@@ -1,108 +1,22 @@
 // Gets the flash's act. This will early exit if flashActData is missing.
-// By default, if there's no flash whose list of flashes includes this flash,
-// the output dependency will be null; set {notFoundMode: 'exit'} to early
-// exit instead.
-//
-// This step models with Flash.withAlbum.
+// If there's no flash whose list of flashes includes this flash, the output
+// dependency will be null.
 
 import {input, templateCompositeFrom} from '#composite';
-import {is} from '#validators';
 
-import {exitWithoutDependency, withResultOfAvailabilityCheck}
-  from '#composite/control-flow';
-import {withPropertyFromList} from '#composite/data';
+import {withUniqueReferencingThing} from '#composite/wiki-data';
 
 export default templateCompositeFrom({
   annotation: `withFlashAct`,
 
-  inputs: {
-    notFoundMode: input({
-      validate: is('exit', 'null'),
-      defaultValue: 'null',
-    }),
-  },
-
   outputs: ['#flashAct'],
 
   steps: () => [
-    // null flashActData is always an early exit.
-
-    exitWithoutDependency({
-      dependency: 'flashActData',
-      mode: input.value('null'),
-    }),
-
-    // empty flashActData conditionally exits early or outputs null.
-
-    withResultOfAvailabilityCheck({
-      from: 'flashActData',
-      mode: input.value('empty'),
-    }).outputs({
-      '#availability': '#flashActDataAvailability',
-    }),
-
-    {
-      dependencies: [input('notFoundMode'), '#flashActDataAvailability'],
-      compute(continuation, {
-        [input('notFoundMode')]: notFoundMode,
-        ['#flashActDataAvailability']: flashActDataIsAvailable,
-      }) {
-        if (flashActDataIsAvailable) return continuation();
-        switch (notFoundMode) {
-          case 'exit': return continuation.exit(null);
-          case 'null': return continuation.raiseOutput({'#flashAct': null});
-        }
-      },
-    },
-
-    withPropertyFromList({
-      list: 'flashActData',
-      property: input.value('flashes'),
-    }),
-
-    {
-      dependencies: [input.myself(), '#flashActData.flashes'],
-      compute: (continuation, {
-        [input.myself()]: track,
-        ['#flashActData.flashes']: flashLists,
-      }) => continuation({
-        ['#flashActIndex']:
-          flashLists.findIndex(flashes => flashes.includes(track)),
-      }),
-    },
-
-    // album not found conditionally exits or outputs null.
-
-    withResultOfAvailabilityCheck({
-      from: '#flashActIndex',
-      mode: input.value('index'),
+    withUniqueReferencingThing({
+      data: 'flashActData',
+      list: input.value('flashes'),
     }).outputs({
-      '#availability': '#flashActAvailability',
+      ['#uniqueReferencingThing']: '#flashAct',
     }),
-
-    {
-      dependencies: [input('notFoundMode'), '#flashActAvailability'],
-      compute(continuation, {
-        [input('notFoundMode')]: notFoundMode,
-        ['#flashActAvailability']: flashActIsAvailable,
-      }) {
-        if (flashActIsAvailable) return continuation();
-        switch (notFoundMode) {
-          case 'exit': return continuation.exit(null);
-          case 'null': return continuation.raiseOutput({'#flashAct': null});
-        }
-      },
-    },
-
-    {
-      dependencies: ['flashActData', '#flashActIndex'],
-      compute: (continuation, {
-        ['flashActData']: flashActData,
-        ['#flashActIndex']: flashActIndex,
-      }) => continuation.raiseOutput({
-        ['#flashAct']:
-          flashActData[flashActIndex],
-      }),
-    },
   ],
 });
diff --git a/src/data/composite/things/track-section/index.js b/src/data/composite/things/track-section/index.js
new file mode 100644
index 0000000..3202ed4
--- /dev/null
+++ b/src/data/composite/things/track-section/index.js
@@ -0,0 +1 @@
+export {default as withAlbum} from './withAlbum.js';
diff --git a/src/data/composite/things/track-section/withAlbum.js b/src/data/composite/things/track-section/withAlbum.js
new file mode 100644
index 0000000..608cc0c
--- /dev/null
+++ b/src/data/composite/things/track-section/withAlbum.js
@@ -0,0 +1,22 @@
+// Gets the track section's album. This will early exit if ownAlbumData is
+// missing. If there's no album whose list of track sections includes this one,
+// the output dependency will be null.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withAlbum`,
+
+  outputs: ['#album'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      data: 'ownAlbumData',
+      list: input.value('trackSections'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#album',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
index cc723a2..8959de9 100644
--- a/src/data/composite/things/track/index.js
+++ b/src/data/composite/things/track/index.js
@@ -9,3 +9,4 @@ export {default as withContainingTrackSection} from './withContainingTrackSectio
 export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js';
 export {default as withOtherReleases} from './withOtherReleases.js';
 export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js';
+export {default as withPropertyFromOriginalRelease} from './withPropertyFromOriginalRelease.js';
diff --git a/src/data/composite/things/track/inheritFromOriginalRelease.js b/src/data/composite/things/track/inheritFromOriginalRelease.js
index 27ed138..38ab06b 100644
--- a/src/data/composite/things/track/inheritFromOriginalRelease.js
+++ b/src/data/composite/things/track/inheritFromOriginalRelease.js
@@ -1,8 +1,6 @@
-// Early exits with a value inherited from the original release, if
-// this track is a rerelease, and otherwise continues with no further
-// dependencies provided. If allowOverride is true, then the continuation
-// will also be called if the original release exposed the requested
-// property as null.
+// Early exits with the value for the same property as specified on the
+// original release, if this track is a rerelease, and otherwise continues
+// without providing any further dependencies.
 //
 // Like withOriginalRelease, this will early exit (with notFoundValue) if the
 // original release is specified by reference and that reference doesn't
@@ -10,41 +8,34 @@
 
 import {input, templateCompositeFrom} from '#composite';
 
-import withOriginalRelease from './withOriginalRelease.js';
+import {exposeDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+
+import withPropertyFromOriginalRelease
+  from './withPropertyFromOriginalRelease.js';
 
 export default templateCompositeFrom({
   annotation: `inheritFromOriginalRelease`,
 
   inputs: {
-    property: input({type: 'string'}),
-    allowOverride: input({type: 'boolean', defaultValue: false}),
-    notFoundValue: input({defaultValue: null}),
+    notFoundValue: input({
+      defaultValue: null,
+    }),
   },
 
   steps: () => [
-    withOriginalRelease({
+    withPropertyFromOriginalRelease({
+      property: input.thisProperty(),
       notFoundValue: input('notFoundValue'),
     }),
 
-    {
-      dependencies: [
-        '#originalRelease',
-        input('property'),
-        input('allowOverride'),
-      ],
-
-      compute: (continuation, {
-        ['#originalRelease']: originalRelease,
-        [input('property')]: originalProperty,
-        [input('allowOverride')]: allowOverride,
-      }) => {
-        if (!originalRelease) return continuation();
-
-        const value = originalRelease[originalProperty];
-        if (allowOverride && value === null) return continuation();
-
-        return continuation.exit(value);
-      },
-    },
+    raiseOutputWithoutDependency({
+      dependency: '#isRerelease',
+      mode: input.value('falsy'),
+    }),
+
+    exposeDependency({
+      dependency: '#originalValue',
+    }),
   ],
 });
diff --git a/src/data/composite/things/track/withAlbum.js b/src/data/composite/things/track/withAlbum.js
index cbd16dc..03b840d 100644
--- a/src/data/composite/things/track/withAlbum.js
+++ b/src/data/composite/things/track/withAlbum.js
@@ -1,108 +1,22 @@
 // Gets the track's album. This will early exit if albumData is missing.
-// By default, if there's no album whose list of tracks includes this track,
-// the output dependency will be null; set {notFoundMode: 'exit'} to early
-// exit instead.
-//
-// This step models with Flash.withFlashAct.
+// If there's no album whose list of tracks includes this track, the output
+// dependency will be null.
 
 import {input, templateCompositeFrom} from '#composite';
-import {is} from '#validators';
 
-import {exitWithoutDependency, withResultOfAvailabilityCheck}
-  from '#composite/control-flow';
-import {withPropertyFromList} from '#composite/data';
+import {withUniqueReferencingThing} from '#composite/wiki-data';
 
 export default templateCompositeFrom({
   annotation: `withAlbum`,
 
-  inputs: {
-    notFoundMode: input({
-      validate: is('exit', 'null'),
-      defaultValue: 'null',
-    }),
-  },
-
   outputs: ['#album'],
 
   steps: () => [
-    // null albumData is always an early exit.
-
-    exitWithoutDependency({
-      dependency: 'albumData',
-      mode: input.value('null'),
-    }),
-
-    // empty albumData conditionally exits early or outputs null.
-
-    withResultOfAvailabilityCheck({
-      from: 'albumData',
-      mode: input.value('empty'),
-    }).outputs({
-      '#availability': '#albumDataAvailability',
-    }),
-
-    {
-      dependencies: [input('notFoundMode'), '#albumDataAvailability'],
-      compute(continuation, {
-        [input('notFoundMode')]: notFoundMode,
-        ['#albumDataAvailability']: albumDataIsAvailable,
-      }) {
-        if (albumDataIsAvailable) return continuation();
-        switch (notFoundMode) {
-          case 'exit': return continuation.exit(null);
-          case 'null': return continuation.raiseOutput({'#album': null});
-        }
-      },
-    },
-
-    withPropertyFromList({
-      list: 'albumData',
-      property: input.value('tracks'),
-    }),
-
-    {
-      dependencies: [input.myself(), '#albumData.tracks'],
-      compute: (continuation, {
-        [input.myself()]: track,
-        ['#albumData.tracks']: trackLists,
-      }) => continuation({
-        ['#albumIndex']:
-          trackLists.findIndex(tracks => tracks.includes(track)),
-      }),
-    },
-
-    // album not found conditionally exits or outputs null.
-
-    withResultOfAvailabilityCheck({
-      from: '#albumIndex',
-      mode: input.value('index'),
+    withUniqueReferencingThing({
+      data: 'albumData',
+      list: input.value('tracks'),
     }).outputs({
-      '#availability': '#albumAvailability',
+      ['#uniqueReferencingThing']: '#album',
     }),
-
-    {
-      dependencies: [input('notFoundMode'), '#albumAvailability'],
-      compute(continuation, {
-        [input('notFoundMode')]: notFoundMode,
-        ['#albumAvailability']: albumIsAvailable,
-      }) {
-        if (albumIsAvailable) return continuation();
-        switch (notFoundMode) {
-          case 'exit': return continuation.exit(null);
-          case 'null': return continuation.raiseOutput({'#album': null});
-        }
-      },
-    },
-
-    {
-      dependencies: ['albumData', '#albumIndex'],
-      compute: (continuation, {
-        ['albumData']: albumData,
-        ['#albumIndex']: albumIndex,
-      }) => continuation.raiseOutput({
-        ['#album']:
-          albumData[albumIndex],
-      }),
-    },
   ],
 });
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
index fac8e21..e01720b 100644
--- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -7,11 +7,15 @@ import {input, templateCompositeFrom} from '#composite';
 import find from '#find';
 import {isBoolean} from '#validators';
 
-import {exitWithoutDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 import {withResolvedReference} from '#composite/wiki-data';
 
+import {
+  exitWithoutDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
 export default templateCompositeFrom({
   annotation: `withAlwaysReferenceByDirectory`,
 
@@ -22,6 +26,29 @@ export default templateCompositeFrom({
       validate: input.value(isBoolean),
     }),
 
+    // withAlwaysReferenceByDirectory is sort of a fragile area - we can't
+    // find the track's album the normal way because albums' track lists
+    // recurse back into alwaysReferenceByDirectory!
+    withResolvedReference({
+      ref: 'dataSourceAlbum',
+      data: 'albumData',
+      find: input.value(find.album),
+    }).outputs({
+      '#resolvedReference': '#album',
+    }),
+
+    withPropertyFromObject({
+      object: '#album',
+      property: input.value('alwaysReferenceTracksByDirectory'),
+    }),
+
+    // Falsy mode means this exposes true if the album's property is true,
+    // but continues if the property is false (which is also the default).
+    exposeDependencyOrContinue({
+      dependency: '#album.alwaysReferenceTracksByDirectory',
+      mode: input.value('falsy'),
+    }),
+
     // Remaining code is for defaulting to true if this track is a rerelease of
     // another with the same name, so everything further depends on access to
     // trackData as well as originalReleaseTrack.
diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js
index b2e5f2b..eaac14d 100644
--- a/src/data/composite/things/track/withContainingTrackSection.js
+++ b/src/data/composite/things/track/withContainingTrackSection.js
@@ -1,63 +1,42 @@
 // Gets the track section containing this track from its album's track list.
-// If notFoundMode is set to 'exit', this will early exit if the album can't be
-// found or if none of its trackSections includes the track for some reason.
 
 import {input, templateCompositeFrom} from '#composite';
 import {is} from '#validators';
 
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
 import withPropertyFromAlbum from './withPropertyFromAlbum.js';
 
 export default templateCompositeFrom({
   annotation: `withContainingTrackSection`,
 
-  inputs: {
-    notFoundMode: input({
-      validate: is('exit', 'null'),
-      defaultValue: 'null',
-    }),
-  },
-
   outputs: ['#trackSection'],
 
   steps: () => [
     withPropertyFromAlbum({
       property: input.value('trackSections'),
-      notFoundMode: input('notFoundMode'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#album.trackSections',
+      output: input.value({'#trackSection': null}),
     }),
 
     {
       dependencies: [
         input.myself(),
-        input('notFoundMode'),
         '#album.trackSections',
       ],
 
-      compute(continuation, {
+      compute: (continuation, {
         [input.myself()]: track,
         [input('notFoundMode')]: notFoundMode,
         ['#album.trackSections']: trackSections,
-      }) {
-        if (!trackSections) {
-          return continuation.raiseOutput({
-            ['#trackSection']: null,
-          });
-        }
-
-        const trackSection =
-          trackSections.find(({tracks}) => tracks.includes(track));
-
-        if (trackSection) {
-          return continuation.raiseOutput({
-            ['#trackSection']: trackSection,
-          });
-        } else if (notFoundMode === 'exit') {
-          return continuation.exit(null);
-        } else {
-          return continuation.raiseOutput({
-            ['#trackSection']: null,
-          });
-        }
-      },
+      }) => continuation({
+        ['#trackSection']:
+          trackSections.find(({tracks}) => tracks.includes(track))
+            ?? null,
+      }),
     },
   ],
 });
diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js
index b236a6e..d41390f 100644
--- a/src/data/composite/things/track/withPropertyFromAlbum.js
+++ b/src/data/composite/things/track/withPropertyFromAlbum.js
@@ -1,7 +1,5 @@
 // Gets a single property from this track's album, providing it as the same
-// property name prefixed with '#album.' (by default). If the track's album
-// isn't available, then by default, the property will be provided as null;
-// set {notFoundMode: 'exit'} to early exit instead.
+// property name prefixed with '#album.' (by default).
 
 import {input, templateCompositeFrom} from '#composite';
 import {is} from '#validators';
@@ -15,11 +13,6 @@ export default templateCompositeFrom({
 
   inputs: {
     property: input.staticValue({type: 'string'}),
-
-    notFoundMode: input({
-      validate: is('exit', 'null'),
-      defaultValue: 'null',
-    }),
   },
 
   outputs: ({
@@ -27,9 +20,7 @@ export default templateCompositeFrom({
   }) => ['#album.' + property],
 
   steps: () => [
-    withAlbum({
-      notFoundMode: input('notFoundMode'),
-    }),
+    withAlbum(),
 
     withPropertyFromObject({
       object: '#album',
diff --git a/src/data/composite/things/track/withPropertyFromOriginalRelease.js b/src/data/composite/things/track/withPropertyFromOriginalRelease.js
new file mode 100644
index 0000000..fd37f6d
--- /dev/null
+++ b/src/data/composite/things/track/withPropertyFromOriginalRelease.js
@@ -0,0 +1,86 @@
+// Provides a value inherited from the original release, if applicable, and a
+// flag indicating if this track is a rerelase or not.
+//
+// Like withOriginalRelease, this will early exit (with notFoundValue) if the
+// original release is specified by reference and that reference doesn't
+// resolve to anything.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withOriginalRelease from './withOriginalRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritFromOriginalRelease`,
+
+  inputs: {
+    property: input({type: 'string'}),
+
+    notFoundValue: input({
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ({
+    [input.staticValue('property')]: property,
+  }) =>
+    ['#isRerelease'].concat(
+      (property
+        ? ['#original.' + property]
+        : ['#originalValue'])),
+
+  steps: () => [
+    withOriginalRelease({
+      notFoundValue: input('notFoundValue'),
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#originalRelease',
+    }),
+
+    {
+      dependencies: [
+        '#availability',
+        input.staticValue('property'),
+      ],
+
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input.staticValue('property')]: property,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutput(
+              Object.assign(
+                {'#isRerelease': false},
+                (property
+                  ? {['#original.' + property]: null}
+                  : {'#originalValue': null})))),
+    },
+
+    withPropertyFromObject({
+      object: '#originalRelease',
+      property: input('property'),
+    }),
+
+    {
+      dependencies: [
+        '#value',
+        input.staticValue('property'),
+      ],
+
+      compute: (continuation, {
+        ['#value']: value,
+        [input.staticValue('property')]: property,
+      }) =>
+        continuation.raiseOutput(
+          Object.assign(
+            {'#isRerelease': true},
+            (property
+              ? {['#original.' + property]: value}
+              : {'#originalValue': value}))),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index 3ccfa75..15ebaff 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -6,6 +6,8 @@
 
 export {default as exitWithoutContribs} from './exitWithoutContribs.js';
 export {default as inputWikiData} from './inputWikiData.js';
+export {default as withDirectory} from './withDirectory.js';
+export {default as withDirectoryFromName} from './withDirectoryFromName.js';
 export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
 export {default as withResolvedContribs} from './withResolvedContribs.js';
 export {default as withResolvedReference} from './withResolvedReference.js';
@@ -13,3 +15,4 @@ export {default as withResolvedReferenceList} from './withResolvedReferenceList.
 export {default as withReverseContributionList} from './withReverseContributionList.js';
 export {default as withReverseReferenceList} from './withReverseReferenceList.js';
 export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js';
+export {default as withUniqueReferencingThing} from './withUniqueReferencingThing.js';
diff --git a/src/data/composite/wiki-data/withDirectory.js b/src/data/composite/wiki-data/withDirectory.js
new file mode 100644
index 0000000..b08b615
--- /dev/null
+++ b/src/data/composite/wiki-data/withDirectory.js
@@ -0,0 +1,55 @@
+// Select a directory, either using a manually specified directory, or
+// computing it from a name. By default these values are the current thing's
+// 'directory' and 'name' properties, so it can be used without any options
+// to get the current thing's effective directory (assuming no custom rules).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+
+import withDirectoryFromName from './withDirectoryFromName.js';
+
+export default templateCompositeFrom({
+  annotation: `withDirectory`,
+
+  inputs: {
+    directory: input({
+      validate: isDirectory,
+      defaultDependency: 'directory',
+      acceptsNull: true,
+    }),
+
+    name: input({
+      validate: isName,
+      defaultDependency: 'name',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('directory'),
+    }),
+
+    {
+      dependencies: ['#availability', input('directory')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('directory')]: directory,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#directory']: directory
+            })
+          : continuation()),
+    },
+
+    withDirectoryFromName({
+      name: input('name'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withDirectoryFromName.js b/src/data/composite/wiki-data/withDirectoryFromName.js
new file mode 100644
index 0000000..034464e
--- /dev/null
+++ b/src/data/composite/wiki-data/withDirectoryFromName.js
@@ -0,0 +1,42 @@
+// Compute a directory from a name - by default the current thing's own name.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isName} from '#validators';
+import {getKebabCase} from '#wiki-data';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withDirectoryFromName`,
+
+  inputs: {
+    name: input({
+      validate: isName,
+      defaultDependency: 'name',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('name'),
+      mode: input.value('falsy'),
+      output: input.value({
+        ['#directory']: null,
+      }),
+    }),
+
+    {
+      dependencies: [input('name')],
+      compute: (continuation, {
+        [input('name')]: name,
+      }) => continuation({
+        ['#directory']:
+          getKebabCase(name),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js
index 77b0f96..9526638 100644
--- a/src/data/composite/wiki-data/withResolvedContribs.js
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -1,7 +1,8 @@
 // Resolves the contribsByRef contained in the provided dependency,
 // providing (named by the second argument) the result. "Resolving"
-// means mapping the "who" reference of each contribution to an artist
-// object, and filtering out those whose "who" doesn't match any artist.
+// means mapping the artist reference of each contribution to an artist
+// object, and filtering out those whose artist reference doesn't match
+// any artist.
 
 import {input, templateCompositeFrom} from '#composite';
 import find from '#find';
@@ -46,29 +47,30 @@ export default templateCompositeFrom({
 
     withPropertiesFromList({
       list: input('from'),
-      properties: input.value(['who', 'what']),
+      properties: input.value(['artist', 'annotation']),
       prefix: input.value('#contribs'),
     }),
 
     withResolvedReferenceList({
-      list: '#contribs.who',
+      list: '#contribs.artist',
       data: 'artistData',
       find: input.value(find.artist),
       notFoundMode: input('notFoundMode'),
     }).outputs({
-      ['#resolvedReferenceList']: '#contribs.who',
+      ['#resolvedReferenceList']: '#contribs.artist',
     }),
 
     {
-      dependencies: ['#contribs.who', '#contribs.what'],
+      dependencies: ['#contribs.artist', '#contribs.annotation'],
 
       compute(continuation, {
-        ['#contribs.who']: who,
-        ['#contribs.what']: what,
+        ['#contribs.artist']: artist,
+        ['#contribs.annotation']: annotation,
       }) {
-        filterMultipleArrays(who, what, (who, _what) => who);
+        filterMultipleArrays(artist, annotation, (artist, _annotation) => artist);
         return continuation({
-          ['#resolvedContribs']: stitchArrays({who, what}),
+          ['#resolvedContribs']:
+            stitchArrays({artist, annotation}),
         });
       },
     },
diff --git a/src/data/composite/wiki-data/withReverseContributionList.js b/src/data/composite/wiki-data/withReverseContributionList.js
index eccb58b..91e125e 100644
--- a/src/data/composite/wiki-data/withReverseContributionList.js
+++ b/src/data/composite/wiki-data/withReverseContributionList.js
@@ -11,7 +11,8 @@
 
 import {input, templateCompositeFrom} from '#composite';
 
-import {exitWithoutDependency} from '#composite/control-flow';
+import {exitWithoutDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
 
 import inputWikiData from './inputWikiData.js';
 
@@ -32,10 +33,17 @@ export default templateCompositeFrom({
   outputs: ['#reverseContributionList'],
 
   steps: () => [
+    // Early exit with an empty array if the data list isn't available.
     exitWithoutDependency({
       dependency: input('data'),
       value: input.value([]),
+    }),
+
+    // Raise an empty array (don't early exit) if the data list is empty.
+    raiseOutputWithoutDependency({
+      dependency: input('data'),
       mode: input.value('empty'),
+      output: input.value({'#reverseContributionList': []}),
     }),
 
     {
@@ -58,10 +66,10 @@ export default templateCompositeFrom({
           for (const referencingThing of data) {
             const referenceList = referencingThing[list];
 
-            // Destructuring {who} is the only unique part of the
+            // Destructuring {artist} is the only unique part of the
             // withReverseContributionList implementation, compared to
             // withReverseReferneceList.
-            for (const {who: referencedThing} of referenceList) {
+            for (const {artist: referencedThing} of referenceList) {
               if (cacheRecord.has(referencedThing)) {
                 cacheRecord.get(referencedThing).push(referencingThing);
               } else {
diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js
index 2d7a421..8cd540a 100644
--- a/src/data/composite/wiki-data/withReverseReferenceList.js
+++ b/src/data/composite/wiki-data/withReverseReferenceList.js
@@ -13,7 +13,8 @@
 
 import {input, templateCompositeFrom} from '#composite';
 
-import {exitWithoutDependency} from '#composite/control-flow';
+import {exitWithoutDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
 
 import inputWikiData from './inputWikiData.js';
 
@@ -34,10 +35,17 @@ export default templateCompositeFrom({
   outputs: ['#reverseReferenceList'],
 
   steps: () => [
+    // Early exit with an empty array if the data list isn't available.
     exitWithoutDependency({
       dependency: input('data'),
       value: input.value([]),
+    }),
+
+    // Raise an empty array (don't early exit) if the data list is empty.
+    raiseOutputWithoutDependency({
+      dependency: input('data'),
       mode: input.value('empty'),
+      output: input.value({'#reverseReferenceList': []}),
     }),
 
     {
diff --git a/src/data/composite/wiki-data/withUniqueReferencingThing.js b/src/data/composite/wiki-data/withUniqueReferencingThing.js
new file mode 100644
index 0000000..61c1061
--- /dev/null
+++ b/src/data/composite/wiki-data/withUniqueReferencingThing.js
@@ -0,0 +1,51 @@
+// Like withReverseReferenceList, but this is specifically for special "unique"
+// references, meaning this thing is referenced by exactly one or zero things
+// in the data list.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+
+import inputWikiData from './inputWikiData.js';
+import withReverseReferenceList from './withReverseReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withUniqueReferencingThing`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: false}),
+    list: input({type: 'string'}),
+  },
+
+  outputs: ['#uniqueReferencingThing'],
+
+  steps: () => [
+    // Early exit with null (not an empty array) if the data list
+    // isn't available.
+    exitWithoutDependency({
+      dependency: input('data'),
+    }),
+
+    withReverseReferenceList({
+      data: input('data'),
+      list: input('list'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#reverseReferenceList',
+      mode: input.value('empty'),
+      output: input.value({'#uniqueReferencingThing': null}),
+    }),
+
+    {
+      dependencies: ['#reverseReferenceList'],
+      compute: (continuation, {
+        ['#reverseReferenceList']: reverseReferenceList,
+      }) => continuation({
+        ['#uniqueReferencingThing']:
+          reverseReferenceList[0],
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-properties/contributionList.js b/src/data/composite/wiki-properties/contributionList.js
index 8fde2ca..aad12a2 100644
--- a/src/data/composite/wiki-properties/contributionList.js
+++ b/src/data/composite/wiki-properties/contributionList.js
@@ -3,15 +3,15 @@
 // into one property. Update value will look something like this:
 //
 //   [
-//     {who: 'Artist Name', what: 'Viola'},
-//     {who: 'artist:john-cena', what: null},
+//     {artist: 'Artist Name', annotation: 'Viola'},
+//     {artist: 'artist:john-cena', annotation: null},
 //     ...
 //   ]
 //
 // ...typically as processed from YAML, spreadsheet, or elsewhere.
-// Exposes as the same, but with the "who" replaced with matches found in
-// artistData - which means this always depends on an `artistData` property
-// also existing on this object!
+// Exposes as the same, but with the artist property replaced with matches
+// found in artistData - which means this always depends on an `artistData`
+// property also existing on this object!
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/wiki-properties/directory.js b/src/data/composite/wiki-properties/directory.js
index 0b2181c..41ce4b2 100644
--- a/src/data/composite/wiki-properties/directory.js
+++ b/src/data/composite/wiki-properties/directory.js
@@ -2,22 +2,32 @@
 // almost any data object. Also corresponds to a part of the URL which pages of
 // such objects are visited at.
 
-import {isDirectory} from '#validators';
-import {getKebabCase} from '#wiki-data';
-
-// TODO: Not templateCompositeFrom.
-
-export default function() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isDirectory},
-    expose: {
-      dependencies: ['name'],
-      transform(directory, {name}) {
-        if (directory === null && name === null) return null;
-        else if (directory === null) return getKebabCase(name);
-        else return directory;
-      },
-    },
-  };
-}
+import {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withDirectory} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `directory`,
+
+  compose: false,
+
+  inputs: {
+    name: input({
+      validate: isName,
+      defaultDependency: 'name',
+    }),
+  },
+
+  steps: () => [
+    withDirectory({
+      directory: input.updateValue({validate: isDirectory}),
+    }),
+
+    exposeDependency({
+      dependency: '#directory',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/referenceList.js b/src/data/composite/wiki-properties/referenceList.js
index af634a6..ebd5947 100644
--- a/src/data/composite/wiki-properties/referenceList.js
+++ b/src/data/composite/wiki-properties/referenceList.js
@@ -1,5 +1,6 @@
 // Stores and exposes a list of references to other data objects; all items
-// must be references to the same type, which is specified on the class input.
+// must be references to the same type, which is either implied from the class
+// input, or explicitly set on the referenceType input.
 //
 // See also:
 //  - singleReference
@@ -18,7 +19,17 @@ export default templateCompositeFrom({
   compose: false,
 
   inputs: {
-    class: input.staticValue({validate: isThingClass}),
+    class: input.staticValue({
+      validate: isThingClass,
+      acceptsNull: true,
+      defaultValue: null,
+    }),
+
+    referenceType: input.staticValue({
+      type: 'string',
+      acceptsNull: true,
+      defaultValue: null,
+    }),
 
     data: inputWikiData({allowMixedTypes: false}),
 
@@ -27,10 +38,13 @@ export default templateCompositeFrom({
 
   update: ({
     [input.staticValue('class')]: thingClass,
+    [input.staticValue('referenceType')]: referenceType,
   }) => ({
     validate:
       validateReferenceList(
-        thingClass[Symbol.for('Thing.referenceType')]),
+        (thingClass
+          ? thingClass[Symbol.for('Thing.referenceType')]
+          : referenceType)),
   }),
 
   steps: () => [
diff --git a/src/data/serialize.js b/src/data/serialize.js
index 8cac330..2ecbf76 100644
--- a/src/data/serialize.js
+++ b/src/data/serialize.js
@@ -16,7 +16,10 @@ export function toRefs(things) {
 }
 
 export function toContribRefs(contribs) {
-  return contribs?.map(({who, what}) => ({who: toRef(who), what}));
+  return contribs?.map(({artist, annotation}) => ({
+    artist: toRef(artist),
+    annotation,
+  }));
 }
 
 export function toCommentaryRefs(entries) {
diff --git a/src/data/thing.js b/src/data/thing.js
index 706e893..29f50d2 100644
--- a/src/data/thing.js
+++ b/src/data/thing.js
@@ -17,17 +17,52 @@ export default class Thing extends CacheableObject {
   static yamlDocumentSpec = Symbol.for('Thing.yamlDocumentSpec');
   static getYamlLoadingSpec = Symbol.for('Thing.getYamlLoadingSpec');
 
+  static isThingConstructor = Symbol.for('Thing.isThingConstructor');
+  static isThing = Symbol.for('Thing.isThing');
+
+  // To detect:
+  // Symbol.for('Thing.isThingConstructor') in constructor
+  static [Symbol.for('Thing.isThingConstructor')] = NaN;
+
+  static [CacheableObject.propertyDescriptors] = {
+    // To detect:
+    // Object.hasOwn(object, Symbol.for('Thing.isThing'))
+    [Symbol.for('Thing.isThing')]: {
+      flags: {expose: true},
+      expose: {compute: () => NaN},
+    },
+  };
+
+  static [Symbol.for('Thing.selectAll')] = _wikiData => [];
+
   // Default custom inspect function, which may be overridden by Thing
   // subclasses. This will be used when displaying aggregate errors and other
   // command-line logging - it's the place to provide information useful in
   // identifying the Thing being presented.
   [inspect.custom]() {
-    const cname = this.constructor.name;
+    const constructorName = this.constructor.name;
+
+    let name;
+    try {
+      if (this.name) {
+        name = colors.green(`"${this.name}"`);
+      }
+    } catch (error) {
+      name = colors.yellow(`couldn't get name`);
+    }
+
+    let reference;
+    try {
+      if (this.directory) {
+        reference = colors.blue(Thing.getReference(this));
+      }
+    } catch (error) {
+      reference = colors.yellow(`couldn't get reference`);
+    }
 
     return (
-      (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) +
-      (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '')
-    );
+      (name ? `${constructorName} ${name}` : `${constructorName}`) +
+      (reference ? ` (${reference})` : ''));
   }
 
   static getReference(thing) {
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 336d777..e9f55b2 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -1,20 +1,25 @@
 export const DATA_ALBUM_DIRECTORY = 'album';
 
 import * as path from 'node:path';
+import {inspect} from 'node:util';
 
+import CacheableObject from '#cacheable-object';
+import {colors} from '#cli';
 import {input} from '#composite';
 import find from '#find';
 import {traverse} from '#node-utils';
 import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
-import {empty} from '#sugar';
+import {accumulateSum, empty} from '#sugar';
 import Thing from '#thing';
-import {isDate} from '#validators';
+import {isColor, isDate, validateWikiData} from '#validators';
 import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions}
   from '#yaml';
 
-import {exposeDependency, exposeUpdateValueOrContinue}
+import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
-import {exitWithoutContribs} from '#composite/wiki-data';
+import {withPropertyFromObject} from '#composite/data';
+import {exitWithoutContribs, withDirectory, withResolvedReference}
+  from '#composite/wiki-data';
 
 import {
   additionalFiles,
@@ -31,16 +36,24 @@ import {
   referenceList,
   simpleDate,
   simpleString,
+  singleReference,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withTracks, withTrackSections} from '#composite/things/album';
+import {withTracks} from '#composite/things/album';
+import {withAlbum} from '#composite/things/track-section';
 
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
 
-  static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({
+    ArtTag,
+    Artist,
+    Group,
+    Track,
+    TrackSection,
+  }) => ({
     // Update & expose
 
     name: name('Unnamed Album'),
@@ -48,6 +61,8 @@ export class Album extends Thing {
     directory: directory(),
     urls: urls(),
 
+    alwaysReferenceTracksByDirectory: flag(false),
+
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
 
@@ -92,6 +107,11 @@ export class Album extends Thing {
       simpleString(),
     ],
 
+    coverArtDimensions: [
+      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      dimensions(),
+    ],
+
     bannerDimensions: [
       exitWithoutContribs({contribs: 'bannerArtistContribs'}),
       dimensions(),
@@ -104,10 +124,11 @@ export class Album extends Thing {
     commentary: commentary(),
     additionalFiles: additionalFiles(),
 
-    trackSections: [
-      withTrackSections(),
-      exposeDependency({dependency: '#trackSections'}),
-    ],
+    trackSections: referenceList({
+      referenceType: input.value('unqualified-track-section'),
+      data: 'ownTrackSectionData',
+      find: input.value(find.unqualifiedTrackSection),
+    }),
 
     artistContribs: contributionList(),
     coverArtistContribs: contributionList(),
@@ -148,11 +169,8 @@ export class Album extends Thing {
       class: input.value(Group),
     }),
 
-    // Only the tracks which belong to this album.
-    // Necessary for computing the track list, so provide this statically
-    // or keep it updated.
-    ownTrackData: wikiData({
-      class: input.value(Track),
+    ownTrackSectionData: wikiData({
+      class: input.value(TrackSection),
     }),
 
     // Expose only
@@ -221,6 +239,10 @@ export class Album extends Thing {
       'Album': {property: 'name'},
       'Directory': {property: 'directory'},
 
+      'Always Reference Tracks By Directory': {
+        property: 'alwaysReferenceTracksByDirectory',
+      },
+
       'Bandcamp Album ID': {
         property: 'bandcampAlbumIdentifier',
         transform: String,
@@ -261,6 +283,11 @@ export class Album extends Thing {
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
       'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
 
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
       'Wallpaper Artists': {
         property: 'wallpaperArtistContribs',
         transform: parseContributors,
@@ -329,68 +356,77 @@ export class Album extends Thing {
     headerDocumentThing: Album,
     entryDocumentThing: document =>
       ('Section' in document
-        ? TrackSectionHelper
+        ? TrackSection
         : Track),
 
     save(results) {
       const albumData = [];
+      const trackSectionData = [];
       const trackData = [];
 
       for (const {header: album, entries} of results) {
-        // We can't mutate an array once it's set as a property value,
-        // so prepare the track sections that will show up in a track list
-        // all the way before actually applying them. (It's okay to mutate
-        // an individual section before applying it, since those are just
-        // generic objects; they aren't Things in and of themselves.)
         const trackSections = [];
-        const ownTrackData = [];
 
-        let currentTrackSection = {
+        let currentTrackSection = new TrackSection();
+        let currentTrackSectionTracks = [];
+
+        Object.assign(currentTrackSection, {
           name: `Default Track Section`,
           isDefaultTrackSection: true,
-          tracks: [],
-        };
+        });
 
         const albumRef = Thing.getReference(album);
 
         const closeCurrentTrackSection = () => {
-          if (!empty(currentTrackSection.tracks)) {
-            trackSections.push(currentTrackSection);
+          if (
+            currentTrackSection.isDefaultTrackSection &&
+            empty(currentTrackSectionTracks)
+          ) {
+            return;
           }
+
+          currentTrackSection.tracks =
+            currentTrackSectionTracks
+              .map(track => Thing.getReference(track));
+
+          currentTrackSection.ownTrackData =
+            currentTrackSectionTracks;
+
+          currentTrackSection.ownAlbumData =
+            [album];
+
+          trackSections.push(currentTrackSection);
+          trackSectionData.push(currentTrackSection);
         };
 
         for (const entry of entries) {
-          if (entry instanceof TrackSectionHelper) {
+          if (entry instanceof TrackSection) {
             closeCurrentTrackSection();
-
-            currentTrackSection = {
-              name: entry.name,
-              color: entry.color,
-              dateOriginallyReleased: entry.dateOriginallyReleased,
-              isDefaultTrackSection: false,
-              tracks: [],
-            };
-
+            currentTrackSection = entry;
+            currentTrackSectionTracks = [];
             continue;
           }
 
+          currentTrackSectionTracks.push(entry);
           trackData.push(entry);
 
           entry.dataSourceAlbum = albumRef;
-
-          ownTrackData.push(entry);
-          currentTrackSection.tracks.push(Thing.getReference(entry));
         }
 
         closeCurrentTrackSection();
 
         albumData.push(album);
 
-        album.trackSections = trackSections;
-        album.ownTrackData = ownTrackData;
+        album.trackSections =
+          trackSections
+            .map(trackSection =>
+              `unqualified-track-section:` +
+              trackSection.unqualifiedDirectory);
+
+        album.ownTrackSectionData = trackSections;
       }
 
-      return {albumData, trackData};
+      return {albumData, trackSectionData, trackData};
     },
 
     sort({albumData, trackData}) {
@@ -400,15 +436,139 @@ export class Album extends Thing {
   });
 }
 
-export class TrackSectionHelper extends Thing {
+export class TrackSection extends Thing {
   static [Thing.friendlyName] = `Track Section`;
+  static [Thing.referenceType] = `track-section`;
+
+  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+    // Update & expose
 
-  static [Thing.getPropertyDescriptors] = () => ({
     name: name('Unnamed Track Section'),
-    color: color(),
+
+    unqualifiedDirectory: directory(),
+
+    color: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isColor),
+      }),
+
+      withAlbum(),
+
+      withPropertyFromObject({
+        object: '#album',
+        property: input.value('color'),
+      }),
+
+      exposeDependency({dependency: '#album.color'}),
+    ],
+
     dateOriginallyReleased: simpleDate(),
-    isDefaultTrackGroup: flag(false),
-  })
+
+    isDefaultTrackSection: flag(false),
+
+    album: [
+      withAlbum(),
+      exposeDependency({dependency: '#album'}),
+    ],
+
+    tracks: referenceList({
+      class: input.value(Track),
+      data: 'ownTrackData',
+      find: input.value(find.track),
+    }),
+
+    // Update only
+
+    ownAlbumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    ownTrackData: wikiData({
+      class: input.value(Track),
+    }),
+
+    // Expose only
+
+    directory: [
+      withAlbum(),
+
+      exitWithoutDependency({
+        dependency: '#album',
+      }),
+
+      withPropertyFromObject({
+        object: '#album',
+        property: input.value('directory'),
+      }),
+
+      withDirectory({
+        directory: 'unqualifiedDirectory',
+      }).outputs({
+        '#directory': '#unqualifiedDirectory',
+      }),
+
+      {
+        dependencies: ['#album.directory', '#unqualifiedDirectory'],
+        compute: ({
+          ['#album.directory']: albumDirectory,
+          ['#unqualifiedDirectory']: unqualifiedDirectory,
+        }) =>
+          albumDirectory + '/' + unqualifiedDirectory,
+      },
+    ],
+
+    startIndex: [
+      withAlbum(),
+
+      withPropertyFromObject({
+        object: '#album',
+        property: input.value('trackSections'),
+      }),
+
+      {
+        dependencies: ['#album.trackSections', input.myself()],
+        compute: (continuation, {
+          ['#album.trackSections']: trackSections,
+          [input.myself()]: myself,
+        }) => continuation({
+          ['#index']:
+            trackSections.indexOf(myself),
+        }),
+      },
+
+      exitWithoutDependency({
+        dependency: '#index',
+        mode: input.value('index'),
+        value: input.value(0),
+      }),
+
+      {
+        dependencies: ['#album.trackSections', '#index'],
+        compute: ({
+          ['#album.trackSections']: trackSections,
+          ['#index']: index,
+        }) =>
+          accumulateSum(
+            trackSections
+              .slice(0, index)
+              .map(section => section.tracks.length)),
+      },
+    ],
+  });
+
+  static [Thing.findSpecs] = {
+    trackSection: {
+      referenceTypes: ['track-section'],
+      bindTo: 'trackSectionData',
+    },
+
+    unqualifiedTrackSection: {
+      referenceTypes: ['unqualified-track-section'],
+
+      getMatchableDirectories: trackSection =>
+        [trackSection.unqualifiedDirectory],
+    },
+  };
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
@@ -421,4 +581,48 @@ export class TrackSectionHelper extends Thing {
       },
     },
   };
+
+  [inspect.custom](depth) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (depth >= 0) {
+      let album = null;
+      try {
+        album = this.album;
+      } catch {}
+
+      let first = null;
+      try {
+        first = this.startIndex;
+      } catch {}
+
+      let length = null;
+      try {
+        length = this.tracks.length;
+      } catch {}
+
+      album ??= CacheableObject.getUpdateValue(this, 'ownAlbumData')?.[0];
+
+      if (album) {
+        const albumName = album.name;
+        const albumIndex = album.trackSections.indexOf(this);
+
+        const num =
+          (albumIndex === -1
+            ? 'indeterminate position'
+            : `#${albumIndex + 1}`);
+
+        const range =
+          (albumIndex >= 0 && first !== null && length !== null
+            ? `: ${first + 1}-${first + length + 1}`
+            : '');
+
+        parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
+      }
+    }
+
+    return parts.join('');
+  }
 }
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 73acba6..841d652 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -220,6 +220,11 @@ export class Artist extends Thing {
       data: 'flashData',
       list: input.value('contributorContribs'),
     }),
+
+    flashesAsCommentator: reverseReferenceList({
+      data: 'flashData',
+      list: input.value('commentatorArtists'),
+    }),
   });
 
   static [Thing.getSerializeDescriptors] = ({
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index 81de327..ceed79f 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -2,17 +2,26 @@ export const FLASH_DATA_FILE = 'flashes.yaml';
 
 import {input} from '#composite';
 import find from '#find';
+import {empty} from '#sugar';
 import {sortFlashesChronologically} from '#sort';
 import Thing from '#thing';
-import {anyOf, isColor, isDirectory, isNumber, isString} from '#validators';
+import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
+  from '#validators';
 import {parseDate, parseContributors} from '#yaml';
 
-import {exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 
 import {
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
   color,
+  commentary,
+  commentatorArtists,
   contentString,
   contributionList,
   directory,
@@ -25,6 +34,7 @@ import {
 } from '#composite/wiki-properties';
 
 import {withFlashAct} from '#composite/things/flash';
+import {withFlashSide} from '#composite/things/flash-act';
 
 export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
@@ -89,6 +99,8 @@ export class Flash extends Thing {
 
     urls: urls(),
 
+    commentary: commentary(),
+
     // Update only
 
     artistData: wikiData({
@@ -105,16 +117,23 @@ export class Flash extends Thing {
 
     // Expose only
 
-    act: {
-      flags: {expose: true},
+    commentatorArtists: commentatorArtists(),
 
-      expose: {
-        dependencies: ['this', 'flashActData'],
+    act: [
+      withFlashAct(),
+      exposeDependency({dependency: '#flashAct'}),
+    ],
 
-        compute: ({this: flash, flashActData}) =>
-          flashActData.find((act) => act.flashes.includes(flash)) ?? null,
-      },
-    },
+    side: [
+      withFlashAct(),
+
+      withPropertyFromObject({
+        object: '#flashAct',
+        property: input.value('side'),
+      }),
+
+      exposeDependency({dependency: '#flashAct.side'}),
+    ],
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -153,11 +172,14 @@ export class Flash extends Thing {
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
       'Featured Tracks': {property: 'featuredTracks'},
+
       'Contributors': {
         property: 'contributorContribs',
         transform: parseContributors,
       },
 
+      'Commentary': {property: 'commentary'},
+
       'Review Points': {ignore: true},
     },
   };
@@ -173,19 +195,27 @@ export class FlashAct extends Thing {
     name: name('Unnamed Flash Act'),
     directory: directory(),
     color: color(),
-    listTerminology: contentString(),
 
-    jump: contentString(),
+    listTerminology: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isContentString),
+      }),
 
-    jumpColor: {
-      flags: {update: true, expose: true},
-      update: {validate: isColor},
-      expose: {
-        dependencies: ['color'],
-        transform: (jumpColor, {color}) =>
-          jumpColor ?? color,
-      }
-    },
+      withFlashSide(),
+
+      withPropertyFromObject({
+        object: '#flashSide',
+        property: input.value('listTerminology'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#flashSide.listTerminology',
+      }),
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
 
     flashes: referenceList({
       class: input.value(Flash),
@@ -198,6 +228,17 @@ export class FlashAct extends Thing {
     flashData: wikiData({
       class: input.value(Flash),
     }),
+
+    flashSideData: wikiData({
+      class: input.value(FlashSide),
+    }),
+
+    // Expose only
+
+    side: [
+      withFlashSide(),
+      exposeDependency({dependency: '#flashSide'}),
+    ],
   });
 
   static [Thing.findSpecs] = {
@@ -215,12 +256,51 @@ export class FlashAct extends Thing {
       'Color': {property: 'color'},
       'List Terminology': {property: 'listTerminology'},
 
-      'Jump': {property: 'jump'},
-      'Jump Color': {property: 'jumpColor'},
-
       'Review Points': {ignore: true},
     },
   };
+}
+
+export class FlashSide extends Thing {
+  static [Thing.referenceType] = 'flash-side';
+  static [Thing.friendlyName] = `Flash Side`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    name: name('Unnamed Flash Side'),
+    directory: directory(),
+    color: color(),
+    listTerminology: contentString(),
+
+    acts: referenceList({
+      class: input.value(FlashAct),
+      find: input.value(find.flashAct),
+      data: 'flashActData',
+    }),
+
+    // Update only
+
+    flashActData: wikiData({
+      class: input.value(FlashAct),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Side': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'Color': {property: 'color'},
+      'List Terminology': {property: 'listTerminology'},
+    },
+  };
+
+  static [Thing.findSpecs] = {
+    flashSide: {
+      referenceTypes: ['flash-side'],
+      bindTo: 'flashSideData',
+    },
+  };
 
   static [Thing.getYamlLoadingSpec] = ({
     documentModes: {allInOne},
@@ -231,39 +311,56 @@ export class FlashAct extends Thing {
 
     documentMode: allInOne,
     documentThing: document =>
-      ('Act' in document
+      ('Side' in document
+        ? FlashSide
+     : 'Act' in document
         ? FlashAct
         : Flash),
 
     save(results) {
-      let flashAct;
-      let flashRefs = [];
+      // JavaScript likes you.
 
-      if (results[0] && !(results[0] instanceof FlashAct)) {
-        throw new Error(`Expected an act at top of flash data file`);
+      if (!empty(results) && !(results[0] instanceof FlashSide)) {
+        throw new Error(`Expected a side at top of flash data file`);
       }
 
-      for (const thing of results) {
-        if (thing instanceof FlashAct) {
-          if (flashAct) {
-            Object.assign(flashAct, {flashes: flashRefs});
-          }
+      let index = 0;
+      let thing;
+      for (; thing = results[index]; index++) {
+        const flashSide = thing;
+        const flashActRefs = [];
 
-          flashAct = thing;
-          flashRefs = [];
-        } else {
-          flashRefs.push(Thing.getReference(thing));
+        if (results[index + 1] instanceof Flash) {
+          throw new Error(`Expected an act to immediately follow a side`);
         }
-      }
 
-      if (flashAct) {
-        Object.assign(flashAct, {flashes: flashRefs});
+        for (
+          index++;
+          (thing = results[index]) && thing instanceof FlashAct;
+          index++
+        ) {
+          const flashAct = thing;
+          const flashRefs = [];
+          for (
+            index++;
+            (thing = results[index]) && thing instanceof Flash;
+            index++
+          ) {
+            flashRefs.push(Thing.getReference(thing));
+          }
+          index--;
+          flashAct.flashes = flashRefs;
+          flashActRefs.push(Thing.getReference(flashAct));
+        }
+        index--;
+        flashSide.acts = flashActRefs;
       }
 
       const flashData = results.filter(x => x instanceof Flash);
       const flashActData = results.filter(x => x instanceof FlashAct);
+      const flashSideData = results.filter(x => x instanceof FlashSide);
 
-      return {flashData, flashActData};
+      return {flashData, flashActData, flashSideData};
     },
 
     sort({flashData}) {
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 3bf8409..4f87f49 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -2,10 +2,10 @@ import * as path from 'node:path';
 import {fileURLToPath} from 'node:url';
 
 import {openAggregate, showAggregate} from '#aggregate';
+import CacheableObject from '#cacheable-object';
 import {logError} from '#cli';
 import {compositeFrom} from '#composite';
 import * as serialize from '#serialize';
-
 import Thing from '#thing';
 
 import * as albumClasses from './album.js';
@@ -142,7 +142,10 @@ function evaluatePropertyDescriptors() {
         }
       }
 
-      constructor.propertyDescriptors = results;
+      constructor[CacheableObject.propertyDescriptors] = {
+        ...constructor[CacheableObject.propertyDescriptors] ?? {},
+        ...results,
+      };
     },
 
     showFailedClasses(failedClasses) {
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 93ed40b..dbe1ff3 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -2,6 +2,7 @@ import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
 
 import {withAggregate} from '#aggregate';
 import CacheableObject from '#cacheable-object';
+import {logWarn} from '#cli';
 import * as html from '#html';
 import {empty} from '#sugar';
 import {isLanguageCode} from '#validators';
@@ -17,6 +18,8 @@ import {
 
 import {externalFunction, flag, name} from '#composite/wiki-properties';
 
+export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g;
+
 export class Language extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
@@ -60,14 +63,46 @@ export class Language extends Thing {
     strings: {
       flags: {update: true, expose: true},
       update: {validate: (t) => typeof t === 'object'},
+
       expose: {
-        dependencies: ['inheritedStrings'],
-        transform(strings, {inheritedStrings}) {
-          if (strings || inheritedStrings) {
-            return {...(inheritedStrings ?? {}), ...(strings ?? {})};
-          } else {
-            return null;
+        dependencies: ['inheritedStrings', 'code'],
+        transform(strings, {inheritedStrings, code}) {
+          if (!strings && !inheritedStrings) return null;
+          if (!inheritedStrings) return strings;
+
+          const validStrings = {
+            ...inheritedStrings,
+            ...strings,
+          };
+
+          const optionsFromTemplate = template =>
+            Array.from(template.matchAll(languageOptionRegex))
+              .map(({groups}) => groups.name);
+
+          for (const [key, providedTemplate] of Object.entries(strings)) {
+            const inheritedTemplate = inheritedStrings[key];
+            if (!inheritedTemplate) continue;
+
+            const providedOptions = optionsFromTemplate(providedTemplate);
+            const inheritedOptions = optionsFromTemplate(inheritedTemplate);
+
+            const missingOptionNames =
+              inheritedOptions.filter(name => !providedOptions.includes(name));
+
+            const misplacedOptionNames =
+              providedOptions.filter(name => !inheritedOptions.includes(name));
+
+            if (!empty(missingOptionNames) || !empty(misplacedOptionNames)) {
+              logWarn`Not using ${code ?? '(no code)'} string ${key}:`;
+              if (!empty(missingOptionNames))
+                logWarn`- Missing options: ${missingOptionNames.join(', ')}`;
+              if (!empty(misplacedOptionNames))
+                logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`;
+              validStrings[key] = inheritedStrings[key];
+            }
           }
+
+          return validStrings;
         },
       },
     },
@@ -201,7 +236,7 @@ export class Language extends Thing {
     const output = this.#iterateOverTemplate({
       template: this.strings[key],
 
-      match: /{(?<name>[A-Z0-9_]+)}/g,
+      match: languageOptionRegex,
 
       insert: ({name: optionName}, canceledForming) => {
         if (optionsMap.has(optionName)) {
@@ -357,6 +392,7 @@ export class Language extends Thing {
   // contents, if needed.
   #wrapSanitized(content) {
     return html.tags(content, {
+      [html.blessAttributes]: true,
       [html.joinChildren]: '',
       [html.noEdgeWhitespace]: true,
     });
@@ -522,7 +558,7 @@ export class Language extends Thing {
   }
 
   formatExternalLink(url, {
-    style = 'normal',
+    style = 'platform',
     context = 'generic',
   } = {}) {
     if (!this.externalLinkSpec) {
@@ -540,10 +576,16 @@ export class Language extends Thing {
 
     isExternalLinkStyle(style);
 
-    return getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
-      language: this,
-      context,
-    });
+    const result =
+      getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+        language: this,
+        context,
+      });
+
+    // It's possible for there to not actually be any string available for the
+    // given URL, style, and context, and we want this to be detectable via
+    // html.blank().
+    return result ?? html.blank();
   }
 
   formatIndex(value) {
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 697dad4..725b1bb 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -13,6 +13,7 @@ import {
   parseAdditionalNames,
   parseContributors,
   parseDate,
+  parseDimensions,
   parseDuration,
 } from '#yaml';
 
@@ -34,6 +35,7 @@ import {
   commentatorArtists,
   contentString,
   contributionList,
+  dimensions,
   directory,
   duration,
   flag,
@@ -158,13 +160,15 @@ export class Track extends Thing {
       exposeDependency({dependency: '#album.trackArtDate'}),
     ],
 
+    coverArtDimensions: [
+      exitWithoutUniqueCoverArt(),
+      dimensions(),
+    ],
+
     commentary: commentary(),
 
     lyrics: [
-      inheritFromOriginalRelease({
-        property: input.value('lyrics'),
-      }),
-
+      inheritFromOriginalRelease(),
       contentString(),
     ],
 
@@ -189,7 +193,6 @@ export class Track extends Thing {
 
     artistContribs: [
       inheritFromOriginalRelease({
-        property: input.value('artistContribs'),
         notFoundValue: input.value([]),
       }),
 
@@ -213,7 +216,6 @@ export class Track extends Thing {
 
     contributorContribs: [
       inheritFromOriginalRelease({
-        property: input.value('contributorContribs'),
         notFoundValue: input.value([]),
       }),
 
@@ -248,7 +250,6 @@ export class Track extends Thing {
 
     referencedTracks: [
       inheritFromOriginalRelease({
-        property: input.value('referencedTracks'),
         notFoundValue: input.value([]),
       }),
 
@@ -261,7 +262,6 @@ export class Track extends Thing {
 
     sampledTracks: [
       inheritFromOriginalRelease({
-        property: input.value('sampledTracks'),
         notFoundValue: input.value([]),
       }),
 
@@ -389,6 +389,11 @@ export class Track extends Thing {
 
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
       'Has Cover Art': {
         property: 'disableUniqueCoverArt',
         transform: value =>
diff --git a/src/data/validators.js b/src/data/validators.js
index 4fc2ac6..5d68131 100644
--- a/src/data/validators.js
+++ b/src/data/validators.js
@@ -311,8 +311,11 @@ export function isCommentary(commentaryText) {
 
     const ownInput = commentaryText.slice(position, position + length);
     const restOfInput = commentaryText.slice(position + length);
-    const nextLineBreak = restOfInput.indexOf('\n');
-    const upToNextLineBreak = restOfInput.slice(0, nextLineBreak);
+
+    const upToNextLineBreak =
+      (restOfInput.includes('\n')
+        ? restOfInput.slice(0, restOfInput.indexOf('\n'))
+        : restOfInput);
 
     if (/\S/.test(upToNextLineBreak)) {
       throw new TypeError(
@@ -420,6 +423,14 @@ const illegalContentSpec = [
   {illegal: '\u2005', annotation: `four-per-em space`, ...illegalVisibleSpace},
   {illegal: '\u205f', annotation: `medium mathematical space`, ...illegalVisibleSpace},
   {illegal: '\xa0', annotation: `non-breaking space`, ...illegalVisibleSpace},
+
+  {
+    action: 'replace',
+    illegal: '<a href',
+    annotation: `HTML-style link`,
+    with: '[...](...)',
+    withAnnotation: `markdown`,
+  },
 ];
 
 for (const entry of illegalContentSpec) {
@@ -432,24 +443,23 @@ for (const entry of illegalContentSpec) {
   }
 }
 
-const illegalContentRegexp =
-  new RegExp(
-    illegalContentSpec
-      .map(entry => entry.illegal)
-      .map(illegal => `${illegal}+`)
-      .join('|'),
-    'g');
-
-const illegalCharactersInContent =
+const illegalSequencesInContent =
   illegalContentSpec
     .map(entry => entry.illegal)
-    .join('');
+    .map(illegal =>
+      (illegal.length === 1
+        ? `${illegal}+`
+        : `(?:${illegal})+`))
+    .join('|');
+
+const illegalContentRegexp =
+  new RegExp(illegalSequencesInContent, 'g');
 
 const legalContentNearEndRegexp =
-  new RegExp(`[^\n${illegalCharactersInContent}]+$`);
+  new RegExp(`(?<=^|${illegalSequencesInContent})(?:(?!${illegalSequencesInContent}).)+$`);
 
 const legalContentNearStartRegexp =
-  new RegExp(`^[^\n${illegalCharactersInContent}]+`);
+  new RegExp(`^(?:(?!${illegalSequencesInContent}).)+`);
 
 const trimWhitespaceNearBothSidesRegexp =
   /^ +| +$/gm;
@@ -595,16 +605,37 @@ export function isContentString(content) {
 export function isThingClass(thingClass) {
   isFunction(thingClass);
 
-  if (!Object.hasOwn(thingClass, Symbol.for('Thing.referenceType'))) {
-    throw new TypeError(`Expected a Thing constructor, missing Thing.referenceType`);
+  // This is *expressly* no faster than an instanceof check, because it's
+  // deliberately still walking the prototype chain for the provided object.
+  // (This is necessary because the symbol we're checking is defined only on
+  // the Thing constructor, and not directly on each subclass.) However, it's
+  // preferred over an instanceof check anyway, because instanceof would
+  // require that the #validators module has access to #thing, which it
+  // currently doesn't!
+  if (!(Symbol.for('Thing.isThingConstructor') in thingClass)) {
+    throw new TypeError(`Expected a Thing constructor, missing Thing.isThingConstructor`);
+  }
+
+  return true;
+}
+
+export function isThing(thing) {
+  isObject(thing);
+
+  // This *is* faster than an instanceof check, because it doesn't walk the
+  // prototype chain. It works because this property is set as part of every
+  // Thing subclass's inherited "public class fields" - it's set directly on
+  // every constructed Thing.
+  if (!Object.hasOwn(thing, Symbol.for('Thing.isThing'))) {
+    throw new TypeError(`Expected a Thing, missing Thing.isThing`);
   }
 
   return true;
 }
 
 export const isContribution = validateProperties({
-  who: isArtistRef,
-  what: optional(isStringNonEmpty),
+  artist: isArtistRef,
+  annotation: optional(isStringNonEmpty),
 });
 
 export const isContributionList = validateArrayItems(isContribution);
@@ -612,7 +643,7 @@ export const isContributionList = validateArrayItems(isContribution);
 export const isAdditionalFile = validateProperties({
   title: isName,
   description: optional(isContentString),
-  files: validateArrayItems(isString),
+  files: optional(validateArrayItems(isString)),
 });
 
 export const isAdditionalFileList = validateArrayItems(isAdditionalFile);
@@ -632,10 +663,15 @@ export function isDimensions(dimensions) {
 
   if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`);
 
-  isPositive(dimensions[0]);
-  isInteger(dimensions[0]);
-  isPositive(dimensions[1]);
-  isInteger(dimensions[1]);
+  if (dimensions[0] !== null) {
+    isPositive(dimensions[0]);
+    isInteger(dimensions[0]);
+  }
+
+  if (dimensions[1] !== null) {
+    isPositive(dimensions[1]);
+    isInteger(dimensions[1]);
+  }
 
   return true;
 }
@@ -718,12 +754,31 @@ export function validateReferenceList(type = '') {
   return validateArrayItems(validateReference(type));
 }
 
+export function validateThing({
+  referenceType: expectedReferenceType = '',
+} = {}) {
+  return (thing) => {
+    isThing(thing);
+
+    if (expectedReferenceType) {
+      const {[Symbol.for('Thing.referenceType')]: referenceType} =
+        thing.constructor;
+
+      if (referenceType !== expectedReferenceType) {
+        throw new TypeError(`Expected only ${expectedReferenceType}, got other type: ${referenceType}`);
+      }
+    }
+
+    return true;
+  };
+}
+
 const validateWikiData_cache = {};
 
 export function validateWikiData({
   referenceType = '',
   allowMixedTypes = false,
-}) {
+} = {}) {
   if (referenceType && allowMixedTypes) {
     throw new TypeError(`Don't specify both referenceType and allowMixedTypes`);
   }
@@ -752,25 +807,22 @@ export function validateWikiData({
       let foundOtherObject = false;
 
       for (const object of array) {
-        const {[Symbol.for('Thing.referenceType')]: referenceType} = object.constructor;
-
-        if (referenceType === undefined) {
-          foundOtherObject = true;
-
-          // Early-exit if a Thing has been found - nothing more can be learned.
-          if (foundThing) {
-            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
-          }
-        } else {
-          foundThing = true;
-
+        if (Object.hasOwn(object, Symbol.for('Thing.isThing'))) {
           // Early-exit if a non-Thing object has been found - nothing more can
           // be learned.
           if (foundOtherObject) {
             throw new TypeError(`Expected array of wiki data objects, got mixed items`);
           }
 
-          allRefTypes.add(referenceType);
+          foundThing = true;
+          allRefTypes.add(object.constructor[Symbol.for('Thing.referenceType')]);
+        } else {
+          // Early-exit if a Thing has been found - nothing more can be learned.
+          if (foundThing) {
+            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
+          }
+
+          foundOtherObject = true;
         }
       }
 
diff --git a/src/data/yaml.js b/src/data/yaml.js
index f84f037..c9ce532 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -9,26 +9,32 @@ import yaml from 'js-yaml';
 
 import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
 import {sortByName} from '#sort';
-import {atOffset, empty, filterProperties, typeAppearance, withEntries}
-  from '#sugar';
 import Thing from '#thing';
 import thingConstructors from '#things';
 
 import {
-  filterReferenceErrors,
-  reportContentTextErrors,
-  reportDuplicateDirectories,
-} from '#data-checks';
-
-import {
   annotateErrorWithFile,
   decorateErrorWithIndex,
   decorateErrorWithAnnotation,
   openAggregate,
   showAggregate,
-  withAggregate,
 } from '#aggregate';
 
+import {
+  filterReferenceErrors,
+  reportContentTextErrors,
+  reportDirectoryErrors,
+} from '#data-checks';
+
+import {
+  atOffset,
+  empty,
+  filterProperties,
+  stitchArrays,
+  typeAppearance,
+  withEntries,
+} from '#sugar';
+
 function inspect(value, opts = {}) {
   return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
@@ -393,7 +399,16 @@ export function parseContributors(contributionStrings) {
 
   return contributionStrings.map(item => {
     if (typeof item === 'object' && item['Who'])
-      return {who: item['Who'], what: item['What'] ?? null};
+      return {
+        artist: item['Who'],
+        annotation: item['What'] ?? null,
+      };
+
+    if (typeof item === 'object' && item['Artist'])
+      return {
+        artist: item['Artist'],
+        annotation: item['Annotation'] ?? null,
+      };
 
     if (typeof item !== 'string') return item;
 
@@ -401,8 +416,8 @@ export function parseContributors(contributionStrings) {
     if (!match) return item;
 
     return {
-      who: match.groups.main,
-      what: match.groups.accent ?? null,
+      artist: match.groups.main,
+      annotation: match.groups.accent ?? null,
     };
   });
 }
@@ -523,7 +538,13 @@ export const documentModes = {
 //   them to each other, setting additional properties, etc). Input argument
 //   format depends on documentMode.
 //
-export const getDataSteps = () => {
+export function getAllDataSteps() {
+  try {
+    thingConstructors;
+  } catch (error) {
+    throw new Error(`Thing constructors aren't ready yet, can't get all data steps`);
+  }
+
   const steps = [];
 
   for (const thingConstructor of Object.values(thingConstructors)) {
@@ -539,376 +560,501 @@ export const getDataSteps = () => {
   sortByName(steps, {getName: step => step.title});
 
   return steps;
-};
-
-export async function loadAndProcessDataDocuments({dataPath}) {
-  const processDataAggregate = openAggregate({
-    message: `Errors processing data files`,
-  });
-  const wikiDataResult = {};
-
-  function decorateErrorWithFile(fn) {
-    return decorateErrorWithAnnotation(fn,
-      (caughtError, firstArg) =>
-        annotateErrorWithFile(
-          caughtError,
-          path.relative(
-            dataPath,
-            (typeof firstArg === 'object'
-              ? firstArg.file
-              : firstArg))));
-  }
+}
 
-  function asyncDecorateErrorWithFile(fn) {
-    return decorateErrorWithFile(fn).async;
-  }
+export async function getFilesFromDataStep(dataStep, {dataPath}) {
+  const {documentMode} = dataStep;
 
-  for (const dataStep of getDataSteps()) {
-    await processDataAggregate.nestAsync(
-      {
-        message: `Errors during data step: ${colors.bright(dataStep.title)}`,
-        translucent: true,
-      },
-      async ({call, callAsync, map, mapAsync, push}) => {
-        const {documentMode} = dataStep;
-
-        if (!Object.values(documentModes).includes(documentMode)) {
-          throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
-        }
+  switch (documentMode) {
+    case documentModes.allInOne:
+    case documentModes.oneDocumentTotal: {
+      if (!dataStep.file) {
+        throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
+      }
 
-        // Hear me out, it's been like 1200 years since I wrote the rest of
-        // this beautifully error-containing code and I don't know how to
-        // integrate this nicely. So I'm just returning the result and the
-        // error that should be thrown. Yes, we're back in callback hell,
-        // just without the callbacks. Thank you.
-        const filterBlankDocuments = documents => {
-          const aggregate = openAggregate({
-            message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`,
+      const localFile =
+        (typeof dataStep.file === 'function'
+          ? await dataStep.file(dataPath)
+          : dataStep.file);
+
+      const fileUnderDataPath =
+        path.join(dataPath, localFile);
+
+      const statResult =
+        await stat(fileUnderDataPath).then(
+          () => true,
+          error => {
+            if (error.code === 'ENOENT') {
+              return false;
+            } else {
+              throw error;
+            }
           });
 
-          const filteredDocuments =
-            documents
-              .filter(doc => doc !== null);
-
-          if (filteredDocuments.length !== documents.length) {
-            const blankIndexRangeInfo =
-              documents
-                .map((doc, index) => [doc, index])
-                .filter(([doc]) => doc === null)
-                .map(([doc, index]) => index)
-                .reduce((accumulator, index) => {
-                  if (accumulator.length === 0) {
-                    return [[index, index]];
-                  }
-                  const current = accumulator.at(-1);
-                  const rest = accumulator.slice(0, -1);
-                  if (current[1] === index - 1) {
-                    return rest.concat([[current[0], index]]);
-                  } else {
-                    return accumulator.concat([[index, index]]);
-                  }
-                }, [])
-                .map(([start, end]) => ({
-                  start,
-                  end,
-                  count: end - start + 1,
-                  previous: atOffset(documents, start, -1),
-                  next: atOffset(documents, end, +1),
-                }));
-
-            for (const {start, end, count, previous, next} of blankIndexRangeInfo) {
-              const parts = [];
-
-              if (count === 1) {
-                const range = `#${start + 1}`;
-                parts.push(`${count} document (${colors.yellow(range)}), `);
-              } else {
-                const range = `#${start + 1}-${end + 1}`;
-                parts.push(`${count} documents (${colors.yellow(range)}), `);
-              }
-
-              if (previous === null) {
-                parts.push(`at start of file`);
-              } else if (next === null) {
-                parts.push(`at end of file`);
-              } else {
-                const previousDescription = Object.entries(previous).at(0).join(': ');
-                const nextDescription = Object.entries(next).at(0).join(': ');
-                parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`);
-              }
-
-              aggregate.push(new Error(parts.join('')));
-            }
-          }
+      if (statResult) {
+        return [fileUnderDataPath];
+      } else {
+        return [];
+      }
+    }
+
+    case documentModes.headerAndEntries:
+    case documentModes.onePerFile: {
+      if (!dataStep.files) {
+        throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
+      }
 
-          return {documents: filteredDocuments, aggregate};
-        };
+      const localFiles =
+        (typeof dataStep.files === 'function'
+          ? await dataStep.files(dataPath).then(
+              files => files,
+              error => {
+                if (error.code === 'ENOENT') {
+                  return [];
+                } else {
+                  throw error;
+                }
+              })
+          : dataStep.files);
 
-        const processDocument = (document, thingClassOrFn) => {
-          const thingClass =
-            (thingClassOrFn.prototype instanceof Thing
-              ? thingClassOrFn
-              : thingClassOrFn(document));
+      const filesUnderDataPath =
+        localFiles
+          .map(file => path.join(dataPath, file));
 
-          if (typeof thingClass !== 'function') {
-            throw new Error(`Expected a thing class, got ${typeAppearance(thingClass)}`);
-          }
+      return filesUnderDataPath;
+    }
 
-          if (!(thingClass.prototype instanceof Thing)) {
-            throw new Error(`Expected a thing class, got ${thingClass.name}`);
-          }
+    default:
+      throw new Error(`Unknown document mode ${documentMode.toString()}`);
+  }
+}
 
-          const spec = thingClass[Thing.yamlDocumentSpec];
+export async function loadYAMLDocumentsFromFile(file) {
+  let contents;
+  try {
+    contents = await readFile(file, 'utf-8');
+  } catch (caughtError) {
+    throw new Error(`Failed to read data file`, {cause: caughtError});
+  }
 
-          if (!spec) {
-            throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
-          }
+  let documents;
+  try {
+    documents = yaml.loadAll(contents);
+  } catch (caughtError) {
+    throw new Error(`Failed to parse valid YAML`, {cause: caughtError});
+  }
+
+  const aggregate = openAggregate({
+    message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`,
+  });
 
-          // TODO: Making a function to only call it just like that is
-          // obviously pretty jank! It should be created once per data step.
-          const fn = makeProcessDocument(thingClass, spec);
-          return fn(document);
-        };
-
-        if (
-          documentMode === documentModes.allInOne ||
-          documentMode === documentModes.oneDocumentTotal
-        ) {
-          if (!dataStep.file) {
-            throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
+  const filteredDocuments =
+    documents
+      .filter(doc => doc !== null);
+
+  if (filteredDocuments.length !== documents.length) {
+    const blankIndexRangeInfo =
+      documents
+        .map((doc, index) => [doc, index])
+        .filter(([doc]) => doc === null)
+        .map(([doc, index]) => index)
+        .reduce((accumulator, index) => {
+          if (accumulator.length === 0) {
+            return [[index, index]];
           }
+          const current = accumulator.at(-1);
+          const rest = accumulator.slice(0, -1);
+          if (current[1] === index - 1) {
+            return rest.concat([[current[0], index]]);
+          } else {
+            return accumulator.concat([[index, index]]);
+          }
+        }, [])
+        .map(([start, end]) => ({
+          start,
+          end,
+          count: end - start + 1,
+          previous: atOffset(documents, start, -1),
+          next: atOffset(documents, end, +1),
+        }));
+
+    for (const {start, end, count, previous, next} of blankIndexRangeInfo) {
+      const parts = [];
+
+      if (count === 1) {
+        const range = `#${start + 1}`;
+        parts.push(`${count} document (${colors.yellow(range)}), `);
+      } else {
+        const range = `#${start + 1}-${end + 1}`;
+        parts.push(`${count} documents (${colors.yellow(range)}), `);
+      }
 
-          const file = path.join(
-            dataPath,
-            typeof dataStep.file === 'function'
-              ? await callAsync(dataStep.file, dataPath)
-              : dataStep.file);
+      if (previous === null) {
+        parts.push(`at start of file`);
+      } else if (next === null) {
+        parts.push(`at end of file`);
+      } else {
+        const previousDescription = Object.entries(previous).at(0).join(': ');
+        const nextDescription = Object.entries(next).at(0).join(': ');
+        parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`);
+      }
 
-          const statResult = await callAsync(() =>
-            stat(file).then(
-              () => true,
-              error => {
-                if (error.code === 'ENOENT') {
-                  return false;
-                } else {
-                  throw error;
-                }
-              }));
+      aggregate.push(new Error(parts.join('')));
+    }
+  }
 
-          if (statResult === false) {
-            const saveResult = call(dataStep.save, {
-              [documentModes.allInOne]: [],
-              [documentModes.oneDocumentTotal]: {},
-            }[documentMode]);
+  return {result: filteredDocuments, aggregate};
+}
 
-            if (!saveResult) return;
+// Mapping from dataStep (spec) object each to a sub-map, from thing class to
+// processDocument function.
+const processDocumentFns = new WeakMap();
 
-            Object.assign(wikiDataResult, saveResult);
+export function processThingsFromDataStep(documents, dataStep) {
+  let submap;
+  if (processDocumentFns.has(dataStep)) {
+    submap = processDocumentFns.get(dataStep);
+  } else {
+    submap = new Map();
+    processDocumentFns.set(dataStep, submap);
+  }
 
-            return;
-          }
+  function processDocument(document, thingClassOrFn) {
+    const thingClass =
+      (thingClassOrFn.prototype instanceof Thing
+        ? thingClassOrFn
+        : thingClassOrFn(document));
+
+    let fn;
+    if (submap.has(thingClass)) {
+      fn = submap.get(thingClass);
+    } else {
+      if (typeof thingClass !== 'function') {
+        throw new Error(`Expected a thing class, got ${typeAppearance(thingClass)}`);
+      }
 
-          const readResult = await callAsync(readFile, file, 'utf-8');
+      if (!(thingClass.prototype instanceof Thing)) {
+        throw new Error(`Expected a thing class, got ${thingClass.name}`);
+      }
 
-          if (!readResult) {
-            return;
-          }
+      const spec = thingClass[Thing.yamlDocumentSpec];
 
-          let processResults;
+      if (!spec) {
+        throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
+      }
 
-          switch (documentMode) {
-            case documentModes.oneDocumentTotal: {
-              const yamlResult = call(yaml.load, readResult);
+      fn = makeProcessDocument(thingClass, spec);
+      submap.set(thingClass, fn);
+    }
 
-              if (!yamlResult) {
-                processResults = null;
-                break;
-              }
+    return fn(document);
+  }
 
-              const {thing, aggregate} =
-                processDocument(yamlResult, dataStep.documentThing);
+  const {documentMode} = dataStep;
 
-              processResults = thing;
+  switch (documentMode) {
+    case documentModes.allInOne: {
+      const result = [];
+      const aggregate = openAggregate({message: `Errors processing documents`});
 
-              call(() => aggregate.close());
+      documents.forEach(
+        decorateErrorWithIndex(document => {
+          const {thing, aggregate: subAggregate} =
+            processDocument(document, dataStep.documentThing);
 
-              break;
-            }
+          result.push(thing);
+          aggregate.call(subAggregate.close);
+        }));
 
-            case documentModes.allInOne: {
-              const yamlResults = call(yaml.loadAll, readResult);
+      return {aggregate, result};
+    }
 
-              if (!yamlResults) {
-                processResults = [];
-                return;
-              }
+    case documentModes.oneDocumentTotal: {
+      if (documents.length > 1)
+        throw new Error(`Only expected one document to be present, got ${documents.length}`);
 
-              const {documents, aggregate: filterAggregate} =
-                filterBlankDocuments(yamlResults);
+      const {thing, aggregate} =
+        processDocument(documents[0], dataStep.documentThing);
 
-              call(filterAggregate.close);
+      return {aggregate, result: thing};
+    }
 
-              processResults = [];
+    case documentModes.headerAndEntries: {
+      const headerDocument = documents[0];
+      const entryDocuments = documents.slice(1).filter(Boolean);
 
-              map(documents, decorateErrorWithIndex(document => {
-                const {thing, aggregate} =
-                  processDocument(document, dataStep.documentThing);
+      if (!headerDocument)
+        throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
 
-                processResults.push(thing);
-                aggregate.close();
-              }), {message: `Errors processing documents`});
+      const aggregate = openAggregate({message: `Errors processing documents`});
 
-              break;
-            }
-          }
+      const {thing: headerThing, aggregate: headerAggregate} =
+        processDocument(headerDocument, dataStep.headerDocumentThing);
 
-          if (!processResults) return;
+      try {
+        headerAggregate.close();
+      } catch (caughtError) {
+        caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
+        aggregate.push(caughtError);
+      }
 
-          const saveResult = call(dataStep.save, processResults);
+      const entryThings = [];
 
-          if (!saveResult) return;
+      for (const [index, entryDocument] of entryDocuments.entries()) {
+        const {thing: entryThing, aggregate: entryAggregate} =
+          processDocument(entryDocument, dataStep.entryDocumentThing);
 
-          Object.assign(wikiDataResult, saveResult);
+        entryThings.push(entryThing);
 
-          return;
+        try {
+          entryAggregate.close();
+        } catch (caughtError) {
+          caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
+          aggregate.push(caughtError);
         }
+      }
 
-        if (!dataStep.files) {
-          throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
-        }
+      return {
+        aggregate,
+        result: {
+          header: headerThing,
+          entries: entryThings,
+        },
+      };
+    }
 
-        const filesFromDataStep =
-          (typeof dataStep.files === 'function'
-            ? await callAsync(() =>
-                dataStep.files(dataPath).then(
-                  files => files,
-                  error => {
-                    if (error.code === 'ENOENT') {
-                      return [];
-                    } else {
-                      throw error;
-                    }
-                  }))
-            : dataStep.files);
-
-        const filesUnderDataPath =
-          filesFromDataStep
-            .map(file => path.join(dataPath, file));
-
-        const yamlResults = [];
-
-        await mapAsync(filesUnderDataPath, {message: `Errors loading data files`},
-          asyncDecorateErrorWithFile(async file => {
-            let contents;
-            try {
-              contents = await readFile(file, 'utf-8');
-            } catch (caughtError) {
-              throw new Error(`Failed to read data file`, {cause: caughtError});
-            }
+    case documentModes.onePerFile: {
+      if (documents.length > 1)
+        throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
 
-            let documents;
-            try {
-              documents = yaml.loadAll(contents);
-            } catch (caughtError) {
-              throw new Error(`Failed to parse valid YAML`, {cause: caughtError});
-            }
+      if (empty(documents) || !documents[0])
+        throw new Error(`Expected a document, this file is empty`);
 
-            const {documents: filteredDocuments, aggregate: filterAggregate} =
-              filterBlankDocuments(documents);
-
-            try {
-              filterAggregate.close();
-            } catch (caughtError) {
-              // Blank documents aren't a critical error, they're just something
-              // that should be noted - the (filtered) documents still get pushed.
-              const pathToFile = path.relative(dataPath, file);
-              annotateErrorWithFile(caughtError, pathToFile);
-              push(caughtError);
-            }
+      const {thing, aggregate} =
+        processDocument(documents[0], dataStep.documentThing);
+
+      return {aggregate, result: thing};
+    }
+
+    default:
+      throw new Error(`Unknown document mode ${documentMode.toString()}`);
+  }
+}
+
+export function decorateErrorWithFileFromDataPath(fn, {dataPath}) {
+  return decorateErrorWithAnnotation(fn,
+    (caughtError, firstArg) =>
+      annotateErrorWithFile(
+        caughtError,
+        path.relative(
+          dataPath,
+          (typeof firstArg === 'object'
+            ? firstArg.file
+            : firstArg))));
+}
 
-            yamlResults.push({file, documents: filteredDocuments});
+// Loads a list of files for each data step, and a list of documents
+// for each file.
+export async function loadYAMLDocumentsFromDataSteps(dataSteps, {dataPath}) {
+  const aggregate =
+    openAggregate({
+      message: `Errors loading data files`,
+      translucent: true,
+    });
+
+  const fileLists =
+    await Promise.all(
+      dataSteps.map(dataStep =>
+        getFilesFromDataStep(dataStep, {dataPath})));
+
+  const filePromises =
+    fileLists
+      .map(files => files
+        .map(file =>
+          loadYAMLDocumentsFromFile(file).then(
+            ({result, aggregate}) => {
+              const close =
+                decorateErrorWithFileFromDataPath(aggregate.close, {dataPath});
+
+              aggregate.close = () =>
+                close({file});
+
+              return {result, aggregate};
+            },
+            (error) => {
+              const aggregate = {};
+
+              annotateErrorWithFile(error, path.relative(dataPath, file));
+
+              aggregate.close = () => {
+                throw error;
+              };
+
+              return {result: [], aggregate};
+            })));
+
+  const fileListPromises =
+    filePromises
+      .map(filePromises => Promise.all(filePromises));
+
+  const dataStepPromises =
+    stitchArrays({
+      dataStep: dataSteps,
+      fileListPromise: fileListPromises,
+    }).map(async ({dataStep, fileListPromise}) =>
+        openAggregate({
+          message: `Errors loading data files for data step: ${colors.bright(dataStep.title)}`,
+          translucent: true,
+        }).contain(await fileListPromise));
+
+  const documentLists =
+    aggregate
+      .receive(await Promise.all(dataStepPromises));
+
+  return {aggregate, result: {documentLists, fileLists}};
+}
+
+// Loads a list of things from a list of documents for each file
+// for each data step. Nesting!
+export async function processThingsFromDataSteps(documentLists, fileLists, dataSteps, {dataPath}) {
+  const aggregate =
+    openAggregate({
+      message: `Errors processing documents in data files`,
+      translucent: true,
+    });
+
+  const filePromises =
+    stitchArrays({
+      dataStep: dataSteps,
+      files: fileLists,
+      documentLists: documentLists,
+    }).map(({dataStep, files, documentLists}) =>
+        stitchArrays({
+          file: files,
+          documents: documentLists,
+        }).map(({file, documents}) => {
+            const {result, aggregate} =
+              processThingsFromDataStep(documents, dataStep);
+
+            const close = decorateErrorWithFileFromDataPath(aggregate.close, {dataPath});
+            aggregate.close = () => close({file});
+
+            return {result, aggregate};
           }));
 
-        const processResults = [];
-
-        switch (documentMode) {
-          case documentModes.headerAndEntries:
-            map(yamlResults, {message: `Errors processing documents in data files`, translucent: true},
-              decorateErrorWithFile(({documents}) => {
-                const headerDocument = documents[0];
-                const entryDocuments = documents.slice(1).filter(Boolean);
-
-                if (!headerDocument)
-                  throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
-
-                withAggregate({message: `Errors processing documents`}, ({push}) => {
-                  const {thing: headerObject, aggregate: headerAggregate} =
-                    processDocument(headerDocument, dataStep.headerDocumentThing);
-
-                  try {
-                    headerAggregate.close();
-                  } catch (caughtError) {
-                    caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
-                    push(caughtError);
-                  }
-
-                  const entryObjects = [];
-
-                  for (let index = 0; index < entryDocuments.length; index++) {
-                    const entryDocument = entryDocuments[index];
-
-                    const {thing: entryObject, aggregate: entryAggregate} =
-                      processDocument(entryDocument, dataStep.entryDocumentThing);
-
-                    entryObjects.push(entryObject);
-
-                    try {
-                      entryAggregate.close();
-                    } catch (caughtError) {
-                      caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
-                      push(caughtError);
-                    }
-                  }
-
-                  processResults.push({
-                    header: headerObject,
-                    entries: entryObjects,
-                  });
-                });
-              }));
-            break;
-
-          case documentModes.onePerFile:
-            map(yamlResults, {message: `Errors processing data files as valid documents`},
-              decorateErrorWithFile(({documents}) => {
-                if (documents.length > 1)
-                  throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
-
-                if (empty(documents) || !documents[0])
-                  throw new Error(`Expected a document, this file is empty`);
-
-                const {thing, aggregate} =
-                  processDocument(documents[0], dataStep.documentThing);
-
-                processResults.push(thing);
-                aggregate.close();
-              }));
-            break;
-        }
+  const fileListPromises =
+    filePromises
+      .map(filePromises => Promise.all(filePromises));
+
+  const dataStepPromises =
+    stitchArrays({
+      dataStep: dataSteps,
+      fileListPromise: fileListPromises,
+    }).map(async ({dataStep, fileListPromise}) =>
+        openAggregate({
+          message: `Errors loading data files for data step: ${colors.bright(dataStep.title)}`,
+          translucent: true,
+        }).contain(await fileListPromise));
+
+  const thingLists =
+    aggregate
+      .receive(await Promise.all(dataStepPromises));
+
+  return {aggregate, result: thingLists};
+}
 
-        const saveResult = call(dataStep.save, processResults);
+// Flattens a list of *lists* of things for a given data step (each list
+// corresponding to one YAML file) into results to be saved on the final
+// wikiData object, routing thing lists into the step's save() function.
+export function saveThingsFromDataStep(thingLists, dataStep) {
+  const {documentMode} = dataStep;
 
-        if (!saveResult) return;
+  switch (documentMode) {
+    case documentModes.allInOne: {
+      const things =
+        (empty(thingLists)
+          ? []
+          : thingLists[0]);
 
-        Object.assign(wikiDataResult, saveResult);
-      }
-    );
+      return dataStep.save(things);
+    }
+
+    case documentModes.oneDocumentTotal: {
+      const thing =
+        (empty(thingLists)
+          ? {}
+          : thingLists[0]);
+
+      return dataStep.save(thing);
+    }
+
+    case documentModes.headerAndEntries:
+    case documentModes.onePerFile: {
+      return dataStep.save(thingLists);
+    }
+
+    default:
+      throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
   }
+}
 
-  return {
-    aggregate: processDataAggregate,
-    result: wikiDataResult,
-  };
+// Flattens a list of *lists* of things for each data step (each list
+// corresponding to one YAML file) into the final wikiData object,
+// routing thing lists into each step's save() function.
+export function saveThingsFromDataSteps(thingLists, dataSteps) {
+  const aggregate =
+    openAggregate({
+      message: `Errors finalizing things from data files`,
+      translucent: true,
+    });
+
+  const wikiData = {};
+
+  stitchArrays({
+    dataStep: dataSteps,
+    thingLists: thingLists,
+  }).map(({dataStep, thingLists}) => {
+      try {
+        return saveThingsFromDataStep(thingLists, dataStep);
+      } catch (caughtError) {
+        const error = new Error(
+          `Error finalizing things for data step: ${colors.bright(dataStep.title)}`,
+          {cause: caughtError});
+
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+        aggregate.push(error);
+
+        return null;
+      }
+    })
+    .filter(Boolean)
+    .forEach(saveResult => {
+      Object.assign(wikiData, saveResult);
+    });
+
+  return {aggregate, result: wikiData};
+}
+
+export async function loadAndProcessDataDocuments(dataSteps, {dataPath}) {
+  const aggregate =
+    openAggregate({
+      message: `Errors processing data files`,
+    });
+
+  const {documentLists, fileLists} =
+    aggregate.receive(
+      await loadYAMLDocumentsFromDataSteps(dataSteps, {dataPath}));
+
+  const thingLists =
+    aggregate.receive(
+      await processThingsFromDataSteps(documentLists, fileLists, dataSteps, {dataPath}));
+
+  const wikiData =
+    aggregate.receive(
+      saveThingsFromDataSteps(thingLists, dataSteps));
+
+  return {aggregate, result: wikiData};
 }
 
 // Data linking! Basically, provide (portions of) wikiData to the Things which
@@ -942,6 +1088,11 @@ export function linkWikiDataArrays(wikiData) {
 
     [wikiData.flashActData, [
       'flashData',
+      'flashSideData',
+    ]],
+
+    [wikiData.flashSideData, [
+      'flashActData',
     ]],
 
     [wikiData.groupData, [
@@ -983,15 +1134,13 @@ export function linkWikiDataArrays(wikiData) {
   }
 }
 
-export function sortWikiDataArrays(wikiData) {
+export function sortWikiDataArrays(dataSteps, wikiData) {
   for (const [key, value] of Object.entries(wikiData)) {
     if (!Array.isArray(value)) continue;
     wikiData[key] = value.slice();
   }
 
-  const steps = getDataSteps();
-
-  for (const step of steps) {
+  for (const step of dataSteps) {
     if (!step.sort) continue;
     step.sort(wikiData);
   }
@@ -1018,10 +1167,12 @@ export async function quickLoadAllFromYAML(dataPath, {
 }) {
   const showAggregate = customShowAggregate;
 
+  const dataSteps = getAllDataSteps();
+
   let wikiData;
 
   {
-    const {aggregate, result} = await loadAndProcessDataDocuments({dataPath});
+    const {aggregate, result} = await loadAndProcessDataDocuments(dataSteps, {dataPath});
 
     wikiData = result;
 
@@ -1037,7 +1188,7 @@ export async function quickLoadAllFromYAML(dataPath, {
   linkWikiDataArrays(wikiData);
 
   try {
-    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
+    reportDirectoryErrors(wikiData, {getAllFindSpecs});
     logInfo`No duplicate directories found. (complete data)`;
   } catch (error) {
     showAggregate(error);
@@ -1060,7 +1211,7 @@ export async function quickLoadAllFromYAML(dataPath, {
     logWarn`Content text errors found.`;
   }
 
-  sortWikiDataArrays(wikiData);
+  sortWikiDataArrays(dataSteps, wikiData);
 
   return wikiData;
 }
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 8a58269..c5c5ee4 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -610,8 +610,11 @@ async function generateImageThumbnail(imagePath, thumbtack, {
 
 export async function determineMediaCachePath({
   mediaPath,
+  wikiCachePath,
   providedMediaCachePath,
+
   disallowDoubling = false,
+  regenerateMissingThumbnailCache = false,
 }) {
   if (!mediaPath) {
     return {
@@ -643,45 +646,147 @@ export async function determineMediaCachePath({
     };
   }
 
-  const inferredPath =
+  // Two inferred paths are possible - "adjacent" and "contained".
+  // "Contained" is the preferred format and we'll create it if
+  // wikiCachePath is provided, but if it *isn't* we won't know
+  // where to create it. Since "adjacent" isn't preferred we don't
+  // ever generate it, and we'd prefer not to *newly* generate
+  // thumbs in-place with mediaPath, so give up - we've already
+  // determined mediaPath doesn't include in-place thumbs.
+
+  const adjacentInferredPath =
     path.join(
       path.dirname(mediaPath),
       path.basename(mediaPath) + '-cache');
 
-  let inferredIncludesThumbnailCache;
+  const containedInferredPath =
+    (wikiCachePath
+      ? path.join(wikiCachePath, 'media-cache')
+      : null);
+
+  let adjacentIncludesThumbnailCache;
+  let containedIncludesThumbnailCache;
 
   try {
-    const files = await readdir(inferredPath);
-    inferredIncludesThumbnailCache = files.includes(CACHE_FILE);
+    const files = await readdir(adjacentInferredPath);
+    adjacentIncludesThumbnailCache = files.includes(CACHE_FILE);
   } catch (error) {
     if (error.code === 'ENOENT') {
-      inferredIncludesThumbnailCache = null;
+      adjacentIncludesThumbnailCache = null;
     } else {
-      inferredIncludesThumbnailCache = undefined;
+      adjacentIncludesThumbnailCache = undefined;
+    }
+  }
+
+  if (wikiCachePath) {
+    try {
+      const files = await readdir(containedInferredPath);
+      containedIncludesThumbnailCache = files.includes(CACHE_FILE);
+    } catch (error) {
+      if (error.code === 'ENOENT') {
+        containedIncludesThumbnailCache = null;
+      } else {
+        containedIncludesThumbnailCache = undefined;
+      }
     }
   }
 
-  if (inferredIncludesThumbnailCache === true) {
+  // Go ahead with the contained path if it exists and contains a cache -
+  // no other conditions matter.
+  if (containedIncludesThumbnailCache === true) {
     return {
-      annotation: 'inferred path has cache',
-      mediaCachePath: inferredPath,
+      annotation: `contained path has cache`,
+      mediaCachePath: containedInferredPath,
     };
-  } else if (inferredIncludesThumbnailCache === false) {
+  }
+
+  // Reuse an existing adjacent cache before figuring out what to do
+  // if there's no extant cache at all.
+  if (adjacentIncludesThumbnailCache === true) {
     return {
-      annotation: 'inferred path does not have cache',
-      mediaCachePath: null,
+      annotation: `adjacent path has cache`,
+      mediaCachePath: adjacentInferredPath,
     };
-  } else if (inferredIncludesThumbnailCache === null) {
+  }
+
+  // Throw a very high-priority tantrum if the contained cache exists but
+  // isn't readable. It's the preferred cache and we can't tell if it's
+  // available for use or not!
+  if (wikiCachePath && containedIncludesThumbnailCache === undefined) {
     return {
-      annotation: 'inferred path will be created',
-      mediaCachePath: inferredPath,
+      annotation: `contained path not readable`,
+      mediaCachePath: null,
     };
-  } else {
+  }
+
+  // Throw a secondary tantrum if the adjacent cache exists but
+  // isn't readable. This is just as big of a problem, but if for
+  // some reason both the contained and adjacent caches exist,
+  // the contained one is the one we'd rather have addressed.
+  if (adjacentIncludesThumbnailCache === undefined) {
     return {
-      annotation: 'inferred path not readable',
+      annotation: `adjacent path not readable`,
       mediaCachePath: null,
     };
   }
+
+  // Throw a high-priority tantrum if the contained cache exists but is
+  // missing its cache file, again because it's the more preferred cache.
+  // Unless we're indicated to regenerate such a missing cache file!
+  if (containedIncludesThumbnailCache === false) {
+    if (regenerateMissingThumbnailCache) {
+      return {
+        annotation: `contained path will regenerate missing cache`,
+        mediaCachePath: containedInferredPath,
+      };
+    } else {
+      return {
+        annotation: `contained path does not have cache`,
+        mediaCachePath: null,
+      };
+    }
+  }
+
+  // Throw a secondary tantrum if the adjacent cache exists but is
+  // missing its cache file, because it's the less preferred cache.
+  // Unless we're indicated to regenerate a missing cache file!
+  if (adjacentIncludesThumbnailCache === false) {
+    if (regenerateMissingThumbnailCache) {
+      return {
+        annotation: `adjacent path will regenerate missing cache`,
+        mediaCachePath: adjacentInferredPath,
+      };
+    } else {
+      return {
+        annotation: `adjacent path does not have cache`,
+        mediaCachePath: null,
+      };
+    }
+  }
+
+  // If wikiCachePath was provided and the contained cache just doesn't
+  // exist yet, we'll create it during this run.
+  if (wikiCachePath && containedIncludesThumbnailCache === null) {
+    return {
+      annotation: `contained path will be created`,
+      mediaCachePath: containedInferredPath,
+    };
+  }
+
+  // If the adjacent cache doesn't exist, too dang bad!
+  // We aren't interested in newly creating it, so
+  // don't count it as an option.
+
+  // Similarly, we've already established mediaPath isn't
+  // currently doubling as the thumbnail cache, and we won't
+  // newly start generating thumbnails here either.
+
+  // All options aside struck out, there's no way to continue.
+
+  return {
+    annotation: `missing wiki cache to create media cache inside`,
+    mediaCachePath: null,
+  };
 }
 
 export async function migrateThumbsIntoDedicatedCacheDirectory({
diff --git a/src/import-heck.js b/src/import-heck.js
new file mode 100644
index 0000000..3470fbb
--- /dev/null
+++ b/src/import-heck.js
@@ -0,0 +1,9 @@
+// Due to import time shenanigans, these imports have to come in the specified
+// order. This obviously needs fixing up.
+
+/* precede #find */
+import '#data-checks';
+
+import '#find';
+
+// End of import time shenanigans (hopefully)
diff --git a/src/static/client3.js b/src/static/client4.js
index 236e98a..729836b 100644
--- a/src/static/client3.js
+++ b/src/static/client4.js
@@ -5,7 +5,9 @@
 // that cannot 8e done at static-site compile time, 8y its fundamentally
 // ephemeral nature.
 
-import {empty, filterMultipleArrays, stitchArrays} from '../util/sugar.js';
+import {accumulateSum, empty, filterMultipleArrays, stitchArrays}
+  from '../util/sugar.js';
+import {fetchWithProgress} from './xhr-util.js';
 
 const clientInfo = window.hsmusicClientInfo = Object.create(null);
 
@@ -17,7 +19,7 @@ const clientSteps = {
   addPageListeners: [],
 };
 
-function initInfo(key, description) {
+function initInfo(infoKey, description) {
   const object = {...description};
 
   for (const obj of [
@@ -30,7 +32,47 @@ function initInfo(key, description) {
     Object.preventExtensions(obj);
   }
 
-  clientInfo[key] = object;
+  if (object.session) {
+    const sessionDefaults = object.session;
+
+    object.session = {};
+
+    for (const [key, defaultValue] of Object.entries(sessionDefaults)) {
+      const storageKey = `hsmusic.${infoKey}.${key}`;
+
+      let fallbackValue = defaultValue;
+
+      Object.defineProperty(object.session, key, {
+        get: () => {
+          try {
+            return sessionStorage.getItem(storageKey) ?? defaultValue;
+          } catch (error) {
+            if (error instanceof DOMException) {
+              return fallbackValue;
+            } else {
+              throw error;
+            }
+          }
+        },
+
+        set: (value) => {
+          try {
+            sessionStorage.setItem(storageKey, value);
+          } catch (error) {
+            if (error instanceof DOMException) {
+              fallbackValue = value;
+            } else {
+              throw error;
+            }
+          }
+        },
+      });
+    }
+
+    Object.preventExtensions(object.session);
+  }
+
+  clientInfo[infoKey] = object;
 
   return object;
 }
@@ -193,6 +235,34 @@ class WikiRect extends DOMRect {
     return this.fromRect(element.getBoundingClientRect());
   }
 
+  static fromMouse() {
+    const {clientX, clientY} = liveMousePositionInfo.state;
+
+    return WikiRect.fromRect({
+      x: clientX,
+      y: clientY,
+      width: 0,
+      height: 0,
+    });
+  }
+
+  static fromElementUnderMouse(element) {
+    const mouseRect = WikiRect.fromMouse();
+
+    const rects =
+      Array.from(element.getClientRects())
+        .map(rect => WikiRect.fromRect(rect));
+
+    const rectUnderMouse =
+      rects.find(rect => rect.contains(mouseRect));
+
+    if (rectUnderMouse) {
+      return rectUnderMouse;
+    } else {
+      return rects[0];
+    }
+  }
+
   static leftOf(origin, offset = 0) {
     // Returns a rectangle representing everywhere to the left of the provided
     // point or rectangle (with no top or bottom bounds), towards negative x.
@@ -689,6 +759,29 @@ function mutateCSSCompatibilityContent() {
 clientSteps.getPageReferences.push(getCSSCompatibilityAssistantInfoReferences);
 clientSteps.mutatePageContent.push(mutateCSSCompatibilityContent);
 
+// Ever-updating mouse position helper --------------------
+
+const liveMousePositionInfo = initInfo('liveMousePositionInfo', {
+  state: {
+    clientX: null,
+    clientY: null,
+  },
+});
+
+function addLiveMousePositionPageListeners() {
+  const info = liveMousePositionInfo;
+  const {state} = info;
+
+  document.body.addEventListener('mousemove', domEvent => {
+    Object.assign(state, {
+      clientX: domEvent.clientX,
+      clientY: domEvent.clientY,
+    });
+  });
+}
+
+clientSteps.addPageListeners.push(addLiveMousePositionPageListeners);
+
 // JS-based links -----------------------------------------
 
 const scriptedLinkInfo = initInfo('scriptedLinkInfo', {
@@ -1024,6 +1117,11 @@ const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', {
     currentTouchIdentifiers: new Set(),
     touchIdentifiersBanishedByScrolling: new Set(),
   },
+
+  event: {
+    whenTooltipShows: [],
+    whenTooltipHides: [],
+  },
 });
 
 // Adds DOM event listeners, so must be called during addPageListeners step.
@@ -1280,7 +1378,25 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
 
   // 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;
+  if (state.currentlyShownTooltip === tooltip) {
+    // If the hoverable was *recently* touched - meaning that this is a second
+    // touchend in short succession - then just letting the click come through
+    // naturally would (depending on timing) not actually navigate anywhere,
+    // because we've deliberately banished the *first* touch from navigation.
+    // We do want the second touch to navigate, so clear that recently-touched
+    // state, allowing this touch's click to behave as normal.
+    if (state.hoverableWasRecentlyTouched) {
+      clearTimeout(state.touchTimeout);
+      state.touchTimeout = null;
+      state.hoverableWasRecentlyTouched = false;
+    }
+
+    // Otherwise, this is just a second touch after enough time has passed
+    // that the one which showed the tooltip is no longer "recent", and we're
+    // not in any special state. The link will navigate to its page just like
+    // normal.
+    return;
+  }
 
   const touches = Array.from(domEvent.changedTouches);
   const identifiers = touches.map(touch => touch.identifier);
@@ -1322,8 +1438,9 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
   state.hoverableWasRecentlyTouched = true;
   state.touchTimeout =
     setTimeout(() => {
+      state.touchTimeout = null;
       state.hoverableWasRecentlyTouched = false;
-    }, 250);
+    }, 1200);
 }
 
 function handleTooltipHoverableClicked(hoverable) {
@@ -1425,7 +1542,7 @@ function endTransitioningTooltipHidden() {
 }
 
 function hideCurrentlyShownTooltip(intendingToReplace = false) {
-  const {settings, state} = hoverableTooltipInfo;
+  const {settings, state, event} = hoverableTooltipInfo;
   const {currentlyShownTooltip: tooltip} = state;
 
   // If there was no tooltip to begin with, we're functionally in the desired
@@ -1465,11 +1582,15 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) {
     state.tooltipWasJustHidden = false;
   });
 
+  dispatchInternalEvent(event, 'whenTooltipHides', {
+    tooltip,
+  });
+
   return true;
 }
 
 function showTooltipFromHoverable(hoverable) {
-  const {state} = hoverableTooltipInfo;
+  const {state, event} = hoverableTooltipInfo;
   const {tooltip} = state.registeredHoverables.get(hoverable);
 
   if (!hideCurrentlyShownTooltip(true)) return false;
@@ -1490,6 +1611,10 @@ function showTooltipFromHoverable(hoverable) {
 
   state.tooltipWasJustHidden = false;
 
+  dispatchInternalEvent(event, 'whenTooltipShows', {
+    tooltip,
+  });
+
   return true;
 }
 
@@ -1591,7 +1716,7 @@ function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
     getTooltipBaselineOpportunityAreas(tooltip);
 
   const hoverableRect =
-    WikiRect.fromElement(hoverable).toExtended(5, 10);
+    WikiRect.fromElementUnderMouse(hoverable).toExtended(5, 10);
 
   const tooltipRect =
     peekTooltipClientRect(tooltip);
@@ -1666,7 +1791,7 @@ function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
 
       const regionRect = regionRects[i];
       if (regionRect.width > 0) {
-        return regionRect;
+        return rect;
       } else {
         return WikiRect.fromRect({
           x: regionRect.right - tooltipRect.width,
@@ -2529,7 +2654,7 @@ function addImageOverlayClickHandlers() {
   }
 }
 
-function handleImageLinkClicked(evt) {
+async function handleImageLinkClicked(evt) {
   if (evt.metaKey || evt.shiftKey || evt.altKey) {
     return;
   }
@@ -2596,26 +2721,46 @@ function handleImageLinkClicked(evt) {
   mainImage.addEventListener('load', handleMainImageLoaded);
   mainImage.addEventListener('error', handleMainImageErrored);
 
-  container.style.setProperty('--download-progress', '0%');
-  loadImage(mainSrc, progress => {
-    container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%');
-  }).then(
-    blobUrl => {
-      mainImage.src = blobUrl;
-      container.style.setProperty('--download-progress', '100%');
-    },
-    handleMainImageErrored);
+  const showProgress = amount => {
+    cssProp(container, '--download-progress', `${amount * 100}%`);
+  };
+
+  showProgress(0.00);
+
+  const response =
+    await fetchWithProgress(mainSrc, progress => {
+      if (progress === -1) {
+        // TODO: Indeterminate response progress cue
+        showProgress(0.00);
+      } else {
+        showProgress(0.20 + 0.80 * progress);
+      }
+    });
+
+  if (!response.status.toString().startsWith('2')) {
+    handleMainImageErrored();
+    return;
+  }
+
+  const blob = await response.blob();
+  const blobSrc = URL.createObjectURL(blob);
+
+  mainImage.src = blobSrc;
+  showProgress(1.00);
 
   function handleMainImageLoaded() {
-    mainImage.removeEventListener('load', handleMainImageLoaded);
-    mainImage.removeEventListener('error', handleMainImageErrored);
     container.classList.add('loaded');
+    removeEventListeners();
   }
 
   function handleMainImageErrored() {
+    container.classList.add('errored');
+    removeEventListeners();
+  }
+
+  function removeEventListeners() {
     mainImage.removeEventListener('load', handleMainImageLoaded);
     mainImage.removeEventListener('error', handleMainImageErrored);
-    container.classList.add('errored');
   }
 }
 
@@ -2715,67 +2860,6 @@ function updateFileSizeInformation(fileSize) {
 
 addImageOverlayClickHandlers();
 
-/**
- * Credits: Parziphal, Feb 13, 2017
- * https://stackoverflow.com/a/42196770
- *
- * Loads an image with progress callback.
- *
- * The `onprogress` callback will be called by XMLHttpRequest's onprogress
- * event, and will receive the loading progress ratio as an whole number.
- * However, if it's not possible to compute the progress ratio, `onprogress`
- * will be called only once passing -1 as progress value. This is useful to,
- * for example, change the progress animation to an undefined animation.
- *
- * @param  {string}   imageUrl   The image to load
- * @param  {Function} onprogress
- * @return {Promise}
- */
-function loadImage(imageUrl, onprogress) {
-  return new Promise((resolve, reject) => {
-    var xhr = new XMLHttpRequest();
-    var notifiedNotComputable = false;
-
-    xhr.open('GET', imageUrl, true);
-    xhr.responseType = 'arraybuffer';
-
-    xhr.onprogress = function(ev) {
-      if (ev.lengthComputable) {
-        onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10);
-      } else {
-        if (!notifiedNotComputable) {
-          notifiedNotComputable = true;
-          onprogress(-1);
-        }
-      }
-    }
-
-    xhr.onloadend = function() {
-      if (!xhr.status.toString().match(/^2/)) {
-        reject(xhr);
-      } else {
-        if (!notifiedNotComputable) {
-          onprogress(100);
-        }
-
-        var options = {}
-        var headers = xhr.getAllResponseHeaders();
-        var m = headers.match(/^Content-Type:\s*(.*?)$/mi);
-
-        if (m && m[1]) {
-          options.type = m[1];
-        }
-
-        var blob = new Blob([this.response], options);
-
-        resolve(window.URL.createObjectURL(blob));
-      }
-    }
-
-    xhr.send();
-  });
-}
-
 // "Additional names" box ---------------------------------
 
 const additionalNamesBoxInfo = initInfo('additionalNamesBox', {
@@ -2972,6 +3056,210 @@ function addDatestampTooltipPageListeners() {
 clientSteps.getPageReferences.push(getDatestampTooltipReferences);
 clientSteps.addPageListeners.push(addDatestampTooltipPageListeners);
 
+// Artist external link tooltips --------------------------
+
+// These don't need to have tooltip events specially added as
+// they're implemented with "text with tooltip" components.
+
+const artistExternalLinkTooltipInfo = initInfo('artistExternalLinkTooltipInfo', {
+  tooltips: null,
+  tooltipRows: null,
+
+  settings: {
+    // This is the maximum distance, in CSS pixels, that the mouse
+    // can appear to be moving per second while still considered
+    // "idle". A greater value means higher tolerance for small
+    // movements.
+    maximumIdleSpeed: 40,
+
+    // Leaving the mouse idle for this amount of time, over a single
+    // row of the tooltip, will cause a column of supplemental info
+    // to display.
+    mouseIdleShowInfoDelay: 1000,
+
+    // If none of these tooltips are visible for this amount of time,
+    // the supplemental info column is hidden. It'll never disappear
+    // while a tooltip is actually visible.
+    hideInfoAfterTooltipHiddenDelay: 2250,
+  },
+
+  state: {
+    // This is shared by all tooltips.
+    showingTooltipInfo: false,
+
+    mouseIdleTimeout: null,
+    hideInfoTimeout: null,
+
+    mouseMovementPositions: [],
+    mouseMovementTimestamps: [],
+  },
+});
+
+function getArtistExternalLinkTooltipPageReferences() {
+  const info = artistExternalLinkTooltipInfo;
+
+  info.tooltips =
+    Array.from(document.getElementsByClassName('icons-tooltip'));
+
+  info.tooltipRows =
+    info.tooltips.map(tooltip =>
+      Array.from(tooltip.getElementsByClassName('icon')));
+}
+
+function addArtistExternalLinkTooltipInternalListeners() {
+  const info = artistExternalLinkTooltipInfo;
+
+  hoverableTooltipInfo.event.whenTooltipShows.push(({tooltip}) => {
+    const {state} = info;
+
+    if (info.tooltips.includes(tooltip)) {
+      clearTimeout(state.hideInfoTimeout);
+      state.hideInfoTimeout = null;
+    }
+  });
+
+  hoverableTooltipInfo.event.whenTooltipHides.push(() => {
+    const {settings, state} = info;
+
+    if (state.showingTooltipInfo) {
+      state.hideInfoTimeout =
+        setTimeout(() => {
+          state.hideInfoTimeout = null;
+          hideArtistExternalLinkTooltipInfo();
+        }, settings.hideInfoAfterTooltipHiddenDelay);
+    } else {
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    }
+  });
+}
+
+function addArtistExternalLinkTooltipPageListeners() {
+  const info = artistExternalLinkTooltipInfo;
+
+  for (const tooltip of info.tooltips) {
+    tooltip.addEventListener('mousemove', domEvent => {
+      handleArtistExternalLinkTooltipMouseMoved(domEvent);
+    });
+
+    tooltip.addEventListener('mouseout', () => {
+      const {state} = info;
+
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    });
+  }
+
+  for (const tooltipRow of info.tooltipRows.flat()) {
+    tooltipRow.addEventListener('mouseover', () => {
+      const {state} = info;
+
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    });
+  }
+}
+
+function handleArtistExternalLinkTooltipMouseMoved(domEvent) {
+  const info = artistExternalLinkTooltipInfo;
+  const {settings, state} = info;
+
+  if (state.showingTooltipInfo) {
+    return;
+  }
+
+  // Clean out expired mouse movements
+
+  const expiryTime = 1000;
+
+  if (!empty(state.mouseMovementTimestamps)) {
+    const firstRecentMovementIndex =
+      state.mouseMovementTimestamps
+        .findIndex(value => Date.now() - value <= expiryTime);
+
+    if (firstRecentMovementIndex === -1) {
+      state.mouseMovementTimestamps.splice(0);
+      state.mouseMovementPositions.splice(0);
+    } else if (firstRecentMovementIndex > 0) {
+      state.mouseMovementTimestamps.splice(0, firstRecentMovementIndex - 1);
+      state.mouseMovementPositions.splice(0, firstRecentMovementIndex - 1);
+    }
+  }
+
+  const currentMovementDistance =
+    Math.sqrt(domEvent.movementX ** 2 + domEvent.movementY ** 2);
+
+  state.mouseMovementTimestamps.push(Date.now());
+  state.mouseMovementPositions.push([domEvent.screenX, domEvent.screenY]);
+
+  // We can't really compute speed without having
+  // at least two data points!
+  if (state.mouseMovementPositions.length < 2) {
+    return;
+  }
+
+  const movementTravelDistances =
+    state.mouseMovementPositions.map((current, index, array) => {
+      if (index === 0) return 0;
+
+      const previous = array[index - 1];
+      const deltaX = current[0] - previous[0];
+      const deltaY = current[1] - previous[1];
+      return Math.sqrt(deltaX ** 2 + deltaY ** 2);
+    });
+
+  const totalTravelDistance =
+    accumulateSum(movementTravelDistances);
+
+  // In seconds rather than milliseconds.
+  const timeSinceFirstMovement =
+    (Date.now() - state.mouseMovementTimestamps[0]) / 1000;
+
+  const averageSpeed =
+    Math.floor(totalTravelDistance / timeSinceFirstMovement);
+
+  if (averageSpeed > settings.maximumIdleSpeed) {
+    clearTimeout(state.mouseIdleTimeout);
+    state.mouseIdleTimeout = null;
+  }
+
+  if (state.mouseIdleTimeout) {
+    return;
+  }
+
+  state.mouseIdleTimeout =
+    setTimeout(() => {
+      state.mouseIdleTimeout = null;
+      showArtistExternalLinkTooltipInfo();
+    }, settings.mouseIdleShowInfoDelay);
+}
+
+function showArtistExternalLinkTooltipInfo() {
+  const info = artistExternalLinkTooltipInfo;
+  const {state} = info;
+
+  state.showingTooltipInfo = true;
+
+  for (const tooltip of info.tooltips) {
+    tooltip.classList.add('show-info');
+  }
+}
+
+function hideArtistExternalLinkTooltipInfo() {
+  const info = artistExternalLinkTooltipInfo;
+  const {state} = info;
+
+  state.showingTooltipInfo = false;
+
+  for (const tooltip of info.tooltips) {
+    tooltip.classList.remove('show-info');
+  }
+}
+
+clientSteps.getPageReferences.push(getArtistExternalLinkTooltipPageReferences);
+clientSteps.addInternalListeners.push(addArtistExternalLinkTooltipInternalListeners);
+clientSteps.addPageListeners.push(addArtistExternalLinkTooltipPageListeners);
+
 // Sticky commentary sidebar ------------------------------
 
 const albumCommentarySidebarInfo = initInfo('albumCommentarySidebarInfo', {
diff --git a/src/static/icons.svg b/src/static/icons.svg
index fa7bb26..8c9a80a 100644
--- a/src/static/icons.svg
+++ b/src/static/icons.svg
@@ -1,13 +1,43 @@
 <svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40" display="none" width="0" height="0">
-	<symbol id="icon-globe" viewBox="0 0 40 40"><path d="M20,3C10.6,3,3,10.6,3,20s7.6,17,17,17s17-7.6,17-17S29.4,3,20,3z M34.8,18.9h-6.2c-0.1-2.2-0.3-4.2-0.8-6.1 c1.5-0.4,3-0.9,4.2-1.5c0.6,0.9,1.2,1.8,1.6,2.9C34.3,15.7,34.7,17.3,34.8,18.9z M25.7,26.7c-1.5-0.3-3.1-0.4-4.6-0.5v-5.1h5.4 c-0.1,1.8-0.3,3.5-0.6,5.1C25.8,26.3,25.8,26.5,25.7,26.7z M14.2,26.2c-0.3-1.6-0.6-3.3-0.6-5.1h5.4v5.1c-1.6,0-3.2,0.2-4.6,0.5 C14.2,26.5,14.2,26.3,14.2,26.2z M14.3,13.3c1.5,0.3,3.1,0.4,4.6,0.5v5.1h-5.4c0.1-1.8,0.3-3.5,0.6-5.1 C14.2,13.7,14.2,13.5,14.3,13.3z M21.1,5.4C21.4,5.6,21.7,5.7,22,6c0.8,0.7,1.6,1.7,2.2,3c0.4,0.7,0.7,1.5,0.9,2.3 c-1.3,0.2-2.7,0.4-4,0.4V5.4z M18,6c0.3-0.3,0.6-0.4,0.9-0.6v6.2c-1.4,0-2.8-0.2-4-0.4c0.3-0.8,0.6-1.6,0.9-2.3 C16.5,7.7,17.2,6.7,18,6z M18.9,28.4v6.2c-0.3-0.1-0.6-0.3-0.9-0.6c-0.8-0.7-1.6-1.7-2.2-3c-0.4-0.7-0.7-1.5-0.9-2.3 C16.2,28.6,17.5,28.4,18.9,28.4z M22,34c-0.3,0.3-0.6,0.4-0.9,0.6v-6.2c1.4,0,2.8,0.2,4,0.4c-0.3,0.8-0.6,1.6-0.9,2.3 C23.5,32.3,22.8,33.3,22,34z M21.1,18.9v-5.1c1.6,0,3.2-0.2,4.6-0.5c0,0.2,0.1,0.4,0.1,0.5c0.3,1.6,0.6,3.3,0.6,5.1H21.1z M30.5,9.5 c0,0,0.1,0.1,0.1,0.1c-1,0.4-2.2,0.8-3.4,1.1c-0.6-1.9-1.4-3.5-2.4-4.8c0.3,0.1,0.6,0.2,0.9,0.3C27.5,7.1,29.1,8.1,30.5,9.5z M14.2,6.3c0.3-0.1,0.6-0.2,0.9-0.3c-0.9,1.3-1.7,2.9-2.4,4.8c-1.2-0.3-2.3-0.7-3.4-1.1c0,0,0.1-0.1,0.1-0.1 C10.9,8.1,12.5,7.1,14.2,6.3z M7.9,11.4c1.3,0.6,2.7,1.1,4.2,1.5c-0.4,1.9-0.7,3.9-0.8,6.1H5.2c0.1-1.6,0.5-3.2,1.1-4.7 C6.8,13.2,7.3,12.3,7.9,11.4z M5.2,21.1h6.2c0.1,2.2,0.3,4.2,0.8,6.1c-1.5,0.4-3,0.9-4.2,1.5c-0.6-0.9-1.2-1.8-1.6-2.9 C5.7,24.3,5.3,22.7,5.2,21.1z M9.5,30.5c0,0-0.1-0.1-0.1-0.1c1-0.4,2.2-0.8,3.4-1.1c0.6,1.9,1.4,3.5,2.4,4.8 c-0.3-0.1-0.6-0.2-0.9-0.3C12.5,32.9,10.9,31.9,9.5,30.5z M25.8,33.7c-0.3,0.1-0.6,0.2-0.9,0.3c0.9-1.3,1.7-2.9,2.4-4.8 c1.2,0.3,2.3,0.7,3.4,1.1c0,0-0.1,0.1-0.1,0.1C29.1,31.9,27.5,32.9,25.8,33.7z M32.1,28.6c-1.3-0.6-2.7-1.1-4.2-1.5 c0.4-1.9,0.7-3.9,0.8-6.1h6.2c-0.1,1.6-0.5,3.2-1.1,4.7C33.2,26.8,32.7,27.7,32.1,28.6z"/></symbol>
-	<!-- Thanks for clicking a convert button for the greater good, @PeterShaggyNoble! https://github.com/simple-icons/simple-icons/issues/1974#issuecomment-637606360 -->
-	<symbol id="icon-newgrounds" viewbox="0 0 40"><path d="M 21.67012,2.5595806 C 21.281556,2.6425058 21.182046,2.7041073 20.355164,3.3769857 20.042418,3.6304997 19.575668,4.0072168 19.319784,4.2109758 18.793802,4.6327094 18.656383,4.7724975 18.535549,5.0165344 L 18.452624,5.1894925 17.308257,5.9926819 C 16.678026,6.4357391 16.092811,6.8479955 16.005148,6.9095971 L 15.848774,7.0209537 15.69714,6.9190742 15.545505,6.8148255 13.022211,6.4428469 C 11.633807,6.239088 10.475225,6.0661298 10.449162,6.059022 10.408885,6.0519141 10.378084,5.9997897 10.321221,5.8434166 10.226449,5.585164 10.001367,5.1468453 9.9326572,5.0852438 9.8639478,5.0212729 9.3237497,4.7014188 9.2858411,4.7014188 9.2479324,4.7014188 9.2479324,4.7037881 9.2811025,4.6066472 9.3024261,4.5474149 9.3355962,4.5189835 9.4327371,4.4763363 9.5725252,4.4123654 9.5843716,4.3886725 9.6341267,4.0190633 9.6578196,3.8484744 9.6554503,3.8129351 9.6246495,3.76318 9.5938488,3.7181635 9.5891102,3.6684084 9.6009566,3.5072966 L 9.6175417,3.305907 9.5322472,3.2656291 C 9.4777536,3.2395669 9.4351063,3.1945504 9.4066749,3.1329488 9.3403347,2.9836836 9.1365758,2.7917711 8.9541405,2.7041073 8.7977673,2.6306593 8.7835516,2.6282901 8.4850211,2.6282901 8.2007063,2.6282901 8.1675362,2.6330286 8.0395945,2.6922609 7.7837112,2.8130947 7.5491515,3.0926709 7.5112429,3.3201227 7.4993964,3.3983093 7.4828114,3.4243715 7.430687,3.4456951 7.2885296,3.5049274 7.2766831,3.5404667 7.2766831,3.8792752 7.2766831,4.2346687 7.2885296,4.2725773 7.430687,4.3389174 7.4828114,4.3626103 7.5254586,4.3934111 7.5254586,4.4099961 7.5254586,4.4242119 7.5301972,4.4526434 7.5373051,4.4692284 7.5467822,4.4952906 7.4614878,4.5284606 7.1724344,4.6042779 6.9639369,4.6611409 6.7649165,4.7251117 6.7270079,4.7488046 6.6748835,4.7796054 6.6346056,4.8506841 6.5493111,5.071028 6.3858301,5.4785459 6.3052742,5.7770765 6.246042,6.1917022 6.1702247,6.712946 6.2010255,6.9356593 6.3905687,7.2223434 6.4308466,7.2839449 6.4616474,7.3360693 6.4569088,7.3408079 6.4521702,7.3455464 6.2081334,7.5113967 5.9143414,7.7104171 5.2106623,8.1890137 4.0354944,9.0680203 3.9738929,9.1627919 3.8909677,9.2907335 3.7535489,11.00373 3.7203789,12.370811 L 3.7037938,12.993934 3.3104917,13.344589 C 2.7157999,13.870571 2.2229876,14.344429 1.5548478,15.022046 0.79193642,15.796804 0.78008997,15.81102 0.73744275,16.017148 0.69716482,16.21143 0.64504044,16.801383 0.65925618,16.924586 0.66873334,16.998034 0.64977902,17.038312 0.51946807,17.227855 0.05982581,17.888887 -0.09180875,18.675491 0.05271794,19.620838 0.19013676,20.51406 0.77772068,21.321988 1.3842589,21.44993 1.4742919,21.468884 3.966785,21.475992 10.354391,21.475992 H 19.196581 L 19.362431,21.378851 C 19.748626,21.146661 20.293562,20.663326 20.646587,20.241592 21.184415,19.597145 21.698551,18.587827 21.961543,17.661435 22.205579,16.803752 22.309828,16.038471 22.321675,14.995984 L 22.331152,14.379968 22.66996,14.135932 C 23.07274,13.844509 23.108279,13.813708 23.127233,13.728414 23.148557,13.626534 23.10354,13.493854 22.913997,13.095813 22.473309,12.174159 21.947327,11.607899 21.288664,11.342539 20.926163,11.195643 19.736779,11.032162 18.739308,10.989514 18.29862,10.97056 18.33179,10.994253 18.327052,10.695723 18.324682,10.463532 18.286774,10.321375 18.210956,10.266881 18.168309,10.23608 18.056953,10.212387 17.83187,10.183956 17.656543,10.162632 17.509647,10.143678 17.504908,10.141309 17.502539,10.13657 17.488323,10.044168 17.476477,9.9351804 17.443307,9.6698199 17.348535,9.2788871 17.237178,8.9448172 17.185054,8.7955519 17.144776,8.6699796 17.144776,8.6676103 17.144776,8.6628717 17.258502,8.5989009 17.400659,8.5254529 17.540447,8.4496356 18.128031,8.13452 18.706138,7.824143 L 19.760472,7.260252 H 19.959493 C 20.06848,7.260252 20.222484,7.243667 20.30304,7.2223434 20.419135,7.1915426 21.146507,6.8906428 22.890304,6.1514243 23.018246,6.0969306 23.243328,5.9476653 23.394963,5.8197237 23.641369,5.6088569 23.859344,5.2558327 23.949377,4.9170242 24.010978,4.6824645 24.018086,4.2180836 23.961223,3.9858932 23.781157,3.2466747 23.129603,2.6496137 22.395123,2.5477342 22.151086,2.5121948 21.859663,2.5169334 21.67012,2.5595806 Z M 22.357214,2.9268206 C 22.786055,2.99553 23.179358,3.2703676 23.418656,3.6636698 23.631892,4.0214326 23.688755,4.5331992 23.553705,4.9146549 23.40444,5.3387578 23.006399,5.7273214 22.594143,5.8505245 22.414077,5.9050181 22.018406,5.9239724 21.831232,5.8884331 21.317096,5.7865536 20.838499,5.3671893 20.672649,4.874377 20.644217,4.7843439 20.615786,4.6232322 20.606309,4.490552 L 20.592093,4.2631001 20.53523,4.4052576 C 20.504429,4.4834441 20.471259,4.6137551 20.464151,4.6966802 L 20.449936,4.8435762 19.620684,5.4145751 18.791433,5.9832047 18.772478,5.8813252 C 18.717985,5.5899026 18.810387,5.2202933 18.99993,4.9928415 19.158672,4.800929 20.33621,3.8105658 21.018565,3.2964298 21.475838,2.9481442 21.854925,2.8438954 22.357214,2.9268206 Z M 8.9778334,3.2822141 C 9.2147624,3.3177534 9.4303678,3.3746164 9.4493221,3.4054172 9.4801229,3.452803 9.4706457,3.6447155 9.4303678,3.7750264 9.3948284,3.8982295 9.3948284,3.8982295 9.3071647,3.8911216 9.2289781,3.8840138 9.2171317,3.8745366 9.1815923,3.7773957 9.1436837,3.6778855 9.1342065,3.6684084 9.032327,3.649454 8.8878004,3.6233919 8.8238295,3.6423462 8.7717051,3.7300099 8.7480122,3.7702878 8.7029957,3.8129351 8.6698257,3.8271508 8.6034856,3.8508437 8.3404944,3.8555823 8.2078141,3.8342587 8.1296276,3.8200429 8.1154118,3.8081965 8.0940882,3.7323792 8.0822418,3.6849934 8.0703953,3.5760061 8.0703953,3.4883423 8.0703953,3.2585212 8.0751339,3.2561519 8.4755439,3.2561519 8.6579792,3.2561519 8.8854311,3.2679984 8.9778334,3.2822141 Z M 9.0773436,3.943246 C 9.0962979,4.000109 9.1270986,4.0380176 9.179223,4.0593412 9.269256,4.0996191 9.269256,4.1043577 9.2052852,4.310486 9.1602687,4.4550126 9.1484223,4.4715977 9.0583892,4.5142449 8.925709,4.5734771 8.7574894,4.5711079 8.6129627,4.5095063 8.4755439,4.4502741 8.4281581,4.3768261 8.4092038,4.1920215 L 8.394988,4.0688184 8.5466226,4.0522333 C 8.7385351,4.0285404 8.8309374,3.9906318 8.8735846,3.9100759 8.9020161,3.8603209 8.9233397,3.8484744 8.9802027,3.853213 9.0370656,3.8579516 9.0560199,3.8769059 9.0773436,3.943246 Z M 7.8287277,5.0497044 C 8.3120629,5.5306703 9.0844514,5.6207033 9.326119,5.2274012 9.3971977,5.111306 9.4019363,5.1089367 9.4753843,5.1302603 9.6791432,5.1871233 9.6815125,5.1894925 9.6815125,5.3624507 9.6815125,5.5093467 9.7123133,5.7794458 9.743114,5.8979103 9.7549605,5.9500346 9.7502219,5.9500346 9.4753843,5.9642504 9.2976875,5.9737275 9.1318372,5.9974204 9.0181113,6.0305905 8.9209704,6.059022 8.5466226,6.2248723 8.1912291,6.3978304 7.6747239,6.6489752 7.5349358,6.7105767 7.5136121,6.6868838 7.4662263,6.6347594 7.210343,6.2153951 7.2198202,6.2059179 7.2269281,6.2011794 7.2885296,6.2343494 7.3596083,6.2793659 L 7.4899192,6.3622911 7.4970271,6.1419471 C 7.5088736,5.8647402 7.4685956,5.5140853 7.3927784,5.2250319 7.3619776,5.1018288 7.340654,4.9952108 7.3477618,4.9881029 7.357239,4.980995 7.4330563,4.9620407 7.52072,4.947825 7.6083837,4.9312399 7.6818317,4.9193935 7.6865703,4.9170242 7.6889396,4.9170242 7.7529104,4.9762564 7.8287277,5.0497044 Z M 18.542657,5.7249521 C 18.542657,5.895541 18.59952,6.1419471 18.668229,6.2864738 L 18.722723,6.4049383 16.448205,7.790973 C 15.19485,8.5515151 14.16184,9.1675305 14.152363,9.155684 14.140516,9.1462068 14.126301,9.0727589 14.119193,8.992203 14.102608,8.8216141 14.149993,8.6747181 14.251873,8.5775772 14.320582,8.5112371 18.495271,5.6064876 18.526072,5.6041183 18.535549,5.601749 18.542657,5.658612 18.542657,5.7249521 Z M 12.595739,6.7982405 C 14.100238,7.0138458 15.353593,7.1939119 15.379655,7.2010198 15.405717,7.2057583 15.445995,7.2247127 15.469688,7.2412977 15.507597,7.2697292 15.476796,7.2981606 15.166419,7.5161353 L 14.822872,7.7601722 13.25914,7.5445668 C 12.273516,7.4095173 11.662239,7.3360693 11.610115,7.3455464 11.517712,7.3621315 11.328169,7.480596 11.306845,7.5350896 11.299738,7.554044 11.3258,7.6914628 11.363708,7.8407281 11.456111,8.1984908 11.52482,8.6841953 11.543774,9.1130368 11.560359,9.4471067 11.55799,9.4636917 11.515343,9.4636917 11.437156,9.4636917 10.195648,9.2978414 10.183802,9.2883642 10.179063,9.2812564 10.160109,9.1296218 10.141155,8.9519251 10.079553,8.3193246 9.9326572,7.6914628 9.6886204,7.0019994 9.6175417,6.7982405 9.5535708,6.6015894 9.546463,6.56605 9.5275087,6.4618013 9.5914795,6.4073076 9.7383755,6.4073076 9.8047156,6.4073076 11.09124,6.5826351 12.595739,6.7982405 Z M 13.247294,7.8881139 C 13.870417,7.9734083 14.384553,8.0468563 14.389292,8.0515949 14.39403,8.0563334 14.292151,8.1321507 14.164209,8.2198145 14.02916,8.3122168 13.915434,8.4046191 13.898849,8.4425277 13.839616,8.5681001 13.711675,8.9400786 13.602687,9.2978414 13.541086,9.4992311 13.484223,9.6721892 13.474746,9.6816664 13.467638,9.6911435 13.164369,9.6650814 12.801867,9.6248034 12.230869,9.5584633 12.145574,9.5442476 12.15979,9.5134468 12.169267,9.4921232 12.211914,9.3736587 12.256931,9.2480863 12.42515,8.7576433 12.380134,8.3027396 12.121881,7.9023296 12.069757,7.8217737 12.02711,7.750695 12.02711,7.7459565 12.02711,7.7246328 12.117143,7.73411 13.247294,7.8881139 Z M 16.846245,9.0111573 C 16.933909,9.2338706 17.040527,9.6698199 17.073697,9.9328111 17.087913,10.053645 17.09739,10.157894 17.092652,10.160263 17.085544,10.16974 16.405558,10.086815 16.322632,10.067861 16.272877,10.056014 16.272877,10.053645 16.310786,9.9446576 16.339217,9.8617324 16.348695,9.7290522 16.351064,9.4423681 L 16.353433,9.0561738 16.542976,8.9519251 C 16.644856,8.8950621 16.741997,8.8476763 16.758582,8.8476763 16.772797,8.8476763 16.813075,8.9211243 16.846245,9.0111573 Z M 15.983824,9.6224341 C 15.971977,9.7290522 15.950654,9.8617324 15.934069,9.9138568 15.910376,9.9920434 15.89616,10.008628 15.844036,10.008628 15.737418,10.006259 14.938967,9.8949025 14.922382,9.8783175 14.884473,9.8427781 14.986353,9.776438 15.46258,9.5252932 L 15.971977,9.2575635 15.988562,9.3428579 C 15.99804,9.3902437 15.99567,9.5158161 15.983824,9.6224341 Z M 14.06233,10.162632 C 16.152043,10.416146 17.872148,10.624644 17.883994,10.624644 17.893472,10.624644 17.902949,10.693353 17.902949,10.778648 V 10.932652 H 17.635219 C 17.29878,10.932652 17.23007,10.965822 17.038158,11.209858 16.943386,11.333062 16.888893,11.382817 16.853353,11.382817 16.824922,11.382817 16.052533,11.30463 15.140357,11.207489 L 13.477115,11.034531 13.339696,10.911328 C 12.915593,10.532241 12.406196,10.297682 11.768857,10.186325 11.337646,10.112877 10.366237,10.008628 10.103246,10.008628 10.065337,10.008628 10.060599,9.9849355 10.060599,9.8404088 V 9.6745585 L 10.162478,9.6887743 C 10.216972,9.6958821 11.972616,9.9091182 14.06233,10.162632 Z M 10.084292,10.420885 C 11.261829,10.501441 11.59116,10.541719 12.055541,10.660183 12.380134,10.740739 12.522291,10.802341 12.716573,10.93739 13.081444,11.190904 13.446314,11.6387 13.66192,12.098342 13.749583,12.285516 13.853832,12.5722 13.841986,12.584047 13.82777,12.600632 10.003736,12.259454 9.9895202,12.242869 9.980043,12.233392 9.9445037,12.13862 9.9065951,12.034371 9.7146826,11.479957 9.3877205,11.046377 9.015742,10.849726 8.8190909,10.747847 8.8214602,10.747847 8.378403,10.890004 7.8026655,11.077178 6.956829,11.43968 6.1749633,11.835351 5.8172005,12.015417 5.6134416,12.122035 4.8718538,12.522445 4.7462814,12.591155 4.7439121,12.591155 4.8126215,12.531922 4.9002853,12.456105 5.5636865,12.008309 5.8669556,11.816397 6.776763,11.247767 7.7766033,10.762063 8.6508714,10.465901 L 8.9588791,10.361653 9.2550403,10.373499 C 9.4185213,10.380607 9.7904998,10.401931 10.084292,10.420885 Z M 18.779586,11.382817 C 19.575668,11.425464 20.70345,11.574729 21.042258,11.683716 21.478207,11.823505 21.883356,12.157574 22.212687,12.648017 22.33589,12.832822 22.577558,13.256925 22.615467,13.356435 22.63916,13.415667 22.63679,13.420406 22.575189,13.420406 22.489894,13.420406 19.556713,13.13846 19.551975,13.131353 19.549605,13.126614 19.523543,13.029473 19.495112,12.911009 19.334,12.266562 18.893312,11.745318 18.251234,11.43968 18.151724,11.392294 18.068799,11.352016 18.068799,11.347277 18.068799,11.344908 18.118554,11.344908 18.182525,11.349647 18.244127,11.354385 18.511856,11.368601 18.779586,11.382817 Z M 15.384394,11.643439 C 16.990772,11.797442 17.050004,11.80692 17.424352,11.991724 17.886364,12.216807 18.234649,12.541399 18.409977,12.90627 18.549765,13.197693 18.594781,13.403821 18.608997,13.832662 L 18.620844,14.185687 18.516595,14.171471 C 18.459732,14.164363 17.33195,14.060114 16.014625,13.939281 L 13.614534,13.718937 V 13.562563 13.40619 L 13.946235,13.091075 C 14.247134,12.80676 14.277935,12.768851 14.277935,12.695403 14.277935,12.517707 14.031529,11.875629 13.846724,11.577098 L 13.799339,11.501281 H 13.858571 C 13.891741,11.501281 14.578835,11.565252 15.384394,11.643439 Z M 9.0394349,12.8731 C 9.4114134,13.598103 9.5132929,14.531603 9.3237497,15.469842 9.1531608,16.31094 8.6840414,17.092805 8.124889,17.462415 7.8239891,17.663804 7.6486617,17.718298 7.3003761,17.720667 7.0658163,17.720667 6.9899991,17.71119 6.8596881,17.666174 6.5469418,17.559556 6.2531499,17.313149 6.0185902,16.960125 5.7011053,16.483898 5.5399936,15.960285 5.4997157,15.27556 L 5.4831306,15.019677 5.6205494,14.924905 C 5.6963667,14.872781 5.7674454,14.825395 5.7769226,14.823026 5.7863997,14.818287 5.7958769,14.927274 5.7958769,15.062324 5.7958769,15.199743 5.8029848,15.327684 5.8100926,15.349008 5.8219391,15.379809 5.8645863,15.386917 6.0209595,15.386917 6.1275775,15.386917 6.2649963,15.394025 6.3242286,15.401132 L 6.4308466,15.415348 6.4687553,15.574091 C 6.4877096,15.659385 6.5137718,15.756526 6.5208796,15.787327 L 6.5374647,15.84419 6.2270877,15.832343 C 5.878802,15.818127 5.8906485,15.81102 5.9427729,15.981608 L 5.9688351,16.074011 H 6.2957971 6.6227591 L 6.7080536,16.24223 C 6.8691653,16.564454 7.0895092,16.820337 7.3216997,16.950648 L 7.4377949,17.019358 7.3453926,17.038312 C 7.210343,17.066743 6.8644267,17.059635 6.7672858,17.026465 6.6914685,17.000403 6.6890992,17.002772 6.7293772,17.033573 6.8170409,17.099913 7.0397541,17.170992 7.2056045,17.180469 7.3998862,17.194685 7.684201,17.128345 7.8524206,17.031204 7.994578,16.948279 8.2386149,16.713719 8.3760337,16.528914 8.9138625,15.801542 9.1128829,14.64059 8.8617382,13.702352 8.8048752,13.491485 8.6485021,13.133722 8.5466226,12.979718 L 8.4968675,12.90627 8.6816721,12.77359 C 8.7811823,12.700142 8.8759539,12.64091 8.8925389,12.63854 8.9067547,12.63854 8.9730948,12.745158 9.0394349,12.8731 Z M 11.778334,13.396713 C 12.581523,13.448837 13.25914,13.491485 13.285203,13.491485 13.32785,13.491485 13.330219,13.512808 13.330219,13.977189 V 14.465263 L 13.218862,14.451047 C 13.154892,14.443939 12.941656,14.427354 12.740266,14.413139 12.446474,14.391815 12.373026,14.394184 12.356441,14.417877 12.346964,14.434462 12.330379,14.526864 12.320902,14.624005 12.309055,14.759055 12.313794,14.806441 12.337487,14.830134 12.365918,14.853826 13.022211,14.913059 13.268618,14.913059 13.311265,14.913059 13.313634,14.927274 13.299418,15.202112 13.282833,15.47695 13.1928,16.287247 13.173846,16.308571 13.169107,16.313309 12.955871,16.303832 12.699988,16.289616 12.318532,16.265923 12.22613,16.265923 12.202437,16.291985 12.185852,16.308571 12.157421,16.400973 12.140836,16.493375 12.117143,16.630794 12.117143,16.671072 12.140836,16.694765 12.162159,16.716088 12.323271,16.735043 12.619432,16.751628 12.868208,16.765843 13.074336,16.782429 13.079074,16.787167 13.098029,16.806121 12.92744,17.483738 12.818452,17.820178 L 12.707096,18.158986 12.562569,18.156617 C 12.484383,18.156617 12.264039,18.14714 12.074495,18.135293 11.820981,18.121077 11.719102,18.123447 11.688301,18.142401 11.633807,18.17794 11.503497,18.47884 11.520082,18.526226 11.539036,18.573612 11.603007,18.58072 12.081603,18.599674 12.311424,18.606782 12.500968,18.623367 12.500968,18.632844 12.500968,18.687338 12.140836,19.355477 11.984462,19.592406 L 11.799658,19.871983 11.25709,19.867244 10.712154,19.862506 10.593689,20.009401 C 10.363868,20.293716 10.370976,20.312671 10.686091,20.317409 10.804556,20.319778 11.008315,20.326886 11.136257,20.336364 L 11.373186,20.350579 11.200227,20.499845 C 10.998838,20.672803 10.759539,20.836284 10.508395,20.976072 L 10.328329,21.073213 8.6911493,21.068474 7.0516006,21.061366 7.2624674,20.900255 C 8.4708053,19.983339 9.4635378,18.17794 9.8686864,16.156936 9.9989974,15.50775 10.053491,14.995984 10.08903,14.053006 L 10.117462,13.301942 H 10.216972 C 10.273835,13.301942 10.975145,13.344589 11.778334,13.396713 Z M 20.608678,14.216487 21.880987,14.330213 21.892833,14.424985 C 21.899941,14.479479 21.909418,14.631113 21.916526,14.763793 L 21.926003,15.00783 H 21.817016 C 21.755414,15.00783 21.556394,14.995984 21.376328,14.981768 21.110967,14.962814 21.039889,14.962814 21.018565,14.988876 20.973549,15.036262 20.952225,15.351377 20.990134,15.384547 21.006719,15.396394 21.222324,15.422456 21.46873,15.439041 L 21.914157,15.467473 21.897572,15.735202 C 21.883356,16.005301 21.833601,16.419927 21.783846,16.671072 L 21.757784,16.808491 H 21.589564 C 21.497162,16.806121 21.279187,16.796644 21.10386,16.784798 20.928532,16.775321 20.772159,16.770582 20.755574,16.77769 20.715296,16.791906 20.630002,17.118868 20.653694,17.166253 20.672649,17.206531 20.722404,17.21127 21.381067,17.249179 21.584825,17.261025 21.646427,17.272872 21.646427,17.298934 21.646427,17.355797 21.373959,18.161355 21.298141,18.324836 L 21.229432,18.47884 20.933271,18.462255 C 20.772159,18.455147 20.53523,18.44567 20.407288,18.44567 L 20.177467,18.443301 20.089803,18.618628 C 20.042418,18.7134 20.011617,18.808171 20.018725,18.829495 20.032941,18.867404 20.115866,18.87925 20.70345,18.910051 L 21.025673,18.929005 20.859823,19.21332 C 20.76979,19.369693 20.613417,19.61373 20.513906,19.755887 L 20.331471,20.011771 19.786534,20.009401 19.241598,20.007032 19.104179,20.177621 C 19.028362,20.270023 18.969129,20.362426 18.973868,20.383749 18.980976,20.41455 19.040208,20.424027 19.348216,20.438243 19.549605,20.44772 19.758103,20.459567 19.807858,20.461936 L 19.90263,20.469044 19.70124,20.637263 C 19.592253,20.732035 19.419294,20.867084 19.315046,20.940532 L 19.130241,21.073213 H 17.829501 16.53113 L 16.941017,20.663326 C 17.376966,20.229745 17.639958,19.902783 17.917165,19.44551 18.53318,18.433824 18.969129,17.116498 19.144457,15.74231 19.196581,15.334792 19.236859,14.671391 19.227382,14.358645 L 19.217905,14.081438 19.277137,14.093284 C 19.310307,14.100392 19.909737,14.154886 20.608678,14.216487 Z M 4.9997955,16.074011 C 5.0211191,16.168782 5.0329655,16.254077 5.0282269,16.261185 5.0187498,16.2754 4.7249578,16.47679 3.6895781,17.1781 L 3.2654752,17.464784 3.1446414,17.303672 C 3.0783013,17.213639 2.9740525,17.102283 2.9100817,17.052528 2.8484801,17.002772 2.7963558,16.950648 2.7939865,16.938802 2.7916172,16.924586 3.2725831,16.5763 3.860167,16.164044 L 4.931086,15.412979 4.9476711,15.657016 C 4.9571482,15.792065 4.9808411,15.979239 4.9997955,16.074011 Z M 2.5309953,17.481369 C 2.6589369,17.642481 2.822418,17.983659 2.8840195,18.218218 2.985899,18.618628 2.9645753,19.237013 2.8318951,19.684809 2.6897377,20.161036 2.3296056,20.639633 1.9884279,20.803114 1.8770712,20.855238 1.8083618,20.871823 1.6377729,20.878931 1.5193084,20.883669 1.3795203,20.876562 1.3250267,20.862346 1.1710228,20.819699 0.95304814,20.660956 0.82984506,20.497475 0.59054677,20.17999 0.45075866,19.78195 0.42469647,19.343631 0.41285002,19.125656 0.44602008,18.637583 0.47919014,18.554657 0.4886673,18.526226 0.68294908,18.393546 0.69479553,18.405392 0.69953411,18.410131 0.68768766,18.481209 0.66636405,18.564135 0.62608612,18.737093 0.59291606,19.234644 0.61660896,19.329415 0.6308247,19.388648 0.63556328,19.391017 0.82747577,19.391017 H 1.0217575 L 1.0596662,19.533174 1.0975748,19.675332 H 0.88670802 C 0.6782105,19.675332 0.67584121,19.675332 0.69005695,19.727456 0.71611914,19.81275 0.73033488,19.817489 0.94594027,19.834074 L 1.1520685,19.84829 1.2255165,19.98097 C 1.3084416,20.125497 1.5358935,20.376641 1.6046029,20.397965 1.6306651,20.407442 1.6496194,20.421658 1.6496194,20.433504 1.6496194,20.466674 1.3937361,20.487998 1.2942259,20.461936 1.1615457,20.428766 1.1994543,20.469044 1.343981,20.51643 1.5027234,20.570923 1.7467603,20.554338 1.8960256,20.483259 2.0310751,20.416919 2.2419419,20.208422 2.3627757,20.025987 2.7252771,19.462095 2.7797707,18.58072 2.4859788,17.971812 2.4054229,17.803592 2.1874482,17.550078 2.0666144,17.483738 2.0287058,17.462415 2.038183,17.448199 2.161386,17.360535 L 2.3011741,17.263394 2.3675143,17.31078 C 2.4054229,17.336842 2.4788709,17.41266 2.5309953,17.481369 Z M 4.7154807,17.502693 4.8268373,17.640111 4.4785517,17.805962 C 4.2866392,17.895995 4.1255274,17.967074 4.1231582,17.962335 4.0994653,17.941011 4.0686645,17.696974 4.0852495,17.680389 4.1089424,17.656697 4.5970162,17.355797 4.6017547,17.360535 4.604124,17.362905 4.6538791,17.426875 4.7154807,17.502693 Z M 6.6867299,18.296405 C 6.8762731,18.393546 7.0563392,18.670753 7.136895,18.988237 7.1961273,19.21332 7.1890194,19.630315 7.1250486,19.900414 7.0113227,20.376641 6.7222693,20.798375 6.3976766,20.957117 6.262627,21.023458 6.2318263,21.030565 6.0612374,21.023458 5.8148312,21.011611 5.6868896,20.935794 5.5328857,20.710711 5.3575582,20.457197 5.3101725,20.277131 5.3101725,19.864875 5.3101725,19.44551 5.3528197,19.239382 5.4997157,18.94559 5.682151,18.585458 5.9143414,18.348529 6.172594,18.265604 6.3218593,18.218218 6.5611576,18.232434 6.6867299,18.296405 Z M 4.4003651,18.827126 C 4.6491405,18.966914 4.8007751,19.31283 4.8007751,19.739302 4.8007751,20.172882 4.6799413,20.533015 4.440643,20.798375 4.1658054,21.106383 3.7819804,21.139553 3.54979,20.871823 3.3744625,20.675172 3.2844295,20.395596 3.2844295,20.063895 3.2844295,19.616099 3.4171097,19.260706 3.6872088,18.983499 3.860167,18.808171 3.9241378,18.77974 4.1586975,18.777371 4.2724234,18.775001 4.3316557,18.786848 4.4003651,18.827126 Z"/></symbol>
-	<symbol id="icon-bandcamp" viewBox="0 0 40 40"><path d="M7.1,13.3c5.6,0,11.1,0,16.7,0c0,1.5,0,3.1,0,4.6c0.7-0.7,1.5-1.5,3.2-1.3c2.6,0.3,3.8,3,3.6,5.6c-0.1,1.1-0.5,2.4-1.3,3.1 c-0.9,0.9-2.9,1.4-4.6,0.5c-0.4-0.2-0.7-0.6-1-1.1c0,0.4,0,0.8,0,1.3c-0.6,0-1.3,0-1.9,0c0-4.2,0-8.3,0-12.5 c-2.3,3.9-4.6,8.4-6.9,12.5c-4.9,0-9.8,0-14.7,0C2.5,21.7,4.8,17.5,7.1,13.3L7.1,13.3z M24.3,19c-1.4,1.9-0.4,6.7,2.8,5.5 c2.4-0.9,2-6.6-1.2-6.3C25.2,18.3,24.7,18.5,24.3,19L24.3,19z"/> <path d="M39.7,19.9c-0.6,0-1.3,0-2,0c0-1.6-1.9-2-3.1-1.5c-2.3,1.1-1.8,7.1,1.6,6.2c0.8-0.2,1.2-0.9,1.4-2c0.6,0,1.3,0,2,0 c-0.1,2.4-2.1,3.9-4.4,3.7c-2.1-0.1-3.8-1.8-4-4.2c-0.2-2.9,1.3-5.9,5-5.5C38.3,16.8,39.6,17.9,39.7,19.9z"/></symbol>
-	<symbol id="icon-deviantart" viewBox="0 0 40 40"><path d="M30,9.2L24,20.9l0.5,0.6H30v8.3H19.9L19,30.5l-2.8,5.5l-0.6,0.6h-6v-6.1l6.1-11.7l-0.5-0.6H9.5V9.8h10.2l0.9-0.6l2.8-5.5 L24,3.2h6C30,3.2,30,9.2,30,9.2z"/></symbol>
-	<symbol id="icon-soundcloud" viewBox="0 0 40 40"><path d="M13.8,27.4l0.3-4.2L13.8,14c0-0.1-0.1-0.2-0.1-0.3c-0.1-0.1-0.2-0.1-0.3-0.1c-0.1,0-0.2,0-0.3,0.1C13.1,13.8,13,13.9,13,14 l-0.2,9.2l0.2,4.2c0,0.1,0.1,0.2,0.1,0.3c0.1,0.1,0.2,0.1,0.3,0.1C13.7,27.8,13.8,27.7,13.8,27.4z M18.8,26.9l0.2-3.7l-0.2-10.3 c0-0.2-0.1-0.3-0.2-0.4c-0.1-0.1-0.2-0.1-0.3-0.1s-0.2,0-0.3,0.1c-0.1,0.1-0.2,0.2-0.2,0.4l0,0.1l-0.2,10.1c0,0,0.1,1.4,0.2,4.1v0 c0,0.1,0,0.2,0.1,0.3c0.1,0.1,0.2,0.2,0.4,0.2c0.1,0,0.2-0.1,0.3-0.2c0.1-0.1,0.2-0.2,0.2-0.4L18.8,26.9z M1.2,20.9l0.3,2.2 l-0.3,2.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.1-0.1-0.2-0.2l-0.3-2.2l0.3-2.2c0-0.1,0.1-0.2,0.2-0.2S1.2,20.8,1.2,20.9z M2.7,19.5 l0.4,3.6l-0.4,3.6c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L2,23.2l0.4-3.6c0-0.1,0.1-0.2,0.2-0.2C2.6,19.4,2.7,19.4,2.7,19.5z M4.2,18.9l0.4,4.3l-0.4,4.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2l-0.4-4.2l0.4-4.3c0-0.1,0.1-0.2,0.2-0.2 C4.2,18.7,4.2,18.7,4.2,18.9z M5.8,18.8l0.4,4.4l-0.4,4.3c0,0.2-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L5,23.2l0.4-4.4 c0-0.2,0.1-0.2,0.2-0.2C5.7,18.5,5.8,18.6,5.8,18.8z M7.4,19.1l0.4,4.1l-0.4,4.3c0,0.2-0.1,0.3-0.3,0.3c-0.1,0-0.1,0-0.2-0.1 c-0.1-0.1-0.1-0.1-0.1-0.2l-0.3-4.3l0.3-4.1c0-0.1,0-0.1,0.1-0.2C7,18.8,7,18.8,7.1,18.8C7.3,18.8,7.4,18.9,7.4,19.1L7.4,19.1z M9,16.5l0.4,6.7L9,27.5c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3l0.3-6.7 c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.1,0,0.2,0.1S9,16.4,9,16.5z M10.5,15l0.3,8.2l-0.3,4.3c0,0.1,0,0.2-0.1,0.2 c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3L9.9,15c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.2,0,0.2,0.1 C10.5,14.8,10.5,14.9,10.5,15z M12.2,14.3l0.3,8.9l-0.3,4.2c0,0.2-0.1,0.4-0.4,0.4c-0.2,0-0.3-0.1-0.4-0.4l-0.3-4.2l0.3-8.9 c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.2-0.1c0.1,0,0.2,0,0.3,0.1C12.1,14.1,12.2,14.2,12.2,14.3z M18.8,27.3L18.8,27.3L18.8,27.3z M15.4,14.2l0.3,8.9l-0.3,4.2c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1s-0.1-0.2-0.1-0.3l-0.2-4.2 l0.2-8.9c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C15.4,14,15.4,14.1,15.4,14.2L15.4,14.2z M17.1,14.6 l0.2,8.6l-0.2,4.1c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1c-0.1-0.1-0.1-0.2-0.2-0.3L16,23.2l0.2-8.6 c0-0.1,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C17.1,14.3,17.1,14.4,17.1,14.6z M20.7,23.2l-0.2,4 c0,0.2-0.1,0.3-0.2,0.4c-0.1,0.1-0.2,0.2-0.4,0.2c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4l-0.1-2l-0.1-2L19.4,12V12 c0-0.2,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1c0.2,0.1,0.2,0.2,0.3,0.5L20.7,23.2z M39.4,22.9 c0,1.4-0.5,2.5-1.4,3.5c-0.9,1-2,1.4-3.4,1.4H21.4c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4V11.5c0-0.3,0.2-0.5,0.5-0.6 c1-0.4,2-0.6,3-0.6c2.2,0,4.1,0.8,5.7,2.3c1.6,1.5,2.5,3.4,2.7,5.7c0.6-0.3,1.2-0.4,1.8-0.4c1.3,0,2.4,0.5,3.4,1.5 C38.9,20.3,39.4,21.5,39.4,22.9L39.4,22.9z"/></symbol>
-	<symbol id="icon-tumblr" viewBox="0 0 40 40"><path d="M26.8,29.7l1.6,4.6c-0.3,0.5-1,0.9-2.2,1.3s-2.3,0.6-3.4,0.6c-1.4,0-2.6-0.1-3.7-0.5s-2.1-0.8-2.8-1.4 c-0.7-0.6-1.3-1.3-1.9-2.1c-0.5-0.8-0.9-1.6-1.1-2.3c-0.2-0.8-0.3-1.5-0.3-2.3V16.9H9.7v-4.2c0.9-0.3,1.8-0.8,2.5-1.4 s1.3-1.1,1.8-1.8s0.8-1.3,1.1-2c0.3-0.7,0.5-1.4,0.7-1.9S16,4.6,16.1,4c0-0.1,0-0.1,0.1-0.2s0.1-0.1,0.1-0.1h4.8V12h6.5v4.9h-6.5V27 c0,0.4,0,0.8,0.1,1.1c0.1,0.3,0.2,0.7,0.4,1s0.5,0.6,1,0.8c0.4,0.2,1,0.3,1.6,0.3C25.2,30.2,26.1,30,26.8,29.7L26.8,29.7z"/></symbol>
-	<symbol id="icon-twitter" viewBox="0 0 40 40"><path d="M36.3,10.2c-1,1.3-2.1,2.5-3.4,3.5c0,0.2,0,0.4,0,1c0,1.7-0.2,3.6-0.9,5.3c-0.6,1.7-1.2,3.5-2.4,5.1 c-1.1,1.5-2.3,3.1-3.7,4.3c-1.4,1.2-3.3,2.3-5.3,3c-2.1,0.8-4.2,1.2-6.6,1.2c-3.6,0-7-1-10.2-3c0.4,0,1.1,0.1,1.5,0.1 c3.1,0,5.9-1,8.2-2.9c-1.4,0-2.7-0.4-3.8-1.3c-1.2-1-1.9-2-2.2-3.3c0.4,0.1,1,0.1,1.2,0.1c0.6,0,1.2-0.1,1.7-0.2 c-1.4-0.3-2.7-1.1-3.7-2.3s-1.4-2.6-1.4-4.2v-0.1c1,0.6,2,0.9,3,0.9c-1-0.6-1.5-1.3-2.2-2.4c-0.6-1-0.9-2.1-0.9-3.3s0.3-2.3,1-3.4 c1.5,2.1,3.6,3.6,6,4.9s4.9,2,7.6,2.1c-0.1-0.6-0.1-1.1-0.1-1.4c0-1.8,0.8-3.5,2-4.7c1.2-1.2,2.9-2,4.7-2c2,0,3.6,0.8,4.8,2.1 c1.4-0.3,2.9-0.9,4.2-1.5c-0.4,1.5-1.4,2.7-2.9,3.6C33.8,11.2,35.1,10.9,36.3,10.2L36.3,10.2z"/></symbol>
-	<symbol id="icon-youtube" viewBox="0 0 40 40"><path d="M24.3,27v4.2c0,0.9-0.3,1.3-0.8,1.3c-0.3,0-0.6-0.1-0.9-0.4v-6c0.3-0.3,0.6-0.4,0.9-0.4C24,25.6,24.3,26.1,24.3,27L24.3,27z M31.1,27v0.9h-1.8V27c0-0.9,0.3-1.4,0.9-1.4C30.8,25.6,31.1,26.1,31.1,27L31.1,27z M11.7,22.6h2.1v-1.9H7.6v1.9h2.1v11.4h2 L11.7,22.6L11.7,22.6z M17.5,34.1h1.8v-9.9h-1.8v7.6c-0.4,0.6-0.8,0.8-1.1,0.8c-0.2,0-0.4-0.1-0.4-0.4c0,0,0-0.3,0-0.7v-7.3h-1.8V32 c0,0.7,0.1,1.1,0.2,1.5c0.2,0.5,0.5,0.7,1.2,0.7c0.6,0,1.3-0.4,2-1.2L17.5,34.1L17.5,34.1z M26.1,31.1v-4c0-1-0.1-1.6-0.2-2 c-0.2-0.7-0.7-1.1-1.4-1.1c-0.7,0-1.3,0.4-1.9,1.1v-4.4h-1.8v13.3h1.8v-1c0.6,0.7,1.2,1.1,1.9,1.1c0.7,0,1.2-0.4,1.4-1.1 C26,32.7,26.1,32.1,26.1,31.1L26.1,31.1z M32.9,30.9v-0.3H31c0,0.7,0,1.1,0,1.2c-0.1,0.5-0.4,0.7-0.8,0.7c-0.6,0-0.9-0.5-0.9-1.4 v-1.7h3.6v-2.1c0-1.1-0.2-1.8-0.5-2.3c-0.5-0.7-1.2-1-2.1-1c-0.9,0-1.6,0.3-2.1,1c-0.4,0.5-0.6,1.3-0.6,2.3v3.5 c0,1.1,0.2,1.8,0.6,2.3c0.5,0.7,1.2,1,2.2,1c1,0,1.7-0.4,2.2-1.1c0.2-0.4,0.4-0.7,0.4-1.1C32.9,31.9,32.9,31.5,32.9,30.9L32.9,30.9z M20.7,12.5V8.3c0-0.9-0.3-1.4-0.9-1.4c-0.6,0-0.9,0.5-0.9,1.4v4.2c0,0.9,0.3,1.4,0.9,1.4C20.4,14,20.7,13.5,20.7,12.5z M35.1,27.6 c0,3.1-0.2,5.5-0.5,7c-0.2,0.8-0.6,1.5-1.2,2c-0.6,0.5-1.3,0.8-2,0.9c-2.5,0.3-6.2,0.4-11.1,0.4s-8.7-0.1-11.1-0.4 c-0.8-0.1-1.5-0.4-2.1-0.9c-0.6-0.5-1-1.2-1.2-2c-0.3-1.5-0.5-3.8-0.5-7c0-3.1,0.2-5.5,0.5-7c0.2-0.8,0.6-1.5,1.2-2 c0.6-0.5,1.3-0.9,2.1-0.9c2.5-0.3,6.2-0.4,11.1-0.4s8.7,0.1,11.1,0.4c0.8,0.1,1.5,0.4,2.1,0.9c0.6,0.5,1,1.2,1.2,2 C34.9,22.1,35.1,24.4,35.1,27.6z M15.1,2h2l-2.4,8v5.4h-2V10c-0.2-1-0.6-2.4-1.2-4.3c-0.5-1.4-0.9-2.6-1.3-3.8h2.1l1.4,5.3L15.1,2z M22.5,8.7v3.5c0,1.1-0.2,1.9-0.6,2.4c-0.5,0.7-1.2,1-2.1,1c-0.9,0-1.6-0.3-2.1-1c-0.4-0.5-0.6-1.3-0.6-2.4V8.7 c0-1.1,0.2-1.9,0.6-2.3c0.5-0.7,1.2-1,2.1-1c0.9,0,1.6,0.3,2.1,1C22.3,6.8,22.5,7.6,22.5,8.7z M29.2,5.4v10h-1.8v-1.1 c-0.7,0.8-1.4,1.2-2.1,1.2c-0.6,0-1-0.2-1.2-0.7C24,14.5,24,14,24,13.4V5.4h1.8v7.4c0,0.4,0,0.7,0,0.7c0,0.3,0.2,0.4,0.4,0.4 c0.4,0,0.7-0.3,1.1-0.9V5.4C27.4,5.4,29.2,5.4,29.2,5.4z"/></symbol>
-    <symbol id="icon-instagram" viewBox="0 0 40 40"><path d="M20,7c4.2,0,4.7,0,6.3,0.1c1.5,0.1,2.3,0.3,3,0.5C30,8,30.5,8.3,31.1,8.9c0.5,0.5,0.9,1.1,1.2,1.8c0.2,0.5,0.5,1.4,0.5,3 C33,15.3,33,15.8,33,20s0,4.7-0.1,6.3c-0.1,1.5-0.3,2.3-0.5,3c-0.3,0.7-0.6,1.2-1.2,1.8c-0.5,0.5-1.1,0.9-1.8,1.2 c-0.5,0.2-1.4,0.5-3,0.5C24.7,33,24.2,33,20,33s-4.7,0-6.3-0.1c-1.5-0.1-2.3-0.3-3-0.5C10,32,9.5,31.7,8.9,31.1 C8.4,30.6,8,30,7.7,29.3c-0.2-0.5-0.5-1.4-0.5-3C7,24.7,7,24.2,7,20s0-4.7,0.1-6.3c0.1-1.5,0.3-2.3,0.5-3C8,10,8.3,9.5,8.9,8.9 C9.4,8.4,10,8,10.7,7.7c0.5-0.2,1.4-0.5,3-0.5C15.3,7.1,15.8,7,20,7z M20,4.3c-4.3,0-4.8,0-6.5,0.1c-1.6,0-2.8,0.3-3.8,0.7 C8.7,5.5,7.8,6,6.9,6.9C6,7.8,5.5,8.7,5.1,9.7c-0.4,1-0.6,2.1-0.7,3.8c-0.1,1.7-0.1,2.2-0.1,6.5s0,4.8,0.1,6.5 c0,1.6,0.3,2.8,0.7,3.8c0.4,1,0.9,1.9,1.8,2.8c0.9,0.9,1.7,1.4,2.8,1.8c1,0.4,2.1,0.6,3.8,0.7c1.6,0.1,2.2,0.1,6.5,0.1 s4.8,0,6.5-0.1c1.6-0.1,2.9-0.3,3.8-0.7c1-0.4,1.9-0.9,2.8-1.8c0.9-0.9,1.4-1.7,1.8-2.8c0.4-1,0.6-2.1,0.7-3.8 c0.1-1.6,0.1-2.2,0.1-6.5s0-4.8-0.1-6.5c-0.1-1.6-0.3-2.9-0.7-3.8c-0.4-1-0.9-1.9-1.8-2.8c-0.9-0.9-1.7-1.4-2.8-1.8 c-1-0.4-2.1-0.6-3.8-0.7C24.8,4.3,24.3,4.3,20,4.3L20,4.3L20,4.3z"/><path d="M20,11.9c-4.5,0-8.1,3.7-8.1,8.1s3.7,8.1,8.1,8.1s8.1-3.7,8.1-8.1S24.5,11.9,20,11.9z M20,25.2c-2.9,0-5.2-2.3-5.2-5.2 s2.3-5.2,5.2-5.2s5.2,2.3,5.2,5.2S22.9,25.2,20,25.2z"/><path d="M30.6,11.6c0,1-0.8,1.9-1.9,1.9c-1,0-1.9-0.8-1.9-1.9s0.8-1.9,1.9-1.9C29.8,9.7,30.6,10.5,30.6,11.6z"/></symbol>
-    <symbol id="icon-mastodon" viewBox="-20 -20 237 255"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z"/></symbol>
+  <symbol id="icon-globe" viewBox="0 0 40 40"><path d="M20,3C10.6,3,3,10.6,3,20s7.6,17,17,17s17-7.6,17-17S29.4,3,20,3z M34.8,18.9h-6.2c-0.1-2.2-0.3-4.2-0.8-6.1 c1.5-0.4,3-0.9,4.2-1.5c0.6,0.9,1.2,1.8,1.6,2.9C34.3,15.7,34.7,17.3,34.8,18.9z M25.7,26.7c-1.5-0.3-3.1-0.4-4.6-0.5v-5.1h5.4 c-0.1,1.8-0.3,3.5-0.6,5.1C25.8,26.3,25.8,26.5,25.7,26.7z M14.2,26.2c-0.3-1.6-0.6-3.3-0.6-5.1h5.4v5.1c-1.6,0-3.2,0.2-4.6,0.5 C14.2,26.5,14.2,26.3,14.2,26.2z M14.3,13.3c1.5,0.3,3.1,0.4,4.6,0.5v5.1h-5.4c0.1-1.8,0.3-3.5,0.6-5.1 C14.2,13.7,14.2,13.5,14.3,13.3z M21.1,5.4C21.4,5.6,21.7,5.7,22,6c0.8,0.7,1.6,1.7,2.2,3c0.4,0.7,0.7,1.5,0.9,2.3 c-1.3,0.2-2.7,0.4-4,0.4V5.4z M18,6c0.3-0.3,0.6-0.4,0.9-0.6v6.2c-1.4,0-2.8-0.2-4-0.4c0.3-0.8,0.6-1.6,0.9-2.3 C16.5,7.7,17.2,6.7,18,6z M18.9,28.4v6.2c-0.3-0.1-0.6-0.3-0.9-0.6c-0.8-0.7-1.6-1.7-2.2-3c-0.4-0.7-0.7-1.5-0.9-2.3 C16.2,28.6,17.5,28.4,18.9,28.4z M22,34c-0.3,0.3-0.6,0.4-0.9,0.6v-6.2c1.4,0,2.8,0.2,4,0.4c-0.3,0.8-0.6,1.6-0.9,2.3 C23.5,32.3,22.8,33.3,22,34z M21.1,18.9v-5.1c1.6,0,3.2-0.2,4.6-0.5c0,0.2,0.1,0.4,0.1,0.5c0.3,1.6,0.6,3.3,0.6,5.1H21.1z M30.5,9.5 c0,0,0.1,0.1,0.1,0.1c-1,0.4-2.2,0.8-3.4,1.1c-0.6-1.9-1.4-3.5-2.4-4.8c0.3,0.1,0.6,0.2,0.9,0.3C27.5,7.1,29.1,8.1,30.5,9.5z M14.2,6.3c0.3-0.1,0.6-0.2,0.9-0.3c-0.9,1.3-1.7,2.9-2.4,4.8c-1.2-0.3-2.3-0.7-3.4-1.1c0,0,0.1-0.1,0.1-0.1 C10.9,8.1,12.5,7.1,14.2,6.3z M7.9,11.4c1.3,0.6,2.7,1.1,4.2,1.5c-0.4,1.9-0.7,3.9-0.8,6.1H5.2c0.1-1.6,0.5-3.2,1.1-4.7 C6.8,13.2,7.3,12.3,7.9,11.4z M5.2,21.1h6.2c0.1,2.2,0.3,4.2,0.8,6.1c-1.5,0.4-3,0.9-4.2,1.5c-0.6-0.9-1.2-1.8-1.6-2.9 C5.7,24.3,5.3,22.7,5.2,21.1z M9.5,30.5c0,0-0.1-0.1-0.1-0.1c1-0.4,2.2-0.8,3.4-1.1c0.6,1.9,1.4,3.5,2.4,4.8 c-0.3-0.1-0.6-0.2-0.9-0.3C12.5,32.9,10.9,31.9,9.5,30.5z M25.8,33.7c-0.3,0.1-0.6,0.2-0.9,0.3c0.9-1.3,1.7-2.9,2.4-4.8 c1.2,0.3,2.3,0.7,3.4,1.1c0,0-0.1,0.1-0.1,0.1C29.1,31.9,27.5,32.9,25.8,33.7z M32.1,28.6c-1.3-0.6-2.7-1.1-4.2-1.5 c0.4-1.9,0.7-3.9,0.8-6.1h6.2c-0.1,1.6-0.5,3.2-1.1,4.7C33.2,26.8,32.7,27.7,32.1,28.6z"/></symbol>
+
+  <symbol id="icon-appleMusic" viewBox="-20 -20 401 401"><path d="M 112.60938 0 C 108.30938 0 104.01093 -0.00046873 99.710938 0.01953125 C 96.090941 0.03953123 92.469606 0.0796876 88.849609 0.1796875 C 80.959617 0.39968728 72.999211 0.85953266 65.199219 2.2695312 C 57.279227 3.6895298 49.920462 6.0196912 42.730469 9.6796875 C 35.660476 13.279684 29.190073 17.979849 23.580078 23.589844 C 17.970084 29.199838 13.269918 35.660476 9.6699219 42.730469 C 6.0099255 49.930462 3.6797642 57.300711 2.2597656 65.220703 C 0.85976703 73.020695 0.38968729 80.979383 0.1796875 88.859375 C 0.0796876 92.479371 0.03953123 96.100707 0.01953125 99.720703 C -0.00046873 104.0107 0 108.30938 0 112.60938 L 0 247.38086 C 0 251.68086 -0.00046873 255.9793 0.01953125 260.2793 C 0.03953123 263.89929 0.0796876 267.52063 0.1796875 271.14062 C 0.38968729 279.03062 0.85976702 286.9793 2.2597656 294.7793 C 3.6797642 302.69929 6.0099255 310.06954 9.6699219 317.26953 C 13.269918 324.33952 17.970084 330.80016 23.580078 336.41016 C 29.190073 342.02015 35.660476 346.72032 42.730469 350.32031 C 49.920462 353.98031 57.289227 356.30047 65.199219 357.73047 C 72.999211 359.13047 80.959617 359.60055 88.849609 359.81055 C 92.469606 359.91055 96.090941 359.9507 99.710938 359.9707 C 104.01093 360.0007 108.30938 359.99023 112.60938 359.99023 L 247.38086 359.99023 C 251.68086 359.99023 255.9793 359.9907 260.2793 359.9707 C 263.89929 359.9507 267.52063 359.91055 271.14062 359.81055 C 279.03062 359.60055 286.98907 359.13047 294.78906 357.73047 C 302.70905 356.31047 310.06977 353.98031 317.25977 350.32031 C 324.32976 346.72032 330.80016 342.02015 336.41016 336.41016 C 342.02015 330.80016 346.72032 324.33952 350.32031 317.26953 C 353.98031 310.06954 356.31047 302.69929 357.73047 294.7793 C 359.13047 286.9793 359.60055 279.02062 359.81055 271.14062 C 359.91055 267.52063 359.9507 263.89929 359.9707 260.2793 C 360.0007 255.9793 359.99023 251.68086 359.99023 247.38086 L 359.99023 112.60938 L 360 112.60938 C 360 108.30938 360.00047 104.01093 359.98047 99.710938 C 359.96047 96.090941 359.92031 92.469606 359.82031 88.849609 C 359.61031 80.959617 359.14023 73.01093 357.74023 65.210938 C 356.32024 57.290945 353.99007 49.920696 350.33008 42.720703 C 346.73008 35.65071 342.02992 29.190073 336.41992 23.580078 C 330.80993 17.970084 324.33952 13.269918 317.26953 9.6699219 C 310.07954 6.0099255 302.71077 3.6897642 294.80078 2.2597656 C 287.00079 0.85976703 279.04038 0.38968729 271.15039 0.1796875 C 267.53039 0.0796876 263.90906 0.03953123 260.28906 0.01953125 C 255.98907 -0.00046873 251.69062 0 247.39062 0 L 112.60938 0 z M 254.5 55 C 260.28999 54.500001 263.53953 58.300944 263.51953 64.460938 L 263.51953 234.26953 C 263.51953 238.82953 263.47953 242.9593 262.51953 247.5293 C 261.58953 251.95929 259.89929 256.13086 257.2793 259.88086 C 254.6693 263.62086 251.32968 266.69024 247.42969 268.99023 C 243.48969 271.32023 239.34968 272.64906 234.92969 273.53906 C 226.6297 275.20906 220.94914 275.58953 215.61914 274.51953 C 210.47915 273.47953 206.12086 271.11992 202.63086 267.91992 C 197.46086 263.18993 194.23906 256.7896 193.53906 250.09961 C 192.71906 242.25962 195.32094 233.8907 201.21094 227.7207 C 204.18093 224.60071 207.91063 222.14094 212.89062 220.21094 C 218.10062 218.19094 223.84946 216.97922 232.68945 215.19922 L 239.67969 213.78906 C 242.74968 213.16906 245.37024 212.39078 247.49023 209.80078 C 249.63023 207.20078 249.66016 204.02086 249.66016 200.88086 L 249.66016 121.58984 C 249.66016 115.51985 246.93062 113.87047 241.14062 114.98047 C 236.99063 115.79047 148.05078 133.73047 148.05078 133.73047 C 143.03079 134.95047 141.26953 136.59055 141.26953 142.81055 L 141.26953 258.96094 C 141.26953 263.52093 141.04008 267.65071 140.08008 272.2207 C 139.15008 276.6507 137.45984 280.82032 134.83984 284.57031 C 132.22985 288.31031 128.89023 291.37969 124.99023 293.67969 C 121.05024 296.00969 116.91023 297.39906 112.49023 298.28906 C 104.19024 299.96906 98.509682 300.33953 93.179688 299.26953 C 88.039693 298.23953 83.67945 295.80937 80.189453 292.60938 C 75.019458 287.87938 72.010546 281.47906 71.310547 274.78906 C 70.490548 266.94907 72.879537 258.58015 78.769531 252.41016 C 81.739528 249.29016 85.469224 246.83039 90.449219 244.90039 C 95.659214 242.88039 101.41001 241.67062 110.25 239.89062 L 117.24023 238.48047 C 120.31023 237.86047 122.93078 237.08023 125.05078 234.49023 C 127.17078 231.90024 127.41992 228.86047 127.41992 225.73047 L 127.41992 91.810547 C 127.41992 90.010549 127.57016 88.789453 127.66016 88.189453 C 128.09016 85.369456 129.21977 82.950233 131.25977 81.240234 C 132.94976 79.820236 135.13969 78.830234 137.92969 78.240234 L 137.9707 78.230469 L 244.9707 56.640625 C 245.9007 56.450625 253.63 55.08 254.5 55 z " /></symbol>
+  <symbol id="icon-artstation" viewBox="45 35 118.8 118.8"><path d="M 92.900391 51.5 L 146.59961 144.5 L 155.09961 129.80078 C 156.69961 127.00078 157.19922 125.80039 157.19922 123.40039 C 157.19922 121.30039 156.6 119.29961 155.5 117.59961 L 120.69922 57.199219 C 118.89922 53.799222 115.40078 51.5 111.30078 51.5 L 92.900391 51.5 z M 84.199219 66.599609 L 60.199219 108.09961 L 108.09961 108.09961 L 84.199219 66.599609 z M 51.400391 123.30078 L 60.300781 138.69922 C 62.100779 142.19922 65.700785 144.59961 69.800781 144.59961 L 129.09961 144.59961 L 116.80078 123.30078 L 51.400391 123.30078 z " /></symbol>
+  <symbol id="icon-bandcamp" viewBox="-55 -145 440 440"><path d="M 87.222998,512.00002 H 274.006 l -87.225,-161.01 H 0 Z" transform="matrix(1.3333333,0,0,-1.3333333,0,682.66667)" /></symbol>
+
+  <symbol id="icon-carrd" viewBox="20 20 600 600">
+    <!-- Carrd Brand Asset: Symbol (Dark) | (c) Carrd Inc. | "Carrd" is a registered trademark of Carrd Inc. -->
+    <path d="M529.2,465.1L269.1,590c-1.6,0.8-3.4,1.2-5.2,1.2c-2.2,0-4.4-0.6-6.4-1.8c-3.5-2.2-5.6-6-5.6-10.2V455.5 l-140.5-58.8c-4.5-1.9-7.4-6.2-7.4-11.1V60.8c0-4.1,2.1-8,5.6-10.2c3.5-2.2,7.9-2.4,11.6-0.7l270.4,129.8l127.3-61.1 c3.7-1.8,8.1-1.5,11.6,0.7c3.5,2.2,5.6,6,5.6,10.2v324.8C536,458.9,533.4,463.1,529.2,465.1z M128,79.9v297.7l123.9,51.9v-59.8 l-77.9-31.4c-6.1-2.5-9.1-9.5-6.7-15.6c2.5-6.2,9.5-9.1,15.6-6.7l69,27.8v-49.1l-77.9-31.4c-6.1-2.5-9.1-9.5-6.7-15.6 c2.5-6.2,9.5-9.1,15.6-6.7l69,27.8v-14.3c0-4.6,2.6-8.8,6.8-10.8l17.8-8.6l-103.1-46.9c-6-2.7-8.7-9.9-6-15.9c2.7-6,9.9-8.7,15.9-6 l121.3,55.3l59.1-28.4L128,79.9z M512,148.6L275.9,262v24.3c0,0,0,0,0,0.1v74.9c0,0,0,0,0,0.1v198.8L512,446.7V148.6z M321,303.8 l135.3-65.4c6-2.9,13.1-0.4,16,5.6c2.9,6,0.4,13.2-5.6,16l-135.3,65.4c-1.7,0.8-3.5,1.2-5.2,1.2c-4.5,0-8.7-2.5-10.8-6.8 C312.6,313.9,315.1,306.7,321,303.8z M321,378.8l135.3-65.4c6-2.9,13.1-0.4,16,5.6c2.9,6,0.4,13.2-5.6,16l-135.3,65.4 c-1.7,0.8-3.5,1.2-5.2,1.2c-4.5,0-8.7-2.5-10.8-6.8C312.6,388.8,315.1,381.7,321,378.8z M321,453.7l135.3-65.4 c6-2.9,13.1-0.4,16,5.6c2.9,6,0.4,13.2-5.6,16l-135.3,65.4c-1.7,0.8-3.5,1.2-5.2,1.2c-4.5,0-8.7-2.5-10.8-6.8 C312.6,463.8,315.1,456.6,321,453.7z"/>
+  </symbol>
+
+  <symbol id="icon-bluesky" viewBox="0 0 600 530"><path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/></symbol>
+  <symbol id="icon-cohost" viewBox="0 -40 180 180"><path fill-rule="evenodd" clip-rule="evenodd" d="M142.814 106.403C131.705 113.206 118.897 118.552 104.39 122.439C88.2779 126.756 73.0919 128.487 58.8317 127.631C44.5716 126.775 32.4222 123.055 22.3834 116.471C12.3446 109.887 5.57634 100.068 2.07868 87.0142C-1.43905 73.886 -0.492012 61.9799 4.9198 51.2958C10.3316 40.6118 19.0083 31.3714 30.95 23.5747C42.8917 15.7779 56.9185 9.72092 73.0304 5.40379C89.0677 1.1066 104.193 -0.627685 118.406 0.200922C127.955 0.757684 136.568 2.6028 144.246 5.73626C147.995 7.26657 151.521 9.10414 154.824 11.249C164.89 17.7858 171.672 27.581 175.17 40.6346C178.667 53.6882 177.697 65.5807 172.258 76.312C171.498 77.8112 170.675 79.2823 169.789 80.7261C169.163 77.9074 167.906 75.4497 166.018 73.353C165.091 72.3236 164.061 71.3784 162.926 70.5172C160.603 68.7538 157.845 67.3429 154.652 66.2845C149.898 64.7092 144.602 63.9216 138.763 63.9216C132.896 63.9216 127.58 64.7024 122.813 66.2641C118.046 67.8259 114.257 70.1752 111.446 73.3122C108.635 76.4492 107.23 80.4078 107.23 85.188C107.23 89.9411 108.635 93.8928 111.446 97.0434C114.257 100.194 118.046 102.564 122.813 104.153C127.58 105.741 132.896 106.536 138.763 106.536C140.143 106.536 141.493 106.492 142.814 106.403ZM91.9944 97.9397C90.8808 99.1348 88.9185 100.404 86.1074 101.749C83.2963 103.093 79.9081 104.227 75.9427 105.151C71.9773 106.074 67.7132 106.536 63.1502 106.536C59.1577 106.536 55.2466 106.149 51.417 105.375C47.5875 104.601 44.1245 103.372 41.0283 101.688C37.932 100.004 35.4672 97.8039 33.6339 95.0879C31.8006 92.3719 30.8839 89.0719 30.8839 85.188C30.8839 81.2498 31.8006 77.9227 33.6339 75.2066C35.4672 72.4906 37.932 70.3042 41.0283 68.6475C44.1245 66.9907 47.5875 65.7888 51.417 65.0419C55.2466 64.295 59.1577 63.9216 63.1502 63.9216C67.7403 63.9216 71.9773 64.329 75.8612 65.1438C79.7451 65.9586 83.079 67.0246 85.8629 68.3419C88.6469 69.6592 90.6635 71.0647 91.9129 72.5585L81.4834 79.4028C79.9624 77.7461 77.6538 76.4221 74.5575 75.4307C71.4613 74.4394 67.6996 73.9437 63.2725 73.9437C61.0997 73.9437 58.9065 74.1135 56.6929 74.4529C54.4793 74.7925 52.4491 75.3696 50.6022 76.1844C48.7554 76.9992 47.2683 78.1399 46.1412 79.6066C45.014 81.0732 44.4504 82.9337 44.4504 85.188C44.4504 87.4151 45.014 89.2553 46.1412 90.7083C47.2683 92.1614 48.7554 93.3157 50.6022 94.1712C52.4491 95.0268 54.4793 95.6311 56.6929 95.9842C58.9065 96.3373 61.0997 96.5138 63.2725 96.5138C67.6181 96.5138 71.3866 95.9706 74.5779 94.8842C77.7692 93.7978 80.0167 92.5484 81.3204 91.1361L91.9944 97.9397ZM138.763 96.3508C144.439 96.3508 148.839 95.3323 151.963 93.2953C155.086 91.2583 156.648 88.5559 156.648 85.188C156.648 81.7386 155.079 79.0294 151.942 77.0603C148.805 75.0912 144.412 74.1066 138.763 74.1066C133.086 74.1066 128.666 75.0912 125.502 77.0603C122.338 79.0294 120.756 81.7386 120.756 85.188C120.756 88.6102 122.338 91.3262 125.502 93.3361C128.666 95.3459 133.086 96.3508 138.763 96.3508Z"/></symbol>
+  <symbol id="icon-deviantart" viewBox="0 0 40 40"><path d="M30,9.2L24,20.9l0.5,0.6H30v8.3H19.9L19,30.5l-2.8,5.5l-0.6,0.6h-6v-6.1l6.1-11.7l-0.5-0.6H9.5V9.8h10.2l0.9-0.6l2.8-5.5 L24,3.2h6C30,3.2,30,9.2,30,9.2z"/></symbol>
+  <symbol id="icon-facebook" viewBox="100 100 733 733"><path d="m 0,0 c 0,138.071 -111.929,250 -250,250 -138.071,0 -250,-111.929 -250,-250 0,-117.245 80.715,-215.622 189.606,-242.638 v 166.242 h -51.552 V 0 h 51.552 v 32.919 c 0,85.092 38.508,124.532 122.048,124.532 15.838,0 43.167,-3.105 54.347,-6.211 V 81.986 c -5.901,0.621 -16.149,0.932 -28.882,0.932 -40.993,0 -56.832,-15.528 -56.832,-55.9 V 0 h 81.659 l -14.028,-76.396 h -67.631 V -248.169 C -95.927,-233.218 0,-127.818 0,0" transform="matrix(1.3333333,0,0,-1.3333333,799.99998,466.66668)"/></symbol>
+  <symbol id="icon-kofi" viewBox="100 60 700 760"><path d="M 207.0293 281.82031 C 181.39936 281.82031 167.74023 305.44029 167.74023 322.49023 L 167.74023 327.68945 C 167.73023 329.70945 166.47977 530.46002 169.00977 638.17969 C 169.00977 638.38969 169.01906 638.61031 169.03906 638.82031 C 171.56906 679.76019 194.721 699.32909 213.71094 708.53906 C 233.32088 718.04903 252.52008 718.16969 253.33008 718.17969 L 253.41016 718.17969 C 261.93013 718.17969 463.3196 718.16992 562.0293 716.91992 C 567.75927 716.91992 573.18042 716.91969 580.40039 715.17969 L 580.65039 715.11914 C 610.6803 707.43917 632.08003 689.87056 644.25 662.89062 C 649.99997 650.14068 653.47987 635.77072 654.83984 619.05078 C 682.38978 618.35078 708.30022 612.90958 731.91016 602.84961 C 756.78007 592.24964 777.84029 577.07064 794.49023 557.7207 C 827.19014 519.72082 839.42919 469.40891 828.94922 416.03906 L 828.93945 416.03906 L 828.93945 416.01953 L 828.91992 415.91992 L 828.90039 415.80078 C 817.58042 359.06096 786.439 328.33075 762.28906 312.55078 C 733.99915 292.75084 698.67996 281.83984 662.83008 281.83984 L 207.0293 281.82031 z M 656.9707 392.17969 L 670.67969 392.17969 C 686.12963 392.17969 699.87089 398.51003 709.38086 410 L 709.64062 410.31055 C 717.58062 419.44052 721.43945 431.00925 721.43945 445.69922 C 721.43945 479.18913 709.16007 497.62956 680.41016 507.26953 C 672.92016 508.04953 665.0507 508.48984 656.9707 508.58984 L 656.9707 392.17969 z M 476.60742 393.72461 C 485.18645 393.7029 494.3015 395.30658 503.74023 399.28906 C 552.80015 420.42902 551.42025 477.44944 522.32031 511.85938 C 522.31746 511.86266 522.3134 511.86584 522.31055 511.86914 C 520.24235 514.26239 518.02021 516.7657 515.66992 519.35352 C 514.72786 520.39083 513.68242 521.49514 512.70117 522.55859 C 511.23104 524.15182 509.80205 525.71777 508.25391 527.36133 C 508.2503 527.36515 508.24774 527.36922 508.24414 527.37305 C 506.12619 529.6214 503.7259 532.03298 501.48828 534.35352 C 498.27088 537.69012 495.15981 540.96685 491.77148 544.38867 C 486.2458 549.96915 480.54963 555.60713 474.88477 561.15039 C 474.66348 561.36692 474.44579 561.58848 474.22461 561.80469 C 474.22132 561.8079 474.21813 561.81124 474.21484 561.81445 C 468.33146 567.56551 462.48624 573.2008 456.90039 578.53711 C 456.89719 578.54017 456.89383 578.54381 456.89062 578.54688 C 445.71718 589.22101 435.57811 598.69928 428.23242 605.50781 C 428.22984 605.5102 428.22523 605.51519 428.22266 605.51758 C 420.88542 612.31814 416.33008 616.46094 416.33008 616.46094 C 416.33008 616.46094 416.31111 616.476 416.31055 616.47656 C 416.25736 616.52936 413.7392 619.01347 410.82031 618.42969 L 410.81055 618.42969 C 409.53055 618.22969 408.34914 617.54016 407.36914 616.66016 C 407.14434 616.45713 405.99082 615.40947 405.69727 615.14453 C 405.69644 615.16685 405.68945 615.17525 405.68945 615.19922 C 393.78949 604.57924 316.9109 535.74932 299.71094 511.85938 C 281.70924 486.23263 272.47566 443.08094 294.81055 416.36328 C 295.52774 415.5049 296.27745 414.6632 297.06055 413.83984 C 297.06308 413.83718 297.06583 413.83469 297.06836 413.83203 L 297.07031 413.83008 C 297.85371 413.00684 298.66556 412.20625 299.50391 411.42969 C 299.50727 411.42658 299.51031 411.42303 299.51367 411.41992 C 300.35226 410.64352 301.21872 409.89266 302.10938 409.16406 C 328.93957 387.21554 378.74326 387.20684 412.30078 424.44922 C 412.30862 424.44038 412.94176 423.72533 414.1582 422.50391 C 415.37822 421.27829 417.17743 419.55137 419.49805 417.54102 C 419.50123 417.53826 419.50463 417.53401 419.50781 417.53125 C 420.66455 416.52948 421.9529 415.45886 423.35938 414.3457 C 434.64596 405.41287 453.72216 393.78252 476.60742 393.72461 z " /></symbol>
+  <symbol id="icon-instagram" viewBox="-50 -50 1100 1100"><path class="cls-1" d="M295.42,6c-53.2,2.51-89.53,11-121.29,23.48-32.87,12.81-60.73,30-88.45,57.82S40.89,143,28.17,175.92c-12.31,31.83-20.65,68.19-23,121.42S2.3,367.68,2.56,503.46,3.42,656.26,6,709.6c2.54,53.19,11,89.51,23.48,121.28,12.83,32.87,30,60.72,57.83,88.45S143,964.09,176,976.83c31.8,12.29,68.17,20.67,121.39,23s70.35,2.87,206.09,2.61,152.83-.86,206.16-3.39S799.1,988,830.88,975.58c32.87-12.86,60.74-30,88.45-57.84S964.1,862,976.81,829.06c12.32-31.8,20.69-68.17,23-121.35,2.33-53.37,2.88-70.41,2.62-206.17s-.87-152.78-3.4-206.1-11-89.53-23.47-121.32c-12.85-32.87-30-60.7-57.82-88.45S862,40.87,829.07,28.19c-31.82-12.31-68.17-20.7-121.39-23S637.33,2.3,501.54,2.56,348.75,3.4,295.42,6m5.84,903.88c-48.75-2.12-75.22-10.22-92.86-17-23.36-9-40-19.88-57.58-37.29s-28.38-34.11-37.5-57.42c-6.85-17.64-15.1-44.08-17.38-92.83-2.48-52.69-3-68.51-3.29-202s.22-149.29,2.53-202c2.08-48.71,10.23-75.21,17-92.84,9-23.39,19.84-40,37.29-57.57s34.1-28.39,57.43-37.51c17.62-6.88,44.06-15.06,92.79-17.38,52.73-2.5,68.53-3,202-3.29s149.31.21,202.06,2.53c48.71,2.12,75.22,10.19,92.83,17,23.37,9,40,19.81,57.57,37.29s28.4,34.07,37.52,57.45c6.89,17.57,15.07,44,17.37,92.76,2.51,52.73,3.08,68.54,3.32,202s-.23,149.31-2.54,202c-2.13,48.75-10.21,75.23-17,92.89-9,23.35-19.85,40-37.31,57.56s-34.09,28.38-57.43,37.5c-17.6,6.87-44.07,15.07-92.76,17.39-52.73,2.48-68.53,3-202.05,3.29s-149.27-.25-202-2.53m407.6-674.61a60,60,0,1,0,59.88-60.1,60,60,0,0,0-59.88,60.1M245.77,503c.28,141.8,115.44,256.49,257.21,256.22S759.52,643.8,759.25,502,643.79,245.48,502,245.76,245.5,361.22,245.77,503m90.06-.18a166.67,166.67,0,1,1,167,166.34,166.65,166.65,0,0,1-167-166.34" transform="translate(-2.5 -2.5)"/></symbol>
+  <symbol id="icon-itch" viewBox="-20 -20 265 241"><path d="M31.99 1.365C21.287 7.72.2 31.945 0 38.298v10.516C0 62.144 12.46 73.86 23.773 73.86c13.584 0 24.902-11.258 24.903-24.62 0 13.362 10.93 24.62 24.515 24.62 13.586 0 24.165-11.258 24.165-24.62 0 13.362 11.622 24.62 25.207 24.62h.246c13.586 0 25.208-11.258 25.208-24.62 0 13.362 10.58 24.62 24.164 24.62 13.585 0 24.515-11.258 24.515-24.62 0 13.362 11.32 24.62 24.903 24.62 11.313 0 23.773-11.714 23.773-25.046V38.298c-.2-6.354-21.287-30.58-31.988-36.933C180.118.197 157.056-.005 122.685 0c-34.37.003-81.228.54-90.697 1.365zm65.194 66.217a28.025 28.025 0 0 1-4.78 6.155c-5.128 5.014-12.157 8.122-19.906 8.122a28.482 28.482 0 0 1-19.948-8.126c-1.858-1.82-3.27-3.766-4.563-6.032l-.006.004c-1.292 2.27-3.092 4.215-4.954 6.037a28.5 28.5 0 0 1-19.948 8.12c-.934 0-1.906-.258-2.692-.528-1.092 11.372-1.553 22.24-1.716 30.164l-.002.045c-.02 4.024-.04 7.333-.06 11.93.21 23.86-2.363 77.334 10.52 90.473 19.964 4.655 56.7 6.775 93.555 6.788h.006c36.854-.013 73.59-2.133 93.554-6.788 12.883-13.14 10.31-66.614 10.52-90.474-.022-4.596-.04-7.905-.06-11.93l-.003-.045c-.162-7.926-.623-18.793-1.715-30.165-.786.27-1.757.528-2.692.528a28.5 28.5 0 0 1-19.948-8.12c-1.862-1.822-3.662-3.766-4.955-6.037l-.006-.004c-1.294 2.266-2.705 4.213-4.563 6.032a28.48 28.48 0 0 1-19.947 8.125c-7.748 0-14.778-3.11-19.906-8.123a28.025 28.025 0 0 1-4.78-6.155 27.99 27.99 0 0 1-4.736 6.155 28.49 28.49 0 0 1-19.95 8.124c-.27 0-.54-.012-.81-.02h-.007c-.27.008-.54.02-.813.02a28.49 28.49 0 0 1-19.95-8.123 27.992 27.992 0 0 1-4.736-6.155zm-20.486 26.49l-.002.01h.015c8.113.017 15.32 0 24.25 9.746 7.028-.737 14.372-1.105 21.722-1.094h.006c7.35-.01 14.694.357 21.723 1.094 8.93-9.747 16.137-9.73 24.25-9.746h.014l-.002-.01c3.833 0 19.166 0 29.85 30.007L210 165.244c8.504 30.624-2.723 31.373-16.727 31.4-20.768-.773-32.267-15.855-32.267-30.935-11.496 1.884-24.907 2.826-38.318 2.827h-.006c-13.412 0-26.823-.943-38.318-2.827 0 15.08-11.5 30.162-32.267 30.935-14.004-.027-25.23-.775-16.726-31.4L46.85 124.08C57.534 94.073 72.867 94.073 76.7 94.073zm45.985 23.582v.006c-.02.02-21.863 20.08-25.79 27.215l14.304-.573v12.474c0 .584 5.74.346 11.486.08h.006c5.744.266 11.485.504 11.485-.08v-12.474l14.304.573c-3.928-7.135-25.79-27.215-25.79-27.215v-.006l-.003.002z" color="#000"/></symbol>
+  <symbol id="icon-linktree" viewBox="0 0 24 24"><path d="m13.511 5.853 4.005-4.117 2.325 2.381-4.201 4.005h5.909v3.305h-5.937l4.229 4.108-2.325 2.334-5.741-5.769-5.741 5.769-2.325-2.325 4.229-4.108H2V8.122h5.909L3.708 4.117l2.325-2.381 4.005 4.117V0h3.473v5.853zM10.038 16.16h3.473v7.842h-3.473V16.16z"/></symbol>
+  <symbol id="icon-mastodon" viewBox="-20 -20 237 255"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z"/></symbol>
+  <symbol id="icon-patreon" viewBox="-80 -80 1160 1160"><path d="M1033.05,324.45c-0.19-137.9-107.59-250.92-233.6-291.7c-156.48-50.64-362.86-43.3-512.28,27.2 C106.07,145.41,49.18,332.61,47.06,519.31c-1.74,153.5,13.58,557.79,241.62,560.67c169.44,2.15,194.67-216.18,273.07-321.33 c55.78-74.81,127.6-95.94,216.01-117.82C929.71,603.22,1033.27,483.3,1033.05,324.45z"/></symbol>
+  <symbol id="icon-spotify" viewBox="0 0 41 40"><path d="m 40.738,21.322 c -0.015,0.221 -0.029,0.441 -0.051,0.659 -0.013,0.13 -0.032,0.258 -0.047,0.386 -0.024,0.197 -0.047,0.393 -0.076,0.587 -0.021,0.136 -0.046,0.271 -0.07,0.406 -0.032,0.185 -0.063,0.37 -0.1,0.553 -0.028,0.138 -0.06,0.274 -0.091,0.411 -0.04,0.178 -0.08,0.355 -0.125,0.531 -0.035,0.138 -0.074,0.274 -0.112,0.411 -0.048,0.172 -0.096,0.344 -0.149,0.515 a 19.186,19.186 0 0 1 -0.304,0.907 20.859,20.859 0 0 1 -0.515,1.278 26.182,26.182 0 0 1 -0.404,0.857 c -0.08,0.159 -0.163,0.315 -0.246,0.471 -0.066,0.122 -0.131,0.243 -0.199,0.364 a 21.5,21.5 0 0 1 -0.271,0.463 c -0.07,0.116 -0.139,0.232 -0.211,0.347 -0.097,0.155 -0.198,0.307 -0.3,0.459 -0.073,0.109 -0.144,0.219 -0.219,0.326 -0.108,0.156 -0.221,0.308 -0.334,0.461 -0.074,0.1 -0.145,0.2 -0.221,0.299 -0.124,0.162 -0.253,0.319 -0.383,0.477 -0.069,0.085 -0.136,0.171 -0.206,0.255 -0.159,0.188 -0.324,0.371 -0.489,0.554 -0.046,0.049 -0.088,0.101 -0.134,0.15 -0.215,0.231 -0.434,0.457 -0.66,0.678 -0.04,0.04 -0.084,0.078 -0.125,0.118 a 19.93,19.93 0 0 1 -0.562,0.525 c -0.081,0.073 -0.165,0.141 -0.246,0.212 -0.156,0.135 -0.311,0.27 -0.471,0.4 -0.097,0.079 -0.196,0.154 -0.294,0.231 -0.15,0.117 -0.299,0.234 -0.452,0.348 -0.107,0.079 -0.216,0.154 -0.324,0.231 l -0.339,0.237 c 0.032,-0.023 0.067,-0.042 0.1,-0.064 -0.038,0.026 -0.077,0.048 -0.115,0.074 -0.032,0.022 -0.063,0.045 -0.095,0.066 -0.115,0.077 -0.231,0.151 -0.347,0.225 a 19.368,19.368 0 0 1 -0.599,0.371 l -0.07,0.04 c 0.015,-0.008 0.031,-0.016 0.045,-0.025 l -0.209,0.121 c -0.148,0.086 -0.296,0.17 -0.446,0.252 l -0.197,0.104 c -0.115,0.061 -0.232,0.121 -0.349,0.18 l -0.061,0.03 c -0.09,0.045 -0.178,0.091 -0.269,0.135 -0.122,0.058 -0.244,0.116 -0.368,0.173 l -0.1,0.044 c -0.11,0.05 -0.22,0.099 -0.331,0.147 l -0.048,0.021 c -0.097,0.042 -0.196,0.082 -0.294,0.123 a 15.8,15.8 0 0 1 -0.516,0.203 c -0.117,0.044 -0.233,0.087 -0.35,0.129 l -0.151,0.055 c -0.129,0.045 -0.26,0.088 -0.391,0.13 -0.107,0.035 -0.214,0.07 -0.322,0.103 l -0.043,0.013 c -0.116,0.036 -0.232,0.07 -0.349,0.103 l -0.334,0.094 c -0.143,0.038 -0.285,0.077 -0.429,0.113 l -0.073,0.016 -0.18,0.044 c 0.032,-0.007 0.062,-0.017 0.094,-0.024 l -0.208,0.047 c 0.037,-0.009 0.076,-0.014 0.114,-0.023 -0.088,0.021 -0.177,0.037 -0.265,0.057 l -0.013,0.003 c -0.143,0.031 -0.285,0.064 -0.429,0.092 -0.149,0.029 -0.299,0.054 -0.449,0.079 -0.088,0.015 -0.175,0.034 -0.263,0.048 0.041,-0.007 0.081,-0.017 0.122,-0.024 l -0.217,0.036 c 0.032,-0.005 0.064,-0.007 0.095,-0.012 l -0.147,0.021 -0.118,0.02 c -0.199,0.029 -0.4,0.051 -0.601,0.075 -0.13,0.015 -0.26,0.034 -0.392,0.046 l -0.03,0.004 h -0.019 c -0.054,0.005 -0.11,0.007 -0.164,0.012 -0.124,0.011 -0.248,0.018 -0.372,0.027 -0.421,0.03 -0.845,0.049 -1.273,0.053 -0.038,0 -0.074,0.005 -0.111,0.005 h -0.153 c -0.036,0 -0.07,-0.004 -0.106,-0.005 A 21.083,21.083 0 0 1 18.9,39.931 C 18.781,39.922 18.661,39.915 18.542,39.905 18.494,39.9 18.445,39.899 18.396,39.894 h -0.018 a 19.603,19.603 0 0 1 -1.065,-0.138 c 0.051,0.008 0.103,0.012 0.154,0.019 -0.127,-0.019 -0.257,-0.03 -0.383,-0.05 l -0.18,-0.032 c 0.048,0.008 0.095,0.021 0.143,0.029 -0.086,-0.014 -0.17,-0.034 -0.256,-0.049 l -0.03,-0.005 c -0.053,-0.009 -0.104,-0.022 -0.156,-0.031 a 21.03,21.03 0 0 1 -1.011,-0.206 l 0.096,0.019 -0.158,-0.036 0.062,0.017 c -0.145,-0.034 -0.286,-0.077 -0.43,-0.115 l 0.023,0.006 C 15.172,39.319 15.158,39.314 15.143,39.31 l 0.021,0.006 c -0.291,-0.075 -0.582,-0.15 -0.868,-0.237 L 14.26,39.069 C 14.221,39.057 14.183,39.043 14.144,39.03 l -0.018,-0.006 c -0.161,-0.051 -0.319,-0.11 -0.479,-0.165 l 0.208,0.071 c -0.11,-0.037 -0.219,-0.076 -0.328,-0.115 l 0.12,0.044 c -0.245,-0.084 -0.49,-0.168 -0.73,-0.261 l -0.121,-0.047 c -0.017,-0.007 -0.033,-0.015 -0.05,-0.021 a 21.151,21.151 0 0 1 -0.491,-0.205 c 0.107,0.046 0.215,0.089 0.322,0.133 -0.159,-0.065 -0.317,-0.134 -0.474,-0.202 0.051,0.022 0.101,0.047 0.152,0.069 -0.168,-0.072 -0.338,-0.142 -0.504,-0.219 l -0.036,-0.015 c -0.022,-0.01 -0.045,-0.019 -0.067,-0.03 -0.079,-0.036 -0.154,-0.08 -0.232,-0.117 -0.16,-0.078 -0.32,-0.154 -0.478,-0.235 0.135,0.069 0.273,0.133 0.41,0.2 a 21.302,21.302 0 0 1 -0.584,-0.296 c 0.059,0.031 0.115,0.065 0.174,0.096 -0.133,-0.069 -0.265,-0.137 -0.396,-0.208 L 10.494,37.476 C 10.445,37.45 10.397,37.421 10.349,37.394 10.286,37.36 10.227,37.321 10.165,37.285 l -0.053,-0.03 0.027,0.015 C 9.922,37.146 9.708,37.02 9.497,36.889 9.56,36.928 9.62,36.972 9.684,37.011 9.581,36.949 9.481,36.883 9.38,36.82 L 9.371,36.814 9.217,36.717 C 9.139,36.667 9.065,36.612 8.988,36.561 8.823,36.452 8.654,36.349 8.492,36.235 8.613,36.32 8.742,36.395 8.865,36.477 8.649,36.333 8.431,36.192 8.221,36.039 l 0.044,0.033 C 8.25,36.062 8.236,36.05 8.221,36.039 8.025,35.895 7.834,35.745 7.643,35.595 L 7.634,35.588 7.632,35.587 C 7.548,35.52 7.462,35.456 7.378,35.388 L 7.377,35.387 7.344,35.359 C 7.112,35.169 6.882,34.975 6.659,34.775 L 6.641,34.76 6.627,34.747 C 6.545,34.674 6.469,34.597 6.389,34.522 6.231,34.376 6.073,34.23 5.92,34.079 L 5.901,34.061 5.864,34.026 5.901,34.061 C 5.838,33.999 5.778,33.934 5.716,33.871 5.766,33.922 5.813,33.976 5.864,34.026 5.813,33.975 5.765,33.921 5.714,33.869 L 5.632,33.783 C 5.493,33.639 5.353,33.497 5.218,33.349 L 5.216,33.346 C 5.139,33.262 5.064,33.176 4.989,33.091 L 4.931,33.024 A 21.99,21.99 0 0 1 4.335,32.314 C 4.315,32.29 4.296,32.264 4.277,32.239 A 19.844,19.844 0 0 1 3.729,31.519 C 3.707,31.488 3.687,31.456 3.665,31.426 A 22.618,22.618 0 0 1 3.334,30.947 C 3.279,30.865 3.222,30.783 3.168,30.699 3.143,30.661 3.121,30.622 3.097,30.584 A 18.36,18.36 0 0 1 2.799,30.102 C 2.787,30.08 2.773,30.059 2.76,30.038 2.723,29.977 2.685,29.917 2.649,29.855 2.622,29.809 2.598,29.761 2.572,29.714 A 20.391,20.391 0 0 1 2.298,29.212 C 2.255,29.133 2.21,29.056 2.169,28.976 2.143,28.926 2.12,28.875 2.095,28.825 A 16.057,16.057 0 0 1 1.839,28.289 C 1.806,28.22 1.771,28.151 1.739,28.081 L 1.687,27.96 C 1.594,27.752 1.507,27.541 1.422,27.329 L 1.352,27.16 1.323,27.081 A 18.694,18.694 0 0 1 1.044,26.308 L 1.01,26.212 1,26.181 A 19.533,19.533 0 0 1 0.394,23.914 L 0.386,23.874 C 0.37,23.795 0.359,23.715 0.344,23.637 A 19.434,19.434 0 0 1 0.224,22.955 L 0.209,22.867 C 0.193,22.755 0.183,22.641 0.169,22.528 A 17.848,17.848 0 0 1 0.096,21.912 C 0.094,21.892 0.09,21.872 0.089,21.851 0.086,21.824 0.087,21.796 0.085,21.768 A 19.6,19.6 0 0 1 0,19.995 c 0,-0.257 0.01,-0.513 0.02,-0.768 -0.004,0.116 -0.014,0.231 -0.016,0.348 0.002,-0.117 0.012,-0.232 0.016,-0.348 0.006,-0.145 0.012,-0.29 0.02,-0.435 0.005,-0.075 0.005,-0.151 0.011,-0.226 -0.006,0.075 -0.006,0.151 -0.011,0.226 0.012,-0.193 0.028,-0.384 0.045,-0.575 -0.011,0.116 -0.026,0.232 -0.034,0.349 0.008,-0.118 0.024,-0.234 0.034,-0.352 0.013,-0.144 0.026,-0.287 0.042,-0.43 0.022,-0.19 0.047,-0.38 0.074,-0.569 0.02,-0.142 0.04,-0.283 0.063,-0.423 V 16.791 C 0.295,16.604 0.33,16.418 0.366,16.233 V 16.231 C 0.393,16.092 0.42,15.953 0.45,15.815 v -10e-4 a 19.77,19.77 0 0 1 0.13,-0.55 C 0.613,15.128 0.647,14.991 0.684,14.856 V 14.854 C 0.733,14.674 0.785,14.497 0.839,14.32 L 0.84,14.316 c 0.04,-0.134 0.08,-0.269 0.123,-0.401 l 0.001,-0.003 c 0.057,-0.175 0.118,-0.348 0.18,-0.521 L 1.146,13.386 C 1.193,13.255 1.239,13.124 1.289,12.994 L 1.29,12.991 C 1.355,12.82 1.424,12.652 1.493,12.484 L 1.496,12.477 C 1.549,12.349 1.602,12.22 1.657,12.093 L 1.659,12.09 a 21.895,21.895 0 0 1 0.231,-0.5 c 0.059,-0.125 0.117,-0.251 0.179,-0.374 l 0.002,-0.004 c 0.08,-0.161 0.164,-0.319 0.249,-0.477 l 0.005,-0.01 C 2.39,10.604 2.454,10.481 2.522,10.361 L 2.524,10.357 C 2.611,10.202 2.703,10.049 2.794,9.896 L 2.801,9.885 C 2.872,9.767 2.942,9.648 3.015,9.531 L 3.018,9.527 C 3.112,9.377 3.21,9.23 3.308,9.083 L 3.316,9.07 C 3.393,8.956 3.468,8.841 3.547,8.728 L 3.55,8.723 C 3.65,8.579 3.755,8.438 3.859,8.296 L 3.87,8.282 C 3.952,8.171 4.032,8.06 4.116,7.951 L 4.12,7.946 C 4.226,7.808 4.337,7.673 4.448,7.537 L 4.46,7.522 C 4.547,7.415 4.633,7.308 4.722,7.203 A 0.018,0.018 0 0 1 4.726,7.198 C 4.838,7.066 4.955,6.936 5.071,6.807 L 5.086,6.791 C 5.178,6.688 5.268,6.585 5.362,6.484 L 5.367,6.48 C 5.485,6.353 5.608,6.23 5.729,6.107 L 5.746,6.09 C 5.843,5.992 5.938,5.893 6.037,5.797 A 0.018,0.018 0 0 0 6.041,5.792 L 6.077,5.756 6.043,5.791 C 6.15,5.687 6.261,5.587 6.37,5.485 6.28,5.569 6.187,5.65 6.098,5.735 6.203,5.634 6.313,5.537 6.42,5.438 L 6.439,5.421 C 6.541,5.327 6.641,5.232 6.744,5.141 L 6.749,5.137 6.802,5.088 6.749,5.137 C 6.86,5.038 6.976,4.944 7.09,4.847 6.994,4.928 6.896,5.005 6.802,5.088 6.914,4.99 7.03,4.897 7.143,4.802 A 0.425,0.425 0 0 1 7.165,4.784 C 7.27,4.696 7.374,4.606 7.481,4.52 L 7.486,4.516 7.538,4.473 7.487,4.515 C 7.603,4.422 7.722,4.333 7.84,4.243 7.74,4.32 7.637,4.394 7.538,4.473 7.655,4.379 7.777,4.29 7.897,4.199 L 7.92,4.181 C 8.03,4.098 8.137,4.014 8.248,3.933 L 8.252,3.93 8.303,3.892 8.255,3.928 C 8.376,3.84 8.499,3.757 8.621,3.672 8.516,3.746 8.407,3.816 8.303,3.892 8.426,3.802 8.554,3.718 8.68,3.632 L 8.706,3.614 C 8.818,3.536 8.929,3.457 9.043,3.382 L 9.047,3.379 9.096,3.346 9.052,3.376 C 9.177,3.294 9.306,3.217 9.432,3.137 9.321,3.207 9.206,3.274 9.096,3.346 9.226,3.261 9.359,3.182 9.491,3.1 L 9.52,3.083 C 9.635,3.011 9.749,2.937 9.866,2.868 L 9.87,2.866 A 0.739,0.739 0 0 1 9.917,2.837 l -0.04,0.025 c 0.13,-0.077 0.263,-0.149 0.395,-0.223 -0.118,0.066 -0.239,0.129 -0.355,0.198 0.135,-0.08 0.275,-0.154 0.412,-0.23 L 10.361,2.589 C 10.479,2.524 10.595,2.456 10.714,2.392 A 0.008,0.008 0 0 0 10.718,2.39 l 0.045,-0.024 -0.036,0.02 C 10.863,2.314 11.003,2.247 11.14,2.178 11.015,2.241 10.887,2.3 10.763,2.366 10.905,2.291 11.05,2.223 11.193,2.151 a 0.378,0.378 0 0 0 0.035,-0.017 c 0.12,-0.059 0.238,-0.121 0.36,-0.178 l 0.003,-0.002 0.043,-0.021 -0.031,0.016 c 0.143,-0.067 0.289,-0.129 0.434,-0.193 -0.134,0.06 -0.27,0.115 -0.403,0.177 0.148,-0.069 0.299,-0.131 0.449,-0.197 L 12.121,1.719 C 12.242,1.666 12.362,1.61 12.485,1.56 l 0.003,-0.002 a 0.684,0.684 0 0 0 0.04,-0.017 L 12.502,1.553 C 12.654,1.49 12.809,1.434 12.963,1.375 12.818,1.43 12.672,1.483 12.528,1.541 12.682,1.478 12.84,1.422 12.996,1.362 L 13.039,1.346 C 13.161,1.299 13.281,1.25 13.405,1.205 h 0.002 A 0.426,0.426 0 0 1 13.445,1.191 L 13.423,1.199 C 13.588,1.14 13.757,1.088 13.924,1.033 13.764,1.086 13.603,1.135 13.445,1.191 13.606,1.134 13.77,1.084 13.932,1.03 l 0.05,-0.016 c 0.122,-0.04 0.241,-0.083 0.363,-0.12 h 0.002 L 14.382,0.883 14.365,0.889 C 14.539,0.836 14.716,0.79 14.891,0.742 l -0.027,0.008 0.07,-0.02 0.061,-0.017 c 0.104,-0.028 0.206,-0.059 0.311,-0.086 h 0.001 l 0.031,-0.008 -0.01,0.003 c 0.18,-0.045 0.363,-0.083 0.545,-0.123 L 15.809,0.513 C 15.901,0.492 15.992,0.47 16.085,0.451 l 0.088,-0.02 c 0.038,-0.008 0.075,-0.018 0.112,-0.025 l 0.001,-0.001 0.027,-0.004 -0.004,0.001 c 0.188,-0.038 0.379,-0.068 0.57,-0.1 L 16.777,0.319 C 16.947,0.29 17.115,0.256 17.285,0.231 L 17.286,0.23 17.303,0.228 h 0.004 C 17.459,0.205 17.614,0.19 17.767,0.17 17.664,0.183 17.559,0.195 17.456,0.209 17.608,0.188 17.762,0.17 17.915,0.152 L 17.767,0.17 C 17.944,0.148 18.12,0.122 18.299,0.104 H 18.3 l 0.021,-0.002 h 10e-4 c 0.151,-0.015 0.304,-0.023 0.456,-0.034 l -0.149,0.011 c 0.126,-0.01 0.251,-0.02 0.377,-0.028 l -0.228,0.017 c 0.183,-0.014 0.365,-0.032 0.549,-0.041 h 10e-4 L 19.351,0.026 C 19.503,0.018 19.657,0.018 19.809,0.014 L 19.724,0.016 C 19.947,0.009 20.169,0 20.394,0 c 11.264,0 20.394,8.951 20.394,19.995 0,0.335 -0.01,0.668 -0.026,0.999 -0.006,0.11 -0.017,0.219 -0.024,0.328 z M 8.562,26.391 c 0.155,0.67 0.838,1.09 1.522,0.936 7.098,-1.59 13.126,-0.942 17.914,1.928 0.6,0.358 1.383,0.175 1.749,-0.416 A 1.231,1.231 0 0 0 29.325,27.127 C 23.949,23.905 17.286,23.156 9.517,24.898 A 1.242,1.242 0 0 0 8.562,26.391 Z M 7.748,20.54 c 0.256,0.823 1.144,1.287 1.986,1.038 6.484,-1.929 14.841,-0.973 20.321,2.331 0.747,0.449 1.727,0.22 2.187,-0.512 A 1.543,1.543 0 0 0 31.72,21.252 C 25.424,17.461 16.217,16.392 8.809,18.596 A 1.557,1.557 0 0 0 7.748,20.54 Z M 34.404,14.509 C 26.836,10.104 14.861,9.69 7.657,11.835 a 1.864,1.864 0 0 0 -1.271,2.332 c 0.305,0.989 1.37,1.546 2.379,1.246 6.275,-1.867 17.118,-1.514 23.693,2.313 a 1.927,1.927 0 0 0 2.613,-0.655 1.848,1.848 0 0 0 -0.667,-2.562 z M 20.34,0 h 0.054 C 20.198,0 20.004,0.009 19.809,0.014 19.986,0.009 20.162,0 20.34,0 Z"/></symbol>
+
+  <symbol id="icon-toyhouse" viewBox="0 0 197 190">
+    <!-- Via: https://logos.fandom.com/wiki/File:Toyhouse.svg -->
+    <path d="M 92.377573 54.858845 L -6.2923729 154.22229 L 25.027679 154.05951 L 25.027679 245.16092 L 159.06549 245.16092 L 159.06549 153.9484 L 191.43458 153.9484 L 152.83642 115.35024 L 152.83642 69.076582 L 121.69051 69.076582 L 121.69051 84.093236 L 92.377573 54.858845 z M 108.84479 170.6156 L 108.62206 196.7153 L 135.0499 196.7153 L 135.0499 170.99543 L 144.0168 170.99543 L 144.0168 232.6604 L 134.73519 232.6604 L 134.73519 203.89264 L 108.70061 203.89264 L 108.70061 233.01438 L 99.73423 233.01438 L 99.73423 181.1023 C 99.73423 178.74267 99.733714 178.97633 99.733714 178.97633 L 81.812844 178.97633 L 81.812844 232.98079 L 70.800596 232.98079 L 70.800596 179.03214 L 54.338035 179.03214 L 54.338035 170.63369 L 108.84479 170.6156 z " transform="translate(6.2923729,-54.858845)"/>
+  </symbol>
+
+  <symbol id="icon-internetArchive" viewBox="0 0 27 30"><path d="M 26.666667,28.604651 V 30 H 0 l 2.8368794e-4,-1.395349 z m -1.052632,-2.093023 v 1.744186 H 1.0526316 V 26.511628 Z M 3.624692,7.6744186 3.9174691,7.8215328 4.0639977,10.173954 4.2105263,13.996393 v 3.676169 L 4.0639977,22.255044 4.039623,25.342193 3.624692,25.465116 H 2.1602464 L 1.7209407,25.342193 1.5503175,22.255044 1.4035088,17.697034 V 14.021147 L 1.5503175,10.173954 1.6842385,7.8088748 1.9896232,7.6744186 Z m 21.052795,0 0.293116,0.1471142 0.146277,2.3524212 0.146278,3.822439 v 3.676169 l -0.146278,4.582482 -0.0241,3.087149 -0.415294,0.122923 h -1.464458 l -0.439393,-0.122923 -0.171218,-3.087149 -0.146278,-4.55801 v -3.675887 l 0.146278,-3.847193 0.134508,-2.3650792 0.305166,-0.1344562 z m -14.737064,0 0.292806,0.1471142 0.146544,2.3524212 0.146543,3.822439 v 3.676169 l -0.146543,4.582482 -0.0241,3.087149 -0.415253,0.122923 H 8.4758312 L 8.0362015,25.342193 7.8655613,22.255044 7.7192982,17.697034 V 14.021147 L 7.8655613,10.173954 8.000056,7.8088748 8.3049108,7.6744186 Z m 8.070175,0 0.292807,0.1471142 0.146543,2.3524212 0.146543,3.822439 v 3.676169 l -0.146543,4.582482 -0.0241,3.087149 -0.415253,0.122923 h -1.464591 l -0.43935,-0.122923 -0.17092,-3.087149 -0.146263,-4.55801 v -3.675887 l 0.146263,-3.847193 0.134494,-2.3650792 0.305135,-0.1344562 z M 25.614035,4.5348837 V 6.9767442 H 1.0526316 V 4.5348837 Z M 13.080676,0 25.964912,2.9333134 25.448414,3.8372093 H 0.77192525 L 0,3.1041615 Z"/></symbol>
+
+  <symbol id="icon-newgrounds" viewbox="0 0 40">
+    <!-- Thanks for clicking a convert button for the greater good, @PeterShaggyNoble! https://github.com/simple-icons/simple-icons/issues/1974#issuecomment-637606360 -->
+    <path d="M 21.67012,2.5595806 C 21.281556,2.6425058 21.182046,2.7041073 20.355164,3.3769857 20.042418,3.6304997 19.575668,4.0072168 19.319784,4.2109758 18.793802,4.6327094 18.656383,4.7724975 18.535549,5.0165344 L 18.452624,5.1894925 17.308257,5.9926819 C 16.678026,6.4357391 16.092811,6.8479955 16.005148,6.9095971 L 15.848774,7.0209537 15.69714,6.9190742 15.545505,6.8148255 13.022211,6.4428469 C 11.633807,6.239088 10.475225,6.0661298 10.449162,6.059022 10.408885,6.0519141 10.378084,5.9997897 10.321221,5.8434166 10.226449,5.585164 10.001367,5.1468453 9.9326572,5.0852438 9.8639478,5.0212729 9.3237497,4.7014188 9.2858411,4.7014188 9.2479324,4.7014188 9.2479324,4.7037881 9.2811025,4.6066472 9.3024261,4.5474149 9.3355962,4.5189835 9.4327371,4.4763363 9.5725252,4.4123654 9.5843716,4.3886725 9.6341267,4.0190633 9.6578196,3.8484744 9.6554503,3.8129351 9.6246495,3.76318 9.5938488,3.7181635 9.5891102,3.6684084 9.6009566,3.5072966 L 9.6175417,3.305907 9.5322472,3.2656291 C 9.4777536,3.2395669 9.4351063,3.1945504 9.4066749,3.1329488 9.3403347,2.9836836 9.1365758,2.7917711 8.9541405,2.7041073 8.7977673,2.6306593 8.7835516,2.6282901 8.4850211,2.6282901 8.2007063,2.6282901 8.1675362,2.6330286 8.0395945,2.6922609 7.7837112,2.8130947 7.5491515,3.0926709 7.5112429,3.3201227 7.4993964,3.3983093 7.4828114,3.4243715 7.430687,3.4456951 7.2885296,3.5049274 7.2766831,3.5404667 7.2766831,3.8792752 7.2766831,4.2346687 7.2885296,4.2725773 7.430687,4.3389174 7.4828114,4.3626103 7.5254586,4.3934111 7.5254586,4.4099961 7.5254586,4.4242119 7.5301972,4.4526434 7.5373051,4.4692284 7.5467822,4.4952906 7.4614878,4.5284606 7.1724344,4.6042779 6.9639369,4.6611409 6.7649165,4.7251117 6.7270079,4.7488046 6.6748835,4.7796054 6.6346056,4.8506841 6.5493111,5.071028 6.3858301,5.4785459 6.3052742,5.7770765 6.246042,6.1917022 6.1702247,6.712946 6.2010255,6.9356593 6.3905687,7.2223434 6.4308466,7.2839449 6.4616474,7.3360693 6.4569088,7.3408079 6.4521702,7.3455464 6.2081334,7.5113967 5.9143414,7.7104171 5.2106623,8.1890137 4.0354944,9.0680203 3.9738929,9.1627919 3.8909677,9.2907335 3.7535489,11.00373 3.7203789,12.370811 L 3.7037938,12.993934 3.3104917,13.344589 C 2.7157999,13.870571 2.2229876,14.344429 1.5548478,15.022046 0.79193642,15.796804 0.78008997,15.81102 0.73744275,16.017148 0.69716482,16.21143 0.64504044,16.801383 0.65925618,16.924586 0.66873334,16.998034 0.64977902,17.038312 0.51946807,17.227855 0.05982581,17.888887 -0.09180875,18.675491 0.05271794,19.620838 0.19013676,20.51406 0.77772068,21.321988 1.3842589,21.44993 1.4742919,21.468884 3.966785,21.475992 10.354391,21.475992 H 19.196581 L 19.362431,21.378851 C 19.748626,21.146661 20.293562,20.663326 20.646587,20.241592 21.184415,19.597145 21.698551,18.587827 21.961543,17.661435 22.205579,16.803752 22.309828,16.038471 22.321675,14.995984 L 22.331152,14.379968 22.66996,14.135932 C 23.07274,13.844509 23.108279,13.813708 23.127233,13.728414 23.148557,13.626534 23.10354,13.493854 22.913997,13.095813 22.473309,12.174159 21.947327,11.607899 21.288664,11.342539 20.926163,11.195643 19.736779,11.032162 18.739308,10.989514 18.29862,10.97056 18.33179,10.994253 18.327052,10.695723 18.324682,10.463532 18.286774,10.321375 18.210956,10.266881 18.168309,10.23608 18.056953,10.212387 17.83187,10.183956 17.656543,10.162632 17.509647,10.143678 17.504908,10.141309 17.502539,10.13657 17.488323,10.044168 17.476477,9.9351804 17.443307,9.6698199 17.348535,9.2788871 17.237178,8.9448172 17.185054,8.7955519 17.144776,8.6699796 17.144776,8.6676103 17.144776,8.6628717 17.258502,8.5989009 17.400659,8.5254529 17.540447,8.4496356 18.128031,8.13452 18.706138,7.824143 L 19.760472,7.260252 H 19.959493 C 20.06848,7.260252 20.222484,7.243667 20.30304,7.2223434 20.419135,7.1915426 21.146507,6.8906428 22.890304,6.1514243 23.018246,6.0969306 23.243328,5.9476653 23.394963,5.8197237 23.641369,5.6088569 23.859344,5.2558327 23.949377,4.9170242 24.010978,4.6824645 24.018086,4.2180836 23.961223,3.9858932 23.781157,3.2466747 23.129603,2.6496137 22.395123,2.5477342 22.151086,2.5121948 21.859663,2.5169334 21.67012,2.5595806 Z M 22.357214,2.9268206 C 22.786055,2.99553 23.179358,3.2703676 23.418656,3.6636698 23.631892,4.0214326 23.688755,4.5331992 23.553705,4.9146549 23.40444,5.3387578 23.006399,5.7273214 22.594143,5.8505245 22.414077,5.9050181 22.018406,5.9239724 21.831232,5.8884331 21.317096,5.7865536 20.838499,5.3671893 20.672649,4.874377 20.644217,4.7843439 20.615786,4.6232322 20.606309,4.490552 L 20.592093,4.2631001 20.53523,4.4052576 C 20.504429,4.4834441 20.471259,4.6137551 20.464151,4.6966802 L 20.449936,4.8435762 19.620684,5.4145751 18.791433,5.9832047 18.772478,5.8813252 C 18.717985,5.5899026 18.810387,5.2202933 18.99993,4.9928415 19.158672,4.800929 20.33621,3.8105658 21.018565,3.2964298 21.475838,2.9481442 21.854925,2.8438954 22.357214,2.9268206 Z M 8.9778334,3.2822141 C 9.2147624,3.3177534 9.4303678,3.3746164 9.4493221,3.4054172 9.4801229,3.452803 9.4706457,3.6447155 9.4303678,3.7750264 9.3948284,3.8982295 9.3948284,3.8982295 9.3071647,3.8911216 9.2289781,3.8840138 9.2171317,3.8745366 9.1815923,3.7773957 9.1436837,3.6778855 9.1342065,3.6684084 9.032327,3.649454 8.8878004,3.6233919 8.8238295,3.6423462 8.7717051,3.7300099 8.7480122,3.7702878 8.7029957,3.8129351 8.6698257,3.8271508 8.6034856,3.8508437 8.3404944,3.8555823 8.2078141,3.8342587 8.1296276,3.8200429 8.1154118,3.8081965 8.0940882,3.7323792 8.0822418,3.6849934 8.0703953,3.5760061 8.0703953,3.4883423 8.0703953,3.2585212 8.0751339,3.2561519 8.4755439,3.2561519 8.6579792,3.2561519 8.8854311,3.2679984 8.9778334,3.2822141 Z M 9.0773436,3.943246 C 9.0962979,4.000109 9.1270986,4.0380176 9.179223,4.0593412 9.269256,4.0996191 9.269256,4.1043577 9.2052852,4.310486 9.1602687,4.4550126 9.1484223,4.4715977 9.0583892,4.5142449 8.925709,4.5734771 8.7574894,4.5711079 8.6129627,4.5095063 8.4755439,4.4502741 8.4281581,4.3768261 8.4092038,4.1920215 L 8.394988,4.0688184 8.5466226,4.0522333 C 8.7385351,4.0285404 8.8309374,3.9906318 8.8735846,3.9100759 8.9020161,3.8603209 8.9233397,3.8484744 8.9802027,3.853213 9.0370656,3.8579516 9.0560199,3.8769059 9.0773436,3.943246 Z M 7.8287277,5.0497044 C 8.3120629,5.5306703 9.0844514,5.6207033 9.326119,5.2274012 9.3971977,5.111306 9.4019363,5.1089367 9.4753843,5.1302603 9.6791432,5.1871233 9.6815125,5.1894925 9.6815125,5.3624507 9.6815125,5.5093467 9.7123133,5.7794458 9.743114,5.8979103 9.7549605,5.9500346 9.7502219,5.9500346 9.4753843,5.9642504 9.2976875,5.9737275 9.1318372,5.9974204 9.0181113,6.0305905 8.9209704,6.059022 8.5466226,6.2248723 8.1912291,6.3978304 7.6747239,6.6489752 7.5349358,6.7105767 7.5136121,6.6868838 7.4662263,6.6347594 7.210343,6.2153951 7.2198202,6.2059179 7.2269281,6.2011794 7.2885296,6.2343494 7.3596083,6.2793659 L 7.4899192,6.3622911 7.4970271,6.1419471 C 7.5088736,5.8647402 7.4685956,5.5140853 7.3927784,5.2250319 7.3619776,5.1018288 7.340654,4.9952108 7.3477618,4.9881029 7.357239,4.980995 7.4330563,4.9620407 7.52072,4.947825 7.6083837,4.9312399 7.6818317,4.9193935 7.6865703,4.9170242 7.6889396,4.9170242 7.7529104,4.9762564 7.8287277,5.0497044 Z M 18.542657,5.7249521 C 18.542657,5.895541 18.59952,6.1419471 18.668229,6.2864738 L 18.722723,6.4049383 16.448205,7.790973 C 15.19485,8.5515151 14.16184,9.1675305 14.152363,9.155684 14.140516,9.1462068 14.126301,9.0727589 14.119193,8.992203 14.102608,8.8216141 14.149993,8.6747181 14.251873,8.5775772 14.320582,8.5112371 18.495271,5.6064876 18.526072,5.6041183 18.535549,5.601749 18.542657,5.658612 18.542657,5.7249521 Z M 12.595739,6.7982405 C 14.100238,7.0138458 15.353593,7.1939119 15.379655,7.2010198 15.405717,7.2057583 15.445995,7.2247127 15.469688,7.2412977 15.507597,7.2697292 15.476796,7.2981606 15.166419,7.5161353 L 14.822872,7.7601722 13.25914,7.5445668 C 12.273516,7.4095173 11.662239,7.3360693 11.610115,7.3455464 11.517712,7.3621315 11.328169,7.480596 11.306845,7.5350896 11.299738,7.554044 11.3258,7.6914628 11.363708,7.8407281 11.456111,8.1984908 11.52482,8.6841953 11.543774,9.1130368 11.560359,9.4471067 11.55799,9.4636917 11.515343,9.4636917 11.437156,9.4636917 10.195648,9.2978414 10.183802,9.2883642 10.179063,9.2812564 10.160109,9.1296218 10.141155,8.9519251 10.079553,8.3193246 9.9326572,7.6914628 9.6886204,7.0019994 9.6175417,6.7982405 9.5535708,6.6015894 9.546463,6.56605 9.5275087,6.4618013 9.5914795,6.4073076 9.7383755,6.4073076 9.8047156,6.4073076 11.09124,6.5826351 12.595739,6.7982405 Z M 13.247294,7.8881139 C 13.870417,7.9734083 14.384553,8.0468563 14.389292,8.0515949 14.39403,8.0563334 14.292151,8.1321507 14.164209,8.2198145 14.02916,8.3122168 13.915434,8.4046191 13.898849,8.4425277 13.839616,8.5681001 13.711675,8.9400786 13.602687,9.2978414 13.541086,9.4992311 13.484223,9.6721892 13.474746,9.6816664 13.467638,9.6911435 13.164369,9.6650814 12.801867,9.6248034 12.230869,9.5584633 12.145574,9.5442476 12.15979,9.5134468 12.169267,9.4921232 12.211914,9.3736587 12.256931,9.2480863 12.42515,8.7576433 12.380134,8.3027396 12.121881,7.9023296 12.069757,7.8217737 12.02711,7.750695 12.02711,7.7459565 12.02711,7.7246328 12.117143,7.73411 13.247294,7.8881139 Z M 16.846245,9.0111573 C 16.933909,9.2338706 17.040527,9.6698199 17.073697,9.9328111 17.087913,10.053645 17.09739,10.157894 17.092652,10.160263 17.085544,10.16974 16.405558,10.086815 16.322632,10.067861 16.272877,10.056014 16.272877,10.053645 16.310786,9.9446576 16.339217,9.8617324 16.348695,9.7290522 16.351064,9.4423681 L 16.353433,9.0561738 16.542976,8.9519251 C 16.644856,8.8950621 16.741997,8.8476763 16.758582,8.8476763 16.772797,8.8476763 16.813075,8.9211243 16.846245,9.0111573 Z M 15.983824,9.6224341 C 15.971977,9.7290522 15.950654,9.8617324 15.934069,9.9138568 15.910376,9.9920434 15.89616,10.008628 15.844036,10.008628 15.737418,10.006259 14.938967,9.8949025 14.922382,9.8783175 14.884473,9.8427781 14.986353,9.776438 15.46258,9.5252932 L 15.971977,9.2575635 15.988562,9.3428579 C 15.99804,9.3902437 15.99567,9.5158161 15.983824,9.6224341 Z M 14.06233,10.162632 C 16.152043,10.416146 17.872148,10.624644 17.883994,10.624644 17.893472,10.624644 17.902949,10.693353 17.902949,10.778648 V 10.932652 H 17.635219 C 17.29878,10.932652 17.23007,10.965822 17.038158,11.209858 16.943386,11.333062 16.888893,11.382817 16.853353,11.382817 16.824922,11.382817 16.052533,11.30463 15.140357,11.207489 L 13.477115,11.034531 13.339696,10.911328 C 12.915593,10.532241 12.406196,10.297682 11.768857,10.186325 11.337646,10.112877 10.366237,10.008628 10.103246,10.008628 10.065337,10.008628 10.060599,9.9849355 10.060599,9.8404088 V 9.6745585 L 10.162478,9.6887743 C 10.216972,9.6958821 11.972616,9.9091182 14.06233,10.162632 Z M 10.084292,10.420885 C 11.261829,10.501441 11.59116,10.541719 12.055541,10.660183 12.380134,10.740739 12.522291,10.802341 12.716573,10.93739 13.081444,11.190904 13.446314,11.6387 13.66192,12.098342 13.749583,12.285516 13.853832,12.5722 13.841986,12.584047 13.82777,12.600632 10.003736,12.259454 9.9895202,12.242869 9.980043,12.233392 9.9445037,12.13862 9.9065951,12.034371 9.7146826,11.479957 9.3877205,11.046377 9.015742,10.849726 8.8190909,10.747847 8.8214602,10.747847 8.378403,10.890004 7.8026655,11.077178 6.956829,11.43968 6.1749633,11.835351 5.8172005,12.015417 5.6134416,12.122035 4.8718538,12.522445 4.7462814,12.591155 4.7439121,12.591155 4.8126215,12.531922 4.9002853,12.456105 5.5636865,12.008309 5.8669556,11.816397 6.776763,11.247767 7.7766033,10.762063 8.6508714,10.465901 L 8.9588791,10.361653 9.2550403,10.373499 C 9.4185213,10.380607 9.7904998,10.401931 10.084292,10.420885 Z M 18.779586,11.382817 C 19.575668,11.425464 20.70345,11.574729 21.042258,11.683716 21.478207,11.823505 21.883356,12.157574 22.212687,12.648017 22.33589,12.832822 22.577558,13.256925 22.615467,13.356435 22.63916,13.415667 22.63679,13.420406 22.575189,13.420406 22.489894,13.420406 19.556713,13.13846 19.551975,13.131353 19.549605,13.126614 19.523543,13.029473 19.495112,12.911009 19.334,12.266562 18.893312,11.745318 18.251234,11.43968 18.151724,11.392294 18.068799,11.352016 18.068799,11.347277 18.068799,11.344908 18.118554,11.344908 18.182525,11.349647 18.244127,11.354385 18.511856,11.368601 18.779586,11.382817 Z M 15.384394,11.643439 C 16.990772,11.797442 17.050004,11.80692 17.424352,11.991724 17.886364,12.216807 18.234649,12.541399 18.409977,12.90627 18.549765,13.197693 18.594781,13.403821 18.608997,13.832662 L 18.620844,14.185687 18.516595,14.171471 C 18.459732,14.164363 17.33195,14.060114 16.014625,13.939281 L 13.614534,13.718937 V 13.562563 13.40619 L 13.946235,13.091075 C 14.247134,12.80676 14.277935,12.768851 14.277935,12.695403 14.277935,12.517707 14.031529,11.875629 13.846724,11.577098 L 13.799339,11.501281 H 13.858571 C 13.891741,11.501281 14.578835,11.565252 15.384394,11.643439 Z M 9.0394349,12.8731 C 9.4114134,13.598103 9.5132929,14.531603 9.3237497,15.469842 9.1531608,16.31094 8.6840414,17.092805 8.124889,17.462415 7.8239891,17.663804 7.6486617,17.718298 7.3003761,17.720667 7.0658163,17.720667 6.9899991,17.71119 6.8596881,17.666174 6.5469418,17.559556 6.2531499,17.313149 6.0185902,16.960125 5.7011053,16.483898 5.5399936,15.960285 5.4997157,15.27556 L 5.4831306,15.019677 5.6205494,14.924905 C 5.6963667,14.872781 5.7674454,14.825395 5.7769226,14.823026 5.7863997,14.818287 5.7958769,14.927274 5.7958769,15.062324 5.7958769,15.199743 5.8029848,15.327684 5.8100926,15.349008 5.8219391,15.379809 5.8645863,15.386917 6.0209595,15.386917 6.1275775,15.386917 6.2649963,15.394025 6.3242286,15.401132 L 6.4308466,15.415348 6.4687553,15.574091 C 6.4877096,15.659385 6.5137718,15.756526 6.5208796,15.787327 L 6.5374647,15.84419 6.2270877,15.832343 C 5.878802,15.818127 5.8906485,15.81102 5.9427729,15.981608 L 5.9688351,16.074011 H 6.2957971 6.6227591 L 6.7080536,16.24223 C 6.8691653,16.564454 7.0895092,16.820337 7.3216997,16.950648 L 7.4377949,17.019358 7.3453926,17.038312 C 7.210343,17.066743 6.8644267,17.059635 6.7672858,17.026465 6.6914685,17.000403 6.6890992,17.002772 6.7293772,17.033573 6.8170409,17.099913 7.0397541,17.170992 7.2056045,17.180469 7.3998862,17.194685 7.684201,17.128345 7.8524206,17.031204 7.994578,16.948279 8.2386149,16.713719 8.3760337,16.528914 8.9138625,15.801542 9.1128829,14.64059 8.8617382,13.702352 8.8048752,13.491485 8.6485021,13.133722 8.5466226,12.979718 L 8.4968675,12.90627 8.6816721,12.77359 C 8.7811823,12.700142 8.8759539,12.64091 8.8925389,12.63854 8.9067547,12.63854 8.9730948,12.745158 9.0394349,12.8731 Z M 11.778334,13.396713 C 12.581523,13.448837 13.25914,13.491485 13.285203,13.491485 13.32785,13.491485 13.330219,13.512808 13.330219,13.977189 V 14.465263 L 13.218862,14.451047 C 13.154892,14.443939 12.941656,14.427354 12.740266,14.413139 12.446474,14.391815 12.373026,14.394184 12.356441,14.417877 12.346964,14.434462 12.330379,14.526864 12.320902,14.624005 12.309055,14.759055 12.313794,14.806441 12.337487,14.830134 12.365918,14.853826 13.022211,14.913059 13.268618,14.913059 13.311265,14.913059 13.313634,14.927274 13.299418,15.202112 13.282833,15.47695 13.1928,16.287247 13.173846,16.308571 13.169107,16.313309 12.955871,16.303832 12.699988,16.289616 12.318532,16.265923 12.22613,16.265923 12.202437,16.291985 12.185852,16.308571 12.157421,16.400973 12.140836,16.493375 12.117143,16.630794 12.117143,16.671072 12.140836,16.694765 12.162159,16.716088 12.323271,16.735043 12.619432,16.751628 12.868208,16.765843 13.074336,16.782429 13.079074,16.787167 13.098029,16.806121 12.92744,17.483738 12.818452,17.820178 L 12.707096,18.158986 12.562569,18.156617 C 12.484383,18.156617 12.264039,18.14714 12.074495,18.135293 11.820981,18.121077 11.719102,18.123447 11.688301,18.142401 11.633807,18.17794 11.503497,18.47884 11.520082,18.526226 11.539036,18.573612 11.603007,18.58072 12.081603,18.599674 12.311424,18.606782 12.500968,18.623367 12.500968,18.632844 12.500968,18.687338 12.140836,19.355477 11.984462,19.592406 L 11.799658,19.871983 11.25709,19.867244 10.712154,19.862506 10.593689,20.009401 C 10.363868,20.293716 10.370976,20.312671 10.686091,20.317409 10.804556,20.319778 11.008315,20.326886 11.136257,20.336364 L 11.373186,20.350579 11.200227,20.499845 C 10.998838,20.672803 10.759539,20.836284 10.508395,20.976072 L 10.328329,21.073213 8.6911493,21.068474 7.0516006,21.061366 7.2624674,20.900255 C 8.4708053,19.983339 9.4635378,18.17794 9.8686864,16.156936 9.9989974,15.50775 10.053491,14.995984 10.08903,14.053006 L 10.117462,13.301942 H 10.216972 C 10.273835,13.301942 10.975145,13.344589 11.778334,13.396713 Z M 20.608678,14.216487 21.880987,14.330213 21.892833,14.424985 C 21.899941,14.479479 21.909418,14.631113 21.916526,14.763793 L 21.926003,15.00783 H 21.817016 C 21.755414,15.00783 21.556394,14.995984 21.376328,14.981768 21.110967,14.962814 21.039889,14.962814 21.018565,14.988876 20.973549,15.036262 20.952225,15.351377 20.990134,15.384547 21.006719,15.396394 21.222324,15.422456 21.46873,15.439041 L 21.914157,15.467473 21.897572,15.735202 C 21.883356,16.005301 21.833601,16.419927 21.783846,16.671072 L 21.757784,16.808491 H 21.589564 C 21.497162,16.806121 21.279187,16.796644 21.10386,16.784798 20.928532,16.775321 20.772159,16.770582 20.755574,16.77769 20.715296,16.791906 20.630002,17.118868 20.653694,17.166253 20.672649,17.206531 20.722404,17.21127 21.381067,17.249179 21.584825,17.261025 21.646427,17.272872 21.646427,17.298934 21.646427,17.355797 21.373959,18.161355 21.298141,18.324836 L 21.229432,18.47884 20.933271,18.462255 C 20.772159,18.455147 20.53523,18.44567 20.407288,18.44567 L 20.177467,18.443301 20.089803,18.618628 C 20.042418,18.7134 20.011617,18.808171 20.018725,18.829495 20.032941,18.867404 20.115866,18.87925 20.70345,18.910051 L 21.025673,18.929005 20.859823,19.21332 C 20.76979,19.369693 20.613417,19.61373 20.513906,19.755887 L 20.331471,20.011771 19.786534,20.009401 19.241598,20.007032 19.104179,20.177621 C 19.028362,20.270023 18.969129,20.362426 18.973868,20.383749 18.980976,20.41455 19.040208,20.424027 19.348216,20.438243 19.549605,20.44772 19.758103,20.459567 19.807858,20.461936 L 19.90263,20.469044 19.70124,20.637263 C 19.592253,20.732035 19.419294,20.867084 19.315046,20.940532 L 19.130241,21.073213 H 17.829501 16.53113 L 16.941017,20.663326 C 17.376966,20.229745 17.639958,19.902783 17.917165,19.44551 18.53318,18.433824 18.969129,17.116498 19.144457,15.74231 19.196581,15.334792 19.236859,14.671391 19.227382,14.358645 L 19.217905,14.081438 19.277137,14.093284 C 19.310307,14.100392 19.909737,14.154886 20.608678,14.216487 Z M 4.9997955,16.074011 C 5.0211191,16.168782 5.0329655,16.254077 5.0282269,16.261185 5.0187498,16.2754 4.7249578,16.47679 3.6895781,17.1781 L 3.2654752,17.464784 3.1446414,17.303672 C 3.0783013,17.213639 2.9740525,17.102283 2.9100817,17.052528 2.8484801,17.002772 2.7963558,16.950648 2.7939865,16.938802 2.7916172,16.924586 3.2725831,16.5763 3.860167,16.164044 L 4.931086,15.412979 4.9476711,15.657016 C 4.9571482,15.792065 4.9808411,15.979239 4.9997955,16.074011 Z M 2.5309953,17.481369 C 2.6589369,17.642481 2.822418,17.983659 2.8840195,18.218218 2.985899,18.618628 2.9645753,19.237013 2.8318951,19.684809 2.6897377,20.161036 2.3296056,20.639633 1.9884279,20.803114 1.8770712,20.855238 1.8083618,20.871823 1.6377729,20.878931 1.5193084,20.883669 1.3795203,20.876562 1.3250267,20.862346 1.1710228,20.819699 0.95304814,20.660956 0.82984506,20.497475 0.59054677,20.17999 0.45075866,19.78195 0.42469647,19.343631 0.41285002,19.125656 0.44602008,18.637583 0.47919014,18.554657 0.4886673,18.526226 0.68294908,18.393546 0.69479553,18.405392 0.69953411,18.410131 0.68768766,18.481209 0.66636405,18.564135 0.62608612,18.737093 0.59291606,19.234644 0.61660896,19.329415 0.6308247,19.388648 0.63556328,19.391017 0.82747577,19.391017 H 1.0217575 L 1.0596662,19.533174 1.0975748,19.675332 H 0.88670802 C 0.6782105,19.675332 0.67584121,19.675332 0.69005695,19.727456 0.71611914,19.81275 0.73033488,19.817489 0.94594027,19.834074 L 1.1520685,19.84829 1.2255165,19.98097 C 1.3084416,20.125497 1.5358935,20.376641 1.6046029,20.397965 1.6306651,20.407442 1.6496194,20.421658 1.6496194,20.433504 1.6496194,20.466674 1.3937361,20.487998 1.2942259,20.461936 1.1615457,20.428766 1.1994543,20.469044 1.343981,20.51643 1.5027234,20.570923 1.7467603,20.554338 1.8960256,20.483259 2.0310751,20.416919 2.2419419,20.208422 2.3627757,20.025987 2.7252771,19.462095 2.7797707,18.58072 2.4859788,17.971812 2.4054229,17.803592 2.1874482,17.550078 2.0666144,17.483738 2.0287058,17.462415 2.038183,17.448199 2.161386,17.360535 L 2.3011741,17.263394 2.3675143,17.31078 C 2.4054229,17.336842 2.4788709,17.41266 2.5309953,17.481369 Z M 4.7154807,17.502693 4.8268373,17.640111 4.4785517,17.805962 C 4.2866392,17.895995 4.1255274,17.967074 4.1231582,17.962335 4.0994653,17.941011 4.0686645,17.696974 4.0852495,17.680389 4.1089424,17.656697 4.5970162,17.355797 4.6017547,17.360535 4.604124,17.362905 4.6538791,17.426875 4.7154807,17.502693 Z M 6.6867299,18.296405 C 6.8762731,18.393546 7.0563392,18.670753 7.136895,18.988237 7.1961273,19.21332 7.1890194,19.630315 7.1250486,19.900414 7.0113227,20.376641 6.7222693,20.798375 6.3976766,20.957117 6.262627,21.023458 6.2318263,21.030565 6.0612374,21.023458 5.8148312,21.011611 5.6868896,20.935794 5.5328857,20.710711 5.3575582,20.457197 5.3101725,20.277131 5.3101725,19.864875 5.3101725,19.44551 5.3528197,19.239382 5.4997157,18.94559 5.682151,18.585458 5.9143414,18.348529 6.172594,18.265604 6.3218593,18.218218 6.5611576,18.232434 6.6867299,18.296405 Z M 4.4003651,18.827126 C 4.6491405,18.966914 4.8007751,19.31283 4.8007751,19.739302 4.8007751,20.172882 4.6799413,20.533015 4.440643,20.798375 4.1658054,21.106383 3.7819804,21.139553 3.54979,20.871823 3.3744625,20.675172 3.2844295,20.395596 3.2844295,20.063895 3.2844295,19.616099 3.4171097,19.260706 3.6872088,18.983499 3.860167,18.808171 3.9241378,18.77974 4.1586975,18.777371 4.2724234,18.775001 4.3316557,18.786848 4.4003651,18.827126 Z"/>
+  </symbol>
+
+  <symbol id="icon-soundcloud" viewBox="0 0 40 40"><path d="M13.8,27.4l0.3-4.2L13.8,14c0-0.1-0.1-0.2-0.1-0.3c-0.1-0.1-0.2-0.1-0.3-0.1c-0.1,0-0.2,0-0.3,0.1C13.1,13.8,13,13.9,13,14 l-0.2,9.2l0.2,4.2c0,0.1,0.1,0.2,0.1,0.3c0.1,0.1,0.2,0.1,0.3,0.1C13.7,27.8,13.8,27.7,13.8,27.4z M18.8,26.9l0.2-3.7l-0.2-10.3 c0-0.2-0.1-0.3-0.2-0.4c-0.1-0.1-0.2-0.1-0.3-0.1s-0.2,0-0.3,0.1c-0.1,0.1-0.2,0.2-0.2,0.4l0,0.1l-0.2,10.1c0,0,0.1,1.4,0.2,4.1v0 c0,0.1,0,0.2,0.1,0.3c0.1,0.1,0.2,0.2,0.4,0.2c0.1,0,0.2-0.1,0.3-0.2c0.1-0.1,0.2-0.2,0.2-0.4L18.8,26.9z M1.2,20.9l0.3,2.2 l-0.3,2.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.1-0.1-0.2-0.2l-0.3-2.2l0.3-2.2c0-0.1,0.1-0.2,0.2-0.2S1.2,20.8,1.2,20.9z M2.7,19.5 l0.4,3.6l-0.4,3.6c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L2,23.2l0.4-3.6c0-0.1,0.1-0.2,0.2-0.2C2.6,19.4,2.7,19.4,2.7,19.5z M4.2,18.9l0.4,4.3l-0.4,4.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2l-0.4-4.2l0.4-4.3c0-0.1,0.1-0.2,0.2-0.2 C4.2,18.7,4.2,18.7,4.2,18.9z M5.8,18.8l0.4,4.4l-0.4,4.3c0,0.2-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L5,23.2l0.4-4.4 c0-0.2,0.1-0.2,0.2-0.2C5.7,18.5,5.8,18.6,5.8,18.8z M7.4,19.1l0.4,4.1l-0.4,4.3c0,0.2-0.1,0.3-0.3,0.3c-0.1,0-0.1,0-0.2-0.1 c-0.1-0.1-0.1-0.1-0.1-0.2l-0.3-4.3l0.3-4.1c0-0.1,0-0.1,0.1-0.2C7,18.8,7,18.8,7.1,18.8C7.3,18.8,7.4,18.9,7.4,19.1L7.4,19.1z M9,16.5l0.4,6.7L9,27.5c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3l0.3-6.7 c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.1,0,0.2,0.1S9,16.4,9,16.5z M10.5,15l0.3,8.2l-0.3,4.3c0,0.1,0,0.2-0.1,0.2 c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3L9.9,15c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.2,0,0.2,0.1 C10.5,14.8,10.5,14.9,10.5,15z M12.2,14.3l0.3,8.9l-0.3,4.2c0,0.2-0.1,0.4-0.4,0.4c-0.2,0-0.3-0.1-0.4-0.4l-0.3-4.2l0.3-8.9 c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.2-0.1c0.1,0,0.2,0,0.3,0.1C12.1,14.1,12.2,14.2,12.2,14.3z M18.8,27.3L18.8,27.3L18.8,27.3z M15.4,14.2l0.3,8.9l-0.3,4.2c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1s-0.1-0.2-0.1-0.3l-0.2-4.2 l0.2-8.9c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C15.4,14,15.4,14.1,15.4,14.2L15.4,14.2z M17.1,14.6 l0.2,8.6l-0.2,4.1c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1c-0.1-0.1-0.1-0.2-0.2-0.3L16,23.2l0.2-8.6 c0-0.1,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C17.1,14.3,17.1,14.4,17.1,14.6z M20.7,23.2l-0.2,4 c0,0.2-0.1,0.3-0.2,0.4c-0.1,0.1-0.2,0.2-0.4,0.2c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4l-0.1-2l-0.1-2L19.4,12V12 c0-0.2,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1c0.2,0.1,0.2,0.2,0.3,0.5L20.7,23.2z M39.4,22.9 c0,1.4-0.5,2.5-1.4,3.5c-0.9,1-2,1.4-3.4,1.4H21.4c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4V11.5c0-0.3,0.2-0.5,0.5-0.6 c1-0.4,2-0.6,3-0.6c2.2,0,4.1,0.8,5.7,2.3c1.6,1.5,2.5,3.4,2.7,5.7c0.6-0.3,1.2-0.4,1.8-0.4c1.3,0,2.4,0.5,3.4,1.5 C38.9,20.3,39.4,21.5,39.4,22.9L39.4,22.9z"/></symbol>
+  <symbol id="icon-tiktok" viewBox="0 0 5 5.292"><path fill-rule="evenodd" clip-rule="evenodd" d="M 3.0056593,3.7402047 C 2.9917683,4.1032048 2.686077,4.394531 2.3113304,4.394531 c -0.08567,0 -0.1676888,-0.015266 -0.2434637,-0.043152 0.075775,0.027886 0.1578202,0.043152 0.2434902,0.043152 0.3747466,0 0.6804387,-0.2913262 0.6943554,-0.6542999 l 0.00132,-3.24141643 h 0.6058809 c 0.058393,0.30815327 0.2455538,0.57257183 0.5048391,0.73777393 7.93e-5,1.058e-4 1.852e-4,2.117e-4 2.645e-4,3.175e-4 0.1804944,0.1149589 0.3957012,0.1820292 0.6267297,0.1820292 v 0.1800449 c 0,0 0,0 2.65e-5,2.65e-5 V 2.227616 c -0.4291437,0 -0.8268027,-0.1341672 -1.1513855,-0.3618624 v 1.6436603 c 0,0.8208777 -0.6833226,1.4886974 -1.5232747,1.4886974 -0.3245565,0 -0.6255391,-0.1000367 -0.8729448,-0.269816 C 1.1970357,4.728163 1.1969034,4.7280043 1.1967446,4.727872 0.80416835,4.4583206 0.54689375,4.0127459 0.54689375,3.5092552 c 0,-0.8208513 0.68332265,-1.4886974 1.52327485,-1.4886974 0.069689,0 0.1380032,0.00561 0.2052587,0.014526 V 2.22669 C 1.5091597,2.2441785 0.88092205,2.817015 0.79752745,3.5485978 0.88100145,2.8170944 1.5091863,2.2443373 2.2754008,2.2268487 v 0.6340861 c -0.06498,-0.01987 -0.1336642,-0.031432 -0.2052851,-0.031432 -0.3835836,0 -0.6956521,0.3050312 -0.6956521,0.6799109 0,0.2610585 0.1515497,0.4878806 0.3729741,0.6017548 0,2.64e-5 0,2.64e-5 0,2.64e-5 0.096544,0.049661 0.2062111,0.078103 0.3226514,0.078103 0.3747467,0 0.6804387,-0.2913261 0.6943554,-0.6542997 l 0.00132,-3.24144299 H 3.593361 c 0,0.0701124 0.00691,0.13863846 0.019525,0.20525906 H 3.0070087 Z" /></symbol>
+  <symbol id="icon-tumblr" viewBox="6 2 40 34"><path d="m 27.827893,29.747564 c 0,2.493786 1.258711,3.356328 3.263194,3.356328 h 2.843742 v 6.339769 h -5.384094 c -4.847873,0 -8.461022,-2.494139 -8.461022,-8.461022 v -9.555691 h -4.405136 v -5.174192 c 4.847872,-1.258711 6.875638,-5.43066 7.109177,-9.04381 h 5.034139 v 8.204552 h 5.873397 v 6.01345 h -5.873397 v 8.320616"/></symbol>
+  <symbol id="icon-twitch" viewBox="0 0 2400 2800"><g><path d="M500,0L0,500v1800h600v500l500-500h400l900-900V0H500z M2200,1300l-400,400h-400l-350,350v-350H600V200h1600 V1300z"/><rect x="1700" y="550" width="200" height="600"/><rect x="1150" y="550" width="200" height="600"/></g></symbol>
+  <symbol id="icon-twitter" viewBox="0 0 40 40"><path d="M36.3,10.2c-1,1.3-2.1,2.5-3.4,3.5c0,0.2,0,0.4,0,1c0,1.7-0.2,3.6-0.9,5.3c-0.6,1.7-1.2,3.5-2.4,5.1 c-1.1,1.5-2.3,3.1-3.7,4.3c-1.4,1.2-3.3,2.3-5.3,3c-2.1,0.8-4.2,1.2-6.6,1.2c-3.6,0-7-1-10.2-3c0.4,0,1.1,0.1,1.5,0.1 c3.1,0,5.9-1,8.2-2.9c-1.4,0-2.7-0.4-3.8-1.3c-1.2-1-1.9-2-2.2-3.3c0.4,0.1,1,0.1,1.2,0.1c0.6,0,1.2-0.1,1.7-0.2 c-1.4-0.3-2.7-1.1-3.7-2.3s-1.4-2.6-1.4-4.2v-0.1c1,0.6,2,0.9,3,0.9c-1-0.6-1.5-1.3-2.2-2.4c-0.6-1-0.9-2.1-0.9-3.3s0.3-2.3,1-3.4 c1.5,2.1,3.6,3.6,6,4.9s4.9,2,7.6,2.1c-0.1-0.6-0.1-1.1-0.1-1.4c0-1.8,0.8-3.5,2-4.7c1.2-1.2,2.9-2,4.7-2c2,0,3.6,0.8,4.8,2.1 c1.4-0.3,2.9-0.9,4.2-1.5c-0.4,1.5-1.4,2.7-2.9,3.6C33.8,11.2,35.1,10.9,36.3,10.2L36.3,10.2z"/></symbol>
+  <symbol id="icon-youtube" viewBox="0 -30 256 256"><g transform="matrix(1.3333333,0,0,-1.3333333,0,256)"><path d="M 0,0 V 52.338 L 46,26.168 Z M 102.322,68.806 C 100.298,76.428 94.334,82.43 86.762,84.467 73.037,88.169 18,88.169 18,88.169 c 0,0 -55.037,0 -68.762,-3.702 C -58.334,82.43 -64.298,76.428 -66.322,68.806 -70,54.992 -70,26.169 -70,26.169 c 0,0 0,-28.822 3.678,-42.637 2.024,-7.622 7.988,-13.624 15.56,-15.662 13.725,-3.701 68.762,-3.701 68.762,-3.701 0,0 55.037,0 68.762,3.701 7.572,2.038 13.536,8.04 15.56,15.662 C 106,-2.653 106,26.169 106,26.169 c 0,0 0,28.823 -3.678,42.637" transform="translate(78,69.8311)"/></g></symbol>
 </svg>
diff --git a/src/static/site6.css b/src/static/site7.css
index 4d6d79a..c23acff 100644
--- a/src/static/site6.css
+++ b/src/static/site7.css
@@ -32,7 +32,9 @@
 /* Layout - Common */
 
 body {
-  margin: 10px;
+  position: relative;
+  margin: 0;
+  padding: 10px;
   overflow-y: scroll;
 }
 
@@ -41,8 +43,8 @@ body::before {
   position: fixed;
   top: 0;
   left: 0;
-  width: 100%;
-  height: 100%;
+  width: 100vw;
+  height: 100vh;
   z-index: -1;
 
   /* NB: these are 100 LVW, "largest view width", etc.
@@ -56,7 +58,7 @@ body::before {
 
 #page-container {
   max-width: 1100px;
-  margin: 10px auto 50px;
+  margin: 0 auto 40px;
   padding: 15px 0;
 }
 
@@ -468,6 +470,18 @@ a:not([href]):hover {
   text-decoration: none;
 }
 
+.external-link:not(.from-content) {
+  white-space: nowrap;
+}
+
+.external-link.indicate-external::after {
+  content: '\00a0➚';
+}
+
+.external-link.indicate-external:hover::after {
+  color: white;
+}
+
 .nav-main-links .nav-link.current > span.nav-link-content > a {
   font-weight: 800;
 }
@@ -476,13 +490,13 @@ a:not([href]):hover {
   display: inline-block;
 }
 
-.nav-links-index .nav-link.has-divider::before,
-.nav-links-groups .nav-link.has-divider::before {
+.nav-links-index .nav-link:not(:first-child)::before,
+.nav-links-groups .nav-link:not(:first-child)::before {
   content: "\0020\00b7\0020";
   font-weight: 800;
 }
 
-.nav-links-hierarchical .nav-link.has-divider::before {
+.nav-links-hierarchical .nav-link:not(:first-child)::before {
   content: "\0020/\0020";
 }
 
@@ -594,6 +608,48 @@ li:not(:first-child:last-child) .tooltip,
           user-select: none;
 
   cursor: default;
+
+  display: grid;
+
+  grid-template-columns:
+    [icon-start] auto [icon-end domain-start] auto [domain-end];
+}
+
+.icons-tooltip .icon {
+  grid-column-start: icon-start;
+  grid-column-end: icon-end;
+}
+
+.icons-tooltip .icon-platform {
+  display: none;
+
+  grid-column-start: domain-start;
+  grid-column-end: domain-end;
+
+  --icon-platform-opacity: 0.8;
+  padding-right: 4px;
+  opacity: 0.8;
+}
+
+.icons-tooltip.show-info .icon-platform {
+  display: inline;
+  animation: icon-platform 0.2s forwards linear;
+}
+
+@keyframes icon-platform {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: var(--icon-platform-opacity);
+  }
+}
+
+.icons-tooltip .icon:hover + .icon-platform {
+  --icon-platform-opacity: 1;
+  text-decoration: underline;
+  text-decoration-color: #ffffffaa;
 }
 
 .datetimestamp-tooltip .tooltip-content,
@@ -903,7 +959,6 @@ ul.quick-info li:not(:last-child)::after {
 }
 
 li .by {
-  display: inline-block;
   font-style: oblique;
   max-width: 600px;
 }
@@ -1020,6 +1075,38 @@ li > ul {
   margin-top: 5px;
 }
 
+.additional-files-list {
+  padding-left: 0;
+}
+
+.additional-files-list > li {
+  list-style-type: none;
+}
+
+.additional-files-list summary {
+  /* Sorry, Safari!
+   * https://bugs.webkit.org/show_bug.cgi?id=157323
+   */
+  list-style-position: outside;
+  margin-left: 40px;
+}
+
+.additional-files-list details ul {
+  margin-left: 40px;
+  margin-top: 2px;
+  margin-bottom: 10px;
+}
+
+.additional-files-list .entry-description {
+  list-style-type: none;
+  max-width: 540px;
+
+  /* This should be margin-bottom, but cascading rules
+   * cause some awkwardness - `#content li` takes precedence.
+   */
+  padding-bottom: 3px;
+}
+
 .group-contributions-table {
   display: inline-block;
 }
@@ -1894,7 +1981,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   position: absolute;
   width: 100%;
   box-sizing: border-box;
-  padding: 10px 40px 5px 20px;
+  padding: 10px 20px 5px 20px;
   margin-top: 0;
   z-index: -1;
 
@@ -1924,6 +2011,10 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   opacity: 0;
 }
 
+.content-sticky-subheading {
+  padding-right: 20px;
+}
+
 .content-sticky-heading-container h2.visible {
   margin-top: 0;
   opacity: 1;
@@ -2047,9 +2138,13 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   border-radius: 0 0 8px 8px;
   border: 2px solid var(--primary-color);
   background: var(--deep-ghost-color);
-  padding: 3px;
   overflow: hidden;
 
+  box-shadow:
+    0 0 90px 30px #00000060,
+    0 0 20px 10px #00000040,
+    0 0 10px 3px #00000080;
+
   -webkit-backdrop-filter: blur(3px);
           backdrop-filter: blur(3px);
 }
@@ -2060,6 +2155,8 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   overflow: hidden;
   width: 80vmin;
   height: 80vmin;
+  margin-left: auto;
+  margin-right: auto;
 }
 
 #image-overlay-image,
@@ -2073,8 +2170,10 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
 #image-overlay-image {
   position: absolute;
-  top: 0;
-  left: 0;
+  top: 3px;
+  left: 3px;
+  width: calc(100% - 6px);
+  height: calc(100% - 4px);
 }
 
 #image-overlay-image-thumb {
@@ -2202,12 +2301,12 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     z-index: 2;
   }
 
-  html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+10)) {
+  html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+7)) {
     flex-basis: 23%;
     margin: 15px;
   }
 
-  html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:nth-child(n+10) {
+  html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:nth-child(n+7) {
     flex-basis: 18%;
     margin: 10px;
   }
@@ -2216,7 +2315,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
 /* Layout - Medium or Thin */
 
 @media (max-width: 899.98px) {
-  .sidebar-column:not(.no-hide) {
+  .sidebar.collapsible,
+  .sidebar-column.all-boxes-collapsible {
     display: none;
   }
 
@@ -2224,15 +2324,15 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     display: block;
   }
 
-  .layout-columns.vertical-when-thin {
+  .layout-columns {
     flex-direction: column;
   }
 
-  .layout-columns.vertical-when-thin > *:not(:last-child) {
+  .layout-columns > *:not(:last-child) {
     margin-bottom: 10px;
   }
 
-  .sidebar-column.no-hide {
+  .sidebar-column {
     max-width: unset !important;
     flex-basis: unset !important;
     margin-right: 0 !important;
diff --git a/src/static/xhr-util.js b/src/static/xhr-util.js
new file mode 100644
index 0000000..8a43072
--- /dev/null
+++ b/src/static/xhr-util.js
@@ -0,0 +1,64 @@
+/* eslint-env browser */
+
+/**
+ * This fetch function is adapted from a `loadImage` function
+ * credited to Parziphal, Feb 13, 2017.
+ * https://stackoverflow.com/a/42196770
+ *
+ * The callback is generally run with the loading progress as a decimal 0-1.
+ * However, if it's not possible to compute the progress ration (which might
+ * only become apparent after a progress amount *has* been sent!),
+ * the callback will be run with the value -1.
+ *
+ * The return promise resolves to a manually instantiated Response object
+ * which generally behaves the same as a normal fetch response; access headers,
+ * text, blob, arrayBuffer as usual. Accordingly, non-200 responses do *not*
+ * reject the prmoise, so be sure to check the response status yourself.
+ */
+export function fetchWithProgress(url, progressCallback) {
+  return new Promise(resolve => {
+    const xhr = new XMLHttpRequest();
+    let notifiedNotComputable = false;
+
+    xhr.open('GET', url, true);
+    xhr.responseType = 'arraybuffer';
+
+    xhr.onprogress = event => {
+      if (notifiedNotComputable) {
+        return;
+      }
+
+      if (!event.lengthComputable) {
+        notifiedNotComputable = true;
+        progressCallback(-1);
+        return;
+      }
+
+      progressCallback(event.loaded / event.total);
+    };
+
+    xhr.onloadend = () => {
+      const body = xhr.response;
+
+      const options = {
+        status: xhr.status,
+        headers:
+          parseResponseHeaders(xhr.getAllResponseHeaders()),
+      };
+
+      resolve(new Response(body, options));
+    };
+
+    xhr.send();
+  });
+
+  function parseResponseHeaders(headers) {
+    return (
+      Object.fromEntries(
+        headers
+          .trim()
+          .split(/[\r\n]+/)
+          .map(line => line.match(/(.+?):\s*(.+)/))
+          .map(match => [match[1], match[2]])));
+  }
+}
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index cd05ce5..301fd5f 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -325,15 +325,17 @@ releaseInfo:
 
     entry:
       _: "{TITLE}"
-      withDescription: "{TITLE}: {DESCRIPTION}"
+
+      noFilesAvailable: >-
+        There are no files available or listed for this entry.
 
     file:
       _: "{FILE}"
       withSize: "{FILE} ({SIZE})"
 
     shortcut:
-      _: "View {ANCHOR_LINK}: {TITLES}"
-      anchorLink: "additional files"
+      _: "View {LINK}."
+      link: "additional files"
 
   sheetMusicFiles:
     heading: "Print or download sheet music files:"
@@ -453,6 +455,10 @@ misc:
     # Combination of above.
     withContribution.withExternalLinks: "{ARTIST} ({CONTRIB}) ({LINKS})"
 
+    # Displayed in an artist's tooltip, if one of their URLs
+    # isn't a specially detected web platform.
+    noExternalLinkPlatformName: "Other"
+
   # chronology:
   #
   #   "Chronology links" are a section that appear in the nav bar for
@@ -500,19 +506,33 @@ misc:
     withHandle:
       "{PLATFORM} ({HANDLE})"
 
-    local: "Wiki Archive (local upload)"
+    opensInNewTab:
+      _: "{LINK} ({ANNOTATION})"
+      annotation: "opens in new tab"
+
+    invalidURL:
+      _: "{LINK} ({ANNOTATION})"
+      annotation: "invalid URL"
 
+    amazonMusic: "Amazon Music"
+    appleMusic: "Apple Music"
+    artstation: "ArtStation"
     bandcamp: "Bandcamp"
 
     bgreco:
       _: "bgreco.net"
       flash: "bgreco.net (high quality audio)"
 
+    bluesky: "Bluesky"
+    carrd: "Carrd"
+    cohost: "Cohost"
+
     deconreconstruction:
       _: "Deconreconstruction"
       music: "MUSIC@DCRC"
 
     deviantart: "DeviantArt"
+    facebook: "Facebook"
 
     fandom:
       _: "Fandom"
@@ -521,20 +541,36 @@ misc:
         _: "MSPA Wiki"
         page: "MSPA Wiki ({PAGE})"
 
+    gamebanana: "GameBanana"
+
     homestuck:
       _: "Homestuck"
       page: "Homestuck (page {PAGE})"
       secretPage: "Homestuck (secret page)"
 
+    hsmusic:
+      _: "HSMusic"
+      archive: "HSMusic (wiki archive)"
+
     instagram: "Instagram"
+    internetArchive: "Internet Archive"
+    itch: "itch.io"
+    kofi: "Ko-fi"
+    linktree: "Linktree"
     mastodon: "Mastodon"
+    mspfa: "MSPFA"
+    neocities: "Neocities"
     newgrounds: "Newgrounds"
     patreon: "Patreon"
     poetryFoundation: "Poetry Foundation"
     soundcloud: "SoundCloud"
     spotify: "Spotify"
+    tiktok: "TikTok"
+    toyhouse: "Toyhouse"
     tumblr: "Tumblr"
+    twitch: "Twitch"
     twitter: "Twitter"
+    waybackMachine: "Wayback Machine"
     wikipedia: "Wikipedia"
 
     youtube:
@@ -1034,9 +1070,8 @@ flashIndex:
 flashSidebar:
   flashList:
 
-    # These two strings are the default ones used when a flash act
-    # doesn't specify a custom phrasing.
-    flashesInThisAct: "Flashes in this act"
+    # This is the default string used when neither a flash act nor its side
+    # specifies a custom phrasing.
     entriesInThisSection: "Entries in this section"
 
 #
@@ -1617,6 +1652,7 @@ listingPage:
       file:
         _: "{TITLE}"
         withMultipleFiles: "{TITLE} ({FILES})"
+        withNoFiles: "{TITLE} (no files)"
 
     # other.midiProjectFiles:
     #   Same as other.allSheetMusic, but for MIDI & project files.
@@ -1629,6 +1665,7 @@ listingPage:
       file:
         _: "{TITLE}"
         withMultipleFiles: "{TITLE} ({FILES})"
+        withNoFiles: "{TITLE} (no files)"
 
     # other.additionalFiles:
     #   Same as other.allSheetMusic, but for additional files.
@@ -1641,6 +1678,7 @@ listingPage:
       file:
         _: "{TITLE}"
         withMultipleFiles: "{TITLE} ({FILES})"
+        withNoFiles: "{TITLE} (no files available)"
 
     # other.randomPages:
     #   Special listing which shows a bunch of buttons that each
diff --git a/src/upd8.js b/src/upd8.js
index 4c79007..f35f9e5 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -31,6 +31,8 @@
 // Oh yeah, like. Just run this through some relatively recent version of
 // node.js and you'll 8e fine. ...Within the project root. O8viously.
 
+import '#import-heck';
+
 import {execSync} from 'node:child_process';
 import {readdir, readFile} from 'node:fs/promises';
 import * as path from 'node:path';
@@ -38,31 +40,17 @@ import {fileURLToPath} from 'node:url';
 
 import wrap from 'word-wrap';
 
-// Due to import time shenanigans, these imports have to come in the specified
-// order. This obviously needs fixing up.
-
-/* precede #find */
-import {
-  filterReferenceErrors,
-  reportDuplicateDirectories,
-  reportContentTextErrors,
-} from '#data-checks';
-
-import {bindFind, getAllFindSpecs} from '#find';
-
-// End of import time shenanigans (hopefully)
-
-import {showAggregate} from '#aggregate';
+import {mapAggregate, showAggregate} from '#aggregate';
 import CacheableObject from '#cacheable-object';
 import {displayCompositeCacheAnalysis} from '#composite';
+import {bindFind, getAllFindSpecs} from '#find';
 import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile}
   from '#language';
 import {isMain, traverse} from '#node-utils';
 import {sortByName} from '#sort';
 import {empty, withEntries} from '#sugar';
 import {generateURLs, urlSpec} from '#urls';
-import {linkWikiDataArrays, loadAndProcessDataDocuments, sortWikiDataArrays}
-  from '#yaml';
+import {identifyAllWebRoutes} from '#web-routes';
 
 import {
   colors,
@@ -75,6 +63,12 @@ import {
   progressCallAll,
 } from '#cli';
 
+import {
+  filterReferenceErrors,
+  reportDirectoryErrors,
+  reportContentTextErrors,
+} from '#data-checks';
+
 import genThumbs, {
   CACHE_FILE as thumbsCacheFile,
   defaultMagickThreads,
@@ -84,6 +78,15 @@ import genThumbs, {
   verifyImagePaths,
 } from '#thumbs';
 
+import {
+  getAllDataSteps,
+  linkWikiDataArrays,
+  loadYAMLDocumentsFromDataSteps,
+  processThingsFromDataSteps,
+  saveThingsFromDataSteps,
+  sortWikiDataArrays,
+} from '#yaml';
+
 import FileSizePreloader from './file-size-preloader.js';
 import {listingSpec, listingTargetSpec} from './listing-spec.js';
 import * as buildModes from './write/build-modes/index.js';
@@ -140,8 +143,8 @@ async function main() {
     precacheCommonData:
       {...defaultStepStatus, name: `precache common data`},
 
-    reportDuplicateDirectories:
-      {...defaultStepStatus, name: `report duplicate directories`},
+    reportDirectoryErrors:
+      {...defaultStepStatus, name: `report directory errors`},
 
     filterReferenceErrors:
       {...defaultStepStatus, name: `filter reference errors`},
@@ -174,6 +177,9 @@ async function main() {
     preloadFileSizes:
       {...defaultStepStatus, name: `preload file sizes`},
 
+    identifyWebRoutes:
+      {...defaultStepStatus, name: `identify web routes`},
+
     performBuild:
       {...defaultStepStatus, name: `perform selected build mode`},
   };
@@ -241,7 +247,12 @@ async function main() {
     },
 
     'media-cache-path': {
-      help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred by adding "-cache" to the end of the media directory`,
+      help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred either by loading "media-cache" from --cache-path, or by adding "-cache" to the end of the media directory\n\nAlso may be provided via the HSMUSIC_MEDIA_CACHE environment variable`,
+      type: 'value',
+    },
+
+    'cache-path': {
+      help: `Specify path to general cache directory, usually containing generated thumbnails and assorted files reused between builds\n\nRequired for some features and may always be required if you're starting a new workspace\n\nAlso may be provided via the HSMUSIC_CACHE environment varaible`,
       type: 'value',
     },
 
@@ -285,6 +296,11 @@ async function main() {
       type: 'flag',
     },
 
+    'new-thumbs': {
+      help: `Repair a media cache that's completely missing its index file by starting clean and not reusing any existing thumbnails`,
+      type: 'flag',
+    },
+
     'skip-file-sizes': {
       help: `Skips preloading file sizes for images and additional files, which will be left blank in the build`,
       type: 'flag',
@@ -474,6 +490,7 @@ async function main() {
 
   const dataPath = cliOptions['data-path'] || process.env.HSMUSIC_DATA;
   const mediaPath = cliOptions['media-path'] || process.env.HSMUSIC_MEDIA;
+  const wikiCachePath = cliOptions['cache-path'] || process.env.HSMUSIC_CACHE;
   const langPath = cliOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
 
   const thumbsOnly = cliOptions['thumbs-only'] ?? false;
@@ -501,6 +518,10 @@ async function main() {
     logError`${`Expected --media-path option or HSMUSIC_MEDIA to be set`}`;
   }
 
+  if (!wikiCachePath) {
+    logWarn`No --cache-path option nor HSMUSIC_CACHE set; provide for more features`;
+  }
+
   if (!dataPath || !mediaPath) {
     return false;
   }
@@ -556,18 +577,29 @@ async function main() {
           logWarn`Redundant option ${cliPart}`;
         }
       } else {
-        if (cliFlagNegates) {
-          step.status = STATUS_NOT_APPLICABLE;
-          step.annotation = `--${cliFlag} provided`;
-        }
+        step.status =
+          (cliFlagNegates
+            ? STATUS_NOT_APPLICABLE
+            : STATUS_NOT_STARTED);
+
+        step.annotation = `--${cliFlag} provided`;
+
         if (cliFlagWarning) {
           for (const line of cliFlagWarning.split('\n')) {
             logWarn(line);
           }
         }
+
+        return;
       }
     }
 
+    if (buildConfig?.required === true) {
+      step.status = STATUS_NOT_STARTED;
+      step.annotation = `required for --${selectedBuildModeFlag}`;
+      return;
+    }
+
     if (buildConfig?.applicable === false) {
       step.status = STATUS_NOT_APPLICABLE;
       step.annotation = `N/A for --${selectedBuildModeFlag}`;
@@ -580,6 +612,12 @@ async function main() {
       return;
     }
 
+    if (buildConfig?.default === 'perform') {
+      step.status = STATUS_NOT_STARTED;
+      step.annotation = `default for --${selectedBuildModeFlag}`;
+      return;
+    }
+
     switch (defaultValue) {
       case 'skip':
         step.status = STATUS_NOT_APPLICABLE;
@@ -647,6 +685,11 @@ async function main() {
       },
     });
 
+    fallbackStep('identifyWebRoutes', {
+      default: 'skip',
+      buildConfig: 'webRoutes',
+    });
+
     fallbackStep('verifyImagePaths', {
       default: 'perform',
       buildConfig: 'mediaValidation',
@@ -740,31 +783,118 @@ async function main() {
     timeStart: Date.now(),
   });
 
+  const regenerateMissingThumbnailCache =
+    cliOptions['new-thumbs'] ?? false;
+
   const {mediaCachePath, annotation: mediaCachePathAnnotation} =
     await determineMediaCachePath({
       mediaPath,
+      wikiCachePath,
+
       providedMediaCachePath:
         cliOptions['media-cache-path'] || process.env.HSMUSIC_MEDIA_CACHE,
+
+      regenerateMissingThumbnailCache,
+
       disallowDoubling:
         stepStatusSummary.migrateThumbnails.status === STATUS_NOT_STARTED,
     });
 
+  if (regenerateMissingThumbnailCache) {
+    if (
+      mediaCachePathAnnotation !== `contained path will regenerate missing cache` &&
+      mediaCachePathAnnotation !== `adjacent path will regenerate missing cache`
+    ) {
+      if (mediaCachePath) {
+        logError`Determined a media cache path. (${mediaCachePathAnnotation})`;
+        console.error('');
+        logWarn`By using ${'--new-thumbs'}, you requested to generate completely`;
+        logWarn`new thumbnails, but there's already a ${'thumbnail-cache.json'}`;
+        logWarn`file where it's expected, within this media cache:`;
+        logWarn`${path.resolve(mediaCachePath)}`;
+        console.error('');
+        logWarn`If you really do want to completely regenerate all thumbnails`;
+        logWarn`and not reuse any existing ones, move aside ${'thumbnail-cache.json'}`;
+        logWarn`and run with ${'--new-thumbs'} again.`;
+
+        Object.assign(stepStatusSummary.determineMediaCachePath, {
+          status: STATUS_FATAL_ERROR,
+          annotation: `--new-thumbs provided but regeneration not needed`,
+          timeEnd: Date.now(),
+        });
+
+        return false;
+      } else {
+        logError`Couldn't determine a media cache path. (${mediaCachePathAnnotation})`;
+        console.error('');
+        logWarn`You requested to generate completely new thumbnails, but`;
+        logWarn`the media cache wasn't readable or just couldn't be found.`;
+        logWarn`Run again without ${'--new-thumbs'} - you should investigate`;
+        logWarn`what's going on before continuing.`;
+
+        Object.assign(stepStatusSummary.determineMediaCachePath, {
+          status: STATUS_FATAL_ERROR,
+          annotation: mediaCachePathAnnotation,
+          timeEnd: Date.now(),
+        });
+
+        return false;
+      }
+    }
+  }
+
   if (!mediaCachePath) {
     logError`Couldn't determine a media cache path. (${mediaCachePathAnnotation})`;
 
     switch (mediaCachePathAnnotation) {
-      case 'inferred path does not have cache':
-        logError`If you're certain this is the right path, you can provide it via`;
-        logError`${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}, and it should work.`;
+      case `contained path does not have cache`:
+        console.error('');
+        logError`You've provided a ${'--cache-path'} or ${'HSMUSIC_CACHE_PATH'},`;
+        logError`${path.resolve(wikiCachePath)}`;
+        console.error('');
+        logError`It contains a ${'media-cache'} folder, but this folder is`;
+        logError`missing its ${'thumbnail-cache.json'} file. This means there's`;
+        logError`no information available to reuse. If you use this cache,`;
+        logError`hsmusic will generate any existing thumbnails over again.`;
+        console.error('');
+        logError`* Try to see if you can recover or locate a copy of your`;
+        logError`  ${'thumbnail-cache.json'} file and put it back in place;`;
+        logError`* Or, generate all-new thumbnails with ${'--new-thumbs'}.`;
+        break;
+
+      case 'adjacent path does not have cache':
+        console.error('');
+        logError`You have an existing ${'media-cache'} folder next to your media path,`;
+        logError`${path.resolve(mediaPath)}`;
+        console.error('');
+        logError`The ${'media-cache'} folder is missing its ${'thumbnail-cache.json'}`;
+        logError`file. This means there's no information available to reuse,`;
+        logError`and if you use this cache, hsmusic will generate any existing`;
+        logError`thumbnails over again.`;
+        console.error('');
+        logError`* Try to see if you can recover or locate a copy of your`;
+        logError`  ${'thumbnail-cache.json'} file and put it back in place;`;
+        logError`* Or, generate all-new thumbnails with ${'--new-thumbs'}.`;
         break;
 
-      case 'inferred path not readable':
+      case `contained path not readable`:
+      case `adjacent path not readable`:
+        console.error('');
         logError`The folder couldn't be read, which usually indicates`;
         logError`a permissions error. Try to resolve this, or provide`;
         logError`a new path with ${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}.`;
         break;
 
-      case 'media path not provided': /* unreachable */
+      case `missing wiki cache to create media cache inside`:
+        console.error('');
+        logError`It looks like you're starting totally fresh, so please`;
+        logError`create a ${'cache'} folder and provide it with ${'--cache-path'}`;
+        logError`or ${'HSMUSIC_CACHE'}. The media cache will automatically be`;
+        logError`generated inside of this folder!`;
+        break;
+
+      case `media path not provided`: /* unreachable */
+        console.error('');
         logError`It seems a ${'--media-path'} (or ${'HSMUSIC_MEDIA'}) wasn't provided.`;
         logError`Make sure one of these is actually pointing to a path that exists.`;
         break;
@@ -936,32 +1066,102 @@ async function main() {
     CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
   }
 
+  let paragraph = false;
+
   Object.assign(stepStatusSummary.loadDataFiles, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
   });
 
-  let processDataAggregate, wikiDataResult;
+  let yamlDataSteps;
+  let yamlDocumentProcessingAggregate;
 
-  try {
-    ({aggregate: processDataAggregate, result: wikiDataResult} =
-        await loadAndProcessDataDocuments({dataPath}));
-  } catch (error) {
-    console.error(error);
+  {
+    const whoops = (error, stage) => {
+      if (!paragraph) console.log('');
 
-    logError`There was a JavaScript error loading data files.`;
-    fileIssue();
+      console.error(error);
+      niceShowAggregate(error);
 
-    Object.assign(stepStatusSummary.loadDataFiles, {
-      status: STATUS_FATAL_ERROR,
-      annotation: `javascript error - view log for details`,
-      timeEnd: Date.now(),
-    });
+      logError`There was a JavaScript error ${stage}.`;
+      fileIssue();
 
-    return false;
-  }
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `javascript error - view log for details`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
+    };
+
+    let loadAggregate, loadResult;
+    let processAggregate, processResult;
+    let saveAggregate, saveResult;
+
+    const dataSteps = getAllDataSteps();
+
+    try {
+      ({aggregate: loadAggregate, result: loadResult} =
+          await loadYAMLDocumentsFromDataSteps(
+            dataSteps,
+            {dataPath}));
+    } catch (error) {
+      return whoops(error, `loading data files`);
+    }
+
+    try {
+      loadAggregate.close();
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+
+      logError`The above errors were detected while loading data files.`;
+      logError`Since this indicates some files weren't able to load at all,`;
+      logError`there would probably be pretty bad reference errors if the`;
+      logError`build were to continue. Please resolve these errors and`;
+      logError`then give it another go.`;
+
+      paragraph = true;
+      console.log('');
+
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `error loading data files`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
+    }
+
+    try {
+      ({aggregate: processAggregate, result: processResult} =
+          await processThingsFromDataSteps(
+            loadResult.documentLists,
+            loadResult.fileLists,
+            dataSteps,
+            {dataPath}));
+    } catch (error) {
+      return whoops(error, `processing data files`);
+    }
+
+    try {
+      ({aggregate: saveAggregate, result: saveResult} =
+          saveThingsFromDataSteps(
+            processResult,
+            dataSteps));
+
+      saveAggregate.close();
+      saveAggregate = undefined;
+    } catch (error) {
+      return whoops(error, `finalizing data files`);
+    }
+
+    yamlDataSteps = dataSteps;
+    yamlDocumentProcessingAggregate = processAggregate;
 
-  Object.assign(wikiData, wikiDataResult);
+    Object.assign(wikiData, saveResult);
+  }
 
   {
     const logThings = (prop, label) => {
@@ -974,13 +1174,20 @@ async function main() {
     }
 
     try {
+      if (!paragraph) console.log('');
+
       logInfo`Loaded data and processed objects:`;
       logThings('albumData', 'albums');
       logThings('trackData', 'tracks');
-      logThings(wikiData.artistData.filter(artist => !artist.isAlias), 'artists');
+      logThings(
+        (wikiData.artistData
+          ? wikiData.artistData.filter(artist => !artist.isAlias)
+          : null),
+        'artists');
       if (wikiData.flashData) {
         logThings('flashData', 'flashes');
         logThings('flashActData', 'flash acts');
+        logThings('flashSideData', 'flash sides');
       }
       logThings('groupData', 'groups');
       logThings('groupCategoryData', 'group categories');
@@ -997,21 +1204,28 @@ async function main() {
       if (wikiData.wikiInfo) {
         logInfo` - ${1} wiki config file`;
       }
+
+      console.log('');
+      paragraph = true;
     } catch (error) {
       console.error(`Error showing data summary:`, error);
+      paragraph = false;
     }
 
     let errorless = true;
     try {
-      processDataAggregate.close();
+      yamlDocumentProcessingAggregate.close();
     } catch (error) {
+      if (!paragraph) console.log('');
       niceShowAggregate(error);
+
       logWarn`The above errors were detected while processing data files.`;
+
       errorless = false;
     }
 
     if (!wikiData.wikiInfo) {
-      logError`Can't proceed without wiki info file successfully loading`;
+      logError`Can't proceed without wiki info file successfully loading.`;
 
       Object.assign(stepStatusSummary.loadDataFiles, {
         status: STATUS_FATAL_ERROR,
@@ -1024,15 +1238,20 @@ async function main() {
 
     if (errorless) {
       logInfo`All data files processed without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.loadDataFiles, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
       });
     } else {
-      logWarn`If the remaining valid data is complete enough, the wiki will`;
-      logWarn`still build - but all errored data will be skipped.`;
-      logWarn`(Resolve errors for more complete output!)`;
+      logWarn`This might indicate some fields in the YAML data weren't formatted`;
+      logWarn`correctly, for example. The build should still work, but invalid`;
+      logWarn`fields will be skipped. Take a look at the report above to see`;
+      logWarn`what needs fixing up, for a more complete build!`;
+
+      console.log('');
+      paragraph = true;
 
       Object.assign(stepStatusSummary.loadDataFiles, {
         status: STATUS_HAS_WARNINGS,
@@ -1124,20 +1343,22 @@ async function main() {
   // Filter out any things with duplicate directories throughout the data,
   // warning about them too.
 
-  Object.assign(stepStatusSummary.reportDuplicateDirectories, {
+  Object.assign(stepStatusSummary.reportDirectoryErrors, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
   });
 
   try {
-    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
+    reportDirectoryErrors(wikiData, {getAllFindSpecs});
     logInfo`No duplicate directories found - nice!`;
+    paragraph = false;
 
-    Object.assign(stepStatusSummary.reportDuplicateDirectories, {
+    Object.assign(stepStatusSummary.reportDirectoryErrors, {
       status: STATUS_DONE_CLEAN,
       timeEnd: Date.now(),
     });
   } catch (aggregate) {
+    if (!paragraph) console.log('');
     niceShowAggregate(aggregate);
 
     logWarn`The above duplicate directories were detected while reviewing data files.`;
@@ -1145,7 +1366,10 @@ async function main() {
     logWarn`correct, the build can't continue. Specify unique 'Directory' fields in`;
     logWarn`some or all of these data entries to resolve the errors.`;
 
-    Object.assign(stepStatusSummary.reportDuplicateDirectories, {
+    console.log('');
+    paragraph = true;
+
+    Object.assign(stepStatusSummary.reportDirectoryErrors, {
       status: STATUS_FATAL_ERROR,
       annotation: `duplicate directories found`,
       timeEnd: Date.now(),
@@ -1170,17 +1394,23 @@ async function main() {
       filterReferenceErrorsAggregate.close();
 
       logInfo`All references validated without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.filterReferenceErrors, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
       });
     } catch (error) {
+      if (!paragraph) console.log('');
       niceShowAggregate(error);
 
       logWarn`The above errors were detected while validating references in data files.`;
-      logWarn`The wiki will still build, but these connections between data objects`;
-      logWarn`will be completely skipped. Resolve the errors for more complete output.`;
+      logWarn`The wiki should still build, but these connections between data objects`;
+      logWarn`will be skipped, which might have unexpected consequences. Take a look at`;
+      logWarn`the report above to see what needs fixing up, for a more complete build!`;
+
+      console.log('');
+      paragraph = true;
 
       Object.assign(stepStatusSummary.filterReferenceErrors, {
         status: STATUS_HAS_WARNINGS,
@@ -1198,19 +1428,25 @@ async function main() {
 
     try {
       reportContentTextErrors(wikiData, {bindFind});
+
       logInfo`All content text validated without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.reportContentTextErrors, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
       });
     } catch (error) {
+      if (!paragraph) console.log('');
       niceShowAggregate(error);
 
       logWarn`The above errors were detected while processing content text in data files.`;
       logWarn`The wiki will still build, but placeholders will be displayed in these spots.`;
       logWarn`Resolve the errors for more complete output.`;
 
+      console.log('');
+      paragraph = true;
+
       Object.assign(stepStatusSummary.reportContentTextErrors, {
         status: STATUS_HAS_WARNINGS,
         annotation: `view log for details`,
@@ -1227,7 +1463,7 @@ async function main() {
     timeStart: Date.now(),
   });
 
-  sortWikiDataArrays(wikiData);
+  sortWikiDataArrays(yamlDataSteps, wikiData);
 
   Object.assign(stepStatusSummary.sortWikiDataArrays, {
     status: STATUS_DONE_CLEAN,
@@ -1522,6 +1758,7 @@ async function main() {
     }
 
     logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
+    paragraph = false;
 
     finalDefaultLanguage = customDefaultLanguage;
     finalDefaultLanguageAnnotation = `using wiki-specified custom default language`;
@@ -1602,6 +1839,7 @@ async function main() {
   }
 
   logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
+  paragraph = false;
 
   Object.assign(stepStatusSummary.initializeDefaultLanguage, {
     status: STATUS_DONE_CLEAN,
@@ -1684,7 +1922,7 @@ async function main() {
             ...(track.midiProjectFiles ?? []),
           ]),
         ]
-          .flatMap((fileGroup) => fileGroup.files)
+          .flatMap((fileGroup) => fileGroup.files ?? [])
           .map((file) => ({
             device: path.join(
               mediaPath,
@@ -1734,6 +1972,7 @@ async function main() {
     await fileSizePreloader.waitUntilDoneLoading();
 
     logInfo`Preloading filesizes for ${imageFilePaths.length} full-resolution images...`;
+    paragraph = false;
 
     fileSizePreloader.loadPaths(...imageFilePaths.map((path) => path.device));
     await fileSizePreloader.waitUntilDoneLoading();
@@ -1750,6 +1989,7 @@ async function main() {
       });
     } else {
       logInfo`Done preloading filesizes without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.preloadFileSizes, {
         status: STATUS_DONE_CLEAN,
@@ -1758,6 +1998,62 @@ async function main() {
     }
   }
 
+  let webRoutes = null;
+
+  if (stepStatusSummary.identifyWebRoutes.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.identifyWebRoutes, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const fromRoot = urls.from('shared.root');
+
+    try {
+      const webRouteSources = await identifyAllWebRoutes({
+        mediaCachePath,
+        mediaPath,
+        wikiCachePath,
+      });
+
+      const {aggregate, result} =
+        mapAggregate(
+          webRouteSources,
+          ({to, ...rest}) => ({
+            ...rest,
+            to: fromRoot.to(...to),
+          }),
+          {message: `Errors computing effective web route paths`},);
+
+      aggregate.close();
+      webRoutes = result;
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+
+      logError`There was an issue identifying web routes!`;
+      fileIssue();
+
+      console.log('');
+      paragraph = true;
+
+      Object.assign(stepStatusSummary.identifyWebRoutes, {
+        status: STATUS_FATAL_ERROR,
+        message: `JavaScript error - view log for details`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
+    }
+
+    logInfo`Successfully determined web routes.`;
+    paragraph = false;
+
+    Object.assign(stepStatusSummary.identifyWebRoutes, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
+  }
+
   if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
     return true;
   }
@@ -1799,6 +2095,9 @@ async function main() {
 
   let buildModeResult;
 
+  logInfo`Passing control over to build mode: ${selectedBuildModeFlag}`;
+  console.log('');
+
   try {
     buildModeResult = await selectedBuildMode.go({
       cliOptions,
@@ -1814,6 +2113,7 @@ async function main() {
       thumbsCache,
       urls,
       urlSpec,
+      webRoutes,
       wikiData,
 
       cachebust: '?' + CACHEBUST,
diff --git a/src/url-spec.js b/src/url-spec.js
index ea5337a..ec971c0 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -1,12 +1,16 @@
 import {withEntries} from '#sugar';
 
+const genericPaths = {
+  root: '',
+  path: '<>',
+};
+
 const urlSpec = {
   data: {
     prefix: 'data/',
 
     paths: {
-      root: '',
-      path: '<>',
+      ...genericPaths,
 
       album: 'album/<>',
       artist: 'artist/<>',
@@ -19,8 +23,7 @@ const urlSpec = {
     // prefix: '_languageCode',
 
     paths: {
-      root: '',
-      path: '<>',
+      ...genericPaths,
       page: '<>/',
 
       home: '',
@@ -61,8 +64,7 @@ const urlSpec = {
 
   shared: {
     paths: {
-      root: '',
-      path: '<>',
+      ...genericPaths,
 
       utilityRoot: 'util',
       staticRoot: 'static',
@@ -78,8 +80,7 @@ const urlSpec = {
     prefix: 'media/',
 
     paths: {
-      root: '',
-      path: '<>',
+      ...genericPaths,
 
       albumAdditionalFile: 'album-additional/<>/<>',
       albumBanner: 'album-art/<>/banner.<>',
@@ -96,11 +97,17 @@ const urlSpec = {
 
   thumb: {
     prefix: 'thumb/',
+    paths: genericPaths,
+  },
 
-    paths: {
-      root: '',
-      path: '<>',
-    },
+  static: {
+    prefix: 'static/',
+    paths: genericPaths,
+  },
+
+  util: {
+    prefix: 'util/',
+    paths: genericPaths,
   },
 };
 
diff --git a/src/util/aggregate.js b/src/util/aggregate.js
index c5d4198..3ad8bdb 100644
--- a/src/util/aggregate.js
+++ b/src/util/aggregate.js
@@ -91,6 +91,46 @@ export function openAggregate({
     return aggregate.callAsync(() => withAggregateAsync(...args));
   };
 
+  aggregate.receive = (results) => {
+    if (!Array.isArray(results)) {
+      if (typeof results === 'object' && results.aggregate) {
+        const {aggregate, result} = results;
+
+        try {
+          aggregate.close();
+        } catch (error) {
+          errors.push(error);
+        }
+
+        return result;
+      }
+
+      throw new Error(`Expected an array or {aggregate, result} object`);
+    }
+
+    return results.map(({aggregate, result}) => {
+      if (!aggregate) {
+        console.log('nope:', results);
+        throw new Error(`Expected an array of {aggregate, result} objects`);
+      }
+
+      try {
+        aggregate.close();
+      } catch (error) {
+        errors.push(error);
+      }
+
+      return result;
+    });
+  };
+
+  aggregate.contain = (results) => {
+    return {
+      aggregate,
+      result: aggregate.receive(results),
+    };
+  };
+
   aggregate.map = (...args) => {
     const parent = aggregate;
     const {result, aggregate: child} = mapAggregate(...args);
@@ -136,18 +176,33 @@ export function aggregateThrows(errorClass) {
   return {[openAggregate.errorClassSymbol]: errorClass};
 }
 
-// Helper function for allowing both (fn, aggregateOpts) and (aggregateOpts, fn)
-// in aggregate utilities.
-function _reorganizeAggregateArguments(arg1, arg2) {
-  if (typeof arg1 === 'function') {
-    return {fn: arg1, opts: arg2 ?? {}};
-  } else if (typeof arg2 === 'function') {
-    return {fn: arg2, opts: arg1 ?? {}};
+// Helper function for allowing both (fn, opts) and (opts, fn) in aggregate
+// utilities (or other shapes besides functions).
+function _reorganizeAggregateArguments(arg1, arg2, desire = v => typeof v === 'function') {
+  if (desire(arg1)) {
+    return [arg1, arg2 ?? {}];
+  } else if (desire(arg2)) {
+    return [arg2, arg1];
   } else {
-    throw new Error(`Expected a function`);
+    return [undefined, undefined];
   }
 }
 
+// Takes a list of {aggregate, result} objects, puts all the aggregates into
+// a new aggregate, and puts all the results into an array, returning both on
+// a new {aggregate, result} object. This is essentailly the generalized
+// composable version of functions like mapAggregate or filterAggregate.
+export function receiveAggregate(arg1, arg2) {
+  const [array, opts] = _reorganizeAggregateArguments(arg1, arg2, Array.isArray);
+  if (!array) {
+    throw new Error(`Expected an array`);
+  }
+
+  const aggregate = openAggregate(opts);
+  const result = aggregate.receive(array);
+  return {aggregate, result};
+}
+
 // Performs an ordinary array map with the given function, collating into a
 // results array (with errored inputs filtered out) and an error aggregate.
 //
@@ -158,12 +213,20 @@ function _reorganizeAggregateArguments(arg1, arg2) {
 // use aggregate.close() to throw the error. (This aggregate may be passed to a
 // parent aggregate: `parent.call(aggregate.close)`!)
 export function mapAggregate(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _mapAggregate('sync', null, array, fn, opts);
 }
 
 export function mapAggregateAsync(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
   return _mapAggregate('async', promiseAll, array, fn, remainingOpts);
 }
@@ -200,12 +263,20 @@ export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 //
 // As with mapAggregate, the returned aggregate property is not yet closed.
 export function filterAggregate(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _filterAggregate('sync', null, array, fn, opts);
 }
 
 export async function filterAggregateAsync(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
   return _filterAggregate('async', promiseAll, array, fn, remainingOpts);
 }
@@ -268,12 +339,20 @@ function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 // function with it, then closing the function and returning the result (if
 // there's no throw).
 export function withAggregate(arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _withAggregate('sync', opts, fn);
 }
 
 export function withAggregateAsync(arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _withAggregate('async', opts, fn);
 }
 
@@ -294,6 +373,7 @@ export function _withAggregate(mode, aggregateOpts, fn) {
 
 export const unhelpfulTraceLines = [
   /sugar/,
+  /aggregate/,
   /node:/,
   /<anonymous>/,
 ];
diff --git a/src/util/cli.js b/src/util/cli.js
index 973fef1..ce513f0 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -215,12 +215,30 @@ export function decorateTime(arg1, arg2) {
     timeSpent: 0,
     timesCalled: 0,
     displayTime() {
-      const averageTime = meta.timeSpent / meta.timesCalled;
+      const align1 = 48;
+      const align2 = 22;
+
+      const averageTime = (meta.timeSpent / meta.timesCalled).toExponential(1);
+      const idPart = typeof id === 'symbol' ? id.description : id;
+      const timePart = `${meta.timeSpent} ms / ${meta.timesCalled} calls`;
+      const avgPart = `(avg: ${averageTime} ms)`;
+
+      const alignPart1 =
+        (idPart.length >= align1
+          ? ' '
+          : ' ' + '.'.repeat(Math.max(0, align1 - 2 - idPart.length)) + ' ');
+
+      const alignPart2 =
+        (timePart.length >= align2
+          ? ' '
+          : ' '.repeat(Math.max(0, align2 - timePart.length)));
+
       console.log(
-        `\x1b[1m${typeof id === 'symbol' ? id.description : id}(...):\x1b[0m ${
-          meta.timeSpent
-        } ms / ${meta.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`
-      );
+        colors.bright(idPart) +
+        alignPart1 +
+        timePart +
+        alignPart2 +
+        colors.dim(avgPart));
     },
   };
 
@@ -228,7 +246,7 @@ export function decorateTime(arg1, arg2) {
 
   const fn = function (...args) {
     const start = Date.now();
-    const ret = functionToBeWrapped(...args);
+    const ret = functionToBeWrapped.apply(this, args);
     const end = Date.now();
     meta.timeSpent += end - start;
     meta.timesCalled++;
@@ -250,11 +268,20 @@ decorateTime.displayTime = function () {
     ...Object.getOwnPropertyNames(map),
   ];
 
-  if (keys.length) {
-    console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m');
-    for (const key of keys) {
-      map[key].displayTime();
-    }
+  if (!keys.length) {
+    return;
+  }
+
+  console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m');
+
+  const metas =
+    keys
+      .map(key => map[key])
+      .filter(meta => meta.timeSpent >= 1)  // Milliseconds!
+      .sort((a, b) => a.timeSpent - b.timeSpent);
+
+  for (const meta of metas) {
+    meta.displayTime();
   }
 };
 
diff --git a/src/util/external-links.js b/src/util/external-links.js
index 8ab8dec..a616efb 100644
--- a/src/util/external-links.js
+++ b/src/util/external-links.js
@@ -1,8 +1,10 @@
-import {empty, stitchArrays} from '#sugar';
+import {empty, stitchArrays, withEntries} from '#sugar';
 
 import {
   anyOf,
   is,
+  isBoolean,
+  isObject,
   isStringNonEmpty,
   looseArrayOf,
   optional,
@@ -13,9 +15,8 @@ import {
 } from '#validators';
 
 export const externalLinkStyles = [
-  'normal',
-  'compact',
   'platform',
+  'handle',
   'icon-id',
 ];
 
@@ -86,25 +87,26 @@ export const isExternalLinkSpec =
       }),
 
       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(validateAllPropertyValues(isExternalLinkExtractSpec)),
+      detail:
+        optional(anyOf(
+          isStringNonEmpty,
+          validateProperties({
+            [validateProperties.validateOtherKeys]:
+              isExternalLinkExtractSpec,
+
+            substring: isStringNonEmpty,
+          }))),
+
+      unusualDomain: optional(isBoolean),
+
+      icon: optional(isStringNonEmpty),
     }));
 
 export const fallbackDescriptor = {
   platform: 'external',
-
-  normal: 'domain',
-  compact: 'domain',
   icon: 'globe',
 };
 
@@ -120,7 +122,7 @@ export const externalLinkSpec = [
     },
 
     platform: 'youtube',
-    substring: 'playlist',
+    detail: 'playlist',
 
     icon: 'youtube',
   },
@@ -133,7 +135,7 @@ export const externalLinkSpec = [
     },
 
     platform: 'youtube',
-    substring: 'fullAlbum',
+    detail: 'fullAlbum',
 
     icon: 'youtube',
   },
@@ -145,43 +147,9 @@ export const externalLinkSpec = [
     },
 
     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',
+    detail: 'fullAlbum',
 
-    normal: 'handle',
-    compact: 'handle',
     icon: 'youtube',
-
-    handle: {
-      pathname: /^(@.*?)\/?$/,
-    },
   },
 
   // Special handling for flash links
@@ -193,7 +161,7 @@ export const externalLinkSpec = [
     },
 
     platform: 'bgreco',
-    substring: 'flash',
+    detail: 'flash',
 
     icon: 'globe',
   },
@@ -203,20 +171,16 @@ export const externalLinkSpec = [
     match: {
       context: 'flash',
       domain: 'homestuck.com',
-      pathname: /^story\/[0-9]+\/?$/,
     },
 
     platform: 'homestuck',
-    substring: 'page',
-
-    normal: 'custom',
-    icon: 'globe',
 
-    custom: {
-      page: {
-        pathname: /[0-9]+/,
-      },
+    detail: {
+      substring: 'page',
+      page: {pathname: /^story\/([0-9]+)\/?$/,},
     },
+
+    icon: 'globe',
   },
 
   {
@@ -227,7 +191,7 @@ export const externalLinkSpec = [
     },
 
     platform: 'homestuck',
-    substring: 'secretPage',
+    detail: 'secretPage',
 
     icon: 'globe',
   },
@@ -239,7 +203,7 @@ export const externalLinkSpec = [
     },
 
     platform: 'youtube',
-    substring: 'flash',
+    detail: 'flash',
 
     icon: 'youtube',
   },
@@ -247,12 +211,48 @@ export const externalLinkSpec = [
   // Generic domains, sorted alphabetically (by string)
 
   {
+    match: {
+      domains: [
+        'music.amazon.co.jp',
+        'music.amazon.com',
+      ],
+    },
+
+    platform: 'amazonMusic',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'music.apple.com'},
+    platform: 'appleMusic',
+    icon: 'appleMusic',
+  },
+
+  {
+    match: {domain: 'artstation.com'},
+
+    platform: 'artstation',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'artstation',
+  },
+
+  {
+    match: {domain: '.artstation.com'},
+
+    platform: 'artstation',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'artstation',
+  },
+
+  {
     match: {domains: ['bc.s3m.us', 'music.solatrus.com']},
 
     platform: 'bandcamp',
+    handle: {domain: /.+/},
+    unusualDomain: true,
 
-    normal: 'domain',
-    compact: 'domain',
     icon: 'bandcamp',
   },
 
@@ -260,11 +260,36 @@ export const externalLinkSpec = [
     match: {domain: '.bandcamp.com'},
 
     platform: 'bandcamp',
+    handle: {domain: /^[^.]+/},
 
-    compact: 'handle',
     icon: 'bandcamp',
+  },
+
+  {
+    match: {domain: 'bsky.app'},
+
+    platform: 'bluesky',
+    handle: {pathname: /^profile\/([^/]+?)(?:\.bsky\.social)?\/?$/},
+
+    icon: 'bluesky',
+  },
+
+  {
+    match: {domain: '.carrd.co'},
+
+    platform: 'carrd',
+    handle: {domain: /^[^.]+/},
 
-    handle: {domain: /^[^.]*/},
+    icon: 'carrd',
+  },
+
+  {
+    match: {domain: 'cohost.org'},
+
+    platform: 'cohost',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'cohost',
   },
 
   {
@@ -280,24 +305,60 @@ export const externalLinkSpec = [
   },
 
   {
+    match: {domain: '.deviantart.com'},
+
+    platform: 'deviantart',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'deviantart',
+  },
+
+  {
     match: {domain: 'deviantart.com'},
+
     platform: 'deviantart',
+    handle: {pathname: /^([^/]+)\/?$/},
+
     icon: 'deviantart',
   },
 
   {
-    match: {
-      domain: 'mspaintadventures.fandom.com',
-      pathname: /^wiki\/(.+)\/?$/,
-    },
+    match: {domain: 'deviantart.com'},
+    platform: 'deviantart',
+    icon: 'deviantart',
+  },
 
-    platform: 'fandom',
-    substring: 'mspaintadventures.page',
+  {
+    match: {domain: 'facebook.com'},
 
-    normal: 'custom',
-    icon: 'globe',
+    platform: 'facebook',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'facebook',
+  },
 
-    custom: {
+  {
+    match: {domain: 'facebook.com'},
+
+    platform: 'facebook',
+    handle: {pathname: /^(?:pages|people)\/([^/]+)\/[0-9]+\/?$/},
+
+    icon: 'facebook',
+  },
+
+  {
+    match: {domain: 'facebook.com'},
+    platform: 'facebook',
+    icon: 'facebook',
+  },
+
+  {
+    match: {domain: 'mspaintadventures.fandom.com'},
+
+    platform: 'fandom.mspaintadventures',
+
+    detail: {
+      substring: 'page',
       page: {
         pathname: /^wiki\/(.+)\/?$/,
         transform: [
@@ -306,52 +367,150 @@ export const externalLinkSpec = [
         ],
       },
     },
+
+    icon: 'globe',
   },
 
   {
     match: {domain: 'mspaintadventures.fandom.com'},
 
-    platform: 'fandom',
-    substring: 'mspaintadventures',
+    platform: 'fandom.mspaintadventures',
 
     icon: 'globe',
   },
 
   {
-    match: {domain: 'fandom.com'},
+    match: {domains: ['fandom.com', '.fandom.com']},
     platform: 'fandom',
     icon: 'globe',
   },
 
   {
+    match: {domain: 'gamebanana.com'},
+    platform: 'gamebanana',
+    icon: 'globe',
+  },
+
+  {
     match: {domain: 'homestuck.com'},
     platform: 'homestuck',
     icon: 'globe',
   },
 
   {
+    match: {
+      domain: 'hsmusic.wiki',
+      pathname: /^media\/misc\/archive/,
+    },
+
+    platform: 'hsmusic.archive',
+
+    icon: 'globe',
+  },
+
+  {
     match: {domain: 'hsmusic.wiki'},
-    platform: 'local',
+    platform: 'hsmusic',
     icon: 'globe',
   },
 
   {
     match: {domain: 'instagram.com'},
+
     platform: 'instagram',
+    handle: {pathname: /^([^/]+)\/?$/},
+
     icon: 'instagram',
   },
 
   {
-    match: {domains: ['types.pl']},
+    match: {domain: 'instagram.com'},
+    platform: 'instagram',
+    icon: 'instagram',
+  },
+
+  // The Wayback Machine is a separate entry.
+  {
+    match: {domain: 'archive.org'},
+    platform: 'internetArchive',
+    icon: 'internetArchive',
+  },
+
+  {
+    match: {domain: '.itch.io'},
+
+    platform: 'itch',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'itch',
+  },
+
+  {
+    match: {domain: 'itch.io'},
+
+    platform: 'itch',
+    handle: {pathname: /^profile\/([^/]+)\/?$/},
+
+    icon: 'itch',
+  },
+
+  {
+    match: {domain: 'ko-fi.com'},
+
+    platform: 'kofi',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'kofi',
+  },
+
+  {
+    match: {domain: 'linktr.ee'},
+
+    platform: 'linktree',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'linktree',
+  },
+
+  {
+    match: {domains: [
+      'mastodon.social',
+      'shrike.club',
+      'types.pl',
+    ]},
 
     platform: 'mastodon',
+    handle: {domain: /.+/},
+    unusualDomain: true,
 
-    normal: 'domain',
-    compact: 'domain',
     icon: 'mastodon',
   },
 
   {
+    match: {domains: ['mspfa.com', '.mspfa.com']},
+    platform: 'mspfa',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: '.neocities.org'},
+
+    platform: 'neocities',
+    handle: {domain: /.+/},
+
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: '.newgrounds.com'},
+
+    platform: 'newgrounds',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'newgrounds',
+  },
+
+  {
     match: {domain: 'newgrounds.com'},
     platform: 'newgrounds',
     icon: 'newgrounds',
@@ -359,8 +518,17 @@ export const externalLinkSpec = [
 
   {
     match: {domain: 'patreon.com'},
+
     platform: 'patreon',
-    icon: 'globe',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'patreon',
+  },
+
+  {
+    match: {domain: 'patreon.com'},
+    platform: 'patreon',
+    icon: 'patreon',
   },
 
   {
@@ -373,51 +541,111 @@ export const externalLinkSpec = [
     match: {domain: 'soundcloud.com'},
 
     platform: 'soundcloud',
+    handle: {pathname: /^([^/]+)\/?$/},
 
-    compact: 'handle',
     icon: 'soundcloud',
+  },
 
-    handle: /([^/]*)\/?$/,
+  {
+    match: {domain: 'soundcloud.com'},
+    platform: 'soundcloud',
+    icon: 'soundcloud',
   },
 
   {
-    match: {domain: 'spotify.com'},
+    match: {domains: ['spotify.com', 'open.spotify.com']},
     platform: 'spotify',
-    icon: 'globe',
+    icon: 'spotify',
+  },
+
+  {
+    match: {domain: 'tiktok.com'},
+
+    platform: 'tiktok',
+    handle: {pathname: /^@?([^/]+)\/?$/},
+
+    icon: 'tiktok',
+  },
+
+  {
+    match: {domain: 'toyhou.se'},
+
+    platform: 'toyhouse',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'toyhouse',
   },
 
   {
     match: {domain: '.tumblr.com'},
 
     platform: 'tumblr',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'tumblr',
+  },
+
+  {
+    match: {domain: 'tumblr.com'},
+
+    platform: 'tumblr',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'tumblr',
+  },
 
-    compact: 'handle',
+  {
+    match: {domain: 'tumblr.com'},
+    platform: 'tumblr',
     icon: 'tumblr',
+  },
 
-    handle: {domain: /^[^.]*/},
+  {
+    match: {domain: 'twitch.tv'},
+
+    platform: 'twitch',
+    handle: {pathname: /^(.+)\/?/},
+
+    icon: 'twitch',
   },
 
   {
     match: {domain: 'twitter.com'},
 
     platform: 'twitter',
+    handle: {pathname: /^@?([^/]+)\/?$/},
 
-    compact: 'handle',
     icon: 'twitter',
+  },
 
-    handle: {
-      prefix: '@',
-      pathname: /^@?([a-zA-Z0-9_]*)\/?$/,
-    },
+  {
+    match: {domain: 'twitter.com'},
+    platform: 'twitter',
+    icon: 'twitter',
   },
 
   {
-    match: {domain: 'wikipedia.org'},
+    match: {domain: 'web.archive.org'},
+    platform: 'waybackMachine',
+    icon: 'internetArchive',
+  },
+
+  {
+    match: {domains: ['wikipedia.org', '.wikipedia.org']},
     platform: 'wikipedia',
     icon: 'misc',
   },
 
   {
+    match: {domain: 'youtube.com'},
+
+    platform: 'youtube',
+    handle: {pathname: /^@([^/]+)\/?$/},
+
+    icon: 'youtube',
+  },
+
+  {
     match: {domains: ['youtube.com', 'youtu.be']},
     platform: 'youtube',
     icon: 'youtube',
@@ -443,10 +671,30 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, {
 } = {}) {
   const {domain, pathname, query} = urlParts(url);
 
-  const compareDomain = string => domain.includes(string);
+  const compareDomain = string => {
+    // A dot at the start of the descriptor's domain indicates
+    // we're looking to match a subdomain.
+    if (string.startsWith('.')) matchSubdomain: {
+      // "www" is never an acceptable subdomain for this purpose.
+      // Sorry to people whose usernames are www!!
+      if (domain.startsWith('www.')) {
+        return false;
+      }
+
+      return domain.endsWith(string);
+    }
+
+    // No dot means we're looking for an exact/full domain match.
+    // But let "www" pass here too, implicitly.
+    return domain === string || domain === 'www.' + string;
+  };
+
   const comparePathname = regex => regex.test(pathname.slice(1));
   const compareQuery = regex => regex.test(query.slice(1));
 
+  const compareExtractSpec = extract =>
+    extractPartFromExternalLink(url, extract, {mode: 'test'});
+
   const contextArray =
     (Array.isArray(context)
       ? context
@@ -454,33 +702,55 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, {
 
   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.some(c => contextArray.includes(c));
-        if (match.context)
-          return contextArray.includes(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;
-      });
+      .filter(({match}) =>
+        (match.domain
+          ? compareDomain(match.domain)
+       : match.domains
+          ? match.domains.some(compareDomain)
+          : false))
+
+      .filter(({match}) =>
+        (Array.isArray(match.context)
+          ? match.context.some(c => contextArray.includes(c))
+       : match.context
+          ? contextArray.includes(match.context)
+          : true))
+
+      .filter(({match}) =>
+        (match.pathname
+          ? comparePathname(match.pathname)
+       : match.pathnames
+          ? match.pathnames.some(comparePathname)
+          : true))
+
+      .filter(({match}) =>
+        (match.query
+          ? compareQuery(match.query)
+       : match.queries
+          ? match.quieries.some(compareQuery)
+          : true))
+
+      .filter(({handle}) =>
+        (handle
+          ? compareExtractSpec(handle)
+          : true))
+
+      .filter(({detail}) =>
+        (typeof detail === 'object'
+          ? Object.entries(detail)
+              .filter(([key]) => key !== 'substring')
+              .map(([_key, value]) => value)
+              .every(compareExtractSpec)
+          : true));
 
   return [...matchingDescriptors, fallbackDescriptor];
 }
 
-export function extractPartFromExternalLink(url, extract) {
+export function extractPartFromExternalLink(url, extract, {
+  // Set to 'test' to just see if this would extract anything.
+  // This disables running custom transformations.
+  mode = 'extract',
+} = {}) {
   const {domain, pathname, query} = urlParts(url);
 
   let regexen = [];
@@ -556,15 +826,23 @@ export function extractPartFromExternalLink(url, extract) {
   })) {
     const match = test.match(regex);
     if (match) {
-      value = prefix + (match[1] ?? match[0]);
+      value = match[1] ?? match[0];
       break;
     }
   }
 
+  if (mode === 'test') {
+    return !!value;
+  }
+
   if (!value) {
     return null;
   }
 
+  if (prefix) {
+    value = prefix + value;
+  }
+
   for (const fn of transform) {
     value = fn(value);
   }
@@ -587,134 +865,77 @@ export function extractAllCustomPartsFromExternalLink(url, custom) {
 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) {
+  function getDetail() {
+    if (!descriptor.detail) {
       return null;
     }
 
-    const customParts =
-      extractAllCustomPartsFromExternalLink(url, descriptor.custom);
-
-    if (!customParts) {
-      return null;
-    }
+    if (typeof descriptor.detail === 'string') {
+      return language.$(prefix, descriptor.platform, descriptor.detail);
+    } else {
+      const {substring, ...rest} = descriptor.detail;
 
-    return language.$(prefix, descriptor.platform, descriptor.substring, customParts);
-  }
+      const opts =
+        withEntries(rest, entries => entries
+          .map(([key, value]) => [
+            key,
+            extractPartFromExternalLink(url, value),
+          ]));
 
-  function getHandle() {
-    if (!descriptor.handle) {
-      return null;
+      return language.$(prefix, descriptor.platform, substring, opts);
     }
-
-    return extractPartFromExternalLink(url, descriptor.handle);
   }
 
-  function getNormal() {
-    if (descriptor.custom) {
-      if (descriptor.normal === 'custom') {
-        return getCustom();
+  switch (style) {
+    case 'platform': {
+      const platform = language.$(prefix, descriptor.platform);
+      const domain = urlParts(url).domain;
+
+      if (descriptor === fallbackDescriptor) {
+        // The fallback descriptor has a "platform" which is just
+        // the word "External". This isn't really useful when you're
+        // looking for platform info!
+        if (domain) {
+          return language.sanitize(domain.replace(/^www\./, ''));
+        } else {
+          return platform;
+        }
+      } else if (descriptor.detail) {
+        return getDetail();
+      } else if (descriptor.unusualDomain && domain) {
+        return language.$(prefix, 'withDomain', {platform, domain});
       } else {
-        return null;
+        return platform;
       }
     }
 
-    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();
+    case 'handle': {
+      if (descriptor.handle) {
+        return extractPartFromExternalLink(url, descriptor.handle);
       } else {
         return null;
       }
     }
 
-    if (descriptor.compact === 'domain') {
-      const domain = getDomain();
-
-      if (!domain) {
+    case 'icon-id': {
+      if (descriptor.icon) {
+        return descriptor.icon;
+      } else {
         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 === 'handle') {
+    return !!descriptor.handle;
+  }
+
   if (style === 'icon-id') {
     return !!descriptor.icon;
   }
@@ -744,15 +965,13 @@ export function getExternalLinkStringOfStyleFromDescriptors(url, style, descript
 }
 
 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'),
-  };
+  return (
+    Object.fromEntries(
+      externalLinkStyles.map(style =>
+        getExternalLinkStringOfStyleFromDescriptor(
+          url,
+          style,
+          descriptor, {language}))));
 }
 
 export function getExternalLinkStringsFromDescriptors(url, descriptors, {
diff --git a/src/util/html.js b/src/util/html.js
index b8dea51..9e07f9b 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -73,6 +73,15 @@ export const joinChildren = Symbol();
 // or when there are multiple children.
 export const noEdgeWhitespace = Symbol();
 
+// Pass as a value on an object-shaped set of attributes to indicate that it's
+// always, absolutely, no matter what, a valid attribute addition. It will be
+// completely exempt from validation, which may provide a significant speed
+// boost IF THIS OPERATION IS REPEATED MANY TENS OF THOUSANDS OF TIMES.
+// Basically, don't use this unless you're 1) providing a constant set of
+// attributes, and 2) writing a very basic building block which loads of other
+// content will build off of!
+export const blessAttributes = Symbol();
+
 // Don't pass this directly, use html.metatag('blockwrap') instead.
 // Causes *following* content (past the metatag) to be placed inside a span
 // which is styled 'inline-block', which ensures that the words inside the
@@ -373,13 +382,12 @@ export class Tag {
       throw new Error(`Tag <${this.tagName}> is self-closing but got content`);
     }
 
-    let contentArray;
-
-    if (Array.isArray(value)) {
-      contentArray = value;
-    } else {
-      contentArray = [value];
-    }
+    const contentArray =
+      (Array.isArray(value)
+        ? value.flat(Infinity).filter(Boolean)
+     : value
+        ? [value]
+        : []);
 
     if (this.chunkwrap) {
       if (contentArray.some(content => content?.blockwrap)) {
@@ -387,10 +395,7 @@ export class Tag {
       }
     }
 
-    this.#content = contentArray
-      .flat(Infinity)
-      .filter(Boolean);
-
+    this.#content = contentArray;
     this.#content.toString = () => this.#stringifyContent();
   }
 
@@ -697,7 +702,7 @@ export class Tag {
             if (index === 0) {
               content += chunk;
             } else {
-              const whitespace = chunk.match(/^\s+/);
+              const whitespace = chunk.match(/^\s+/) ?? '';
               content += chunkwrapSplitter;
               content += '</span>';
               content += whitespace;
@@ -994,6 +999,12 @@ export class Attributes {
     }
   }
 
+  with(...args) {
+    const clone = this.clone();
+    clone.add(...args);
+    return clone;
+  }
+
   #addMultipleAttributes(attributes) {
     const flatInputAttributes =
       [attributes].flat(Infinity).filter(Boolean);
@@ -1007,6 +1018,8 @@ export class Attributes {
       const setResults = {};
 
       for (const key of Reflect.ownKeys(set)) {
+        if (key === blessAttributes) continue;
+
         const value = set[key];
         setResults[key] = this.#addOneAttribute(key, value);
       }
@@ -1088,8 +1101,17 @@ export class Attributes {
     return this.#attributes[attribute];
   }
 
-  has(attribute) {
-    return attribute in this.#attributes;
+  has(attribute, pattern) {
+    if (typeof pattern === 'undefined') {
+      return attribute in this.#attributes;
+    } else if (this.has(attribute)) {
+      const value = this.get(attribute);
+      if (Array.isArray(value)) {
+        return value.includes(pattern);
+      } else {
+        return value === pattern;
+      }
+    }
   }
 
   remove(attribute) {
@@ -1325,6 +1347,22 @@ export function smush(smushee) {
   return smush(Tag.normalize(smushee));
 }
 
+// Much gentler version of smush - this only flattens nested html.tags(), and
+// guarantees the result is itself an html.tags(). It doesn't manipulate text
+// content, and it doesn't resolve templates.
+export function smooth(smoothie) {
+  // Helper function to avoid intermediate html.tags() calls.
+  function helper(tag) {
+    if (tag instanceof Tag && tag.contentOnly) {
+      return tag.content.flatMap(helper);
+    } else {
+      return tag;
+    }
+  }
+
+  return tags(helper(smoothie));
+}
+
 export function template(description) {
   return new Template(description);
 }
@@ -1546,14 +1584,16 @@ export class Template {
       return true;
     }
 
-    if ('validate' in description) {
+    if (Object.hasOwn(description, 'validate')) {
       description.validate({
         ...commonValidators,
         ...validators,
       })(value);
+
+      return true;
     }
 
-    if ('type' in description) {
+    if (Object.hasOwn(description, 'type')) {
       switch (description.type) {
         case 'html': {
           return isHTML(value);
@@ -1564,14 +1604,14 @@ export class Template {
         }
 
         case 'string': {
+          if (typeof value === 'string')
+            return true;
+
           // Tags and templates are valid in string arguments - they'll be
           // stringified when exposed to the description's .content() function.
           if (value instanceof Tag || value instanceof Template)
             return true;
 
-          if (typeof value !== 'string')
-            throw new TypeError(`Slot expects string, got ${typeof value}`);
-
           return true;
         }
 
@@ -1815,9 +1855,29 @@ export const isAttributesAdditionPair = pair => {
   return true;
 };
 
-export const isAttributesAdditionSinglet =
+const isAttributesAdditionSingletHelper =
   anyOf(
     validateInstanceOf(Template),
     validateInstanceOf(Attributes),
     validateAllPropertyValues(isAttributeValue),
     looseArrayOf(value => isAttributesAdditionSinglet(value)));
+
+export const isAttributesAdditionSinglet = (value) => {
+  if (typeof value === 'object' && value !== null) {
+    if (Object.hasOwn(value, blessAttributes)) {
+      return true;
+    }
+
+    if (
+      Array.isArray(value) &&
+      value.length === 1 &&
+      typeof value[0] === 'object' &&
+      value[0] !== null &&
+      Object.hasOwn(value[0], blessAttributes)
+    ) {
+      return true;
+    }
+  }
+
+  return isAttributesAdditionSingletHelper(value);
+};
diff --git a/src/util/replacer.js b/src/util/replacer.js
index 0a3117e..d1b0a26 100644
--- a/src/util/replacer.js
+++ b/src/util/replacer.js
@@ -5,6 +5,8 @@
 // function, which converts nodes parsed here into actual HTML, links, etc
 // for embedding in a wiki webpage.
 
+import * as marked from 'marked';
+
 import * as html from '#html';
 import {escapeRegex, typeAppearance} from '#sugar';
 
@@ -460,7 +462,14 @@ export function postprocessImages(inputNodes) {
       let match = null, parseFrom = 0;
       while (match = imageRegexp.exec(node.data)) {
         const previousText = node.data.slice(parseFrom, match.index);
-        outputNodes.push({type: 'text', data: previousText});
+
+        outputNodes.push({
+          type: 'text',
+          data: previousText,
+          i: node.i + parseFrom,
+          iEnd: node.i + parseFrom + match.index,
+        });
+
         parseFrom = match.index + match[0].length;
 
         const imageNode = {type: 'image'};
@@ -532,6 +541,8 @@ export function postprocessImages(inputNodes) {
         outputNodes.push({
           type: 'text',
           data: node.data.slice(parseFrom),
+          i: node.i + parseFrom,
+          iEnd: node.iEnd,
         });
       }
 
@@ -574,7 +585,78 @@ export function postprocessHeadings(inputNodes) {
       textContent += node.data.slice(parseFrom);
     }
 
-    outputNodes.push({type: 'text', data: textContent});
+    outputNodes.push({
+      type: 'text',
+      data: textContent,
+      i: node.i,
+      iEnd: node.iEnd,
+    });
+  }
+
+  return outputNodes;
+}
+
+export function postprocessExternalLinks(inputNodes) {
+  const outputNodes = [];
+
+  for (const node of inputNodes) {
+    if (node.type !== 'text') {
+      outputNodes.push(node);
+      continue;
+    }
+
+    const plausibleLinkRegexp = /\[.*?\)/g;
+
+    let textContent = '';
+
+    let plausibleMatch = null, parseFrom = 0;
+    while (plausibleMatch = plausibleLinkRegexp.exec(node.data)) {
+      textContent += node.data.slice(parseFrom, plausibleMatch.index);
+
+      // Pedantic rules use more particular parentheses detection in link
+      // destinations - they allow one level of balanced parentheses, and
+      // otherwise, parentheses must be escaped. This allows for entire links
+      // to be wrapped in parentheses, e.g below:
+      //
+      //   This is so cool. ([You know??](https://example.com))
+      //
+      const definiteMatch =
+        marked.Lexer.rules.inline.pedantic.link
+          .exec(node.data.slice(plausibleMatch.index));
+
+      if (definiteMatch) {
+        const {1: label, 2: href} = definiteMatch;
+
+        // Split the containing text node into two - the second of these will
+        // be added after iterating over matches, or by the next match.
+        if (textContent.length) {
+          outputNodes.push({type: 'text', data: textContent});
+          textContent = '';
+        }
+
+        const offset = plausibleMatch.index + definiteMatch.index;
+        const length = definiteMatch[0].length;
+
+        outputNodes.push({
+          i: node.i + offset,
+          iEnd: node.i + offset + length,
+          type: 'external-link',
+          data: {label, href},
+        });
+
+        parseFrom = offset + length;
+      } else {
+        parseFrom = plausibleMatch.index;
+      }
+    }
+
+    if (parseFrom !== node.data.length) {
+      textContent += node.data.slice(parseFrom);
+    }
+
+    if (textContent.length) {
+      outputNodes.push({type: 'text', data: textContent});
+    }
   }
 
   return outputNodes;
@@ -589,6 +671,7 @@ export function parseInput(input) {
     let output = parseNodes(input, 0);
     output = postprocessImages(output);
     output = postprocessHeadings(output);
+    output = postprocessExternalLinks(output);
     return output;
   } catch (errorNode) {
     if (errorNode.type !== 'error') {
diff --git a/src/util/serialize.js b/src/util/serialize.js
index 4992e2b..eb18a75 100644
--- a/src/util/serialize.js
+++ b/src/util/serialize.js
@@ -14,10 +14,10 @@ export function serializeLink(thing) {
 }
 
 export function serializeContribs(contribs) {
-  return contribs.map(({who, what}) => {
+  return contribs.map(({artist, annotation}) => {
     const ret = {};
-    ret.artist = serializeLink(who);
-    if (what) ret.contribution = what;
+    ret.artist = serializeLink(artist);
+    if (annotation) ret.contribution = annotation;
     return ret;
   });
 }
diff --git a/src/web-routes.js b/src/web-routes.js
new file mode 100644
index 0000000..efd86ca
--- /dev/null
+++ b/src/web-routes.js
@@ -0,0 +1,62 @@
+import {readdir} from 'node:fs/promises';
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const codeSrcPath = __dirname;
+const codeRootPath = path.resolve(codeSrcPath, '..');
+
+function getNodeDependencyRootPath(dependencyName) {
+  const packageJSON =
+    import.meta.resolve(dependencyName + '/package.json');
+
+  return path.dirname(fileURLToPath(packageJSON));
+}
+
+export const stationaryCodeRoutes = [
+  {
+    from: path.join(codeSrcPath, 'static'),
+    to: ['static.root'],
+  },
+
+  {
+    from: path.join(codeSrcPath, 'util'),
+    to: ['util.root'],
+  },
+];
+
+export const dependencyRoutes = [];
+
+export const allStaticWebRoutes = [
+  ...stationaryCodeRoutes,
+  ...dependencyRoutes,
+];
+
+export async function identifyDynamicWebRoutes({
+  mediaPath,
+  mediaCachePath,
+  wikiCachePath,
+}) {
+  const routeFunctions = [
+    () => Promise.resolve([
+      {from: path.resolve(mediaPath), to: ['media.root']},
+      {from: path.resolve(mediaCachePath), to: ['thumb.root']},
+    ]),
+  ];
+
+  const routeCheckPromises =
+    routeFunctions.map(fn => fn());
+
+  const routeCheckResults =
+    await Promise.all(routeCheckPromises);
+
+  return routeCheckResults.flat();
+}
+
+export async function identifyAllWebRoutes(opts) {
+  return [
+    ...allStaticWebRoutes,
+    ...await identifyDynamicWebRoutes(opts),
+  ];
+}
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index a2a84a8..03ef604 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -1,10 +1,11 @@
 import {spawn} from 'node:child_process';
 import * as http from 'node:http';
-import {readFile, stat} from 'node:fs/promises';
+import {open, stat} from 'node:fs/promises';
 import * as path from 'node:path';
+import {pipeline} from 'node:stream/promises';
 import {inspect as nodeInspect} from 'node:util';
 
-import {ENABLE_COLOR, logInfo, logWarn, progressCallAll} from '#cli';
+import {ENABLE_COLOR, colors, logInfo, logWarn, progressCallAll} from '#cli';
 import {watchContentDependencies} from '#content-dependencies';
 import {quickEvaluate} from '#content-function';
 import * as html from '#html';
@@ -27,19 +28,23 @@ export const description = `Hosts a local HTTP server which generates page conte
 
 export const config = {
   fileSizes: {
-    default: true,
+    default: 'perform',
   },
 
   languageReloading: {
-    default: true,
+    default: 'perform',
   },
 
   mediaValidation: {
-    default: true,
+    default: 'perform',
   },
 
   thumbs: {
-    default: true,
+    default: 'perform',
+  },
+
+  webRoutes: {
+    required: true,
   },
 };
 
@@ -88,9 +93,6 @@ export function getCLIOptions() {
 
 export async function go({
   cliOptions,
-  _dataPath,
-  mediaPath,
-  mediaCachePath,
 
   defaultLanguage,
   languages,
@@ -98,6 +100,7 @@ export async function go({
   srcRootPath,
   thumbsCache,
   urls,
+  webRoutes,
   wikiData,
 
   cachebust,
@@ -211,7 +214,7 @@ export async function go({
 
         response.writeHead(200, contentTypeJSON);
         response.end(json);
-        if (loudResponses) console.log(`${requestHead} [200] ${pathname}`);
+        if (loudResponses) console.log(`${requestHead} [200] ${pathname} (${colors.yellow(`special`)})`);
       } catch (error) {
         response.writeHead(500, contentTypeJSON);
         response.end(`Internal error serializing wiki JSON`);
@@ -221,30 +224,27 @@ export async function go({
       return;
     }
 
-    const {
-      area: localFileArea,
-      path: localFilePath
-    } = pathname.match(/^\/(?<area>static|util|media|thumb)\/(?<path>.*)/)?.groups ?? {};
+    const matchedWebRoute =
+      webRoutes
+        .find(({to}) => pathname.startsWith('/' + to));
+
+    if (matchedWebRoute) {
+      const localFilePath = pathname.slice(1 + matchedWebRoute.to.length);
 
-    if (localFileArea) {
       // Not security tested, man, this is a dev server!!
-      const safePath = path.posix.resolve('/', localFilePath).replace(/^\//, '');
-
-      let localDirectory;
-      if (localFileArea === 'static' || localFileArea === 'util') {
-        localDirectory = path.join(srcRootPath, localFileArea);
-      } else if (localFileArea === 'media') {
-        localDirectory = mediaPath;
-      } else if (localFileArea === 'thumb') {
-        localDirectory = mediaCachePath;
-      }
+      const safePath =
+        path.posix
+          .resolve('/', localFilePath)
+          .replace(/^\//, '');
+
+      const localDirectory = matchedWebRoute.from;
 
       let filePath;
       try {
         filePath = path.resolve(localDirectory, decodeURI(safePath.split('/').join(path.sep)));
       } catch (error) {
         response.writeHead(404, contentTypePlain);
-        response.end(`No ${localFileArea} file found for: ${safePath}`);
+        response.end(`File not found for: ${safePath}`);
         console.log(`${requestHead} [404] ${pathname}`);
         console.log(`Failed to decode request pathname`);
       }
@@ -254,12 +254,12 @@ export async function go({
       } catch (error) {
         if (error.code === 'ENOENT') {
           response.writeHead(404, contentTypePlain);
-          response.end(`No ${localFileArea} file found for: ${safePath}`);
+          response.end(`File not found for: ${safePath}`);
           console.log(`${requestHead} [404] ${pathname}`);
           console.log(`ENOENT for stat: ${filePath}`);
         } else {
           response.writeHead(500, contentTypePlain);
-          response.end(`Internal error accessing ${localFileArea} file for: ${safePath}`);
+          response.end(`Internal error accessing file for: ${safePath}`);
           console.error(`${requestHead} [500] ${pathname}`);
           showError(error);
         }
@@ -300,21 +300,33 @@ export async function go({
         'zip': 'application/zip',
       }[extname];
 
+      let fd, size;
       try {
-        const {size} = await stat(filePath);
-        const buffer = await readFile(filePath)
-        response.writeHead(200, contentType ? {
-          'Content-Type': contentType,
-          'Content-Length': size,
-        } : {});
-        response.end(buffer);
-        if (loudResponses) console.log(`${requestHead} [200] ${pathname}`);
+        ({size} = await stat(filePath));
+        fd = await open(filePath);
       } catch (error) {
-        response.writeHead(500, contentTypePlain);
-        response.end(`Failed during file-to-response pipeline`);
-        console.error(`${requestHead} [500] ${pathname}`);
-        showError(error);
+        if (error.code === 'EISDIR') {
+          response.writeHead(404, contentTypePlain);
+          response.end(`File not found for: ${safePath}`);
+          console.error(`${requestHead} [404] ${pathname} (is directory)`);
+        } else {
+          response.writeHead(500, contentTypePlain);
+          response.end(`Failed during file-to-response pipeline`);
+          console.error(`${requestHead} [500] ${pathname}`);
+          showError(error);
+        }
+        return;
       }
+
+      response.writeHead(200, {
+        ...contentType ? {'Content-Type': contentType} : {},
+        'Content-Length': size,
+      });
+
+      await pipeline(fd.createReadStream(), response);
+
+      if (loudResponses) console.log(`${requestHead} [200] ${pathname} (${colors.magenta(`web route`)})`);
+
       return;
     }
 
@@ -421,9 +433,9 @@ export async function go({
             ? `${(timeDelta / 1000).toFixed(2)}s`
             : `${timeDelta}ms`);
 
-        console.log(`${requestHead} [200, ${timeString}] ${pathname}`);
+        console.log(`${requestHead} [200, ${timeString}] ${pathname} (${colors.blue(`page`)})`);
       } else if (loudResponses) {
-        console.log(`${requestHead} [200] ${pathname}`);
+        console.log(`${requestHead} [200] ${pathname} (${colors.blue(`page`)})`);
       }
 
       response.writeHead(200, contentTypeHTML);
@@ -474,10 +486,15 @@ export async function go({
   if (skipServing) {
     logInfo`Ready to serve! But --skip-serving was passed, so all done.`;
   } else {
-    server.listen(port, host);
+    process.on('SIGINT', () => {
+      process.stdout.write('\n');
+      server.close();
+    });
 
-    // Just keep going... forever!!!
-    await new Promise(() => {});
+    await new Promise(resolve => {
+      server.listen(port, host);
+      server.on('close', () => resolve());
+    });
   }
 
   return true;
diff --git a/src/write/build-modes/repl.js b/src/write/build-modes/repl.js
index 2098559..b300e8e 100644
--- a/src/write/build-modes/repl.js
+++ b/src/write/build-modes/repl.js
@@ -6,7 +6,7 @@ export const config = {
   },
 
   languageReloading: {
-    default: true,
+    default: 'perform',
   },
 
   mediaValidation: {
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index a355a00..68cf094 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -38,7 +38,7 @@ export const description = `Generates all page content in one build (according t
 
 export const config = {
   fileSizes: {
-    default: true,
+    default: 'perform',
   },
 
   languageReloading: {
@@ -46,11 +46,15 @@ export const config = {
   },
 
   mediaValidation: {
-    default: true,
+    default: 'perform',
   },
 
   thumbs: {
-    default: true,
+    default: 'perform',
+  },
+
+  webRoutes: {
+    required: true,
   },
 };
 
@@ -99,9 +103,7 @@ export function getCLIOptions() {
 
 export async function go({
   cliOptions,
-  _dataPath,
   mediaPath,
-  mediaCachePath,
   queueSize,
 
   defaultLanguage,
@@ -110,6 +112,7 @@ export async function go({
   srcRootPath,
   thumbsCache,
   urls,
+  webRoutes,
   wikiData,
 
   cachebust,
@@ -148,12 +151,9 @@ export async function go({
 
   await mkdir(outputPath, {recursive: true});
 
-  await writeSymlinks({
-    srcRootPath,
-    mediaPath,
-    mediaCachePath,
+  await writeWebRouteSymlinks({
     outputPath,
-    urls,
+    webRoutes,
   });
 
   if (writeAll) {
@@ -432,42 +432,41 @@ async function writePage({
   ].filter(Boolean));
 }
 
-function writeSymlinks({
-  srcRootPath,
-  mediaPath,
-  mediaCachePath,
+function writeWebRouteSymlinks({
   outputPath,
-  urls,
+  webRoutes,
 }) {
-  return progressPromiseAll('Writing site symlinks.', [
-    link(path.join(srcRootPath, 'util'), 'shared.utilityRoot'),
-    link(path.join(srcRootPath, 'static'), 'shared.staticRoot'),
-    link(mediaPath, 'media.root'),
-    link(mediaCachePath, 'thumb.root'),
-  ]);
-
-  async function link(directory, urlKey) {
-    const pathname = urls.from('shared.root').toDevice(urlKey);
-    const file = path.join(outputPath, pathname);
-
-    try {
-      await unlink(file);
-    } catch (error) {
-      if (error.code !== 'ENOENT') {
-        throw error;
+  const promises =
+    webRoutes.map(async route => {
+      const parts = route.to.split('/');
+      const parentDirectoryParts = parts.slice(0, -1);
+      const symlinkNamePart = parts.at(-1);
+
+      const parentDirectory = path.join(outputPath, ...parentDirectoryParts);
+      const symlinkPath = path.join(parentDirectory, symlinkNamePart);
+
+      try {
+        await unlink(symlinkPath);
+      } catch (error) {
+        if (error.code !== 'ENOENT') {
+          throw error;
+        }
       }
-    }
 
-    try {
-      await symlink(path.resolve(directory), file);
-    } catch (error) {
-      if (error.code === 'EPERM') {
-        await symlink(path.resolve(directory), file, 'junction');
-      } else {
-        throw error;
+      await mkdir(parentDirectory, {recursive: true});
+
+      try {
+        await symlink(route.from, symlinkPath);
+      } catch (error) {
+        if (error.code === 'EPERM') {
+          await symlink(route.from, symlinkPath, 'junction');
+        } else {
+          throw error;
+        }
       }
-    }
-  }
+    });
+
+  return progressPromiseAll(`Writing web route symlinks.`, promises);
 }
 
 async function writeFavicon({