« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/aggregate.js2
-rw-r--r--src/cli.js132
-rw-r--r--src/common-util/sugar.js96
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js11
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js38
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js15
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackListBox.js4
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js23
-rw-r--r--src/content/dependencies/generateArtistCredit.js47
-rw-r--r--src/content/dependencies/generateArtistCreditWikiEditsPart.js1
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js5
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js4
-rw-r--r--src/content/dependencies/generateReleaseInfoListenLine.js159
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js76
-rw-r--r--src/content/dependencies/generateTrackList.js11
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js10
-rw-r--r--src/content/dependencies/generateTrackListItem.js4
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js31
-rw-r--r--src/content/dependencies/linkAlbum.js10
-rw-r--r--src/content/dependencies/linkContribution.js2
-rw-r--r--src/content/dependencies/transformContent.js91
-rw-r--r--src/data/composite/things/album/index.js2
-rw-r--r--src/data/composite/things/album/withCoverArtDate.js (renamed from src/data/composite/wiki-data/withCoverArtDate.js)13
-rw-r--r--src/data/composite/wiki-data/exitWithoutArtwork.js45
-rw-r--r--src/data/composite/wiki-data/index.js3
-rw-r--r--src/data/composite/wiki-data/withHasArtwork.js (renamed from src/data/composite/things/album/withHasCoverArt.js)59
-rw-r--r--src/data/things/album.js436
-rw-r--r--src/data/things/track.js447
-rw-r--r--src/external-links.js27
-rw-r--r--src/find.js8
-rw-r--r--src/gen-thumbs.js121
-rw-r--r--src/html.js72
-rw-r--r--src/page/album.js25
-rw-r--r--src/replacer.js172
-rw-r--r--src/static/css/site.css78
-rw-r--r--src/strings-default.yaml9
-rwxr-xr-xsrc/upd8.js43
-rw-r--r--src/urls-default.yaml2
-rw-r--r--src/write/bind-utilities.js2
39 files changed, 1544 insertions, 792 deletions
diff --git a/src/aggregate.js b/src/aggregate.js
index cb806e89..d5ea2d73 100644
--- a/src/aggregate.js
+++ b/src/aggregate.js
@@ -604,7 +604,7 @@ export function showAggregate(topError, {
       headerPart += ` ${colors.dim(tracePart)}`;
     }
 
-    const head1 = level % 2 === 0 ? '\u21aa' : colors.dim('\u21aa');
+    const head1 = '\u21aa';
     const bar1 = ' ';
 
     const causePart =
diff --git a/src/cli.js b/src/cli.js
index 24534522..ec72a625 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -376,77 +376,79 @@ decorateTime.displayTime = function () {
   }
 };
 
-export function progressPromiseAll(msgOrMsgFn, array) {
+const progressUpdateInterval = 1000 / 60;
+
+function progressShow(message, total) {
+  let start = Date.now(), last = 0, done = 0;
+
+  const progress = () => {
+    const messagePart =
+      (typeof message === 'function'
+        ? message()
+        : message);
+
+    const percent =
+      Math.round((done / total) * 1000) / 10 + '%';
+
+    const percentPart =
+      percent.padEnd('99.9%'.length, ' ');
+
+    return `${messagePart} [${percentPart}]`;
+  };
+
+  process.stdout.write(`\r` + progress());
+
+  return () => {
+    done++;
+
+    if (done === total) {
+      process.stdout.write(
+        `\r\x1b[2m` + progress() +
+        `\x1b[0;32m Done! ` +
+        `\x1b[0;2m(${formatDuration(Date.now() - start)}) ` +
+        `\x1b[0m\n`
+      );
+    } else if (Date.now() - last >= progressUpdateInterval) {
+      process.stdout.write('\r' + progress());
+      last = Date.now();
+    }
+  };
+}
+
+export function progressPromiseAll(message, array) {
   if (!array.length) {
     return Promise.resolve([]);
   }
 
-  const msgFn =
-    typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn;
-
-  let done = 0,
-    total = array.length;
-  process.stdout.write(`\r${msgFn()} [0/${total}]`);
-  const start = Date.now();
-  return Promise.all(
-    array.map((promise) =>
-      Promise.resolve(promise).then((val) => {
-        done++;
-        // const pc = `${done}/${total}`;
-        const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd(
-          '99.9%'.length,
-          ' '
-        );
-        if (done === total) {
-          const time = Date.now() - start;
-          process.stdout.write(
-            `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`
-          );
-        } else {
-          process.stdout.write(`\r${msgFn()} [${pc}] `);
-        }
-        return val;
-      })
-    )
-  );
+  const show = progressShow(message, array.length);
+
+  const next = value => {
+    show();
+
+    return value;
+  };
+
+  const promises =
+    array.map(promise => Promise.resolve(promise).then(next));
+
+  return Promise.all(promises);
 }
 
-export function progressCallAll(msgOrMsgFn, array) {
+export function progressCallAll(message, array) {
   if (!array.length) {
     return [];
   }
 
-  const msgFn =
-    typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn;
+  const show = progressShow(message, array.length);
 
-  const updateInterval = 1000 / 60;
-
-  let done = 0,
-    total = array.length;
-  process.stdout.write(`\r${msgFn()} [0/${total}]`);
-  const start = Date.now();
-  const vals = [];
-  let lastTime = 0;
+  const values = [];
 
   for (const fn of array) {
-    const val = fn();
-    done++;
-
-    if (done === total) {
-      const pc = '100%'.padEnd('99.9%'.length, ' ');
-      const time = Date.now() - start;
-      process.stdout.write(
-        `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`
-      );
-    } else if (Date.now() - lastTime >= updateInterval) {
-      const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd('99.9%'.length, ' ');
-      process.stdout.write(`\r${msgFn()} [${pc}] `);
-      lastTime = Date.now();
-    }
-    vals.push(val);
+    values.push(fn());
+    show();
   }
 
-  return vals;
+  return values;
 }
 
 export function fileIssue({
@@ -459,6 +461,24 @@ export function fileIssue({
   console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`));
 }
 
+// Quick'n dirty function to present a duration nicely for command-line use.
+export function formatDuration(timeDelta) {
+  const seconds = timeDelta / 1000;
+
+  if (seconds > 90) {
+    const modSeconds = Math.floor(seconds % 60);
+    const minutes = Math.floor(seconds - seconds % 60) / 60;
+    return `${minutes}m${modSeconds}s`;
+  }
+
+  if (seconds < 0.1) {
+    return 'instant';
+  }
+
+  const precision = (seconds > 1 ? 3 : 2);
+  return `${seconds.toPrecision(precision)}s`;
+}
+
 export async function logicalCWD() {
   if (process.env.PWD) {
     return process.env.PWD;
diff --git a/src/common-util/sugar.js b/src/common-util/sugar.js
index 9116698f..e931ad59 100644
--- a/src/common-util/sugar.js
+++ b/src/common-util/sugar.js
@@ -315,34 +315,74 @@ export function filterProperties(object, properties, {
   return filteredObject;
 }
 
-export function queue(array, max = 50) {
-  if (max === 0) {
-    return array.map((fn) => fn());
-  }
-
-  const begin = [];
-  let current = 0;
-  const ret = array.map(
-    (fn) =>
-      new Promise((resolve, reject) => {
-        begin.push(() => {
-          current++;
-          Promise.resolve(fn()).then((value) => {
-            current--;
-            if (current < max && begin.length) {
-              begin.shift()();
-            }
-            resolve(value);
-          }, reject);
-        });
-      })
-  );
-
-  for (let i = 0; i < max && begin.length; i++) {
-    begin.shift()();
-  }
-
-  return ret;
+export function queue(functionList, queueSize = 50) {
+  if (queueSize === 0) {
+    return functionList.map(fn => fn());
+  }
+
+  const promiseList = [];
+  const resolveList = [];
+  const rejectList = [];
+
+  for (let i = 0; i < functionList.length; i++) {
+    const promiseWithResolvers = Promise.withResolvers();
+    promiseList.push(promiseWithResolvers.promise);
+    resolveList.push(promiseWithResolvers.resolve);
+    rejectList.push(promiseWithResolvers.reject);
+  }
+
+  let cursor = 0;
+  let running = 0;
+
+  const next = async () => {
+    if (running >= queueSize) {
+      return;
+    }
+
+    if (cursor === functionList.length) {
+      return;
+    }
+
+    const thisFunction = functionList[cursor];
+    const thisResolve = resolveList[cursor];
+    const thisReject = rejectList[cursor];
+
+    delete functionList[cursor];
+    delete resolveList[cursor];
+    delete rejectList[cursor];
+
+    cursor++;
+    running++;
+
+    try {
+      thisResolve(await thisFunction());
+    } catch (error) {
+      thisReject(error);
+    } finally {
+      running--;
+
+      // If the cursor is at 1, this is the first promise that resolved,
+      // so we're now done the "kick start", and can start the remaining
+      // promises (up to queueSize).
+      if (cursor === 1) {
+        // Since only one promise is used for the "kick start", and that one
+        // has just resolved, we know there's none running at all right now,
+        // and can start as many as specified in the queueSize right away.
+        for (let i = 0; i < queueSize; i++) {
+          next();
+        }
+      } else {
+        next();
+      }
+    }
+  };
+
+  // Only start a single promise, as a "kick start", so that it resolves as
+  // early as possible (it will resolve before we use CPU to start the rest
+  // of the promises, up to queueSize).
+  next();
+
+  return promiseList;
 }
 
 export function delay(ms) {
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 1664c788..b9cf20dc 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -174,14 +174,11 @@ export default {
 
           html.tag('p',
             {[html.onlyIfContent]: true},
-            {[html.joinChildren]: html.tag('br')},
 
-            language.encapsulate('releaseInfo', capsule => [
-              language.$(capsule, 'addedToWiki', {
-                [language.onlyIfOptions]: ['date'],
-                date: language.formatDate(data.dateAddedToWiki),
-              }),
-            ])),
+            language.$('releaseInfo.addedToWiki', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.dateAddedToWiki),
+            })),
 
           (!html.isBlank(relations.artistCommentaryEntries) ||
            !html.isBlank(relations.creditSourceEntries))
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 0abb412c..2a958244 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -3,7 +3,7 @@ import {accumulateSum, empty} from '#sugar';
 export default {
   contentDependencies: [
     'generateReleaseInfoContributionsLine',
-    'linkExternal',
+    'generateReleaseInfoListenLine',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -14,15 +14,8 @@ export default {
     relations.artistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.artistContribs);
 
-    relations.wallpaperArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
-
-    relations.bannerArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
-
-    relations.externalLinks =
-      album.urls.map(url =>
-        relation('linkExternal', url));
+    relations.listenLine =
+      relation('generateReleaseInfoListenLine', album);
 
     return relations;
   },
@@ -87,21 +80,16 @@ export default {
         html.tag('p',
           {[html.onlyIfContent]: true},
 
-          language.$(capsule, 'listenOn', {
-            [language.onlyIfOptions]: ['links'],
-
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link =>
-                    link.slot('context', [
-                      'album',
-                      (data.numTracks === 0
-                        ? 'albumNoTracks'
-                     : data.numTracks === 1
-                        ? 'albumOneTrack'
-                        : 'albumMultipleTracks'),
-                    ]))),
+          relations.listenLine.slots({
+            context: [
+              'album',
+
+              (data.numTracks === 0
+                ? 'albumNoTracks'
+             : data.numTracks === 1
+                ? 'albumOneTrack'
+                : 'albumMultipleTracks'),
+            ],
           })),
       ])),
 };
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
index 7cf689cc..464274e3 100644
--- a/src/content/dependencies/generateAlbumSidebar.js
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -108,24 +108,29 @@ export default {
         : null),
   }),
 
-  data: (_query, _sprawl, _album, track) => ({
+  data: (_query, _sprawl, album, track) => ({
     isAlbumPage: !track,
     isTrackPage: !!track,
+
+    albumStyle: album.style,
   }),
 
   generate(data, relations, {html}) {
+    const presentGroupsLikeAlbum =
+      data.isAlbumPage ||
+      data.albumStyle === 'single';
+
     for (const box of [
       ...relations.groupBoxes,
       ...relations.seriesBoxes.flat(),
       ...relations.disconnectedSeriesBoxes,
     ]) {
-      box.setSlot('mode',
-        data.isAlbumPage ? 'album' : 'track');
+      box.setSlot('mode', presentGroupsLikeAlbum ? 'album' : 'track');
     }
 
     return relations.sidebar.slots({
       boxes: [
-        data.isAlbumPage && [
+        presentGroupsLikeAlbum && [
           relations.disconnectedSeriesBoxes,
 
           stitchArrays({
@@ -150,7 +155,7 @@ export default {
         data.isTrackPage &&
           relations.laterTrackReleaseBoxes,
 
-        data.isTrackPage &&
+        !presentGroupsLikeAlbum &&
           relations.conjoinedBox.slots({
             attributes: {class: 'conjoined-group-sidebar-box'},
             boxes:
diff --git a/src/content/dependencies/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
index 3a244e3a..218e07ab 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackListBox.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
@@ -24,7 +24,9 @@ export default {
       attributes: {class: 'track-list-sidebar-box'},
 
       content: [
-        html.tag('h1', relations.albumLink),
+        html.tag('h1', {[html.onlyIfSiblings]: true},
+          relations.albumLink),
+
         relations.trackSections,
       ],
     })
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index dae5fa03..a158d2d4 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -22,10 +22,12 @@ export default {
       !empty(trackSection.tracks);
 
     data.isTrackPage = !!track;
+    data.albumStyle = album.style;
 
     data.name = trackSection.name;
     data.color = trackSection.color;
     data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
+    data.hasSiblingSections = album.trackSections.length > 1;
 
     data.firstTrackNumber =
       (data.hasTrackNumbers
@@ -115,6 +117,21 @@ export default {
                   : trackLink),
             })));
 
+    const list =
+      (data.hasTrackNumbers
+        ? html.tag('ol',
+            {start: data.firstTrackNumber},
+            trackListItems)
+        : html.tag('ul', trackListItems));
+
+    if (data.albumStyle === 'single' && !data.hasSiblingSections) {
+      if (trackListItems.length <= 1) {
+        return html.blank();
+      } else {
+        return list;
+      }
+    }
+
     return html.tag('details',
       data.includesCurrentTrack &&
         {class: 'current'},
@@ -157,11 +174,7 @@ export default {
                 return language.$(workingCapsule, workingOptions);
               })))),
 
-        (data.hasTrackNumbers
-          ? html.tag('ol',
-              {start: data.firstTrackNumber},
-              trackListItems)
-          : html.tag('ul', trackListItems)),
+        list,
       ]);
   },
 };
diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js
index bab32f7d..2d611ca6 100644
--- a/src/content/dependencies/generateArtistCredit.js
+++ b/src/content/dependencies/generateArtistCredit.js
@@ -162,33 +162,42 @@ export default {
       (slots.showAnnotation && data.normalContributionAnnotationsDifferFromContext) ||
       (data.normalContributionArtistsDifferFromContext);
 
+    let content;
+
     if (empty(relations.featuringContributionLinks)) {
       if (effectivelyDiffers) {
-        return language.$(slots.normalStringKey, {
-          ...slots.additionalStringOptions,
-          artists: artistsList,
-        });
+        content =
+          language.$(slots.normalStringKey, {
+            ...slots.additionalStringOptions,
+            artists: artistsList,
+          });
       } else {
         return html.blank();
       }
-    }
-
-    if (effectivelyDiffers && slots.normalFeaturingStringKey) {
-      return language.$(slots.normalFeaturingStringKey, {
-        ...slots.additionalStringOptions,
-        artists: artistsList,
-        featuring: featuringList,
+    } else if (effectivelyDiffers && slots.normalFeaturingStringKey) {
+      content =
+        language.$(slots.normalFeaturingStringKey, {
+          ...slots.additionalStringOptions,
+          artists: artistsList,
+          featuring: featuringList,
       });
     } else if (slots.featuringStringKey) {
-      return language.$(slots.featuringStringKey, {
-        ...slots.additionalStringOptions,
-        artists: featuringList,
-      });
+      content =
+        language.$(slots.featuringStringKey, {
+          ...slots.additionalStringOptions,
+          artists: featuringList,
+        });
     } else {
-      return language.$(slots.normalStringKey, {
-        ...slots.additionalStringOptions,
-        artists: everyoneList,
-      });
+      content =
+        language.$(slots.normalStringKey, {
+          ...slots.additionalStringOptions,
+          artists: everyoneList,
+        });
     }
+
+    // TODO: This is obviously evil.
+    return (
+      html.metatag('chunkwrap', {split: /,| (?=and)/},
+        html.resolve(content)));
   },
 };
diff --git a/src/content/dependencies/generateArtistCreditWikiEditsPart.js b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
index 70296e39..1b9930ee 100644
--- a/src/content/dependencies/generateArtistCreditWikiEditsPart.js
+++ b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
@@ -48,6 +48,7 @@ export default {
                         showAnnotation: slots.showAnnotation,
                         trimAnnotation: true,
                         preventTooltip: true,
+                        preventWrapping: true,
                       }))),
                 }),
           }),
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
index 8628179e..5a7768fc 100644
--- a/src/content/dependencies/generateCoverArtworkOriginDetails.js
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -50,6 +50,10 @@ export default {
 
     artworkThingType:
       query.artworkThingType,
+
+    forSingleStyleAlbum:
+      query.artworkThingType === 'album' &&
+      artwork.thing.style === 'single',
   }),
 
   generate: (data, relations, {html, language, pagePath}) =>
@@ -98,6 +102,7 @@ export default {
           const trackArtFromAlbum =
             pagePath[0] === 'track' &&
             data.artworkThingType === 'album' &&
+            !data.forSingleStyleAlbum &&
               language.$(capsule, 'trackArtFromAlbum', {
                 album:
                   relations.albumLink.slot('color', false),
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
index 4680cb46..cec18240 100644
--- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -127,9 +127,7 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                // TODO: This is obviously evil.
-                html.metatag('chunkwrap', {split: /,| (?=and)/},
-                  html.resolve(artistCredit)));
+                artistCredit);
           }
 
           return language.$(workingCapsule, workingOptions);
diff --git a/src/content/dependencies/generateReleaseInfoListenLine.js b/src/content/dependencies/generateReleaseInfoListenLine.js
new file mode 100644
index 00000000..b02ff6f9
--- /dev/null
+++ b/src/content/dependencies/generateReleaseInfoListenLine.js
@@ -0,0 +1,159 @@
+import {isExternalLinkContext} from '#external-links';
+import {empty, stitchArrays, unique} from '#sugar';
+
+function getReleaseContext(urlString, {
+  _artistURLs,
+  albumArtistURLs,
+}) {
+  const composerBandcampDomains =
+    albumArtistURLs
+      .filter(url => url.hostname.endsWith('.bandcamp.com'))
+      .map(url => url.hostname);
+
+  const url = new URL(urlString);
+
+  if (url.hostname === 'homestuck.bandcamp.com') {
+    return 'officialRelease';
+  }
+
+  if (composerBandcampDomains.includes(url.hostname)) {
+    return 'composerRelease';
+  }
+
+  return null;
+}
+
+export default {
+  contentDependencies: ['linkExternal'],
+  extraDependencies: ['html', 'language'],
+
+  query(thing) {
+    const query = {};
+
+    query.album =
+      (thing.album
+        ? thing.album
+        : thing);
+
+    query.urls =
+      (!empty(thing.urls)
+        ? thing.urls
+     : thing.album &&
+       thing.album.style === 'single' &&
+       thing.album.tracks[0] === thing
+        ? thing.album.urls
+        : []);
+
+    query.artists =
+      thing.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.artistGroups =
+      query.artists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    query.albumArtists =
+      query.album.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.albumArtistGroups =
+      query.albumArtists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    return query;
+  },
+
+  relations: (relation, query, _thing) => ({
+    links:
+      query.urls.map(url => relation('linkExternal', url)),
+  }),
+
+  data(query, thing) {
+    const data = {};
+
+    data.name = thing.name;
+
+    const artistURLs =
+      unique([
+        ...query.artists.flatMap(artist => artist.urls),
+        ...query.artistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const albumArtistURLs =
+      unique([
+        ...query.albumArtists.flatMap(artist => artist.urls),
+        ...query.albumArtistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const boundGetReleaseContext = urlString =>
+      getReleaseContext(urlString, {
+        artistURLs,
+        albumArtistURLs,
+      });
+
+    let releaseContexts =
+      query.urls.map(boundGetReleaseContext);
+
+    const albumReleaseContexts =
+      query.album.urls.map(boundGetReleaseContext);
+
+    const presentReleaseContexts =
+      unique(releaseContexts.filter(Boolean));
+
+    const presentAlbumReleaseContexts =
+      unique(albumReleaseContexts.filter(Boolean));
+
+    if (
+      presentReleaseContexts.length <= 1 &&
+      presentAlbumReleaseContexts.length <= 1
+    ) {
+      releaseContexts =
+        query.urls.map(() => null);
+    }
+
+    data.releaseContexts = releaseContexts;
+
+    return data;
+  },
+
+  slots: {
+    visibleWithoutLinks: {
+      type: 'boolean',
+      default: false,
+    },
+
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('releaseInfo.listenOn', capsule =>
+      (empty(relations.links) && slots.visibleWithoutLinks
+        ? language.$(capsule, 'noLinks', {
+            name:
+              html.tag('i', data.name),
+          })
+
+        : language.$('releaseInfo.listenOn', {
+            [language.onlyIfOptions]: ['links'],
+
+            links:
+              language.formatDisjunctionList(
+                stitchArrays({
+                  link: relations.links,
+                  releaseContext: data.releaseContexts,
+                }).map(({link, releaseContext}) =>
+                    link.slot('context', [
+                      ...
+                      (Array.isArray(slots.context)
+                        ? slots.context
+                        : [slots.context]),
+
+                      releaseContext,
+                    ]))),
+          }))),
+};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 6c16ce27..8556f6cf 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -2,6 +2,7 @@ export default {
   contentDependencies: [
     'generateAdditionalFilesList',
     'generateAdditionalNamesBox',
+    'generateAlbumArtworkColumn',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
@@ -33,6 +34,14 @@ export default {
       (track.isMainRelease
         ? track
         : track.mainReleaseTrack),
+
+    singleTrackSingle:
+      track.album.style === 'single' &&
+      track.album.tracks.length === 1,
+
+    firstTrackInSingle:
+      track.album.style === 'single' &&
+      track === track.album.tracks[0],
   }),
 
   relations: (relation, query, track) => ({
@@ -48,6 +57,9 @@ export default {
     navLinks:
       relation('generateTrackNavLinks', track),
 
+    albumNavLink:
+      relation('linkAlbum', track.album),
+
     albumNavAccent:
       relation('generateAlbumNavAccent', track.album, track),
 
@@ -61,7 +73,9 @@ export default {
       relation('generateAdditionalNamesBox', track.additionalNames),
 
     artworkColumn:
-      relation('generateTrackArtworkColumn', track),
+      (query.firstTrackInSingle
+        ? relation('generateAlbumArtworkColumn', track.album)
+        : relation('generateTrackArtworkColumn', track)),
 
     contentHeading:
       relation('generateContentHeading'),
@@ -79,18 +93,20 @@ export default {
       relation('generateContributionList', track.contributorContribs),
 
     referencedTracksList:
-      relation('generateTrackList', track.referencedTracks),
+      relation('generateTrackList', track.referencedTracks, track),
 
     sampledTracksList:
-      relation('generateTrackList', track.sampledTracks),
+      relation('generateTrackList', track.sampledTracks, track),
 
     referencedByTracksList:
       relation('generateTrackListDividedByGroups',
-        query.mainReleaseTrack.referencedByTracks),
+        query.mainReleaseTrack.referencedByTracks,
+        track),
 
     sampledByTracksList:
       relation('generateTrackListDividedByGroups',
-        query.mainReleaseTrack.sampledByTracks),
+        query.mainReleaseTrack.sampledByTracks,
+        track),
 
     flashesThatFeatureList:
       relation('generateTrackInfoPageFeaturedByFlashesList', track),
@@ -119,12 +135,21 @@ export default {
         .map(entry => relation('generateCommentaryEntry', entry)),
   }),
 
-  data: (_query, track) => ({
+  data: (query, track) => ({
     name:
       track.name,
 
     color:
       track.color,
+
+    dateAlbumAddedToWiki:
+      track.album.dateAddedToWiki,
+
+    singleTrackSingle:
+      query.singleTrackSingle,
+
+    firstTrackInSingle:
+      query.firstTrackInSingle,
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -320,6 +345,22 @@ export default {
             relations.flashesThatFeatureList,
           ]),
 
+          data.firstTrackInSingle &&
+            html.tag('p',
+              {[html.onlyIfContent]: true},
+
+              language.$('releaseInfo.addedToWiki', {
+                [language.onlyIfOptions]: ['date'],
+                date: language.formatDate(data.dateAlbumAddedToWiki),
+              })),
+
+          data.firstTrackInSingle &&
+          (!html.isBlank(relations.lyricsSection) ||
+           !html.isBlank(relations.artistCommentaryEntries) ||
+           !html.isBlank(relations.creditingSourceEntries) ||
+           !html.isBlank(relations.referencingSourceEntries)) &&
+            html.tag('hr', {class: 'main-separator'}),
+
           relations.lyricsSection,
 
           html.tags([
@@ -376,17 +417,28 @@ export default {
         ],
 
         navLinkStyle: 'hierarchical',
-        navLinks: html.resolve(relations.navLinks),
+        navLinks:
+          (data.singleTrackSingle
+            ? [
+                {auto: 'home'},
+                {
+                  html: relations.albumNavLink,
+                  accent: language.$(pageCapsule, 'nav.singleAccent'),
+                },
+              ]
+            : html.resolve(relations.navLinks)),
 
         navBottomRowContent:
-          relations.albumNavAccent.slots({
-            showTrackNavigation: true,
-            showExtraLinks: false,
-          }),
+          (data.singleTrackSingle
+            ? null
+            : relations.albumNavAccent.slots({
+                showTrackNavigation: true,
+                showExtraLinks: false,
+              })),
 
         secondaryNav:
           relations.secondaryNav
-            .slot('mode', 'track'),
+            .slot('mode', data.singleTrackSingle ? 'album' : 'track'),
 
         leftSidebar: relations.sidebar,
 
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index 53a32536..f3ada092 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -2,9 +2,16 @@ export default {
   contentDependencies: ['generateTrackListItem'],
   extraDependencies: ['html'],
 
-  relations: (relation, tracks) => ({
+  query: (tracks, contextTrack) => ({
+    presentedTracks:
+      tracks.map(track =>
+        track.otherReleases.find(({album}) => album === contextTrack.album) ??
+        track),
+  }),
+
+  relations: (relation, query, _tracks, _contextTrack) => ({
     items:
-      tracks
+      query.presentedTracks
         .map(track => relation('generateTrackListItem', track, [])),
   }),
 
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
index 230868d6..9deccc0c 100644
--- a/src/content/dependencies/generateTrackListDividedByGroups.js
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -14,7 +14,7 @@ export default {
       wikiInfo.divideTrackListsByGroups,
   }),
 
-  query(sprawl, tracks) {
+  query(sprawl, tracks, _contextTrack) {
     const dividingGroups = sprawl.divideTrackListsByGroups;
 
     const groupings = new Map();
@@ -50,10 +50,10 @@ export default {
     return {groups, groupedTracks, ungroupedTracks};
   },
 
-  relations: (relation, query, sprawl, tracks) => ({
+  relations: (relation, query, sprawl, tracks, contextTrack) => ({
     flatList:
       (empty(sprawl.divideTrackListsByGroups)
-        ? relation('generateTrackList', tracks)
+        ? relation('generateTrackList', tracks, contextTrack)
         : null),
 
     contentHeading:
@@ -65,12 +65,12 @@ export default {
 
     groupedTrackLists:
       query.groupedTracks
-        .map(tracks => relation('generateTrackList', tracks)),
+        .map(tracks => relation('generateTrackList', tracks, contextTrack)),
 
     ungroupedTrackList:
       (empty(query.ungroupedTracks)
         ? null
-        : relation('generateTrackList', query.ungroupedTracks)),
+        : relation('generateTrackList', query.ungroupedTracks, contextTrack)),
   }),
 
   data: (query, _sprawl, _tracks) => ({
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
index 3c850a18..5678e240 100644
--- a/src/content/dependencies/generateTrackListItem.js
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -97,9 +97,7 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                // TODO: This is obviously evil.
-                html.metatag('chunkwrap', {split: /,| (?=and)/},
-                  html.resolve(relations.credit)));
+                relations.credit);
           }
 
           return language.$(workingCapsule, workingOptions);
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 54e462c7..3298dcc4 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -1,9 +1,7 @@
-import {empty} from '#sugar';
-
 export default {
   contentDependencies: [
     'generateReleaseInfoContributionsLine',
-    'linkExternal',
+    'generateReleaseInfoListenLine',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -11,14 +9,11 @@ export default {
   relations(relation, track) {
     const relations = {};
 
-    relations.artistContributionLinks =
+    relations.artistContributionsLine =
       relation('generateReleaseInfoContributionsLine', track.artistContribs);
 
-    if (!empty(track.urls)) {
-      relations.externalLinks =
-        track.urls.map(url =>
-          relation('linkExternal', url));
-    }
+    relations.listenLine =
+      relation('generateReleaseInfoListenLine', track);
 
     return relations;
   },
@@ -48,7 +43,7 @@ export default {
           {[html.joinChildren]: html.tag('br')},
 
           [
-            relations.artistContributionLinks.slots({
+            relations.artistContributionsLine.slots({
               stringKey: capsule + '.by',
               featuringStringKey: capsule + '.by.featuring',
               chronologyKind: 'track',
@@ -66,17 +61,9 @@ export default {
           ]),
 
         html.tag('p',
-          language.encapsulate(capsule, 'listenOn', capsule =>
-            (relations.externalLinks
-              ? language.$(capsule, {
-                  links:
-                    language.formatDisjunctionList(
-                      relations.externalLinks
-                        .map(link => link.slot('context', 'track'))),
-                })
-              : language.$(capsule, 'noLinks', {
-                  name:
-                    html.tag('i', data.name),
-                })))),
+          relations.listenLine.slots({
+            visibleWithoutLinks: true,
+            context: ['track'],
+          })),
       ])),
 };
diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js
index 36b0d13a..cdfe65d3 100644
--- a/src/content/dependencies/linkAlbum.js
+++ b/src/content/dependencies/linkAlbum.js
@@ -1,8 +1,12 @@
 export default {
-  contentDependencies: ['linkThing'],
+  contentDependencies: ['linkThing', 'linkTrack'],
 
-  relations: (relation, album) =>
-    ({link: relation('linkThing', 'localized.album', album)}),
+  relations: (relation, album) => ({
+    link:
+      (album.style === 'single'
+        ? relation('linkTrack', album.tracks[0])
+        : relation('linkThing', 'localized.album', album)),
+  }),
 
   generate: (relations) => relations.link,
 };
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index c658d461..1db0373b 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -30,7 +30,7 @@ export default {
 
     trimAnnotation: {type: 'boolean', default: false},
 
-    preventWrapping: {type: 'boolean', default: true},
+    preventWrapping: {type: 'boolean', default: false},
     preventTooltip: {type: 'boolean', default: false},
 
     chronologyKind: {type: 'string'},
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 69ecf5a4..e9a75744 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -1,5 +1,6 @@
 import {basename} from 'node:path';
 
+import {logWarn} from '#cli';
 import {bindFind} from '#find';
 import {replacerSpec, parseContentNodes} from '#replacer';
 
@@ -62,20 +63,30 @@ export default {
       Object.values(replacerSpec)
         .map(description => description.link)
         .filter(Boolean)),
+
     'image',
     'generateTextWithTooltip',
     'generateTooltip',
     'linkExternal',
   ],
 
-  extraDependencies: ['html', 'language', 'to', 'wikiData'],
+  extraDependencies: [
+    'html',
+    'language',
+    'niceShowAggregate',
+    'to',
+    'wikiData',
+  ],
 
   sprawl(wikiData, content) {
-    const find = bindFind(wikiData);
+    const find = bindFind(wikiData, {mode: 'quiet'});
 
-    const parsedNodes = parseContentNodes(content ?? '');
+    const {result: parsedNodes, error} =
+      parseContentNodes(content ?? '', {errorMode: 'return'});
 
     return {
+      error,
+
       nodes: parsedNodes
         .map(node => {
           if (node.type !== 'tag') {
@@ -189,6 +200,9 @@ export default {
     return {
       content,
 
+      error:
+        sprawl.error,
+
       nodes:
         sprawl.nodes
           .map(node => {
@@ -301,7 +315,12 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {html, language, to}) {
+  generate(data, relations, slots, {html, language, niceShowAggregate, to}) {
+    if (data.error) {
+      logWarn`Error in content text.`;
+      niceShowAggregate(data.error);
+    }
+
     let imageIndex = 0;
     let internalLinkIndex = 0;
     let externalLinkIndex = 0;
@@ -360,9 +379,8 @@ export default {
                   height && {height},
                   style && {style},
 
-                  align === 'center' &&
-                  !link &&
-                    {class: 'align-center'},
+                  align && !link &&
+                    {class: 'align-' + align},
 
                   pixelate &&
                     {class: 'pixelate'});
@@ -373,8 +391,8 @@ export default {
                     {href: link},
                     {target: '_blank'},
 
-                    align === 'center' &&
-                      {class: 'align-center'},
+                    align &&
+                      {class: 'align-' + align},
 
                     {title:
                       language.encapsulate('misc.external.opensInNewTab', capsule =>
@@ -424,8 +442,8 @@ export default {
               inline: false,
               data:
                 html.tag('div', {class: 'content-image-container'},
-                  align === 'center' &&
-                    {class: 'align-center'},
+                  align &&
+                    {class: 'align-' + align},
 
                   image),
             };
@@ -437,22 +455,31 @@ export default {
                 ? to('media.path', node.src.slice('media/'.length))
                 : node.src);
 
-            const {width, height, align, pixelate} = node;
+            const {width, height, align, inline, pixelate} = node;
 
-            const content =
-              html.tag('div', {class: 'content-video-container'},
-                align === 'center' &&
-                  {class: 'align-center'},
+            const video =
+              html.tag('video',
+                src && {src},
+                width && {width},
+                height && {height},
 
-                html.tag('video',
-                  src && {src},
-                  width && {width},
-                  height && {height},
+                {controls: true},
 
-                  {controls: true},
+                align && inline &&
+                  {class: 'align-' + align},
+
+                pixelate &&
+                  {class: 'pixelate'});
+
+            const content =
+              (inline
+                ? video
+                : html.tag('div', {class: 'content-video-container'},
+                    align &&
+                      {class: 'align-' + align},
+
+                    video));
 
-                  pixelate &&
-                    {class: 'pixelate'}));
 
             return {
               type: 'processed-video',
@@ -466,15 +493,14 @@ export default {
                 ? to('media.path', node.src.slice('media/'.length))
                 : node.src);
 
-            const {align, inline} = node;
+            const {align, inline, nameless} = node;
 
             const audio =
               html.tag('audio',
                 src && {src},
 
-                align === 'center' &&
-                inline &&
-                  {class: 'align-center'},
+                align && inline &&
+                  {class: 'align-' + align},
 
                 {controls: true});
 
@@ -482,13 +508,14 @@ export default {
               (inline
                 ? audio
                 : html.tag('div', {class: 'content-audio-container'},
-                    align === 'center' &&
-                      {class: 'align-center'},
+                    align &&
+                      {class: 'align-' + align},
 
                     [
-                      html.tag('a', {class: 'filename'},
-                        src && {href: src},
-                        language.sanitize(basename(node.src))),
+                      !nameless &&
+                        html.tag('a', {class: 'filename'},
+                          src && {href: src},
+                          language.sanitize(basename(node.src))),
 
                       audio,
                     ]));
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
index dfc6864f..de1d37c3 100644
--- a/src/data/composite/things/album/index.js
+++ b/src/data/composite/things/album/index.js
@@ -1,2 +1,2 @@
-export {default as withHasCoverArt} from './withHasCoverArt.js';
+export {default as withCoverArtDate} from './withCoverArtDate.js';
 export {default as withTracks} from './withTracks.js';
diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/things/album/withCoverArtDate.js
index a114d5ff..978f566a 100644
--- a/src/data/composite/wiki-data/withCoverArtDate.js
+++ b/src/data/composite/things/album/withCoverArtDate.js
@@ -2,8 +2,7 @@ import {input, templateCompositeFrom} from '#composite';
 import {isDate} from '#validators';
 
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
-
-import withResolvedContribs from './withResolvedContribs.js';
+import {withHasArtwork} from '#composite/wiki-data';
 
 export default templateCompositeFrom({
   annotation: `withCoverArtDate`,
@@ -19,14 +18,14 @@ export default templateCompositeFrom({
   outputs: ['#coverArtDate'],
 
   steps: () => [
-    withResolvedContribs({
-      from: 'coverArtistContribs',
-      date: input.value(null),
+    withHasArtwork({
+      contribs: 'coverArtistContribs',
+      artworks: 'coverArtworks',
     }),
 
     raiseOutputWithoutDependency({
-      dependency: '#resolvedContribs',
-      mode: input.value('empty'),
+      dependency: '#hasArtwork',
+      mode: input.value('falsy'),
       output: input.value({'#coverArtDate': null}),
     }),
 
diff --git a/src/data/composite/wiki-data/exitWithoutArtwork.js b/src/data/composite/wiki-data/exitWithoutArtwork.js
new file mode 100644
index 00000000..8e799fda
--- /dev/null
+++ b/src/data/composite/wiki-data/exitWithoutArtwork.js
@@ -0,0 +1,45 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList, isThing, strictArrayOf} from '#validators';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import withHasArtwork from './withHasArtwork.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutArtwork`,
+
+  inputs: {
+    contribs: input({
+      validate: isContributionList,
+      defaultValue: null,
+    }),
+
+    artwork: input({
+      validate: isThing,
+      defaultValue: null,
+    }),
+
+    artworks: input({
+      validate: strictArrayOf(isThing),
+      defaultValue: null,
+    }),
+
+    value: input({
+      defaultValue: null,
+    }),
+  },
+
+  steps: () => [
+    withHasArtwork({
+      contribs: input('contribs'),
+      artwork: input('artwork'),
+      artworks: input('artworks'),
+    }),
+
+    exitWithoutDependency({
+      dependency: '#hasArtwork',
+      mode: input.value('falsy'),
+      value: input('value'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index 38afc2ac..3206575b 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -5,6 +5,7 @@
 //
 
 export {default as exitWithoutContribs} from './exitWithoutContribs.js';
+export {default as exitWithoutArtwork} from './exitWithoutArtwork.js';
 export {default as gobbleSoupyFind} from './gobbleSoupyFind.js';
 export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js';
 export {default as inputNotFoundMode} from './inputNotFoundMode.js';
@@ -16,8 +17,8 @@ export {default as withClonedThings} from './withClonedThings.js';
 export {default as withConstitutedArtwork} from './withConstitutedArtwork.js';
 export {default as withContentNodes} from './withContentNodes.js';
 export {default as withContributionListSums} from './withContributionListSums.js';
-export {default as withCoverArtDate} from './withCoverArtDate.js';
 export {default as withDirectory} from './withDirectory.js';
+export {default as withHasArtwork} from './withHasArtwork.js';
 export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js';
 export {default as withRedatedContributionList} from './withRedatedContributionList.js';
 export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js';
diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/wiki-data/withHasArtwork.js
index fd3f2894..9c22f439 100644
--- a/src/data/composite/things/album/withHasCoverArt.js
+++ b/src/data/composite/wiki-data/withHasArtwork.js
@@ -1,7 +1,5 @@
-// TODO: This shouldn't be coded as an Album-specific thing,
-// or even really to do with cover artworks in particular, either.
-
 import {input, templateCompositeFrom} from '#composite';
+import {isContributionList, isThing, strictArrayOf} from '#validators';
 
 import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
   from '#composite/control-flow';
@@ -9,13 +7,30 @@ import {fillMissingListItems, withFlattenedList, withPropertyFromList}
   from '#composite/data';
 
 export default templateCompositeFrom({
-  annotation: 'withHasCoverArt',
+  annotation: 'withHasArtwork',
+
+  inputs: {
+    contribs: input({
+      validate: isContributionList,
+      defaultValue: null,
+    }),
+
+    artwork: input({
+      validate: isThing,
+      defaultValue: null,
+    }),
+
+    artworks: input({
+      validate: strictArrayOf(isThing),
+      defaultValue: null,
+    }),
+  },
 
-  outputs: ['#hasCoverArt'],
+  outputs: ['#hasArtwork'],
 
   steps: () => [
     withResultOfAvailabilityCheck({
-      from: 'coverArtistContribs',
+      from: input('contribs'),
       mode: input.value('empty'),
     }),
 
@@ -26,19 +41,37 @@ export default templateCompositeFrom({
       }) =>
         (availability
           ? continuation.raiseOutput({
-              ['#hasCoverArt']: true,
+              ['#hasArtwork']: true,
             })
           : continuation()),
     },
 
+    {
+      dependencies: [input('artwork'), input('artworks')],
+      compute: (continuation, {
+        [input('artwork')]: artwork,
+        [input('artworks')]: artworks,
+      }) =>
+        continuation({
+          ['#artworks']:
+            (artwork && artworks
+              ? [artwork, ...artworks]
+           : artwork
+              ? [artwork]
+           : artworks
+              ? artworks
+              : []),
+        }),
+    },
+
     raiseOutputWithoutDependency({
-      dependency: 'coverArtworks',
+      dependency: '#artworks',
       mode: input.value('empty'),
-      output: input.value({'#hasCoverArt': false}),
+      output: input.value({'#hasArtwork': false}),
     }),
 
     withPropertyFromList({
-      list: 'coverArtworks',
+      list: '#artworks',
       property: input.value('artistContribs'),
       internal: input.value(true),
     }),
@@ -46,19 +79,19 @@ export default templateCompositeFrom({
     // Since we're getting the update value for each artwork's artistContribs,
     // it may not be set at all, and in that case won't be exposing as [].
     fillMissingListItems({
-      list: '#coverArtworks.artistContribs',
+      list: '#artworks.artistContribs',
       fill: input.value([]),
     }),
 
     withFlattenedList({
-      list: '#coverArtworks.artistContribs',
+      list: '#artworks.artistContribs',
     }),
 
     withResultOfAvailabilityCheck({
       from: '#flattenedList',
       mode: input.value('empty'),
     }).outputs({
-      '#availability': '#hasCoverArt',
+      '#availability': '#hasArtwork',
     }),
   ],
 });
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 5132b962..a922e565 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -10,7 +10,7 @@ import {traverse} from '#node-utils';
 import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
 import {empty} from '#sugar';
 import Thing from '#thing';
-import {isColor, isDate, isDirectory, isNumber} from '#validators';
+import {is, isColor, isDate, isDirectory, isNumber} from '#validators';
 
 import {
   parseAdditionalFiles,
@@ -25,14 +25,18 @@ import {
   parseWallpaperParts,
 } from '#yaml';
 
-import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
-
-import {exitWithoutContribs, withDirectory, withCoverArtDate}
+import {exitWithoutArtwork, withDirectory, withHasArtwork}
   from '#composite/wiki-data';
 
 import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
   color,
   commentatorArtists,
   constitutibleArtwork,
@@ -58,7 +62,7 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withHasCoverArt, withTracks} from '#composite/things/album';
+import {withCoverArtDate, withTracks} from '#composite/things/album';
 import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
   from '#composite/things/track-section';
 
@@ -76,7 +80,13 @@ export class Album extends Thing {
     TrackSection,
     WikiInfo,
   }) => ({
-    // Update & expose
+    // > Update & expose - Internal relationships
+
+    trackSections: thingList({
+      class: input.value(TrackSection),
+    }),
+
+    // > Update & expose - Identifying metadata
 
     name: name('Unnamed Album'),
     directory: directory(),
@@ -97,22 +107,76 @@ export class Album extends Thing {
     alwaysReferenceTracksByDirectory: flag(false),
     suffixTrackDirectories: flag(false),
 
-    countTracksInArtistTotals: flag(true),
+    style: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(is(...[
+          'album',
+          'single',
+        ])),
+      }),
 
-    color: color(),
-    urls: urls(),
+      exposeConstant({
+        value: input.value('album'),
+      }),
+    ],
+
+    bandcampAlbumIdentifier: simpleString(),
+    bandcampArtworkIdentifier: simpleString(),
 
     additionalNames: thingList({
       class: input.value(AdditionalName),
     }),
 
-    bandcampAlbumIdentifier: simpleString(),
-    bandcampArtworkIdentifier: simpleString(),
-
     date: simpleDate(),
-    trackArtDate: simpleDate(),
     dateAddedToWiki: simpleDate(),
 
+    // > Update & expose - Credits and contributors
+
+    artistContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    // > Update & expose - General configuration
+
+    countTracksInArtistTotals: flag(true),
+
+    hasTrackNumbers: flag(true),
+    isListedOnHomepage: flag(true),
+    isListedInGalleries: flag(true),
+
+    // > Update & expose - General metadata
+
+    color: color(),
+
+    urls: urls(),
+
+    // > Update & expose - Artworks
+
+    coverArtworks: [
+      // This works, lol, because this array describes `expose.transform` for
+      // the coverArtworks property, and compositions generally access the
+      // update value, not what's exposed by property access out in the open.
+      // There's no recursion going on here.
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+    ],
+
+    coverArtistContribs: [
+      withCoverArtDate(),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumCoverArtistContributions'),
+      }),
+    ],
+
     coverArtDate: [
       withCoverArtDate({
         from: input.updateValue({
@@ -124,52 +188,61 @@ export class Album extends Thing {
     ],
 
     coverArtFileExtension: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
+
       fileExtension('jpg'),
     ],
 
-    trackCoverArtFileExtension: fileExtension('jpg'),
+    coverArtDimensions: [
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
 
-    wallpaperFileExtension: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      fileExtension('jpg'),
+      dimensions(),
     ],
 
-    bannerFileExtension: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      fileExtension('jpg'),
-    ],
+    artTags: [
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+        value: input.value([]),
+      }),
 
-    wallpaperStyle: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      simpleString(),
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
     ],
 
-    wallpaperParts: [
-      exitWithoutContribs({
-        contribs: 'wallpaperArtistContribs',
+    referencedArtworks: [
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
         value: input.value([]),
       }),
 
-      wallpaperParts(),
+      referencedArtworkList(),
     ],
 
-    bannerStyle: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      simpleString(),
-    ],
+    trackCoverArtistContribs: contributionList({
+      // May be null, indicating cover art was added for tracks on the date
+      // each track specifies, or else the track's own release date.
+      date: 'trackArtDate',
 
-    coverArtDimensions: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      dimensions(),
-    ],
+      // This is the "correct" value, but it gets overwritten - with the same
+      // value - regardless.
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
 
-    trackDimensions: dimensions(),
+    trackArtDate: simpleDate(),
 
-    bannerDimensions: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      dimensions(),
-    ],
+    trackCoverArtFileExtension: fileExtension('jpg'),
+
+    trackDimensions: dimensions(),
 
     wallpaperArtwork: [
       exitWithoutDependency({
@@ -182,117 +255,113 @@ export class Album extends Thing {
         .call(this, 'Wallpaper Artwork'),
     ],
 
-    bannerArtwork: [
-      exitWithoutDependency({
-        dependency: 'bannerArtistContribs',
-        mode: input.value('empty'),
-        value: input.value(null),
-      }),
+    wallpaperArtistContribs: [
+      withCoverArtDate(),
 
-      constitutibleArtwork.fromYAMLFieldSpec
-        .call(this, 'Banner Artwork'),
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumWallpaperArtistContributions'),
+      }),
     ],
 
-    coverArtworks: [
-      withHasCoverArt(),
-
-      exitWithoutDependency({
-        dependency: '#hasCoverArt',
-        mode: input.value('falsy'),
-        value: input.value([]),
+    wallpaperFileExtension: [
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
       }),
 
-      constitutibleArtworkList.fromYAMLFieldSpec
-        .call(this, 'Cover Artwork'),
+      fileExtension('jpg'),
     ],
 
-    hasTrackNumbers: flag(true),
-    isListedOnHomepage: flag(true),
-    isListedInGalleries: flag(true),
+    wallpaperStyle: [
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
+      }),
 
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
+      simpleString(),
+    ],
 
-    creditingSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
+    wallpaperParts: [
+      // kinda nonsensical or at least unlikely lol, but y'know
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
+        value: input.value([]),
+      }),
 
-    additionalFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
+      wallpaperParts(),
+    ],
 
-    trackSections: thingList({
-      class: input.value(TrackSection),
-    }),
+    bannerArtwork: [
+      exitWithoutDependency({
+        dependency: 'bannerArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
 
-    artistContribs: contributionList({
-      date: 'date',
-      artistProperty: input.value('albumArtistContributions'),
-    }),
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
+    ],
 
-    coverArtistContribs: [
+    bannerArtistContribs: [
       withCoverArtDate(),
 
       contributionList({
         date: '#coverArtDate',
-        artistProperty: input.value('albumCoverArtistContributions'),
+        artistProperty: input.value('albumBannerArtistContributions'),
       }),
     ],
 
-    trackCoverArtistContribs: contributionList({
-      // May be null, indicating cover art was added for tracks on the date
-      // each track specifies, or else the track's own release date.
-      date: 'trackArtDate',
-
-      // This is the "correct" value, but it gets overwritten - with the same
-      // value - regardless.
-      artistProperty: input.value('trackCoverArtistContributions'),
-    }),
+    bannerFileExtension: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
+      }),
 
-    wallpaperArtistContribs: [
-      withCoverArtDate(),
+      fileExtension('jpg'),
+    ],
 
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumWallpaperArtistContributions'),
+    bannerDimensions: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
       }),
-    ],
 
-    bannerArtistContribs: [
-      withCoverArtDate(),
+      dimensions(),
+    ],
 
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumBannerArtistContributions'),
+    bannerStyle: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
       }),
+
+      simpleString(),
     ],
 
+    // > Update & expose - Groups
+
     groups: referenceList({
       class: input.value(Group),
       find: soupyFind.input('group'),
     }),
 
-    artTags: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
+    // > Update & expose - Content entries
 
-      referenceList({
-        class: input.value(ArtTag),
-        find: soupyFind.input('artTag'),
-      }),
-    ],
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
 
-    referencedArtworks: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
+    creditingSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
 
-      referencedArtworkList(),
-    ],
+    // Additional files
+
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
     // Update only
 
@@ -314,8 +383,12 @@ export class Album extends Thing {
     commentatorArtists: commentatorArtists(),
 
     hasCoverArt: [
-      withHasCoverArt(),
-      exposeDependency({dependency: '#hasCoverArt'}),
+      withHasArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
+
+      exposeDependency({dependency: '#hasArtwork'}),
     ],
 
     hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
@@ -478,21 +551,15 @@ export class Album extends Thing {
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
-      'Album': {property: 'name'},
+      // Identifying metadata
 
+      'Album': {property: 'name'},
       'Directory': {property: 'directory'},
       'Directory Suffix': {property: 'directorySuffix'},
       'Suffix Track Directories': {property: 'suffixTrackDirectories'},
-
       'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
-      'Always Reference Tracks By Directory': {
-        property: 'alwaysReferenceTracksByDirectory',
-      },
-
-      'Additional Names': {
-        property: 'additionalNames',
-        transform: parseAdditionalNames,
-      },
+      'Always Reference Tracks By Directory': {property: 'alwaysReferenceTracksByDirectory'},
+      'Style': {property: 'style'},
 
       'Bandcamp Album ID': {
         property: 'bandcampAlbumIdentifier',
@@ -504,20 +571,46 @@ export class Album extends Thing {
         transform: String,
       },
 
-      'Count Tracks In Artist Totals': {property: 'countInArtistTotals'},
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
 
       'Date': {
         property: 'date',
         transform: parseDate,
       },
 
-      'Color': {property: 'color'},
-      'URLs': {property: 'urls'},
+      'Date Added': {
+        property: 'dateAddedToWiki',
+        transform: parseDate,
+      },
+
+      // Credits and contributors
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      // General configuration
+
+      'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'},
 
       'Has Track Numbers': {property: 'hasTrackNumbers'},
       'Listed on Homepage': {property: 'isListedOnHomepage'},
       'Listed in Galleries': {property: 'isListedInGalleries'},
 
+      // General metadata
+
+      'Color': {property: 'color'},
+
+      'URLs': {property: 'urls'},
+
+      // Artworks
+      //  (Note - this YAML section is deliberately ordered differently
+      //   than the corresponding property descriptors.)
+
       'Cover Artwork': {
         property: 'coverArtworks',
         transform:
@@ -561,27 +654,29 @@ export class Album extends Thing {
           }),
       },
 
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
+
       'Cover Art Date': {
         property: 'coverArtDate',
         transform: parseDate,
       },
 
-      'Default Track Cover Art Date': {
-        property: 'trackArtDate',
-        transform: parseDate,
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
       },
 
-      'Date Added': {
-        property: 'dateAddedToWiki',
-        transform: parseDate,
+      'Default Track Cover Artists': {
+        property: 'trackCoverArtistContribs',
+        transform: parseContributors,
       },
 
-      'Cover Art File Extension': {property: 'coverArtFileExtension'},
-      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
-
-      'Cover Art Dimensions': {
-        property: 'coverArtDimensions',
-        transform: parseDimensions,
+      'Default Track Cover Art Date': {
+        property: 'trackArtDate',
+        transform: parseDate,
       },
 
       'Default Track Dimensions': {
@@ -594,8 +689,6 @@ export class Album extends Thing {
         transform: parseContributors,
       },
 
-      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
-
       'Wallpaper Style': {property: 'wallpaperStyle'},
 
       'Wallpaper Parts': {
@@ -608,14 +701,31 @@ export class Album extends Thing {
         transform: parseContributors,
       },
 
-      'Banner Style': {property: 'bannerStyle'},
-      'Banner File Extension': {property: 'bannerFileExtension'},
-
       'Banner Dimensions': {
         property: 'bannerDimensions',
         transform: parseDimensions,
       },
 
+      'Banner Style': {property: 'bannerStyle'},
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
+      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
+      'Banner File Extension': {property: 'bannerFileExtension'},
+
+      'Art Tags': {property: 'artTags'},
+
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+
+      // Groups
+
+      'Groups': {property: 'groups'},
+
+      // Content entries
+
       'Commentary': {
         property: 'commentary',
         transform: parseCommentary,
@@ -626,36 +736,16 @@ export class Album extends Thing {
         transform: parseCreditingSources,
       },
 
+      // Additional files
+
       'Additional Files': {
         property: 'additionalFiles',
         transform: parseAdditionalFiles,
       },
 
-      'Referenced Artworks': {
-        property: 'referencedArtworks',
-        transform: parseAnnotatedReferences,
-      },
+      // Shenanigans
 
       'Franchises': {ignore: true},
-
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
-      },
-
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
-      },
-
-      'Default Track Cover Artists': {
-        property: 'trackCoverArtistContribs',
-        transform: parseContributors,
-      },
-
-      'Groups': {property: 'groups'},
-      'Art Tags': {property: 'artTags'},
-
       'Review Points': {ignore: true},
     },
 
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 8b9420c7..e652de52 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -95,7 +95,13 @@ export class Track extends Thing {
     ReferencingSourcesEntry,
     WikiInfo,
   }) => ({
-    // Update & expose
+    // > Update & expose - Internal relationships
+
+    album: thing({
+      class: input.value(Album),
+    }),
+
+    // > Update & expose - Identifying metadata
 
     name: name('Unnamed Track'),
 
@@ -129,47 +135,76 @@ export class Track extends Thing {
       })
     ],
 
-    album: thing({
-      class: input.value(Album),
-    }),
+    alwaysReferenceByDirectory: [
+      withAlwaysReferenceByDirectory(),
+      exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
+    ],
 
-    additionalNames: thingList({
-      class: input.value(AdditionalName),
+    mainReleaseTrack: singleReference({
+      class: input.value(Track),
+      find: soupyFind.input('track'),
     }),
 
     bandcampTrackIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
 
-    duration: duration(),
-    urls: urls(),
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
+
     dateFirstReleased: simpleDate(),
 
-    color: [
-      exposeUpdateValueOrContinue({
-        validate: input.value(isColor),
-      }),
+    // > Update & expose - Credits and contributors
 
-      withContainingTrackSection(),
+    artistContribs: [
+      inheritContributionListFromMainRelease(),
 
-      withPropertyFromObject({
-        object: '#trackSection',
-        property: input.value('color'),
+      withDate(),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
+        date: '#date',
+      }).outputs({
+        '#resolvedContribs': '#artistContribs',
       }),
 
-      exposeDependencyOrContinue({dependency: '#trackSection.color'}),
+      exposeDependencyOrContinue({
+        dependency: '#artistContribs',
+        mode: input.value('empty'),
+      }),
 
       withPropertyFromAlbum({
-        property: input.value('color'),
+        property: input.value('artistContribs'),
       }),
 
-      exposeDependency({dependency: '#album.color'}),
+      withRecontextualizedContributionList({
+        list: '#album.artistContribs',
+        artistProperty: input.value('trackArtistContributions'),
+      }),
+
+      withRedatedContributionList({
+        list: '#album.artistContribs',
+        date: '#date',
+      }),
+
+      exposeDependency({dependency: '#album.artistContribs'}),
     ],
 
-    alwaysReferenceByDirectory: [
-      withAlwaysReferenceByDirectory(),
-      exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
+    contributorContribs: [
+      inheritContributionListFromMainRelease(),
+
+      withDate(),
+
+      contributionList({
+        date: '#date',
+        artistProperty: input.value('trackContributorContributions'),
+      }),
     ],
 
+    // > Update & expose - General configuration
+
     countInArtistTotals: [
       exposeUpdateValueOrContinue({
         validate: input.value(isBoolean),
@@ -182,32 +217,54 @@ export class Track extends Thing {
       exposeDependency({dependency: '#album.countTracksInArtistTotals'}),
     ],
 
-    // Disables presenting the track as though it has its own unique artwork.
-    // This flag should only be used in select circumstances, i.e. to override
-    // an album's trackCoverArtists. This flag supercedes that property, as well
-    // as the track's own coverArtists.
     disableUniqueCoverArt: flag(),
 
-    // File extension for track's corresponding media file. This represents the
-    // track's unique cover artwork, if any, and does not inherit the extension
-    // of the album's main artwork. It does inherit trackCoverArtFileExtension,
-    // if present on the album.
-    coverArtFileExtension: [
-      exitWithoutUniqueCoverArt(),
+    // > Update & expose - General metadata
+
+    duration: duration(),
 
+    color: [
       exposeUpdateValueOrContinue({
-        validate: input.value(isFileExtension),
+        validate: input.value(isColor),
       }),
 
+      withContainingTrackSection(),
+
+      withPropertyFromObject({
+        object: '#trackSection',
+        property: input.value('color'),
+      }),
+
+      exposeDependencyOrContinue({dependency: '#trackSection.color'}),
+
       withPropertyFromAlbum({
-        property: input.value('trackCoverArtFileExtension'),
+        property: input.value('color'),
       }),
 
-      exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
+      exposeDependency({dependency: '#album.color'}),
+    ],
 
-      exposeConstant({
-        value: input.value('jpg'),
+    urls: urls(),
+
+    // > Update & expose - Artworks
+
+    trackArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Track Artwork'),
+    ],
+
+    coverArtistContribs: [
+      withCoverArtistContribs({
+        from: input.updateValue({
+          validate: isContributionList,
+        }),
       }),
+
+      exposeDependency({dependency: '#coverArtistContribs'}),
     ],
 
     coverArtDate: [
@@ -220,117 +277,59 @@ export class Track extends Thing {
       exposeDependency({dependency: '#trackArtDate'}),
     ],
 
-    coverArtDimensions: [
+    coverArtFileExtension: [
       exitWithoutUniqueCoverArt(),
 
-      exposeUpdateValueOrContinue(),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isFileExtension),
+      }),
 
       withPropertyFromAlbum({
-        property: input.value('trackDimensions'),
+        property: input.value('trackCoverArtFileExtension'),
       }),
 
-      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
-
-      dimensions(),
-    ],
-
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
-
-    creditingSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
-
-    referencingSources: thingList({
-      class: input.value(ReferencingSourcesEntry),
-    }),
-
-    lyrics: [
-      // TODO: Inherited lyrics are literally the same objects, so of course
-      // their .thing properties aren't going to point back to this one, and
-      // certainly couldn't be recontextualized...
-      inheritFromMainRelease(),
+      exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
 
-      thingList({
-        class: input.value(LyricsEntry),
+      exposeConstant({
+        value: input.value('jpg'),
       }),
     ],
 
-    additionalFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
-
-    sheetMusicFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
-
-    midiProjectFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
-
-    mainReleaseTrack: singleReference({
-      class: input.value(Track),
-      find: soupyFind.input('track'),
-    }),
-
-    artistContribs: [
-      inheritContributionListFromMainRelease(),
-
-      withDate(),
-
-      withResolvedContribs({
-        from: input.updateValue({validate: isContributionList}),
-        thingProperty: input.thisProperty(),
-        artistProperty: input.value('trackArtistContributions'),
-        date: '#date',
-      }).outputs({
-        '#resolvedContribs': '#artistContribs',
-      }),
+    coverArtDimensions: [
+      exitWithoutUniqueCoverArt(),
 
-      exposeDependencyOrContinue({
-        dependency: '#artistContribs',
-        mode: input.value('empty'),
-      }),
+      exposeUpdateValueOrContinue(),
 
       withPropertyFromAlbum({
-        property: input.value('artistContribs'),
-      }),
-
-      withRecontextualizedContributionList({
-        list: '#album.artistContribs',
-        artistProperty: input.value('trackArtistContributions'),
+        property: input.value('trackDimensions'),
       }),
 
-      withRedatedContributionList({
-        list: '#album.artistContribs',
-        date: '#date',
-      }),
+      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
 
-      exposeDependency({dependency: '#album.artistContribs'}),
+      dimensions(),
     ],
 
-    contributorContribs: [
-      inheritContributionListFromMainRelease(),
-
-      withDate(),
+    artTags: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
 
-      contributionList({
-        date: '#date',
-        artistProperty: input.value('trackContributorContributions'),
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
       }),
     ],
 
-    coverArtistContribs: [
-      withCoverArtistContribs({
-        from: input.updateValue({
-          validate: isContributionList,
-        }),
+    referencedArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
       }),
 
-      exposeDependency({dependency: '#coverArtistContribs'}),
+      referencedArtworkList(),
     ],
 
+    // > Update & expose - Referenced tracks
+
     referencedTracks: [
       inheritFromMainRelease({
         notFoundValue: input.value([]),
@@ -353,35 +352,46 @@ export class Track extends Thing {
       }),
     ],
 
-    trackArtworks: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
+    // > Update & expose - Additional files
 
-      constitutibleArtworkList.fromYAMLFieldSpec
-        .call(this, 'Track Artwork'),
-    ],
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
-    artTags: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
+    sheetMusicFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
-      referenceList({
-        class: input.value(ArtTag),
-        find: soupyFind.input('artTag'),
+    midiProjectFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    // > Update & expose - Content entries
+
+    lyrics: [
+      // TODO: Inherited lyrics are literally the same objects, so of course
+      // their .thing properties aren't going to point back to this one, and
+      // certainly couldn't be recontextualized...
+      inheritFromMainRelease(),
+
+      thingList({
+        class: input.value(LyricsEntry),
       }),
     ],
 
-    referencedArtworks: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
 
-      referencedArtworkList(),
-    ],
+    creditingSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
+
+    referencingSources: thingList({
+      class: input.value(ReferencingSourcesEntry),
+    }),
 
-    // Update only
+    // > Update only
 
     find: soupyFind(),
     reverse: soupyReverse(),
@@ -401,7 +411,7 @@ export class Track extends Thing {
       class: input.value(WikiInfo),
     }),
 
-    // Expose only
+    // > Expose only
 
     commentatorArtists: commentatorArtists(),
 
@@ -478,14 +488,13 @@ export class Track extends Thing {
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
+      // Identifying metadata
+
       'Track': {property: 'name'},
       'Directory': {property: 'directory'},
       'Suffix Directory': {property: 'suffixDirectoryFromAlbum'},
-
-      'Additional Names': {
-        property: 'additionalNames',
-        transform: parseAdditionalNames,
-      },
+      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+      'Main Release': {property: 'mainReleaseTrack'},
 
       'Bandcamp Track ID': {
         property: 'bandcampTrackIdentifier',
@@ -497,19 +506,71 @@ export class Track extends Thing {
         transform: String,
       },
 
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Date First Released': {
+        property: 'dateFirstReleased',
+        transform: parseDate,
+      },
+
+      // Credits and contributors
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
+
+      // General configuration
+
       'Count In Artist Totals': {property: 'countInArtistTotals'},
 
+      'Has Cover Art': {
+        property: 'disableUniqueCoverArt',
+        transform: value =>
+          (typeof value === 'boolean'
+            ? !value
+            : value),
+      },
+
+      // General metadata
+
       'Duration': {
         property: 'duration',
         transform: parseDuration,
       },
 
       'Color': {property: 'color'},
+
       'URLs': {property: 'urls'},
 
-      'Date First Released': {
-        property: 'dateFirstReleased',
-        transform: parseDate,
+      // Artworks
+
+      'Track Artwork': {
+        property: 'trackArtworks',
+        transform:
+          parseArtwork({
+            thingProperty: 'trackArtworks',
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'trackCoverArtistContributions',
+          }),
+      },
+
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
       },
 
       'Cover Art Date': {
@@ -524,35 +585,19 @@ export class Track extends Thing {
         transform: parseDimensions,
       },
 
-      'Has Cover Art': {
-        property: 'disableUniqueCoverArt',
-        transform: value =>
-          (typeof value === 'boolean'
-            ? !value
-            : value),
-      },
-
-      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+      'Art Tags': {property: 'artTags'},
 
-      'Lyrics': {
-        property: 'lyrics',
-        transform: parseLyrics,
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
       },
 
-      'Commentary': {
-        property: 'commentary',
-        transform: parseCommentary,
-      },
+      // Referenced tracks
 
-      'Crediting Sources': {
-        property: 'creditingSources',
-        transform: parseCreditingSources,
-      },
+      'Referenced Tracks': {property: 'referencedTracks'},
+      'Sampled Tracks': {property: 'sampledTracks'},
 
-      'Referencing Sources': {
-        property: 'referencingSources',
-        transform: parseReferencingSources,
-      },
+      // Additional files
 
       'Additional Files': {
         property: 'additionalFiles',
@@ -569,50 +614,32 @@ export class Track extends Thing {
         transform: parseAdditionalFiles,
       },
 
-      'Main Release': {property: 'mainReleaseTrack'},
-      'Referenced Tracks': {property: 'referencedTracks'},
-      'Sampled Tracks': {property: 'sampledTracks'},
-
-      'Referenced Artworks': {
-        property: 'referencedArtworks',
-        transform: parseAnnotatedReferences,
-      },
-
-      'Franchises': {ignore: true},
-      'Inherit Franchises': {ignore: true},
+      // Content entries
 
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
+      'Lyrics': {
+        property: 'lyrics',
+        transform: parseLyrics,
       },
 
-      'Contributors': {
-        property: 'contributorContribs',
-        transform: parseContributors,
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
       },
 
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
       },
 
-      'Track Artwork': {
-        property: 'trackArtworks',
-        transform:
-          parseArtwork({
-            thingProperty: 'trackArtworks',
-            dimensionsFromThingProperty: 'coverArtDimensions',
-            fileExtensionFromThingProperty: 'coverArtFileExtension',
-            dateFromThingProperty: 'coverArtDate',
-            artTagsFromThingProperty: 'artTags',
-            referencedArtworksFromThingProperty: 'referencedArtworks',
-            artistContribsFromThingProperty: 'coverArtistContribs',
-            artistContribsArtistProperty: 'trackCoverArtistContributions',
-          }),
+      'Referencing Sources': {
+        property: 'referencingSources',
+        transform: parseReferencingSources,
       },
 
-      'Art Tags': {property: 'artTags'},
+      // Shenanigans
 
+      'Franchises': {ignore: true},
+      'Inherit Franchises': {ignore: true},
       'Review Points': {ignore: true},
     },
 
diff --git a/src/external-links.js b/src/external-links.js
index d0583b73..ab1555bd 100644
--- a/src/external-links.js
+++ b/src/external-links.js
@@ -30,6 +30,9 @@ export const externalLinkContexts = [
   'generic',
   'group',
   'track',
+
+  'composerRelease',
+  'officialRelease',
 ];
 
 export const isExternalLinkContext =
@@ -255,6 +258,30 @@ export const externalLinkSpec = [
   },
 
   {
+    match: {
+      domain: '.bandcamp.com',
+      context: 'composerRelease',
+    },
+
+    platform: 'bandcamp.composerRelease',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'bandcamp',
+  },
+
+  {
+    match: {
+      domain: '.bandcamp.com',
+      context: 'officialRelease',
+    },
+
+    platform: 'bandcamp.officialRelease',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'bandcamp',
+  },
+
+  {
     match: {domain: '.bandcamp.com'},
 
     platform: 'bandcamp',
diff --git a/src/find.js b/src/find.js
index d5ef400d..8f2170d4 100644
--- a/src/find.js
+++ b/src/find.js
@@ -391,7 +391,13 @@ function findMixedHelper(config) {
           });
         }
 
-        return byDirectory[referenceType][directory];
+        const match = byDirectory[referenceType][directory];
+
+        if (match) {
+          return match.thing;
+        } else {
+          return null;
+        }
       },
 
       matchByName:
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 7d4bf059..40505189 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -555,42 +555,56 @@ async function determineThumbtacksNeededForFile({
   return mismatchedWithinRightSize;
 }
 
-async function generateImageThumbnail(imagePath, thumbtack, {
+// Write all requested thumbtacks for a source image in one pass
+// This saves a lot of disk reads which are probably the main bottleneck
+function prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks) {
+  const args = [filePathInMedia, '-strip'];
+
+  const basename =
+    path.basename(filePathInMedia, path.extname(filePathInMedia));
+
+  // do larger sizes first
+  thumbtacks.sort((a, b) => thumbnailSpec[b].size - thumbnailSpec[a].size);
+
+  for (const tack of thumbtacks) {
+    const {size, quality} = thumbnailSpec[tack];
+    const filename = `${basename}.${tack}.jpg`;
+    const filePathInCache = path.join(dirnameInCache, filename);
+    args.push(
+      '(', '+clone',
+      '-resize', `${size}x${size}>`,
+      '-interlace', 'Plane',
+      '-quality', `${quality}%`,
+      '-write', filePathInCache,
+      '+delete', ')',
+    );
+  }
+
+  // throw away the (already written) image stream
+  args.push('null:');
+
+  return args;
+}
+
+async function generateImageThumbnails(imagePath, thumbtacks, {
   mediaPath,
   mediaCachePath,
   spawnConvert,
 }) {
+  if (empty(thumbtacks)) return;
+
   const filePathInMedia =
     path.join(mediaPath, imagePath);
 
   const dirnameInCache =
     path.join(mediaCachePath, path.dirname(imagePath));
 
-  const filename =
-    path.basename(imagePath, path.extname(imagePath)) +
-    `.${thumbtack}.jpg`;
-
-  const filePathInCache =
-    path.join(dirnameInCache, filename);
-
   await mkdir(dirnameInCache, {recursive: true});
 
-  const specEntry = thumbnailSpec[thumbtack];
-  const {size, quality} = specEntry;
-
-  const convertProcess = spawnConvert([
-    filePathInMedia,
-    '-strip',
-    '-resize',
-    `${size}x${size}>`,
-    '-interlace',
-    'Plane',
-    '-quality',
-    `${quality}%`,
-    filePathInCache,
-  ]);
-
-  await promisifyProcess(convertProcess, false);
+  const convertArgs =
+    prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks);
+
+  await promisifyProcess(spawnConvert(convertArgs), false);
 }
 
 export async function determineMediaCachePath({
@@ -1099,33 +1113,23 @@ export default async function genThumbs({
   const writeMessageFn = () =>
     `Writing image thumbnails. [failed: ${numFailed}]`;
 
-  const generateCallImageIndices =
-    imageThumbtacksNeeded
-      .flatMap(({length}, index) =>
-        Array.from({length}, () => index));
-
-  const generateCallImagePaths =
-    generateCallImageIndices
-      .map(index => imagePaths[index]);
-
-  const generateCallThumbtacks =
-    imageThumbtacksNeeded.flat();
-
   const generateCallFns =
     stitchArrays({
-      imagePath: generateCallImagePaths,
-      thumbtack: generateCallThumbtacks,
-    }).map(({imagePath, thumbtack}) => () =>
-        generateImageThumbnail(imagePath, thumbtack, {
+      imagePath: imagePaths,
+      thumbtacks: imageThumbtacksNeeded,
+    }).map(({imagePath, thumbtacks}) => () =>
+        generateImageThumbnails(imagePath, thumbtacks, {
           mediaPath,
           mediaCachePath,
           spawnConvert,
         }).catch(error => {
             numFailed++;
-            return ({error});
+            return {error};
           }));
 
-  logInfo`Generating ${generateCallFns.length} thumbnails for ${imagePaths.length} media files.`;
+  const totalThumbs = imageThumbtacksNeeded.reduce((sum, tacks) => sum + tacks.length, 0);
+
+  logInfo`Generating ${totalThumbs} thumbnails for ${imagePaths.length} media files.`;
   if (generateCallFns.length > 500) {
     logInfo`Go get a latte - this could take a while!`;
   }
@@ -1134,37 +1138,30 @@ export default async function genThumbs({
     await progressPromiseAll(writeMessageFn,
       queue(generateCallFns, magickThreads));
 
-  let successfulIndices;
+  let successfulPaths;
 
   {
-    const erroredIndices = generateCallImageIndices.slice();
-    const erroredPaths = generateCallImagePaths.slice();
-    const erroredThumbtacks = generateCallThumbtacks.slice();
+    const erroredPaths = imagePaths.slice();
     const errors = generateCallResults.map(result => result?.error);
 
     const {removed} =
       filterMultipleArrays(
-        erroredIndices,
         erroredPaths,
-        erroredThumbtacks,
         errors,
-        (_index, _imagePath, _thumbtack, error) => error);
+        (_imagePath, error) => error);
 
-    successfulIndices = new Set(removed[0]);
-
-    const chunks =
-      chunkMultipleArrays(erroredPaths, erroredThumbtacks, errors,
-        (imagePath, lastImagePath) => imagePath !== lastImagePath);
+    ([successfulPaths] = removed);
 
     // TODO: This should obviously be an aggregate error.
     // ...Just like every other error report here, and those dang aggregates
     // should be constructable from within the queue, rather than after.
-    for (const [[imagePath], thumbtacks, errors] of chunks) {
-      logError`Failed to generate thumbnails for ${imagePath}:`;
-      for (const {thumbtack, error} of stitchArrays({thumbtack: thumbtacks, error: errors})) {
-        logError`- ${thumbtack}: ${error}`;
-      }
-    }
+    stitchArrays({
+      imagePath: erroredPaths,
+      error: errors,
+    }).forEach(({imagePath, error}) => {
+        logError`Failed to generate thumbnails for ${imagePath}:`;
+        logError`- ${error}`;
+      });
 
     if (empty(errors)) {
       logInfo`All needed thumbnails generated successfully - nice!`;
@@ -1178,8 +1175,8 @@ export default async function genThumbs({
     imagePaths,
     imageThumbtacksNeeded,
     imageDimensions,
-    (_imagePath, _thumbtacksNeeded, _dimensions, index) =>
-      successfulIndices.has(index));
+    (imagePath, _thumbtacksNeeded, _dimensions) =>
+      successfulPaths.includes(imagePath));
 
   for (const {
     imagePath,
diff --git a/src/html.js b/src/html.js
index 3bec4269..dd1d1960 100644
--- a/src/html.js
+++ b/src/html.js
@@ -53,6 +53,17 @@ export const attributeSpec = {
   },
 };
 
+let disabledSlotValidation = false;
+let disabledTagTracing = false;
+
+export function disableSlotValidation() {
+  disabledSlotValidation = true;
+}
+
+export function disableTagTracing() {
+  disabledTagTracing = true;
+}
+
 // Pass to tag() as an attributes key to make tag() return a 8lank tag if the
 // provided content is empty. Useful for when you'll only 8e showing an element
 // according to the presence of content that would 8elong there.
@@ -356,8 +367,10 @@ export class Tag {
     this.attributes = attributes;
     this.content = content;
 
-    this.#traceError = new Error();
-  }
+    if (!disabledTagTracing) {
+      this.#traceError = new Error();
+    }
+}
 
   clone() {
     return Reflect.construct(this.constructor, [
@@ -710,17 +723,19 @@ export class Tag {
             `of ${inspect(this, {compact: true})}`,
             {cause: caughtError});
 
-        error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
-        error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
+        if (this.#traceError && !disabledTagTracing) {
+          error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
+          error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
 
-        error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
-          /content-function\.js/,
-          /util\/html\.js/,
-        ];
+          error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
+            /content-function\.js/,
+            /util\/html\.js/,
+          ];
 
-        error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
-          /content\/dependencies\/(.*\.js:.*(?=\)))/,
-        ];
+          error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
+            /content\/dependencies\/(.*\.js:.*(?=\)))/,
+          ];
+        }
 
         throw error;
       }
@@ -1135,6 +1150,34 @@ export class Attributes {
   }
 
   add(...args) {
+    // Very common case: add({class: 'foo', id: 'bar'})¡
+    // The argument is a plain object (no Template, no Attributes,
+    // no blessAttributes symbol). We can skip the expensive
+    // isAttributesAdditionSinglet() validation and flatten/array handling.
+    if (
+      args.length === 1 &&
+      args[0] &&
+      typeof args[0] === 'object' &&
+      !Array.isArray(args[0]) &&
+      !(args[0] instanceof Attributes) &&
+      !(args[0] instanceof Template) &&
+      !Object.hasOwn(args[0], blessAttributes)
+    ) {
+      const obj = args[0];
+
+      // Preserve existing merge semantics by funnelling each key through
+      // the internal #addOneAttribute helper (handles class/style union,
+      // unique merging, etc.) but avoid *per-object* validation overhead.
+      for (const key of Reflect.ownKeys(obj)) {
+        this.#addOneAttribute(key, obj[key]);
+      }
+
+      // Match the original return style (list of results) so callers that
+      // inspect the return continue to work.
+      return obj;
+    }
+
+    // Fall back to the original slow-but-thorough implementation
     switch (args.length) {
       case 1:
         isAttributesAdditionSinglet(args[0]);
@@ -1146,10 +1189,11 @@ export class Attributes {
 
       default:
         throw new Error(
-          `Expected array or object, or attribute and value`);
+          'Expected array or object, or attribute and value');
     }
   }
 
+
   with(...args) {
     const clone = this.clone();
     clone.add(...args);
@@ -1743,6 +1787,10 @@ export class Template {
   }
 
   static validateSlotValueAgainstDescription(value, description) {
+    if (disabledSlotValidation) {
+      return true;
+    }
+
     if (value === undefined) {
       throw new TypeError(`Specify value as null or don't specify at all`);
     }
diff --git a/src/page/album.js b/src/page/album.js
index 696e2854..e585618c 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -8,15 +8,22 @@ export function targets({wikiData}) {
 
 export function pathsForTarget(album) {
   return [
-    {
-      type: 'page',
-      path: ['album', album.directory],
-
-      contentFunction: {
-        name: 'generateAlbumInfoPage',
-        args: [album],
-      },
-    },
+    (album.style === 'single'
+      ? {
+          type: 'redirect',
+          fromPath: ['album', album.directory],
+          toPath: ['track', album.tracks[0].directory],
+          title: album.name,
+        }
+      : {
+          type: 'page',
+          path: ['album', album.directory],
+
+          contentFunction: {
+            name: 'generateAlbumInfoPage',
+            args: [album],
+          },
+        }),
 
     {
       type: 'page',
diff --git a/src/replacer.js b/src/replacer.js
index 0698eced..779ee78d 100644
--- a/src/replacer.js
+++ b/src/replacer.js
@@ -8,7 +8,7 @@
 import * as marked from 'marked';
 
 import * as html from '#html';
-import {escapeRegex, typeAppearance} from '#sugar';
+import {empty, escapeRegex, typeAppearance} from '#sugar';
 import {matchMarkdownLinks} from '#wiki-data';
 
 export const replacerSpec = {
@@ -464,7 +464,7 @@ export function squashBackslashes(text) {
   // a set of characters where the backslash carries meaning
   // into later formatting (i.e. markdown). Note that we do
   // NOT compress double backslashes into single backslashes.
-  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>-])/g, '$1');
+  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>.-])/g, '$1');
 }
 
 export function restoreRawHTMLTags(text) {
@@ -526,6 +526,7 @@ export function postprocessComments(inputNodes) {
 
 function postprocessHTMLTags(inputNodes, tagName, callback) {
   const outputNodes = [];
+  const errors = [];
 
   const lastNode = inputNodes.at(-1);
 
@@ -593,10 +594,16 @@ function postprocessHTMLTags(inputNodes, tagName, callback) {
           return false;
         })();
 
-        outputNodes.push(
-          callback(attributes, {
-            inline,
-          }));
+        try {
+          outputNodes.push(
+            callback(attributes, {
+              inline,
+            }));
+        } catch (caughtError) {
+          errors.push(new Error(
+            `Failed to process ${match[0]}`,
+            {cause: caughtError}));
+        }
 
         // No longer at the start of a line after the tag - there will at
         // least be text with only '\n' before the next of this tag that's
@@ -619,15 +626,33 @@ function postprocessHTMLTags(inputNodes, tagName, callback) {
     outputNodes.push(node);
   }
 
+  if (!empty(errors)) {
+    throw new AggregateError(
+      errors,
+    `Errors postprocessing <${tagName}> tags`);
+  }
+
   return outputNodes;
 }
 
+function complainAboutMediaSrc(src) {
+  if (!src) {
+    throw new Error(`Missing "src" attribute`);
+  }
+
+  if (src.startsWith('/media/')) {
+    throw new Error(`Start "src" with "media/", not "/media/"`);
+  }
+}
+
 export function postprocessImages(inputNodes) {
   return postprocessHTMLTags(inputNodes, 'img',
     (attributes, {inline}) => {
       const node = {type: 'image'};
 
       node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
+
       node.inline = attributes.get('inline') ?? inline;
 
       if (attributes.get('link')) node.link = attributes.get('link');
@@ -648,10 +673,13 @@ export function postprocessImages(inputNodes) {
 
 export function postprocessVideos(inputNodes) {
   return postprocessHTMLTags(inputNodes, 'video',
-    attributes => {
+    (attributes, {inline}) => {
       const node = {type: 'video'};
 
       node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
+
+      node.inline = attributes.get('inline') ?? inline;
 
       if (attributes.get('width')) node.width = parseInt(attributes.get('width'));
       if (attributes.get('height')) node.height = parseInt(attributes.get('height'));
@@ -668,8 +696,12 @@ export function postprocessAudios(inputNodes) {
       const node = {type: 'audio'};
 
       node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
+
       node.inline = attributes.get('inline') ?? inline;
+
       if (attributes.get('align')) node.align = attributes.get('align');
+      if (attributes.get('nameless')) node.nameless = true;
 
       return node;
     });
@@ -821,54 +853,108 @@ export function postprocessExternalLinks(inputNodes) {
   return outputNodes;
 }
 
-export function parseContentNodes(input) {
+export function parseContentNodes(input, {
+  errorMode = 'throw',
+} = {}) {
   if (typeof input !== 'string') {
     throw new TypeError(`Expected input to be string, got ${typeAppearance(input)}`);
   }
 
-  try {
-    let output = parseNodes(input, 0);
-    output = postprocessComments(output);
-    output = postprocessImages(output);
-    output = postprocessVideos(output);
-    output = postprocessAudios(output);
-    output = postprocessHeadings(output);
-    output = postprocessSummaries(output);
-    output = postprocessExternalLinks(output);
-    return output;
-  } catch (errorNode) {
-    if (errorNode.type !== 'error') {
-      throw errorNode;
-    }
+  let result = null, error = null;
 
-    const {
-      i,
-      data: {message},
-    } = errorNode;
+  process: {
+    try {
+      result = parseNodes(input, 0);
+    } catch (caughtError) {
+      if (caughtError.type === 'error') {
+        const {i, data: {message}} = caughtError;
 
-    let lineStart = input.slice(0, i).lastIndexOf('\n');
-    if (lineStart >= 0) {
-      lineStart += 1;
-    } else {
-      lineStart = 0;
+        let lineStart = input.slice(0, i).lastIndexOf('\n');
+        if (lineStart >= 0) {
+          lineStart += 1;
+        } else {
+          lineStart = 0;
+        }
+
+        let lineEnd = input.slice(i).indexOf('\n');
+        if (lineEnd >= 0) {
+          lineEnd += i;
+        } else {
+          lineEnd = input.length;
+        }
+
+        const line = input.slice(lineStart, lineEnd);
+
+        const cursor = i - lineStart;
+
+        error =
+          new SyntaxError(
+            `Parse error (at pos ${i}): ${message}\n` +
+            line + `\n` +
+            '-'.repeat(cursor) + '^');
+      } else {
+        error = caughtError;
+      }
+
+      // A parse error means there's no output to continue with at all,
+      // so stop here.
+      break process;
     }
 
-    let lineEnd = input.slice(i).indexOf('\n');
-    if (lineEnd >= 0) {
-      lineEnd += i;
-    } else {
-      lineEnd = input.length;
+    const postprocessErrors = [];
+
+    for (const postprocess of [
+      postprocessComments,
+      postprocessImages,
+      postprocessVideos,
+      postprocessAudios,
+      postprocessHeadings,
+      postprocessSummaries,
+      postprocessExternalLinks,
+    ]) {
+      try {
+        result = postprocess(result);
+      } catch (caughtError) {
+        const error =
+          new Error(
+            `Error in step ${`"${postprocess.name}"`}`,
+            {cause: caughtError});
+
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+        postprocessErrors.push(error);
+      }
     }
 
-    const line = input.slice(lineStart, lineEnd);
+    if (!empty(postprocessErrors)) {
+      error =
+        new AggregateError(
+          postprocessErrors,
+        `Errors postprocessing content text`);
 
-    const cursor = i - lineStart;
+      error[Symbol.for('hsmusic.aggregate.translucent')] = 'single';
+    }
+  }
+
+  if (errorMode === 'throw') {
+    if (error) {
+      throw error;
+    } else {
+      return result;
+    }
+  } else if (errorMode === 'return') {
+    if (!result) {
+      result = [{
+        i: 0,
+        iEnd: input.length,
+        type: 'text',
+        data: input,
+      }];
+    }
 
-    throw new SyntaxError([
-      `Parse error (at pos ${i}): ${message}`,
-      line,
-      '-'.repeat(cursor) + '^',
-    ].join('\n'));
+    return {error, result};
+  } else {
+    throw new Error(`Unknown errorMode ${errorMode}`);
   }
 }
 
diff --git a/src/static/css/site.css b/src/static/css/site.css
index fe16f5d2..e82c371e 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -61,7 +61,7 @@ body::before, .wallpaper-part {
 
 #page-container {
   max-width: 1100px;
-  margin: 0 auto 40px;
+  margin: 0 auto 38px;
   padding: 15px 0;
 }
 
@@ -76,10 +76,25 @@ body::before, .wallpaper-part {
   height: unset;
 }
 
+@property --banner-shine {
+  syntax: '<percentage>';
+  initial-value: 0%;
+  inherits: false;
+}
+
 #banner {
   margin: 10px 0;
   width: 100%;
   position: relative;
+
+  --banner-shine: 4%;
+  -webkit-box-reflect: below -12px linear-gradient(transparent, color-mix(in srgb, transparent, var(--banner-shine) white));
+  transition: --banner-shine 0.8s;
+}
+
+#banner:hover {
+  --banner-shine: 35%;
+  transition-delay: 0.3s;
 }
 
 #banner::after {
@@ -261,7 +276,11 @@ body::before, .wallpaper-part {
 #page-container {
   background-color: var(--bg-color, rgba(35, 35, 35, 0.8));
   color: #ffffff;
-  box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
+  border-bottom: 2px solid #fff1;
+  box-shadow:
+    0 0 40px #0008,
+    0 2px 15px -3px #2221,
+    0 2px 6px 2px #1113;
 }
 
 #skippers > * {
@@ -1821,6 +1840,29 @@ p.image-details.origin-details .origin-details {
   margin-top: 0.25em;
 }
 
+.lyrics-entry {
+  clip-path: inset(-15px -20px);
+}
+
+.lyrics-entry::after {
+  content: "";
+  pointer-events: none;
+  display: block;
+
+  /* Slight stretching past the bottom of the screen seems
+   * to make resizing the window (and "revealing" that area)
+   * a bit smoother.
+   */
+  position: fixed;
+  bottom: -20px;
+  left: 0;
+  right: 0;
+
+  height: calc(20px + min(90px, 13.5vh));
+  background: linear-gradient(to bottom, transparent, black 70%, black);
+  opacity: 0.6;
+}
+
 .js-hide,
 .js-show-once-data,
 .js-hide-once-data {
@@ -1840,12 +1882,20 @@ p.image-details.origin-details .origin-details {
   margin-bottom: 1.5em;
 }
 
-a.align-center, img.align-center, audio.align-center {
+.content-image-container.align-full {
+  width: 100%;
+}
+
+a.align-center, img.align-center, audio.align-center, video.align-center {
   display: block;
   margin-left: auto;
   margin-right: auto;
 }
 
+a.align-full, img.align-full, video.align-full {
+  width: 100%;
+}
+
 center {
   margin-top: 1em;
   margin-bottom: 1em;
@@ -2650,6 +2700,7 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las
 .content-video-container,
 .content-audio-container {
   width: fit-content;
+  max-width: 100%;
   background-color: var(--dark-color);
   border: 2px solid var(--primary-color);
   border-radius: 2.5px 2.5px 3px 3px;
@@ -2659,6 +2710,7 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las
 .content-video-container video,
 .content-audio-container audio {
   display: block;
+  max-width: 100%;
 }
 
 .content-video-container.align-center,
@@ -2667,6 +2719,11 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las
   margin-right: auto;
 }
 
+.content-video-container.align-full,
+.content-audio-container.align-full {
+  width: 100%;
+}
+
 .content-audio-container .filename {
   color: white;
   font-family: monospace;
@@ -2733,6 +2790,12 @@ img {
   object-fit: cover;
 }
 
+p > img {
+  max-width: 100%;
+  object-fit: contain;
+  height: auto;
+}
+
 .image-inner-area::after {
   content: "";
   display: block;
@@ -3394,15 +3457,12 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   grid-template-columns: 1fr min(40%, 90px);
 }
 
-.content-sticky-heading-root.has-cover {
-  padding-right: min(40%, 400px);
-}
-
 .content-sticky-heading-row h1 {
   position: relative;
   margin: 0;
   padding-right: 20px;
   line-height: 1.4;
+  overflow-x: hidden;
 }
 
 .content-sticky-heading-row h1 .reference-collapsed-heading {
@@ -3542,7 +3602,9 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 }
 
 #content, .sidebar {
-  contain: paint;
+  /* In the year of our pizza 2025, we try commenting this out.
+   */
+  /*contain: paint;*/
 }
 
 /* Sticky sidebar */
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index ec46e676..75d8f075 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -650,7 +650,12 @@ misc:
     amazonMusic: "Amazon Music"
     appleMusic: "Apple Music"
     artstation: "ArtStation"
-    bandcamp: "Bandcamp"
+
+    bandcamp:
+      _: "Bandcamp"
+
+      composerRelease: "Bandcamp (composer's release)"
+      officialRelease: "Bandcamp (official release)"
 
     bgreco:
       _: "bgreco.net"
@@ -2457,6 +2462,8 @@ trackPage:
 
     backToTrack: "Return to track page"
 
+    singleAccent: "single"
+
     track:
       _: "{TRACK}"
       withNumber: "{NUMBER}. {TRACK}"
diff --git a/src/upd8.js b/src/upd8.js
index 40a25dfb..ae072d5a 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -42,8 +42,9 @@ import wrap from 'word-wrap';
 
 import {mapAggregate, openAggregate, showAggregate} from '#aggregate';
 import CacheableObject from '#cacheable-object';
-import {stringifyCache} from '#cli';
+import {formatDuration, stringifyCache} from '#cli';
 import {displayCompositeCacheAnalysis} from '#composite';
+import * as html from '#html';
 import find, {bindFind, getAllFindSpecs} from '#find';
 import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile}
   from '#language';
@@ -518,6 +519,11 @@ async function main() {
       type: 'flag',
     },
 
+    'skip-self-diagnosis': {
+      help: `Disable some runtime validation for the wiki's own code, which speeds up long builds, but may allow unpredicted corner cases to fail strangely and silently`,
+      type: 'flag',
+    },
+
     'queue-size': {
       help: `Process more or fewer disk files at once to optimize performance or avoid I/O errors, unlimited if set to 0 (between 500 and 700 is usually a safe range for building HSMusic on Windows machines)\nDefaults to ${defaultQueueSize}`,
       type: 'value',
@@ -656,7 +662,8 @@ async function main() {
   const thumbsOnly = cliOptions['thumbs-only'] ?? false;
   const noInput = cliOptions['no-input'] ?? false;
 
-  const showAggregateTraces = cliOptions['show-traces'] ?? false;
+  const skipSelfDiagnosis = cliOptions['skip-self-diagnosis'] ?? false;
+  const showTraces = cliOptions['show-traces'] ?? false;
 
   const precacheMode = cliOptions['precache-mode'] ?? 'common';
 
@@ -1156,6 +1163,18 @@ async function main() {
     return false;
   }
 
+  if (skipSelfDiagnosis) {
+    logWarn`${'Skipping code self-diagnosis.'} (--skip-self-diagnosis provided)`;
+    logWarn`This build should run substantially faster, but corner cases`;
+    logWarn`not previously predicted may fail strangely and silently.`;
+
+    html.disableSlotValidation();
+  }
+
+  if (!showTraces) {
+    html.disableTagTracing();
+  }
+
   Object.assign(stepStatusSummary.determineMediaCachePath, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
@@ -1334,7 +1353,7 @@ async function main() {
 
   const niceShowAggregate = (error, ...opts) => {
     showAggregate(error, {
-      showTraces: showAggregateTraces,
+      showTraces,
       pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
       ...opts,
     });
@@ -3207,6 +3226,7 @@ async function main() {
     developersComment,
     languages,
     missingImagePaths,
+    niceShowAggregate,
     thumbsCache,
     urlSpec,
     urls,
@@ -3363,23 +3383,6 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus
   })();
 }
 
-function formatDuration(timeDelta) {
-  const seconds = timeDelta / 1000;
-
-  if (seconds > 90) {
-    const modSeconds = Math.floor(seconds % 60);
-    const minutes = Math.floor(seconds - seconds % 60) / 60;
-    return `${minutes}m${modSeconds}s`;
-  }
-
-  if (seconds < 0.1) {
-    return 'instant';
-  }
-
-  const precision = (seconds > 1 ? 3 : 2);
-  return `${seconds.toPrecision(precision)}s`;
-}
-
 function showStepStatusSummary() {
   const longestNameLength =
     Math.max(...
diff --git a/src/urls-default.yaml b/src/urls-default.yaml
index 74225efd..cbdd8a23 100644
--- a/src/urls-default.yaml
+++ b/src/urls-default.yaml
@@ -11,7 +11,7 @@ yamlAliases:
   # part of a build. This is so that multiple builds of a wiki can coexist
   # served from the same server / file system root: older builds' HTML files
   # refer to earlier values of STATIC_VERSION, avoiding name collisions.
-  - &staticVersion 5p1
+  - &staticVersion 5p2
 
 data:
   prefix: 'data/'
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index d55ab215..afbf8b2f 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -24,6 +24,7 @@ export function bindUtilities({
   language,
   languages,
   missingImagePaths,
+  niceShowAggregate,
   pagePath,
   pagePathStringFromRoot,
   thumbsCache,
@@ -42,6 +43,7 @@ export function bindUtilities({
     language,
     languages,
     missingImagePaths,
+    niceShowAggregate,
     pagePath,
     pagePathStringFromRoot,
     thumb,