« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/aggregate.js2
-rw-r--r--src/cli.js134
-rw-r--r--src/common-util/sugar.js112
-rw-r--r--src/common-util/wiki-data.js39
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js28
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js77
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunkItem.js30
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js96
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js13
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js8
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js39
-rw-r--r--src/content/dependencies/generateAlbumReferencedArtworksPage.js8
-rw-r--r--src/content/dependencies/generateAlbumReferencingArtworksPage.js8
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js38
-rw-r--r--src/content/dependencies/generateAlbumStyleRules.js107
-rw-r--r--src/content/dependencies/generateAlbumStyleTags.js65
-rw-r--r--src/content/dependencies/generateAlbumWallpaperStyleTag.js38
-rw-r--r--src/content/dependencies/generateArtistCredit.js26
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js137
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js44
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js45
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js108
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js15
-rw-r--r--src/content/dependencies/generateColorStyleRules.js42
-rw-r--r--src/content/dependencies/generateColorStyleTag.js51
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js18
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js6
-rw-r--r--src/content/dependencies/generateContentContentHeading.js39
-rw-r--r--src/content/dependencies/generateCoverArtwork.js39
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js58
-rw-r--r--src/content/dependencies/generateCoverCarousel.js2
-rw-r--r--src/content/dependencies/generateCoverGrid.js24
-rw-r--r--src/content/dependencies/generateExpandableGallerySection.js92
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js20
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js216
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumGrid.js66
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js39
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js26
-rw-r--r--src/content/dependencies/generateGroupGalleryPageSeriesSection.js156
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js3
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js22
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js51
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesChunk.js151
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js23
-rw-r--r--src/content/dependencies/generateLyricsEntry.js82
-rw-r--r--src/content/dependencies/generatePageLayout.js74
-rw-r--r--src/content/dependencies/generateReferencedArtworksPage.js4
-rw-r--r--src/content/dependencies/generateReferencingArtworksPage.js4
-rw-r--r--src/content/dependencies/generateReleaseInfoListenLine.js150
-rw-r--r--src/content/dependencies/generateStaticPage.js14
-rw-r--r--src/content/dependencies/generateStaticURLStyleTag.js23
-rw-r--r--src/content/dependencies/generateStyleTag.js48
-rw-r--r--src/content/dependencies/generateTrackArtistCommentarySection.js88
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js67
-rw-r--r--src/content/dependencies/generateTrackListItem.js3
-rw-r--r--src/content/dependencies/generateTrackReferencedArtworksPage.js8
-rw-r--r--src/content/dependencies/generateTrackReferencingArtworksPage.js8
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js31
-rw-r--r--src/content/dependencies/generateTrackSocialEmbed.js16
-rw-r--r--src/content/dependencies/generateWallpaperStyleTag.js80
-rw-r--r--src/content/dependencies/generateWikiWallpaperStyleTag.js38
-rw-r--r--src/content/dependencies/index.js9
-rw-r--r--src/content/dependencies/linkAdditionalFile.js29
-rw-r--r--src/content/dependencies/linkAlbumAdditionalFile.js24
-rw-r--r--src/content/dependencies/linkExternal.js11
-rw-r--r--src/content/dependencies/listAllAdditionalFilesTemplate.js197
-rw-r--r--src/content/dependencies/listArtistsByContributions.js52
-rw-r--r--src/content/dependencies/transformContent.js200
-rw-r--r--src/data/cacheable-object.js4
-rw-r--r--src/data/checks.js102
-rw-r--r--src/data/composite/control-flow/flipFilter.js36
-rw-r--r--src/data/composite/control-flow/index.js1
-rw-r--r--src/data/composite/control-flow/withAvailabilityFilter.js1
-rw-r--r--src/data/composite/data/index.js1
-rw-r--r--src/data/composite/data/withFilteredList.js16
-rw-r--r--src/data/composite/data/withLengthOfList.js54
-rw-r--r--src/data/composite/data/withNearbyItemFromList.js52
-rw-r--r--src/data/composite/things/artwork/index.js5
-rw-r--r--src/data/composite/things/artwork/withAttachedArtwork.js43
-rw-r--r--src/data/composite/things/artwork/withContribsFromAttachedArtwork.js27
-rw-r--r--src/data/composite/things/artwork/withContribsFromMainArtwork.js29
-rw-r--r--src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js65
-rw-r--r--src/data/composite/things/artwork/withPropertyFromMainArtwork.js100
-rw-r--r--src/data/composite/things/commentary-entry/index.js1
-rw-r--r--src/data/composite/things/content/contentArtists.js40
-rw-r--r--src/data/composite/things/content/hasAnnotationPart.js25
-rw-r--r--src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js60
-rw-r--r--src/data/composite/things/content/index.js7
-rw-r--r--src/data/composite/things/content/withAnnotationParts.js93
-rw-r--r--src/data/composite/things/content/withHasAnnotationPart.js43
-rw-r--r--src/data/composite/things/content/withSourceText.js53
-rw-r--r--src/data/composite/things/content/withSourceURLs.js62
-rw-r--r--src/data/composite/things/content/withWebArchiveDate.js (renamed from src/data/composite/things/commentary-entry/withWebArchiveDate.js)0
-rw-r--r--src/data/composite/things/contribution/thingPropertyMatches.js1
-rw-r--r--src/data/composite/things/track-section/withContinueCountingFrom.js2
-rw-r--r--src/data/composite/things/track/trackAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/withAllReleases.js1
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js1
-rw-r--r--src/data/composite/things/track/withOtherReleases.js3
-rw-r--r--src/data/composite/wiki-data/index.js3
-rw-r--r--src/data/composite/wiki-data/splitContentNodesAround.js87
-rw-r--r--src/data/composite/wiki-data/withConstitutedArtwork.js1
-rw-r--r--src/data/composite/wiki-data/withContentNodes.js25
-rw-r--r--src/data/composite/wiki-data/withResolvedSeriesList.js130
-rw-r--r--src/data/composite/wiki-properties/additionalFiles.js30
-rw-r--r--src/data/composite/wiki-properties/additionalNameList.js14
-rw-r--r--src/data/composite/wiki-properties/index.js3
-rw-r--r--src/data/composite/wiki-properties/referencedArtworkList.js1
-rw-r--r--src/data/composite/wiki-properties/seriesList.js31
-rw-r--r--src/data/thing.js4
-rw-r--r--src/data/things/additional-file.js47
-rw-r--r--src/data/things/additional-name.js21
-rw-r--r--src/data/things/album.js84
-rw-r--r--src/data/things/art-tag.js12
-rw-r--r--src/data/things/artist.js5
-rw-r--r--src/data/things/artwork.js91
-rw-r--r--src/data/things/content.js105
-rw-r--r--src/data/things/contribution.js82
-rw-r--r--src/data/things/flash.js16
-rw-r--r--src/data/things/group.js87
-rw-r--r--src/data/things/homepage-layout.js3
-rw-r--r--src/data/things/index.js4
-rw-r--r--src/data/things/language.js55
-rw-r--r--src/data/things/sorting-rule.js1
-rw-r--r--src/data/things/track.js105
-rw-r--r--src/data/things/wiki-info.js37
-rw-r--r--src/data/yaml.js167
-rw-r--r--src/external-links.js29
-rw-r--r--src/find-reverse.js4
-rw-r--r--src/find.js64
-rw-r--r--src/gen-thumbs.js133
-rw-r--r--src/html.js174
-rw-r--r--src/replacer.js328
-rw-r--r--src/static/css/site.css355
-rw-r--r--src/static/js/client/expandable-gallery-section.js77
-rw-r--r--src/static/js/client/hoverable-tooltip.js30
-rw-r--r--src/static/js/client/index.js2
-rw-r--r--src/static/js/client/quick-description.js2
-rw-r--r--src/static/js/client/sidebar-search.js2
-rw-r--r--src/static/js/search-worker.js2
-rw-r--r--src/strings-default.yaml107
-rwxr-xr-xsrc/upd8.js49
-rw-r--r--src/urls-default.yaml2
-rw-r--r--src/urls.js3
-rw-r--r--src/validators.js26
-rw-r--r--src/web-routes.js2
-rw-r--r--src/write/bind-utilities.js2
-rw-r--r--src/write/build-modes/live-dev-server.js4
-rw-r--r--src/write/build-modes/static-build.js2
149 files changed, 5025 insertions, 2212 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 bd4ec685..ec72a625 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -376,77 +376,79 @@ decorateTime.displayTime = function () {
   }
 };
 
-export function progressPromiseAll(msgOrMsgFn, array) {
+const progressUpdateInterval = 1000 / 60;
+
+function progressShow(message, total) {
+  let start = Date.now(), last = 0, done = 0;
+
+  const progress = () => {
+    const messagePart =
+      (typeof message === 'function'
+        ? message()
+        : message);
+
+    const percent =
+      Math.round((done / total) * 1000) / 10 + '%';
+
+    const percentPart =
+      percent.padEnd('99.9%'.length, ' ');
+
+    return `${messagePart} [${percentPart}]`;
+  };
+
+  process.stdout.write(`\r` + progress());
+
+  return () => {
+    done++;
+
+    if (done === total) {
+      process.stdout.write(
+        `\r\x1b[2m` + progress() +
+        `\x1b[0;32m Done! ` +
+        `\x1b[0;2m(${formatDuration(Date.now() - start)}) ` +
+        `\x1b[0m\n`
+      );
+    } else if (Date.now() - last >= progressUpdateInterval) {
+      process.stdout.write('\r' + progress());
+      last = Date.now();
+    }
+  };
+}
+
+export function progressPromiseAll(message, array) {
   if (!array.length) {
     return Promise.resolve([]);
   }
 
-  const msgFn =
-    typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn;
-
-  let done = 0,
-    total = array.length;
-  process.stdout.write(`\r${msgFn()} [0/${total}]`);
-  const start = Date.now();
-  return Promise.all(
-    array.map((promise) =>
-      Promise.resolve(promise).then((val) => {
-        done++;
-        // const pc = `${done}/${total}`;
-        const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd(
-          '99.9%'.length,
-          ' '
-        );
-        if (done === total) {
-          const time = Date.now() - start;
-          process.stdout.write(
-            `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`
-          );
-        } else {
-          process.stdout.write(`\r${msgFn()} [${pc}] `);
-        }
-        return val;
-      })
-    )
-  );
+  const show = progressShow(message, array.length);
+
+  const next = value => {
+    show();
+
+    return value;
+  };
+
+  const promises =
+    array.map(promise => Promise.resolve(promise).then(next));
+
+  return Promise.all(promises);
 }
 
-export function progressCallAll(msgOrMsgFn, array) {
+export function progressCallAll(message, array) {
   if (!array.length) {
     return [];
   }
 
-  const msgFn =
-    typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn;
+  const show = progressShow(message, array.length);
 
-  const updateInterval = 1000 / 60;
-
-  let done = 0,
-    total = array.length;
-  process.stdout.write(`\r${msgFn()} [0/${total}]`);
-  const start = Date.now();
-  const vals = [];
-  let lastTime = 0;
+  const values = [];
 
   for (const fn of array) {
-    const val = fn();
-    done++;
-
-    if (done === total) {
-      const pc = '100%'.padEnd('99.9%'.length, ' ');
-      const time = Date.now() - start;
-      process.stdout.write(
-        `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`
-      );
-    } else if (Date.now() - lastTime >= updateInterval) {
-      const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd('99.9%'.length, ' ');
-      process.stdout.write(`\r${msgFn()} [${pc}] `);
-      lastTime = Date.now();
-    }
-    vals.push(val);
+    values.push(fn());
+    show();
   }
 
-  return vals;
+  return values;
 }
 
 export function fileIssue({
@@ -459,6 +461,24 @@ export function fileIssue({
   console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`));
 }
 
+// Quick'n dirty function to present a duration nicely for command-line use.
+export function formatDuration(timeDelta) {
+  const seconds = timeDelta / 1000;
+
+  if (seconds > 90) {
+    const modSeconds = Math.floor(seconds % 60);
+    const minutes = Math.floor(seconds - seconds % 60) / 60;
+    return `${minutes}m${modSeconds}s`;
+  }
+
+  if (seconds < 0.1) {
+    return 'instant';
+  }
+
+  const precision = (seconds > 1 ? 3 : 2);
+  return `${seconds.toPrecision(precision)}s`;
+}
+
 export async function logicalCWD() {
   if (process.env.PWD) {
     return process.env.PWD;
@@ -469,7 +489,7 @@ export async function logicalCWD() {
 
   try {
     await stat('/bin/sh');
-  } catch (error) {
+  } catch {
     // Not logical, so sad.
     return process.cwd();
   }
diff --git a/src/common-util/sugar.js b/src/common-util/sugar.js
index 66e160aa..e931ad59 100644
--- a/src/common-util/sugar.js
+++ b/src/common-util/sugar.js
@@ -116,10 +116,14 @@ export function findIndexOrEnd(array, fn) {
 // returns null (or values in the array are nullish), they'll just be skipped in
 // the sum.
 export function accumulateSum(array, fn = x => x) {
+  if (!Array.isArray(array)) {
+    return accumulateSum(Array.from(array, fn));
+  }
+
   return array.reduce(
     (accumulator, value, index, array) =>
       accumulator +
-        fn(value, index, array) ?? 0,
+      (fn(value, index, array) ?? 0),
     0);
 }
 
@@ -221,6 +225,9 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
     ? arr1.every((x, i) => arr2[i] === x)
     : arr1.every((x) => arr2.includes(x)));
 
+export const exhaust = (generatorFunction) =>
+  Array.from(generatorFunction());
+
 export function compareObjects(obj1, obj2, {
   checkOrder = false,
   checkSymbols = true,
@@ -251,11 +258,20 @@ export function compareObjects(obj1, obj2, {
 
 // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
 export const withEntries = (obj, fn) => {
-  const result = fn(Object.entries(obj));
-  if (result instanceof Promise) {
-    return result.then(entries => Object.fromEntries(entries));
+  if (obj instanceof Map) {
+    const result = fn(Array.from(obj.entries()));
+    if (result instanceof Promise) {
+      return result.then(entries => new map(entries));
+    } else {
+      return new Map(result);
+    }
   } else {
-    return Object.fromEntries(result);
+    const result = fn(Object.entries(obj));
+    if (result instanceof Promise) {
+      return result.then(entries => Object.fromEntries(entries));
+    } else {
+      return Object.fromEntries(result);
+    }
   }
 }
 
@@ -299,34 +315,74 @@ export function filterProperties(object, properties, {
   return filteredObject;
 }
 
-export function queue(array, max = 50) {
-  if (max === 0) {
-    return array.map((fn) => fn());
+export function queue(functionList, queueSize = 50) {
+  if (queueSize === 0) {
+    return functionList.map(fn => fn());
   }
 
-  const begin = [];
-  let current = 0;
-  const ret = array.map(
-    (fn) =>
-      new Promise((resolve, reject) => {
-        begin.push(() => {
-          current++;
-          Promise.resolve(fn()).then((value) => {
-            current--;
-            if (current < max && begin.length) {
-              begin.shift()();
-            }
-            resolve(value);
-          }, reject);
-        });
-      })
-  );
+  const promiseList = [];
+  const resolveList = [];
+  const rejectList = [];
 
-  for (let i = 0; i < max && begin.length; i++) {
-    begin.shift()();
+  for (let i = 0; i < functionList.length; i++) {
+    const promiseWithResolvers = Promise.withResolvers();
+    promiseList.push(promiseWithResolvers.promise);
+    resolveList.push(promiseWithResolvers.resolve);
+    rejectList.push(promiseWithResolvers.reject);
   }
 
-  return ret;
+  let cursor = 0;
+  let running = 0;
+
+  const next = async () => {
+    if (running >= queueSize) {
+      return;
+    }
+
+    if (cursor === functionList.length) {
+      return;
+    }
+
+    const thisFunction = functionList[cursor];
+    const thisResolve = resolveList[cursor];
+    const thisReject = rejectList[cursor];
+
+    delete functionList[cursor];
+    delete resolveList[cursor];
+    delete rejectList[cursor];
+
+    cursor++;
+    running++;
+
+    try {
+      thisResolve(await thisFunction());
+    } catch (error) {
+      thisReject(error);
+    } finally {
+      running--;
+
+      // If the cursor is at 1, this is the first promise that resolved,
+      // so we're now done the "kick start", and can start the remaining
+      // promises (up to queueSize).
+      if (cursor === 1) {
+        // Since only one promise is used for the "kick start", and that one
+        // has just resolved, we know there's none running at all right now,
+        // and can start as many as specified in the queueSize right away.
+        for (let i = 0; i < queueSize; i++) {
+          next();
+        }
+      } else {
+        next();
+      }
+    }
+  };
+
+  // Only start a single promise, as a "kick start", so that it resolves as
+  // early as possible (it will resolve before we use CPU to start the rest
+  // of the promises, up to queueSize).
+  next();
+
+  return promiseList;
 }
 
 export function delay(ms) {
diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js
index a4c6b3bd..546f1ad9 100644
--- a/src/common-util/wiki-data.js
+++ b/src/common-util/wiki-data.js
@@ -57,10 +57,9 @@ export function getKebabCase(name) {
 
 // Specific data utilities
 
-// Matches heading details from commentary data in roughly the formats:
+// Matches heading details from commentary data in roughly the format:
 //
-//    <i>artistReference:</i> (annotation, date)
-//    <i>artistReference|artistDisplayText:</i> (annotation, date)
+//    <i>artistText:</i> (annotation, date)
 //
 // where capturing group "annotation" can be any text at all, except that the
 // last entry (past a comma or the only content within parentheses), if parsed
@@ -83,8 +82,9 @@ export function getKebabCase(name) {
 // parentheses can be part of the actual annotation content.
 //
 // Capturing group "artistReference" is all the characters between <i> and </i>
-// (apart from the pipe and "artistDisplayText" text, if present), and is either
-// the name of an artist or an "artist:directory"-style reference.
+// (apart from the pipe and the "artistText" group, if present), and is either
+// the name of one or more artist or "artist:directory"-style references,
+// joined by commas, if multiple.
 //
 // This regular expression *doesn't* match bodies, which will need to be parsed
 // out of the original string based on the indices matched using this.
@@ -94,7 +94,7 @@ const dateRegex = groupName =>
   String.raw`(?<${groupName}>[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4})`;
 
 const commentaryRegexRaw =
-  String.raw`^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?<dateKind>sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?<accessKind>captured|accessed) ${dateRegex('accessDate')})?)?\))?`;
+  String.raw`^<i>(?<artistText>.+?):<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?<dateKind>sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?<accessKind>captured|accessed) ${dateRegex('accessDate')})?)?\))?`;
 export const commentaryRegexCaseInsensitive =
   new RegExp(commentaryRegexRaw, 'gmi');
 export const commentaryRegexCaseSensitive =
@@ -522,3 +522,30 @@ export function combineWikiDataArrays(arrays) {
     return combined;
   }
 }
+
+// Markdown stuff
+
+export function* matchMarkdownLinks(markdownSource, {marked}) {
+  const plausibleLinkRegexp = /\[.*?\)/g;
+
+  let plausibleMatch = null;
+  while (plausibleMatch = plausibleLinkRegexp.exec(markdownSource)) {
+    // Pedantic rules use more particular parentheses detection in link
+    // destinations - they allow one level of balanced parentheses, and
+    // otherwise, parentheses must be escaped. This allows for entire links
+    // to be wrapped in parentheses, e.g below:
+    //
+    //   This is so cool. ([You know??](https://example.com))
+    //
+    const definiteMatch =
+      marked.Lexer.rules.inline.pedantic.link
+        .exec(markdownSource.slice(plausibleMatch.index));
+
+    if (definiteMatch) {
+      const [{length}, label, href] = definiteMatch;
+      const index = plausibleMatch.index + definiteMatch.index;
+
+      yield {label, href, index, length};
+    }
+  }
+}
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
index 68120b23..7e05b5b5 100644
--- a/src/content/dependencies/generateAdditionalFilesList.js
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -1,26 +1,22 @@
-import {stitchArrays} from '#sugar';
-
 export default {
+  contentDependencies: ['generateAdditionalFilesListChunk'],
   extraDependencies: ['html'],
 
-  slots: {
-    chunks: {
-      validate: v => v.strictArrayOf(v.isHTML),
-    },
+  relations: (relation, additionalFiles) => ({
+    chunks:
+      additionalFiles
+        .map(file => relation('generateAdditionalFilesListChunk', file)),
+  }),
 
-    chunkItems: {
-      validate: v => v.strictArrayOf(v.isHTML),
-    },
+  slots: {
+    showFileSizes: {type: 'boolean', default: true},
   },
 
-  generate: (slots, {html}) =>
+  generate: (relations, slots, {html}) =>
     html.tag('ul', {class: 'additional-files-list'},
       {[html.onlyIfContent]: true},
 
-      stitchArrays({
-        chunk: slots.chunks,
-        items: slots.chunkItems,
-      }).map(({chunk, items}) =>
-          chunk.clone()
-            .slot('items', items))),
+      relations.chunks.map(chunk => chunk.slots({
+        showFileSizes: slots.showFileSizes,
+      }))),
 };
diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js
index 507b2329..3cac851b 100644
--- a/src/content/dependencies/generateAdditionalFilesListChunk.js
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -1,46 +1,81 @@
+import {stitchArrays} from '#sugar';
+
 export default {
-  extraDependencies: ['html', 'language'],
+  contentDependencies: ['linkAdditionalFile', 'transformContent'],
+  extraDependencies: ['getSizeOfMediaFile', 'html', 'language', 'urls'],
 
-  slots: {
-    title: {
-      type: 'html',
-      mutable: false,
-    },
+  relations: (relation, file) => ({
+    description:
+      relation('transformContent', file.description),
 
-    description: {
-      type: 'html',
-      mutable: false,
-    },
+    links:
+      file.filenames
+        .map(filename => relation('linkAdditionalFile', file, filename)),
+  }),
+
+  data: (file) => ({
+    title:
+      file.title,
+
+    paths:
+      file.paths,
+  }),
 
-    items: {
-      validate: v => v.looseArrayOf(v.isHTML),
+  slots: {
+    showFileSizes: {
+      type: 'boolean',
     },
   },
 
-  generate: (slots, {html, language}) =>
-    language.encapsulate('releaseInfo.additionalFiles.entry', capsule =>
+  generate: (data, relations, slots, {getSizeOfMediaFile, html, language, urls}) =>
+    language.encapsulate('releaseInfo.additionalFiles', capsule =>
       html.tag('li',
         html.tag('details',
-          html.isBlank(slots.items) &&
+          html.isBlank(relations.links) &&
             {open: true},
 
           [
             html.tag('summary',
               html.tag('span',
-                language.$(capsule, {
+                language.$(capsule, 'entry', {
                   title:
-                    html.tag('b', slots.title),
+                    html.tag('b', data.title),
                 }))),
 
             html.tag('ul', [
               html.tag('li', {class: 'entry-description'},
                 {[html.onlyIfContent]: true},
-                slots.description),
 
-              (html.isBlank(slots.items)
+                relations.description.slot('mode', 'inline')),
+
+              (html.isBlank(relations.links)
                 ? html.tag('li',
-                    language.$(capsule, 'noFilesAvailable'))
-                : slots.items),
+                    language.$(capsule, 'entry.noFilesAvailable'))
+
+                : stitchArrays({
+                    link: relations.links,
+                    path: data.paths,
+                  }).map(({link, path}) =>
+                      html.tag('li',
+                        language.encapsulate(capsule, 'file', workingCapsule => {
+                          const workingOptions = {file: link};
+
+                          if (slots.showFileSizes) {
+                            const fileSize =
+                              getSizeOfMediaFile(
+                                urls
+                                  .from('media.root')
+                                  .to(...path));
+
+                            if (fileSize) {
+                              workingCapsule += '.withSize';
+                              workingOptions.size =
+                                language.formatFileSize(fileSize);
+                            }
+                          }
+
+                          return language.$(workingCapsule, workingOptions);
+                        })))),
             ]),
           ]))),
 };
diff --git a/src/content/dependencies/generateAdditionalFilesListChunkItem.js b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
deleted file mode 100644
index c37d6bb2..00000000
--- a/src/content/dependencies/generateAdditionalFilesListChunkItem.js
+++ /dev/null
@@ -1,30 +0,0 @@
-export default {
-  extraDependencies: ['html', 'language'],
-
-  slots: {
-    fileLink: {
-      type: 'html',
-      mutable: false,
-    },
-
-    fileSize: {
-      validate: v => v.isWholeNumber,
-    },
-  },
-
-  generate(slots, {html, language}) {
-    const itemParts = ['releaseInfo.additionalFiles.file'];
-    const itemOptions = {file: slots.fileLink};
-
-    if (slots.fileSize) {
-      itemParts.push('withSize');
-      itemOptions.size = language.formatFileSize(slots.fileSize);
-    }
-
-    const li =
-      html.tag('li',
-        language.$(...itemParts, itemOptions));
-
-    return li;
-  },
-};
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
deleted file mode 100644
index ad17206f..00000000
--- a/src/content/dependencies/generateAlbumAdditionalFilesList.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import {stitchArrays} from '#sugar';
-
-export default {
-  contentDependencies: [
-    'generateAdditionalFilesList',
-    'generateAdditionalFilesListChunk',
-    'generateAdditionalFilesListChunkItem',
-    'linkAlbumAdditionalFile',
-    'transformContent',
-  ],
-
-  extraDependencies: ['getSizeOfMediaFile', 'html', 'urls'],
-
-  relations: (relation, album, additionalFiles) => ({
-    list:
-      relation('generateAdditionalFilesList', additionalFiles),
-
-    chunks:
-      additionalFiles
-        .map(() => relation('generateAdditionalFilesListChunk')),
-
-    chunkDescriptions:
-      additionalFiles
-        .map(({description}) =>
-          (description
-            ? relation('transformContent', description)
-            : null)),
-
-    chunkItems:
-      additionalFiles
-        .map(({files}) =>
-          (files ?? [])
-            .map(() => relation('generateAdditionalFilesListChunkItem'))),
-
-    chunkItemFileLinks:
-      additionalFiles
-        .map(({files}) =>
-          (files ?? [])
-            .map(file => relation('linkAlbumAdditionalFile', album, file))),
-  }),
-
-  data: (album, additionalFiles) => ({
-    albumDirectory: album.directory,
-
-    chunkTitles:
-      additionalFiles
-        .map(({title}) => title),
-
-    chunkItemLocations:
-      additionalFiles
-        .map(({files}) => files ?? []),
-  }),
-
-  slots: {
-    showFileSizes: {type: 'boolean', default: true},
-  },
-
-  generate: (data, relations, slots, {getSizeOfMediaFile, urls}) =>
-    relations.list.slots({
-      chunks:
-        stitchArrays({
-          chunk: relations.chunks,
-          description: relations.chunkDescriptions,
-          title: data.chunkTitles,
-        }).map(({chunk, title, description}) =>
-            chunk.slots({
-              title,
-              description:
-                (description
-                  ? description.slot('mode', 'inline')
-                  : null),
-            })),
-
-      chunkItems:
-        stitchArrays({
-          items: relations.chunkItems,
-          fileLinks: relations.chunkItemFileLinks,
-          locations: data.chunkItemLocations,
-        }).map(({items, fileLinks, locations}) =>
-            stitchArrays({
-              item: items,
-              fileLink: fileLinks,
-              location: locations,
-            }).map(({item, fileLink, location}) =>
-                item.slots({
-                  fileLink: fileLink,
-                  fileSize:
-                    (slots.showFileSizes
-                      ? getSizeOfMediaFile(
-                          urls
-                            .from('media.root')
-                            .to('media.albumAdditionalFile', data.albumDirectory, location))
-                      : 0),
-                }))),
-    }),
-};
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 1e39b47d..3529c4dc 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -5,7 +5,7 @@ export default {
     'generateAlbumCommentarySidebar',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateCommentaryEntry',
     'generateContentHeading',
     'generateCoverArtwork',
@@ -44,8 +44,8 @@ export default {
     relations.sidebar =
       relation('generateAlbumCommentarySidebar', album);
 
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album, null);
+    relations.albumStyleTags =
+      relation('generateAlbumStyleTags', album, null);
 
     relations.albumLink =
       relation('linkAlbum', album);
@@ -151,7 +151,7 @@ export default {
         headingMode: 'sticky',
 
         color: data.color,
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         mainClasses: ['long-content'],
         mainContent: [
@@ -266,7 +266,10 @@ export default {
                       }),
                   })),
 
-              cover?.slots({mode: 'commentary'}),
+              cover?.slots({
+                mode: 'commentary',
+                color: true,
+              }),
 
               trackDate &&
               trackDate !== data.date &&
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index 2ba3b272..516a7ca8 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -9,7 +9,7 @@ export default {
     'generateAlbumGalleryTrackGrid',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateIntrapageDotSwitcher',
     'generatePageLayout',
     'linkAlbum',
@@ -46,8 +46,8 @@ export default {
     layout:
       relation('generatePageLayout'),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', album, null),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
     albumLink:
       relation('linkAlbum', album),
@@ -106,7 +106,7 @@ export default {
         headingMode: 'static',
 
         color: data.color,
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         mainClasses: ['top-index'],
         mainContent: [
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index d0788523..1664c788 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -2,8 +2,8 @@ import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAdditionalFilesList',
     'generateAdditionalNamesBox',
-    'generateAlbumAdditionalFilesList',
     'generateAlbumArtworkColumn',
     'generateAlbumBanner',
     'generateAlbumNavAccent',
@@ -11,9 +11,10 @@ export default {
     'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
     'generateAlbumSocialEmbed',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateAlbumTrackList',
     'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generatePageLayout',
     'linkAlbumCommentary',
@@ -26,8 +27,8 @@ export default {
     layout:
       relation('generatePageLayout'),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', album, null),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
     socialEmbed:
       relation('generateAlbumSocialEmbed', album),
@@ -55,6 +56,9 @@ export default {
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', album),
+
     releaseInfo:
       relation('generateAlbumReleaseInfo', album),
 
@@ -72,16 +76,14 @@ export default {
       relation('generateAlbumTrackList', album),
 
     additionalFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        album,
-        album.additionalFiles),
+      relation('generateAdditionalFilesList', album.additionalFiles),
 
     artistCommentaryEntries:
       album.commentary
         .map(entry => relation('generateCommentaryEntry', entry)),
 
     creditSourceEntries:
-      album.creditSources
+      album.creditingSources
         .map(entry => relation('generateCommentaryEntry', entry)),
   }),
 
@@ -106,7 +108,7 @@ export default {
 
         color: data.color,
         headingMode: 'sticky',
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         additionalNames: relations.additionalNamesBox,
 
@@ -159,11 +161,11 @@ export default {
                 : html.blank()),
 
               !html.isBlank(relations.creditSourceEntries) &&
-                language.encapsulate(capsule, 'readCreditSources', capsule =>
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
                   language.$(capsule, {
                     link:
                       html.tag('a',
-                        {href: '#credit-sources'},
+                        {href: '#crediting-sources'},
                         language.$(capsule, 'link')),
                   })),
             ])),
@@ -181,6 +183,11 @@ export default {
               }),
             ])),
 
+          (!html.isBlank(relations.artistCommentaryEntries) ||
+           !html.isBlank(relations.creditSourceEntries))
+          &&
+            html.tag('hr', {class: 'main-separator'}),
+
           language.encapsulate('releaseInfo.additionalFiles', capsule =>
             html.tags([
               relations.contentHeading.clone()
@@ -193,20 +200,20 @@ export default {
             ])),
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
                 attributes: {id: 'artist-commentary'},
-                title: language.$('misc.artistCommentary'),
+                string: 'misc.artistCommentary',
               }),
 
             relations.artistCommentaryEntries,
           ]),
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
-                attributes: {id: 'credit-sources'},
-                title: language.$('misc.creditSources'),
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
               }),
 
             relations.creditSourceEntries,
diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
index 7586393c..52c78dc2 100644
--- a/src/content/dependencies/generateAlbumReferencedArtworksPage.js
+++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
@@ -1,6 +1,6 @@
 export default {
   contentDependencies: [
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToAlbumLink',
     'generateReferencedArtworksPage',
     'linkAlbum',
@@ -12,8 +12,8 @@ export default {
     page:
       relation('generateReferencedArtworksPage', album.coverArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', album, null),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
     albumLink:
       relation('linkAlbum', album),
@@ -35,7 +35,7 @@ export default {
             data.name,
         }),
 
-      styleRules: [relations.albumStyleRules],
+      styleTags: relations.albumStyleTags,
 
       navLinks: [
         {auto: 'home'},
diff --git a/src/content/dependencies/generateAlbumReferencingArtworksPage.js b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
index d072d2f6..bc36ae06 100644
--- a/src/content/dependencies/generateAlbumReferencingArtworksPage.js
+++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
@@ -1,6 +1,6 @@
 export default {
   contentDependencies: [
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToAlbumLink',
     'generateReferencingArtworksPage',
     'linkAlbum',
@@ -12,8 +12,8 @@ export default {
     page:
       relation('generateReferencingArtworksPage', album.coverArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', album, null),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
     albumLink:
       relation('linkAlbum', album),
@@ -35,7 +35,7 @@ export default {
             data.name,
         }),
 
-      styleRules: [relations.albumStyleRules],
+      styleTags: relations.albumStyleTags,
 
       navLinks: [
         {auto: 'home'},
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/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js
deleted file mode 100644
index 6bfcc62e..00000000
--- a/src/content/dependencies/generateAlbumStyleRules.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import {empty, stitchArrays} from '#sugar';
-
-export default {
-  extraDependencies: ['to'],
-
-  data(album, track) {
-    const data = {};
-
-    data.hasWallpaper = !empty(album.wallpaperArtistContribs);
-    data.hasBanner = !empty(album.bannerArtistContribs);
-
-    if (data.hasWallpaper) {
-      if (!empty(album.wallpaperParts)) {
-        data.wallpaperMode = 'parts';
-
-        data.wallpaperPaths =
-          album.wallpaperParts.map(part =>
-            (part.asset
-              ? ['media.albumWallpaperPart', album.directory, part.asset]
-              : null));
-
-        data.wallpaperStyles =
-          album.wallpaperParts.map(part => part.style);
-      } else {
-        data.wallpaperMode = 'one';
-        data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension];
-        data.wallpaperStyle = album.wallpaperStyle;
-      }
-    }
-
-    if (data.hasBanner) {
-      data.hasBannerStyle = !!album.bannerStyle;
-      data.bannerStyle = album.bannerStyle;
-    }
-
-    data.albumDirectory = album.directory;
-
-    if (track) {
-      data.trackDirectory = track.directory;
-    }
-
-    return data;
-  },
-
-  generate(data, {to}) {
-    const indent = parts =>
-      (parts ?? [])
-        .filter(Boolean)
-        .join('\n')
-        .split('\n')
-        .map(line => ' '.repeat(4) + line)
-        .join('\n');
-
-    const rule = (selector, parts) =>
-      (!empty(parts.filter(Boolean))
-        ? [`${selector} {`, indent(parts), `}`]
-        : []);
-
-    const oneWallpaperRule =
-      data.wallpaperMode === 'one' &&
-        rule(`body::before`, [
-          `background-image: url("${to(...data.wallpaperPath)}");`,
-          data.wallpaperStyle,
-        ]);
-
-    const wallpaperPartRules =
-      data.wallpaperMode === 'parts' &&
-        stitchArrays({
-          path: data.wallpaperPaths,
-          style: data.wallpaperStyles,
-        }).map(({path, style}, index) =>
-            rule(`.wallpaper-part:nth-child(${index + 1})`, [
-              path && `background-image: url("${to(...path)}");`,
-              style,
-            ]));
-
-    const nukeBasicWallpaperRule =
-      data.wallpaperMode === 'parts' &&
-        rule(`body::before`, ['display: none']);
-
-    const wallpaperRules = [
-      oneWallpaperRule,
-      ...wallpaperPartRules || [],
-      nukeBasicWallpaperRule,
-    ];
-
-    const bannerRule =
-      data.hasBanner &&
-        rule(`#banner img`, [
-          data.bannerStyle,
-        ]);
-
-    const dataRule =
-      rule(`:root`, [
-        data.albumDirectory &&
-          `--album-directory: ${data.albumDirectory};`,
-        data.trackDirectory &&
-          `--track-directory: ${data.trackDirectory};`,
-      ]);
-
-    return (
-      [...wallpaperRules, bannerRule, dataRule]
-        .filter(Boolean)
-        .flat()
-        .join('\n'));
-  },
-};
diff --git a/src/content/dependencies/generateAlbumStyleTags.js b/src/content/dependencies/generateAlbumStyleTags.js
new file mode 100644
index 00000000..4cdc6581
--- /dev/null
+++ b/src/content/dependencies/generateAlbumStyleTags.js
@@ -0,0 +1,65 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['generateAlbumWallpaperStyleTag', 'generateStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album, _track) => ({
+    styleTag:
+      relation('generateStyleTag'),
+
+    wallpaperStyleTag:
+      relation('generateAlbumWallpaperStyleTag', album),
+  }),
+
+  data(album, track) {
+    const data = {};
+
+    data.hasBanner = !empty(album.bannerArtistContribs);
+
+    if (data.hasBanner) {
+      data.hasBannerStyle = !!album.bannerStyle;
+      data.bannerStyle = album.bannerStyle;
+    }
+
+    data.albumDirectory = album.directory;
+
+    if (track) {
+      data.trackDirectory = track.directory;
+    }
+
+    return data;
+  },
+
+  generate: (data, relations, {html}) =>
+    html.tags([
+      relations.wallpaperStyleTag,
+
+      relations.styleTag.clone().slots({
+        attributes: {class: 'album-banner-style'},
+
+        rules: [
+          data.hasBanner && {
+            select: '#banner img',
+            declare: [data.bannerStyle],
+          },
+        ],
+      }),
+
+      relations.styleTag.clone().slots({
+        attributes: {class: 'album-directory-style'},
+
+        rules: [
+          {
+            select: ':root',
+            declare: [
+              data.albumDirectory &&
+                `--album-directory: ${data.albumDirectory};`,
+              data.trackDirectory &&
+                `--track-directory: ${data.trackDirectory};`,
+            ],
+          },
+        ]
+      }),
+    ], {[html.joinChildren]: ''}),
+};
diff --git a/src/content/dependencies/generateAlbumWallpaperStyleTag.js b/src/content/dependencies/generateAlbumWallpaperStyleTag.js
new file mode 100644
index 00000000..47864a1d
--- /dev/null
+++ b/src/content/dependencies/generateAlbumWallpaperStyleTag.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateWallpaperStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    wallpaperStyleTag:
+      (album.hasWallpaperArt
+        ? relation('generateWallpaperStyleTag')
+        : null),
+  }),
+
+  data: (album) => ({
+    singleWallpaperPath:
+      ['media.albumWallpaper', album.directory, album.wallpaperFileExtension],
+
+    singleWallpaperStyle:
+      album.wallpaperStyle,
+
+    wallpaperPartPaths:
+      album.wallpaperParts.map(part =>
+        (part.asset
+          ? ['media.albumWallpaperPart', album.directory, part.asset]
+          : null)),
+
+    wallpaperPartStyles:
+      album.wallpaperParts.map(part => part.style),
+  }),
+
+  generate: (data, relations, {html}) =>
+    (relations.wallpaperStyleTag
+      ? relations.wallpaperStyleTag.slots({
+          singleWallpaperPath: data.singleWallpaperPath,
+          singleWallpaperStyle: data.singleWallpaperStyle,
+          wallpaperPartPaths: data.wallpaperPartPaths,
+          wallpaperPartStyles: data.wallpaperPartStyles,
+        })
+      : html.blank()),
+};
diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js
index 6bdbeb23..bab32f7d 100644
--- a/src/content/dependencies/generateArtistCredit.js
+++ b/src/content/dependencies/generateArtistCredit.js
@@ -36,11 +36,18 @@ export default {
     // Note that the normal contributions will implicitly *always*
     // "differ from context" if no context contributions are given,
     // as in release info lines.
-    query.normalContributionsDifferFromContext =
+
+    query.normalContributionArtistsDifferFromContext =
       !compareArrays(
         query.normalContributions.map(({artist}) => artist),
         contextNormalContributions.map(({artist}) => artist),
-        {checkOrder: false});
+        {checkOrder: true});
+
+    query.normalContributionAnnotationsDifferFromContext =
+      !compareArrays(
+        query.normalContributions.map(({annotation}) => annotation),
+        contextNormalContributions.map(({annotation}) => annotation),
+        {checkOrder: true});
 
     return query;
   },
@@ -60,8 +67,11 @@ export default {
   }),
 
   data: (query, _creditContributions, _contextContributions) => ({
-    normalContributionsDifferFromContext:
-      query.normalContributionsDifferFromContext,
+    normalContributionArtistsDifferFromContext:
+      query.normalContributionArtistsDifferFromContext,
+
+    normalContributionAnnotationsDifferFromContext:
+      query.normalContributionAnnotationsDifferFromContext,
 
     hasWikiEdits:
       !empty(query.wikiEditContributions),
@@ -148,8 +158,12 @@ export default {
         ...relations.featuringContributionLinks,
       ]);
 
+    const effectivelyDiffers =
+      (slots.showAnnotation && data.normalContributionAnnotationsDifferFromContext) ||
+      (data.normalContributionArtistsDifferFromContext);
+
     if (empty(relations.featuringContributionLinks)) {
-      if (data.normalContributionsDifferFromContext) {
+      if (effectivelyDiffers) {
         return language.$(slots.normalStringKey, {
           ...slots.additionalStringOptions,
           artists: artistsList,
@@ -159,7 +173,7 @@ export default {
       }
     }
 
-    if (data.normalContributionsDifferFromContext && slots.normalFeaturingStringKey) {
+    if (effectivelyDiffers && slots.normalFeaturingStringKey) {
       return language.$(slots.normalFeaturingStringKey, {
         ...slots.additionalStringOptions,
         artists: artistsList,
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index 3e0cd1d2..e1fa7a0b 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -1,83 +1,90 @@
-import {empty, filterProperties, stitchArrays, unique} from '#sugar';
+import {accumulateSum, empty, stitchArrays, withEntries} from '#sugar';
 
 export default {
   contentDependencies: ['linkGroup'],
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupCategoryData}) {
-    return {
-      groupOrder: groupCategoryData.flatMap(category => category.groups),
-    }
-  },
+  sprawl: ({groupCategoryData}) => ({
+    groupOrder:
+      groupCategoryData.flatMap(category => category.groups),
+  }),
 
-  query(sprawl, tracksAndAlbums) {
-    const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album);
-    const filteredTracks = tracksAndAlbums.filter(thing => thing.album);
+  query(sprawl, contributions) {
+    const allGroupsUnordered =
+      new Set(contributions.flatMap(contrib => contrib.groups));
 
-    const allAlbums = unique([
-      ...filteredAlbums,
-      ...filteredTracks.map(track => track.album),
-    ]);
+    const allGroupsOrdered =
+      sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
 
-    const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups));
-    const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
+    const groupToThingsCountedForContributions =
+      new Map(allGroupsOrdered.map(group => [group, new Set]));
 
-    const mapTemplate = allGroupsOrdered.map(group => [group, 0]);
-    const groupToCountMap = new Map(mapTemplate);
-    const groupToDurationMap = new Map(mapTemplate);
-    const groupToDurationCountMap = new Map(mapTemplate);
+    const groupToThingsCountedForDuration =
+      new Map(allGroupsOrdered.map(group => [group, new Set]));
 
-    for (const album of filteredAlbums) {
-      for (const group of album.groups) {
-        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
-      }
-    }
+    for (const contrib of contributions) {
+      for (const group of contrib.groups) {
+        if (contrib.countInContributionTotals) {
+          groupToThingsCountedForContributions.get(group).add(contrib.thing);
+        }
 
-    for (const track of filteredTracks) {
-      for (const group of track.album.groups) {
-        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
-        if (track.duration && track.mainReleaseTrack === null) {
-          groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration);
-          groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1);
+        if (contrib.countInDurationTotals) {
+          groupToThingsCountedForDuration.get(group).add(contrib.thing);
         }
       }
     }
 
+    const groupToTotalContributions =
+      withEntries(
+        groupToThingsCountedForContributions,
+        entries => entries.map(
+          ([group, things]) =>
+          ([group, things.size])));
+
+    const groupToTotalDuration =
+      withEntries(
+        groupToThingsCountedForDuration,
+        entries => entries.map(
+          ([group, things]) =>
+          ([group, accumulateSum(things, thing => thing.duration)])))
+
     const groupsSortedByCount =
       allGroupsOrdered
-        .slice()
-        .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a));
+        .filter(group => groupToTotalContributions.get(group) > 0)
+        .sort((a, b) =>
+          (groupToTotalContributions.get(b)
+         - groupToTotalContributions.get(a)));
 
-    // The filter here ensures all displayed groups have at least some duration
-    // when sorting by duration.
     const groupsSortedByDuration =
       allGroupsOrdered
-        .filter(group => groupToDurationMap.get(group) > 0)
-        .sort((a, b) => groupToDurationMap.get(b) - groupToDurationMap.get(a));
+        .filter(group => groupToTotalDuration.get(group) > 0)
+        .sort((a, b) =>
+          (groupToTotalDuration.get(b)
+         - groupToTotalDuration.get(a)));
 
     const groupCountsSortedByCount =
       groupsSortedByCount
-        .map(group => groupToCountMap.get(group));
+        .map(group => groupToTotalContributions.get(group));
 
     const groupDurationsSortedByCount =
       groupsSortedByCount
-        .map(group => groupToDurationMap.get(group));
+        .map(group => groupToTotalDuration.get(group));
 
     const groupDurationsApproximateSortedByCount =
       groupsSortedByCount
-        .map(group => groupToDurationCountMap.get(group) > 1);
+        .map(group => groupToThingsCountedForDuration.get(group).size > 1);
 
     const groupCountsSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToCountMap.get(group));
+        .map(group => groupToTotalContributions.get(group));
 
     const groupDurationsSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToDurationMap.get(group));
+        .map(group => groupToTotalDuration.get(group));
 
     const groupDurationsApproximateSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToDurationCountMap.get(group) > 1);
+        .map(group => groupToThingsCountedForDuration.get(group).size > 1);
 
     return {
       groupsSortedByCount,
@@ -93,29 +100,35 @@ export default {
     };
   },
 
-  relations(relation, query) {
-    return {
-      groupLinksSortedByCount:
-        query.groupsSortedByCount
-          .map(group => relation('linkGroup', group)),
+  relations: (relation, query) => ({
+    groupLinksSortedByCount:
+      query.groupsSortedByCount
+        .map(group => relation('linkGroup', group)),
 
-      groupLinksSortedByDuration:
-        query.groupsSortedByDuration
-          .map(group => relation('linkGroup', group)),
-    };
-  },
+    groupLinksSortedByDuration:
+      query.groupsSortedByDuration
+        .map(group => relation('linkGroup', group)),
+  }),
 
-  data(query) {
-    return filterProperties(query, [
-      'groupCountsSortedByCount',
-      'groupDurationsSortedByCount',
-      'groupDurationsApproximateSortedByCount',
+  data: (query) => ({
+    groupCountsSortedByCount:
+      query.groupCountsSortedByCount,
 
-      'groupCountsSortedByDuration',
-      'groupDurationsSortedByDuration',
-      'groupDurationsApproximateSortedByDuration',
-    ]);
-  },
+    groupDurationsSortedByCount:
+      query.groupDurationsSortedByCount,
+
+    groupDurationsApproximateSortedByCount:
+      query.groupDurationsApproximateSortedByCount,
+
+    groupCountsSortedByDuration:
+      query.groupCountsSortedByDuration,
+
+    groupDurationsSortedByDuration:
+      query.groupDurationsSortedByDuration,
+
+    groupDurationsApproximateSortedByDuration:
+      query.groupDurationsApproximateSortedByDuration,
+  }),
 
   slots: {
     title: {
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 3a3cf8b7..1f738de4 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -20,29 +20,17 @@ export default {
   extraDependencies: ['html', 'language'],
 
   query: (artist) => ({
-    // Even if an artist has served as both "artist" (compositional) and
-    // "contributor" (instruments, production, etc) on the same track, that
-    // track only counts as one unique contribution in the list.
-    allTracks:
-      unique(
-        ([
-          artist.trackArtistContributions,
-          artist.trackContributorContributions,
-        ]).flat()
-          .map(({thing}) => thing)),
-
-    // Artworks are different, though. We intentionally duplicate album data
-    // objects when the artist has contributed some combination of cover art,
-    // wallpaper, and banner - these each count as a unique contribution.
-    allArtworkThings:
-      ([
-        artist.albumCoverArtistContributions,
-        artist.albumWallpaperArtistContributions,
-        artist.albumBannerArtistContributions,
-        artist.trackCoverArtistContributions,
-      ]).flat()
-        .filter(({annotation}) => !annotation?.startsWith('edits for wiki'))
-        .map(({thing}) => thing.thing),
+    trackContributions: [
+      ...artist.trackArtistContributions,
+      ...artist.trackContributorContributions,
+    ],
+
+    artworkContributions: [
+      ...artist.albumCoverArtistContributions,
+      ...artist.albumWallpaperArtistContributions,
+      ...artist.albumBannerArtistContributions,
+      ...artist.trackCoverArtistContributions,
+    ],
 
     // Banners and wallpapers don't show up in the artist gallery page, only
     // cover art.
@@ -93,7 +81,7 @@ export default {
       relation('generateArtistInfoPageTracksChunkedList', artist),
 
     tracksGroupInfo:
-      relation('generateArtistGroupContributionsInfo', query.allTracks),
+      relation('generateArtistGroupContributionsInfo', query.trackContributions),
 
     artworksChunkedList:
       relation('generateArtistInfoPageArtworksChunkedList', artist, false),
@@ -102,7 +90,7 @@ export default {
       relation('generateArtistInfoPageArtworksChunkedList', artist, true),
 
     artworksGroupInfo:
-      relation('generateArtistGroupContributionsInfo', query.allArtworkThings),
+      relation('generateArtistGroupContributionsInfo', query.artworkContributions),
 
     artistGalleryLink:
       (query.hasGallery
@@ -128,7 +116,11 @@ export default {
         .map(({annotation}) => annotation),
 
     totalTrackCount:
-      query.allTracks.length,
+      unique(
+        query.trackContributions
+          .filter(contrib => contrib.countInContributionTotals)
+          .map(contrib => contrib.thing))
+        .length,
 
     totalDuration:
       artist.totalDuration,
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
index 2f2fe0c5..cb436b0f 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
@@ -1,8 +1,11 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: [
     'generateArtistInfoPageChunkItem',
     'generateArtistInfoPageOtherArtistLinks',
     'linkTrack',
+    'transformContent',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -29,6 +32,9 @@ export default {
 
     otherArtistLinks:
       relation('generateArtistInfoPageOtherArtistLinks', [contrib]),
+
+    originDetails:
+      relation('transformContent', contrib.thing.originDetails),
   }),
 
   data: (query, contrib) => ({
@@ -37,6 +43,9 @@ export default {
 
     annotation:
       contrib.annotation,
+
+    label:
+      contrib.thing.label,
   }),
 
   slots: {
@@ -51,9 +60,33 @@ export default {
       otherArtistLinks: relations.otherArtistLinks,
 
       annotation:
-        (slots.filterEditsForWiki
-          ? data.annotation?.replace(/^edits for wiki(: )?/, '')
-          : data.annotation),
+        language.encapsulate('artistPage.creditList.entry.artwork.accent', workingCapsule => {
+          const workingOptions = {};
+
+          const artworkLabel = data.label;
+
+          if (artworkLabel) {
+            workingCapsule += '.withLabel';
+            workingOptions.label =
+              language.typicallyLowerCase(artworkLabel);
+          }
+
+          const contribAnnotation =
+            (slots.filterEditsForWiki
+              ? data.annotation?.replace(/^edits for wiki(: )?/, '')
+              : data.annotation);
+
+          if (contribAnnotation) {
+            workingCapsule += '.withAnnotation';
+            workingOptions.annotation = contribAnnotation;
+          }
+
+          if (empty(Object.keys(workingOptions))) {
+            return html.blank();
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
 
       content:
         language.encapsulate('artistPage.creditList.entry', capsule =>
@@ -68,5 +101,11 @@ export default {
                  : data.kind === 'banner'
                     ? language.$(capsule, 'bannerArt')
                     : language.$(capsule, 'coverArt')))))),
+
+      originDetails:
+        relations.originDetails.slots({
+          mode: 'inline',
+          absorbPunctuationFollowingExternalLinks: false,
+        }),
     }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js
index 7987b642..c80aeab7 100644
--- a/src/content/dependencies/generateArtistInfoPageChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -33,6 +33,11 @@ export default {
       type: 'html',
       mutable: false,
     },
+
+    originDetails: {
+      type: 'html',
+      mutable: false,
+    },
   },
 
   generate: (relations, slots, {html, language}) =>
@@ -40,52 +45,59 @@ export default {
       html.tag('li',
         slots.rerelease && {class: 'rerelease'},
 
-        language.encapsulate(entryCapsule, workingCapsule => {
-          const workingOptions = {entry: slots.content};
-
-          if (!html.isBlank(slots.rereleaseTooltip)) {
-            workingCapsule += '.rerelease';
-            workingOptions.rerelease =
-              relations.textWithTooltip.slots({
-                attributes: {class: 'rerelease'},
-                text: language.$(entryCapsule, 'rerelease.term'),
-                tooltip: slots.rereleaseTooltip,
-              });
-
-            return language.$(workingCapsule, workingOptions);
-          }
-
-          if (!html.isBlank(slots.firstReleaseTooltip)) {
-            workingCapsule += '.firstRelease';
-            workingOptions.firstRelease =
-              relations.textWithTooltip.slots({
-                attributes: {class: 'first-release'},
-                text: language.$(entryCapsule, 'firstRelease.term'),
-                tooltip: slots.firstReleaseTooltip,
-              });
-
-            return language.$(workingCapsule, workingOptions);
-          }
-
-          let anyAccent = false;
-
-          if (!empty(slots.otherArtistLinks)) {
-            anyAccent = true;
-            workingCapsule += '.withArtists';
-            workingOptions.artists =
-              language.formatConjunctionList(slots.otherArtistLinks);
-          }
-
-          if (!html.isBlank(slots.annotation)) {
-            anyAccent = true;
-            workingCapsule += '.withAnnotation';
-            workingOptions.annotation = slots.annotation;
-          }
-
-          if (anyAccent) {
-            return language.$(workingCapsule, workingOptions);
-          } else {
-            return slots.content;
-          }
-        }))),
+        html.tags([
+          language.encapsulate(entryCapsule, workingCapsule => {
+            const workingOptions = {entry: slots.content};
+
+            if (!html.isBlank(slots.rereleaseTooltip)) {
+              workingCapsule += '.rerelease';
+              workingOptions.rerelease =
+                relations.textWithTooltip.slots({
+                  attributes: {class: 'rerelease'},
+                  text: language.$(entryCapsule, 'rerelease.term'),
+                  tooltip: slots.rereleaseTooltip,
+                });
+
+              return language.$(workingCapsule, workingOptions);
+            }
+
+            if (!html.isBlank(slots.firstReleaseTooltip)) {
+              workingCapsule += '.firstRelease';
+              workingOptions.firstRelease =
+                relations.textWithTooltip.slots({
+                  attributes: {class: 'first-release'},
+                  text: language.$(entryCapsule, 'firstRelease.term'),
+                  tooltip: slots.firstReleaseTooltip,
+                });
+
+              return language.$(workingCapsule, workingOptions);
+            }
+
+            let anyAccent = false;
+
+            if (!empty(slots.otherArtistLinks)) {
+              anyAccent = true;
+              workingCapsule += '.withArtists';
+              workingOptions.artists =
+                language.formatConjunctionList(slots.otherArtistLinks);
+            }
+
+            if (!html.isBlank(slots.annotation)) {
+              anyAccent = true;
+              workingCapsule += '.withAnnotation';
+              workingOptions.annotation = slots.annotation;
+            }
+
+            if (anyAccent) {
+              return language.$(workingCapsule, workingOptions);
+            } else {
+              return slots.content;
+            }
+          }),
+
+          html.tag('span', {class: 'origin-details'},
+            {[html.onlyIfContent]: true},
+
+            slots.originDetails),
+        ]))),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
index d0c5e14e..88c5ed54 100644
--- a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -43,6 +43,7 @@ export default {
         flash,
 
         annotation: entry.annotation,
+        annotationParts: entry.annotationParts,
       },
     });
 
@@ -88,10 +89,10 @@ export default {
           thing.commentary
             .filter(entry => entry.artists.includes(artist))
 
-            .filter(({annotation}) =>
+            .filter(entry =>
               (filterWikiEditorCommentary
-                ? annotation?.match(/^wiki editor/i)
-                : !annotation?.match(/^wiki editor/i)))
+                ? entry.isWikiEditorCommentary
+                : !entry.isWikiEditorCommentary))
 
             .map(entry => processEntry({thing, entry})));
 
@@ -184,11 +185,13 @@ export default {
     itemAnnotations:
       query.chunks
         .map(({chunk}) => chunk
-          .map(({annotation}) =>
+          .map(entry =>
             relation('transformContent',
               (filterWikiEditorCommentary
-                ? annotation?.replace(/^wiki editor(, )?/i, '')
-                : annotation)))),
+                ? entry.annotationParts
+                    .filter(part => part !== 'wiki editor')
+                    .join(', ')
+                : entry.annotation)))),
   }),
 
   data: (query, _artist, _filterWikiEditorCommentary) => ({
diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js
deleted file mode 100644
index c412b8f2..00000000
--- a/src/content/dependencies/generateColorStyleRules.js
+++ /dev/null
@@ -1,42 +0,0 @@
-export default {
-  contentDependencies: ['generateColorStyleVariables'],
-  extraDependencies: ['html'],
-
-  relations: (relation) => ({
-    variables:
-      relation('generateColorStyleVariables'),
-  }),
-
-  data: (color) => ({
-    color:
-      color ?? null,
-  }),
-
-  slots: {
-    color: {
-      validate: v => v.isColor,
-    },
-  },
-
-  generate(data, relations, slots) {
-    const color = data.color ?? slots.color;
-
-    if (!color) {
-      return '';
-    }
-
-    return [
-      `:root {`,
-      ...(
-        relations.variables
-          .slots({
-            color,
-            context: 'page-root',
-            mode: 'property-list',
-          })
-          .content
-          .map(line => line + ';')),
-      `}`,
-    ].join('\n');
-  },
-};
diff --git a/src/content/dependencies/generateColorStyleTag.js b/src/content/dependencies/generateColorStyleTag.js
new file mode 100644
index 00000000..2b1a21dd
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleTag.js
@@ -0,0 +1,51 @@
+export default {
+  contentDependencies: ['generateColorStyleVariables', 'generateStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+
+    variables:
+      relation('generateColorStyleVariables'),
+  }),
+
+  data: (color) => ({
+    color:
+      color ?? null,
+  }),
+
+  slots: {
+    color: {
+      validate: v => v.isColor,
+    },
+  },
+
+  generate(data, relations, slots, {html}) {
+    const color =
+      data.color ?? slots.color;
+
+    if (!color) {
+      return html.blank();
+    }
+
+    return relations.styleTag.slots({
+      attributes: [
+        {class: 'color-style'},
+        {'data-color': color},
+      ],
+
+      rules: [
+        {
+          select: ':root',
+          declare:
+            relations.variables.slots({
+              color,
+              context: 'page-root',
+              mode: 'declarations',
+            }).content,
+        },
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
index 5270dbe4..c872d0b6 100644
--- a/src/content/dependencies/generateColorStyleVariables.js
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -18,7 +18,7 @@ export default {
     },
 
     mode: {
-      validate: v => v.is('style', 'property-list'),
+      validate: v => v.is('style', 'declarations'),
       default: 'style',
     },
   },
@@ -50,15 +50,15 @@ export default {
       `--shadow-color: ${shadow}`,
     ];
 
-    let selectedProperties;
+    let selectedDeclarations;
 
     switch (slots.context) {
       case 'any-content':
-        selectedProperties = anyContent;
+        selectedDeclarations = anyContent;
         break;
 
       case 'image-box':
-        selectedProperties = [
+        selectedDeclarations = [
           `--primary-color: ${primary}`,
           `--dim-color: ${dim}`,
           `--deep-color: ${deep}`,
@@ -67,14 +67,14 @@ export default {
         break;
 
       case 'page-root':
-        selectedProperties = [
+        selectedDeclarations = [
           ...anyContent,
           `--page-primary-color: ${primary}`,
         ];
         break;
 
       case 'primary-only':
-        selectedProperties = [
+        selectedDeclarations = [
           `--primary-color: ${primary}`,
         ];
         break;
@@ -82,10 +82,10 @@ export default {
 
     switch (slots.mode) {
       case 'style':
-        return selectedProperties.join('; ');
+        return selectedDeclarations.join('; ');
 
-      case 'property-list':
-        return selectedProperties;
+      case 'declarations':
+        return selectedDeclarations.map(declaration => declaration + ';');
     }
   },
 };
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
index c93020f3..367de506 100644
--- a/src/content/dependencies/generateCommentaryEntry.js
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -12,14 +12,14 @@ export default {
 
   relations: (relation, entry) => ({
     artistLinks:
-      (!empty(entry.artists) && !entry.artistDisplayText
+      (!empty(entry.artists) && !entry.artistText
         ? entry.artists
             .map(artist => relation('linkArtist', artist))
         : null),
 
     artistsContent:
-      (entry.artistDisplayText
-        ? relation('transformContent', entry.artistDisplayText)
+      (entry.artistText
+        ? relation('transformContent', entry.artistText)
         : null),
 
     annotationContent:
diff --git a/src/content/dependencies/generateContentContentHeading.js b/src/content/dependencies/generateContentContentHeading.js
new file mode 100644
index 00000000..314ef197
--- /dev/null
+++ b/src/content/dependencies/generateContentContentHeading.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generateContentHeading'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _thing) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+  }),
+
+  data: (thing) => ({
+    name:
+      thing.name,
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    string: {
+      type: 'string',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.contentHeading.slots({
+      attributes: slots.attributes,
+
+      title:
+        language.$(slots.string, {
+          thing:
+            html.tag('i', data.name),
+        }),
+
+      stickyTitle:
+        language.$(slots.string, 'sticky'),
+    }),
+};
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 2bff4643..78a6103b 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -1,5 +1,6 @@
 export default {
   contentDependencies: [
+    'generateColorStyleAttribute',
     'generateCoverArtworkArtTagDetails',
     'generateCoverArtworkArtistDetails',
     'generateCoverArtworkOriginDetails',
@@ -10,6 +11,9 @@ export default {
   extraDependencies: ['html'],
 
   relations: (relation, artwork) => ({
+    colorStyleAttribute:
+      relation('generateColorStyleAttribute'),
+
     image:
       relation('image', artwork),
 
@@ -30,6 +34,11 @@ export default {
     attachAbove:
       artwork.attachAbove,
 
+    attachedArtworkIsMainArtwork:
+      (artwork.attachAbove
+        ? artwork.attachedArtwork.isMainArtwork
+        : null),
+
     color:
       artwork.thing.color ?? null,
 
@@ -41,7 +50,8 @@ export default {
     alt: {type: 'string'},
 
     color: {
-      validate: v => v.isColor,
+      validate: v => v.anyOf(v.isBoolean, v.isColor),
+      default: false,
     },
 
     mode: {
@@ -63,10 +73,7 @@ export default {
   generate(data, relations, slots, {html}) {
     const {image} = relations;
 
-    image.setSlots({
-      color: slots.color ?? data.color,
-      alt: slots.alt,
-    });
+    image.setSlot('alt', slots.alt);
 
     const square =
       (data.dimensions
@@ -79,6 +86,22 @@ export default {
       image.setSlot('dimensions', data.dimensions);
     }
 
+    const attributes = html.attributes();
+
+    let color = null;
+    if (typeof slots.color === 'boolean') {
+      if (slots.color) {
+        color = data.color;
+      }
+    } else if (slots.color) {
+      color = slots.color;
+    }
+
+    if (color) {
+      relations.colorStyleAttribute.setSlot('color', color);
+      attributes.add(relations.colorStyleAttribute);
+    }
+
     return html.tags([
       data.attachAbove &&
         html.tag('div', {class: 'cover-artwork-joiner'}),
@@ -87,6 +110,12 @@ export default {
         slots.mode === 'commentary' &&
           {class: 'commentary-art'},
 
+        data.attachAbove &&
+        data.attachedArtworkIsMainArtwork &&
+          {class: 'attached-artwork-is-main-artwork'},
+
+        attributes,
+
         (slots.mode === 'primary'
           ? [
               relations.image.slots({
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
index 3eb7c664..8628179e 100644
--- a/src/content/dependencies/generateCoverArtworkOriginDetails.js
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -15,8 +15,8 @@ export default {
       artwork.thing.constructor[Thing.referenceType],
 
     attachedArtistContribs:
-      (!artwork.isMainArtwork && artwork.mainArtwork && artwork.attachAbove
-        ? artwork.mainArtwork.artistContribs
+      (artwork.attachedArtwork
+        ? artwork.attachedArtwork.artistContribs
         : null)
   }),
 
@@ -29,6 +29,9 @@ export default {
     source:
       relation('transformContent', artwork.source),
 
+    originDetails:
+      relation('transformContent', artwork.originDetails),
+
     albumLink:
       (query.artworkThingType === 'album'
         ? relation('linkAlbum', artwork.thing)
@@ -58,6 +61,11 @@ export default {
         {class: 'origin-details'},
 
         (() => {
+          relations.datetimestamp?.setSlots({
+            style: 'year',
+            tooltip: true,
+          });
+
           const artworkBy =
             language.encapsulate(capsule, 'artworkBy', workingCapsule => {
               const workingOptions = {};
@@ -69,11 +77,7 @@ export default {
 
               if (relations.datetimestamp) {
                 workingCapsule += '.withYear';
-                workingOptions.year =
-                  relations.datetimestamp.slots({
-                    style: 'year',
-                    tooltip: true,
-                  });
+                workingOptions.year = relations.datetimestamp;
               }
 
               return relations.credit.slots({
@@ -111,22 +115,56 @@ export default {
                 workingOptions.label = data.label;
               }
 
+              if (html.isBlank(artworkBy) && relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
               return language.$(workingCapsule, workingOptions);
             });
 
           const label =
             html.isBlank(artworkBy) &&
             html.isBlank(source) &&
-            language.$(capsule, 'customLabelAlone', {
-              [language.onlyIfOptions]: ['label'],
-              label: data.label,
+            language.encapsulate(capsule, 'customLabel', workingCapsule => {
+              const workingOptions = {
+                [language.onlyIfOptions]: ['label'],
+                label: data.label,
+              };
+
+              if (relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
+              return language.$(workingCapsule, workingOptions);
             });
 
+          const year =
+            html.isBlank(artworkBy) &&
+            html.isBlank(source) &&
+            html.isBlank(label) &&
+            language.$(capsule, 'year', {
+              [language.onlyIfOptions]: ['year'],
+              year: relations.datetimestamp,
+            });
+
+          const originDetails =
+            html.tag('span', {class: 'origin-details'},
+              {[html.onlyIfContent]: true},
+
+              relations.originDetails.slots({
+                mode: 'inline',
+                absorbPunctuationFollowingExternalLinks: false,
+              }));
+
           return [
             artworkBy,
             trackArtFromAlbum,
             source,
             label,
+            year,
+            originDetails,
           ];
         })())),
 };
diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js
index 430f651e..0705d93e 100644
--- a/src/content/dependencies/generateCoverCarousel.js
+++ b/src/content/dependencies/generateCoverCarousel.js
@@ -19,7 +19,7 @@ export default {
       });
 
     if (empty(stitched)) {
-      return;
+      return html.blank();
     }
 
     const layout = getCarouselLayoutForNumberOfItems(stitched.length);
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index 29ac08b7..e4dfd905 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -15,6 +15,7 @@ export default {
     links: {validate: v => v.strictArrayOf(v.isHTML)},
     names: {validate: v => v.strictArrayOf(v.isHTML)},
     info: {validate: v => v.strictArrayOf(v.isHTML)},
+    notFromThisGroup: {validate: v => v.strictArrayOf(v.isBoolean)},
 
     // Differentiating from sparseArrayOf here - this list of classes should
     // have the same length as the items above, i.e. nulls aren't going to be
@@ -44,7 +45,18 @@ export default {
           link: slots.links,
           name: slots.names,
           info: slots.info,
-        }).map(({classes, image, link, name, info}, index) =>
+
+          notFromThisGroup:
+            slots.notFromThisGroup ??
+            Array.from(slots.links).fill(null)
+        }).map(({
+            classes,
+            image,
+            link,
+            name,
+            info,
+            notFromThisGroup,
+          }, index) =>
             link.slots({
               attributes: [
                 {class: ['grid-item', 'box']},
@@ -71,7 +83,15 @@ export default {
                 html.tag('span',
                   {[html.onlyIfContent]: true},
 
-                  language.sanitize(name)),
+                  (notFromThisGroup
+                    ? language.encapsulate('misc.coverGrid.details.notFromThisGroup', capsule =>
+                        language.$(capsule, {
+                          name,
+                          marker:
+                            html.tag('span', {class: 'grid-name-marker'},
+                              language.$(capsule, 'marker')),
+                        }))
+                    : language.sanitize(name))),
 
                 html.tag('span',
                   {[html.onlyIfContent]: true},
diff --git a/src/content/dependencies/generateExpandableGallerySection.js b/src/content/dependencies/generateExpandableGallerySection.js
new file mode 100644
index 00000000..122ca4b1
--- /dev/null
+++ b/src/content/dependencies/generateExpandableGallerySection.js
@@ -0,0 +1,92 @@
+export default {
+  contentDependencies: ['generateContentHeading'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+  }),
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    contentAboveCut: {
+      type: 'html',
+      mutable: false,
+    },
+
+    contentBelowCut: {
+      type: 'html',
+      mutable: false,
+    },
+
+    caption: {
+      type: 'html',
+      mutable: false,
+    },
+
+    expandCue: {
+      type: 'html',
+      mutable: false,
+    },
+
+    collapseCue: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    html.tag('section', {class: 'expandable-gallery-section'}, [
+      relations.contentHeading.slots({
+        tag: 'h2',
+        title: slots.title,
+      }),
+
+      html.tag('div', {class: 'section-content-above-cut'},
+        {[html.onlyIfContent]: true},
+
+        slots.contentAboveCut),
+
+      html.tag('div', {class: 'section-content-below-cut'},
+        {[html.onlyIfContent]: true},
+
+        !html.isBlank(slots.contentBelowCut) &&
+          {style: 'display: none'},
+
+        slots.contentBelowCut),
+
+      html.tag('div', {class: 'section-expando'},
+        {[html.onlyIfSiblings]: true},
+
+        html.tag('div', {class: 'section-expando-content'},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            html.tag('span', {class: 'section-caption'},
+              slots.caption),
+
+            !html.isBlank(slots.contentBelowCut) &&
+              language.$('misc.coverGrid.expandCollapseCue', {
+                cue:
+                  html.tag('a', {class: 'section-expando-toggle'},
+                    {href: '#'},
+
+                    {[html.joinChildren]: ''},
+                    {[html.noEdgeWhitespace]: true},
+
+                    [
+                      html.tag('span', {class: 'section-expand-cue'},
+                        slots.expandCue),
+
+                      html.tag('span', {class: 'section-collapse-cue'},
+                        {style: 'display: none'},
+                        slots.collapseCue),
+                    ]),
+              }),
+          ])),
+    ]),
+};
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 095e43c4..ee043bfa 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -4,6 +4,7 @@ export default {
   contentDependencies: [
     'generateAdditionalNamesBox',
     'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generateContributionList',
     'generateFlashActSidebar',
@@ -53,6 +54,9 @@ export default {
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', flash),
+
     flashActLink:
       relation('linkFlashAct', flash.act),
 
@@ -70,7 +74,7 @@ export default {
         .map(entry => relation('generateCommentaryEntry', entry)),
 
     creditSourceEntries:
-      flash.commentary
+      flash.creditingSources
         .map(entry => relation('generateCommentaryEntry', entry)),
   }),
 
@@ -133,11 +137,11 @@ export default {
                   })),
 
               !html.isBlank(relations.creditSourceEntries) &&
-                language.encapsulate(capsule, 'readCreditSources', capsule =>
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
                   language.$(capsule, {
                     link:
                       html.tag('a',
-                        {href: '#credit-sources'},
+                        {href: '#crediting-sources'},
                         language.$(capsule, 'link')),
                   })),
             ])),
@@ -168,20 +172,20 @@ export default {
           ]),
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
                 attributes: {id: 'artist-commentary'},
-                title: language.$('misc.artistCommentary'),
+                string: 'misc.artistCommentary',
               }),
 
             relations.artistCommentaryEntries,
           ]),
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
-                attributes: {id: 'credit-sources'},
-                title: language.$('misc.creditSources'),
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
               }),
 
             relations.creditSourceEntries,
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index d51366ca..dfdad0e8 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -1,14 +1,14 @@
 import {sortChronologically} from '#sort';
-import {empty, stitchArrays} from '#sugar';
 import {filterItemsForCarousel, getTotalDuration} from '#wiki-data';
 
 export default {
   contentDependencies: [
     'generateCoverCarousel',
-    'generateCoverGrid',
+    'generateGroupGalleryPageAlbumsByDateView',
+    'generateGroupGalleryPageAlbumsBySeriesView',
     'generateGroupNavLinks',
     'generateGroupSecondaryNav',
-    'generateGroupSidebar',
+    'generateIntrapageDotSwitcher',
     'generatePageLayout',
     'generateQuickDescription',
     'image',
@@ -21,79 +21,73 @@ export default {
   sprawl: ({wikiInfo}) =>
     ({enableGroupUI: wikiInfo.enableGroupUI}),
 
-  relations(relation, sprawl, group) {
-    const relations = {};
+  query(_sprawl, group) {
+    const query = {};
 
-    const albums =
+    query.allAlbums =
       sortChronologically(group.albums.slice(), {latestFirst: true});
 
-    relations.layout =
-      relation('generatePageLayout');
+    query.allTracks =
+      query.allAlbums.flatMap((album) => album.tracks);
 
-    relations.navLinks =
-      relation('generateGroupNavLinks', group);
+    query.carouselAlbums =
+      filterItemsForCarousel(group.featuredAlbums);
 
-    if (sprawl.enableGroupUI) {
-      relations.secondaryNav =
-        relation('generateGroupSecondaryNav', group);
+    return query;
+  },
 
-      relations.sidebar =
-        relation('generateGroupSidebar', group);
-    }
+  relations: (relation, query, sprawl, group) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
+    navLinks:
+      relation('generateGroupNavLinks', group),
 
-    if (!empty(carouselAlbums)) {
-      relations.coverCarousel =
-        relation('generateCoverCarousel');
+    secondaryNav:
+      (sprawl.enableGroupUI
+        ? relation('generateGroupSecondaryNav', group)
+        : null),
 
-      relations.carouselLinks =
-        carouselAlbums
-          .map(album => relation('linkAlbum', album));
+    coverCarousel:
+      relation('generateCoverCarousel'),
 
-      relations.carouselImages =
-        carouselAlbums
-          .map(album => relation('image', album.coverArtworks[0]));
-    }
+    carouselLinks:
+      query.carouselAlbums
+        .map(album => relation('linkAlbum', album)),
 
-    relations.quickDescription =
-      relation('generateQuickDescription', group);
+    carouselImages:
+      query.carouselAlbums
+        .map(album => relation('image', album.coverArtworks[0])),
 
-    relations.coverGrid =
-      relation('generateCoverGrid');
+    quickDescription:
+      relation('generateQuickDescription', group),
 
-    relations.gridLinks =
-      albums
-        .map(album => relation('linkAlbum', album));
+    albumViewSwitcher:
+      relation('generateIntrapageDotSwitcher'),
 
-    relations.gridImages =
-      albums.map(album =>
-        (album.hasCoverArt
-          ? relation('image', album.coverArtworks[0])
-          : relation('image')));
+    albumsBySeriesView:
+      relation('generateGroupGalleryPageAlbumsBySeriesView', group),
 
-    return relations;
-  },
+    albumsByDateView:
+      relation('generateGroupGalleryPageAlbumsByDateView', group),
+  }),
 
-  data(sprawl, group) {
-    const data = {};
+  data: (query, _sprawl, group) => ({
+    name:
+      group.name,
 
-    data.name = group.name;
-    data.color = group.color;
+    color:
+      group.color,
 
-    const albums = sortChronologically(group.albums.slice(), {latestFirst: true});
-    const tracks = albums.flatMap((album) => album.tracks);
+    numAlbums:
+      query.allAlbums.length,
 
-    data.numAlbums = albums.length;
-    data.numTracks = tracks.length;
-    data.totalDuration = getTotalDuration(tracks, {mainReleasesOnly: true});
+    numTracks:
+      query.allTracks.length,
 
-    data.gridNames = albums.map(album => album.name);
-    data.gridDurations = albums.map(album => getTotalDuration(album.tracks));
-    data.gridNumTracks = albums.map(album => album.tracks.length);
-
-    return data;
-  },
+    totalDuration:
+      getTotalDuration(query.allTracks, {mainReleasesOnly: true}),
+  }),
 
   generate: (data, relations, {html, language}) =>
     language.encapsulate('groupGalleryPage', pageCapsule =>
@@ -105,11 +99,10 @@ export default {
 
         mainClasses: ['top-index'],
         mainContent: [
-          relations.coverCarousel
-            ?.slots({
-              links: relations.carouselLinks,
-              images: relations.carouselImages,
-            }),
+          relations.coverCarousel.slots({
+            links: relations.carouselLinks,
+            images: relations.carouselImages,
+          }),
 
           relations.quickDescription,
 
@@ -134,49 +127,78 @@ export default {
                   })),
             })),
 
-          relations.coverGrid
-            .slots({
-              links: relations.gridLinks,
-              names: data.gridNames,
-
-              images:
-                stitchArrays({
-                  image: relations.gridImages,
-                  name: data.gridNames,
-                }).map(({image, name}) =>
-                    image.slots({
-                      missingSourceContent:
-                        language.$('misc.coverGrid.noCoverArt', {
-                          album: name,
-                        }),
-                    })),
-
-              info:
-                stitchArrays({
-                  numTracks: data.gridNumTracks,
-                  duration: data.gridDurations,
-                }).map(({numTracks, duration}) =>
-                    language.$('misc.coverGrid.details.albumLength', {
-                      tracks: language.countTracks(numTracks, {unit: true}),
-                      time: language.formatDuration(duration),
-                    })),
-            }),
+          ([
+            !html.isBlank(relations.albumsBySeriesView),
+            !html.isBlank(relations.albumsByDateView)
+          ]).filter(Boolean).length > 1 &&
+
+            language.encapsulate(pageCapsule, 'albumViewSwitcher', capsule =>
+              html.tag('p', {class: 'gallery-view-switcher'},
+                {class: ['drop', 'shiny']},
+
+                {[html.onlyIfContent]: true},
+                {[html.joinChildren]: html.tag('br')},
+
+                [
+                  language.$(capsule),
+
+                  relations.albumViewSwitcher.slots({
+                    initialOptionIndex: 0,
+
+                    titles: [
+                      !html.isBlank(relations.albumsByDateView) &&
+                        language.$(capsule, 'byDate'),
+
+                      !html.isBlank(relations.albumsBySeriesView) &&
+                        language.$(capsule, 'bySeries'),
+                    ].filter(Boolean),
+
+                    targetIDs: [
+                      !html.isBlank(relations.albumsByDateView) &&
+                        'group-album-gallery-by-date',
+
+                      !html.isBlank(relations.albumsBySeriesView) &&
+                        'group-album-gallery-by-series',
+                    ].filter(Boolean),
+                  }),
+                ])),
+
+          /*
+          data.trackGridLabels.some(value => value !== null) &&
+            html.tag('p', {class: 'gallery-set-switcher'},
+              language.encapsulate(pageCapsule, 'setSwitcher', switcherCapsule =>
+                language.$(switcherCapsule, {
+                  sets:
+                    relations.setSwitcher.slots({
+                      initialOptionIndex: 0,
+
+                      titles:
+                        data.trackGridLabels.map(label =>
+                          label ??
+                          language.$(switcherCapsule, 'unlabeledSet')),
+
+                      targetIDs:
+                        data.trackGridIDs,
+                    }),
+                }))),
+          */
+
+          relations.albumsByDateView,
+
+          relations.albumsBySeriesView.slots({
+            attributes: [
+              !html.isBlank(relations.albumsBySeriesView) &&
+                {style: 'display: none'},
+            ],
+          }),
         ],
 
-        leftSidebar:
-          (relations.sidebar
-            ? relations.sidebar
-                .slot('currentExtra', 'gallery')
-                .content /* TODO: Kludge. */
-            : null),
-
         navLinkStyle: 'hierarchical',
         navLinks:
           relations.navLinks
             .slot('currentExtra', 'gallery')
             .content,
 
-        secondaryNav:
-          relations.secondaryNav ?? null,
+        secondaryNav: relations.secondaryNav,
       })),
 };
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
new file mode 100644
index 00000000..7d9aa2d2
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
@@ -0,0 +1,66 @@
+import {stitchArrays} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'],
+  extraDependencies: ['language'],
+
+  relations: (relation, albums, _group) => ({
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      albums.map(album =>
+        relation('linkAlbum', album)),
+
+    images:
+      albums.map(album =>
+        (album.hasCoverArt
+          ? relation('image', album.coverArtworks[0])
+          : relation('image')))
+  }),
+
+  data: (albums, group) => ({
+    names:
+      albums.map(album => album.name),
+
+    durations:
+      albums.map(album => getTotalDuration(album.tracks)),
+
+    tracks:
+      albums.map(album => album.tracks.length),
+
+    notFromThisGroup:
+      albums.map(album => !album.groups.includes(group)),
+  }),
+
+  generate: (data, relations, {language}) =>
+    language.encapsulate('misc.coverGrid', capsule =>
+      relations.coverGrid.slots({
+        links: relations.links,
+        names: data.names,
+        notFromThisGroup: data.notFromThisGroup,
+
+        images:
+          stitchArrays({
+            image: relations.images,
+            name: data.names,
+          }).map(({image, name}) =>
+              image.slots({
+                missingSourceContent:
+                  language.$(capsule, 'noCoverArt', {
+                    album: name,
+                  }),
+              })),
+
+        info:
+          stitchArrays({
+            tracks: data.tracks,
+            duration: data.durations,
+          }).map(({tracks, duration}) =>
+              language.$(capsule, 'details.albumLength', {
+                tracks: language.countTracks(tracks, {unit: true}),
+                time: language.formatDuration(duration),
+              })),
+      })),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
new file mode 100644
index 00000000..b7d01eb5
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
@@ -0,0 +1,39 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: ['generateGroupGalleryPageAlbumGrid'],
+  extraDependencies: ['html', 'language'],
+
+  query: (group) => ({
+    albums:
+      sortChronologically(group.albums, {latestFirst: true}),
+  }),
+
+  relations: (relation, query, group) => ({
+    albumGrid:
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albums,
+        group),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('groupGalleryPage.albumsByDate', capsule =>
+      html.tag('div', {id: 'group-album-gallery-by-date'},
+        slots.attributes,
+
+        {[html.onlyIfContent]: true},
+
+        html.tag('section', [
+          html.tag('h2',
+            language.$(capsule, 'title')),
+
+          relations.albumGrid,
+        ]))),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js
new file mode 100644
index 00000000..0337275f
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js
@@ -0,0 +1,26 @@
+export default {
+  contentDependencies: ['generateGroupGalleryPageSeriesSection'],
+  extraDependencies: ['html'],
+
+  relations: (relation, group) => ({
+    seriesSections:
+      group.serieses
+        .map(series =>
+          relation('generateGroupGalleryPageSeriesSection', series)),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('div', {id: 'group-album-gallery-by-series'},
+      slots.attributes,
+
+      {[html.onlyIfContent]: true},
+
+      relations.seriesSections),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPageSeriesSection.js b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
new file mode 100644
index 00000000..2ccead5d
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
@@ -0,0 +1,156 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateExpandableGallerySection',
+    'generateGroupGalleryPageAlbumGrid',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(series) {
+    const query = {};
+
+    // Includes undated albums.
+    const albumsLatestFirst =
+      sortChronologically(series.albums, {latestFirst: true});
+
+    query.albumsAboveCut = albumsLatestFirst.slice(0, 4);
+    query.albumsBelowCut = albumsLatestFirst.slice(4);
+
+    query.allAlbumsDated =
+      series.albums.every(album => album.date);
+
+    query.anyAlbumNotFromThisGroup =
+      series.albums.some(album => !album.groups.includes(series.group));
+
+    query.latestAlbum =
+      albumsLatestFirst
+        .filter(album => album.date)
+        .at(0) ??
+      null;
+
+    query.earliestAlbum =
+      albumsLatestFirst
+        .filter(album => album.date)
+        .at(-1) ??
+      null;
+
+    return query;
+  },
+
+  relations: (relation, query, series) => ({
+    gallerySection:
+      relation('generateExpandableGallerySection'),
+
+    gridAboveCut:
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albumsAboveCut,
+        series.group),
+
+    gridBelowCut:
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albumsBelowCut,
+        series.group),
+  }),
+
+  data: (query, series) => ({
+    name:
+      series.name,
+
+    groupName:
+      series.group.name,
+
+    albums:
+      series.albums.length,
+
+    tracks:
+      series.albums
+        .flatMap(album => album.tracks)
+        .length,
+
+    allAlbumsDated:
+      query.allAlbumsDated,
+
+    anyAlbumNotFromThisGroup:
+      query.anyAlbumNotFromThisGroup,
+
+    earliestAlbumDate:
+      (query.earliestAlbum
+        ? query.earliestAlbum.date
+        : null),
+
+    latestAlbumDate:
+      (query.latestAlbum
+        ? query.latestAlbum.date
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupGalleryPage.albumSection', capsule =>
+      relations.gallerySection.slots({
+        title: data.name,
+
+        contentAboveCut: relations.gridAboveCut,
+        contentBelowCut: relations.gridBelowCut,
+
+        caption:
+          language.encapsulate(capsule, 'caption', captionCapsule =>
+            html.tags([
+              data.anyAlbumNotFromThisGroup &&
+                language.$(captionCapsule, 'seriesAlbumsNotFromGroup', {
+                  marker:
+                    language.$('misc.coverGrid.details.notFromThisGroup.marker'),
+
+                  series:
+                    html.tag('i', data.name),
+
+                  group: data.groupName,
+                }),
+
+              language.encapsulate(captionCapsule, workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.tracks =
+                  html.tag('b',
+                    language.countTracks(data.tracks, {unit: true}));
+
+                workingOptions.albums =
+                  html.tag('b',
+                    language.countAlbums(data.albums, {unit: true}));
+
+                if (data.allAlbumsDated) {
+                  const earliestDate = data.earliestAlbumDate;
+                  const latestDate = data.latestAlbumDate;
+
+                  const earliestYear = earliestDate.getFullYear();
+                  const latestYear = latestDate.getFullYear();
+
+                  if (earliestYear === latestYear) {
+                    if (data.albums === 1) {
+                      workingCapsule += '.withDate';
+                      workingOptions.date =
+                        language.formatDate(earliestDate);
+                    } else {
+                      workingCapsule += '.withYear';
+                      workingOptions.year =
+                        language.formatYear(earliestDate);
+                    }
+                  } else {
+                    workingCapsule += '.withYearRange';
+                    workingOptions.yearRange =
+                      language.formatYearRange(earliestDate, latestDate);
+                  }
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              }),
+            ], {[html.joinChildren]: html.tag('br')})),
+
+        expandCue:
+          language.$(capsule, 'expand'),
+
+        collapseCue:
+          language.$(capsule, 'collapse'),
+      })),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
index 99e7e8ff..4680cb46 100644
--- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -127,7 +127,8 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                html.metatag('chunkwrap', {split: ','},
+                // TODO: This is obviously evil.
+                html.metatag('chunkwrap', {split: /,| (?=and)/},
                   html.resolve(artistCredit)));
           }
 
diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js
new file mode 100644
index 00000000..0a929429
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateListAllAdditionalFilesChunk'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _album, additionalFiles) => ({
+    chunk:
+      relation('generateListAllAdditionalFilesChunk', additionalFiles),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots, {language}) =>
+    language.encapsulate('listingPage', slots.stringsKey, pageCapsule =>
+      relations.chunk.slots({
+        title:
+          language.$(pageCapsule, 'albumFiles'),
+
+        stringsKey: slots.stringsKey,
+      })),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js
new file mode 100644
index 00000000..a0af1375
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js
@@ -0,0 +1,51 @@
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateListAllAdditionalFilesAlbumChunk',
+    'generateListAllAdditionalFilesTrackChunk',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album, property) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    albumChunk:
+      relation('generateListAllAdditionalFilesAlbumChunk',
+        album,
+        album[property] ?? []),
+
+    trackChunks:
+      album.tracks.map(track =>
+        relation('generateListAllAdditionalFilesTrackChunk',
+          track,
+          track[property] ?? [])),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tags([
+      relations.heading.slots({
+        tag: 'h3',
+        title: relations.albumLink,
+      }),
+
+      html.tag('dl',
+        {[html.onlyIfContent]: true},
+
+        [
+          relations.albumChunk.slot('stringsKey', slots.stringsKey),
+
+          relations.trackChunks.map(trackChunk =>
+            trackChunk.slot('stringsKey', slots.stringsKey)),
+        ]),
+    ]),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
index deb8c4ea..df652efd 100644
--- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -1,90 +1,99 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
+  contentDependencies: ['linkAdditionalFile'],
   extraDependencies: ['html', 'language'],
 
+  relations: (relation, additionalFiles) => ({
+    links:
+      additionalFiles
+        .map(file => file.filenames
+          .map(filename => relation('linkAdditionalFile', file, filename))),
+  }),
+
+  data: (additionalFiles) => ({
+    titles:
+      additionalFiles
+        .map(file => file.title),
+
+    filenames:
+      additionalFiles
+        .map(file => file.filenames),
+  }),
+
   slots: {
     title: {
       type: 'html',
       mutable: false,
     },
 
-    additionalFileTitles: {
-      validate: v => v.strictArrayOf(v.isHTML),
-    },
-
-    additionalFileLinks: {
-      validate: v => v.strictArrayOf(v.strictArrayOf(v.isHTML)),
-    },
-
-    additionalFileFiles: {
-      validate: v => v.strictArrayOf(v.strictArrayOf(v.isString)),
-    },
-
     stringsKey: {type: 'string'},
   },
 
-  generate(slots, {html, language}) {
-    if (empty(slots.additionalFileLinks)) {
-      return html.blank();
-    }
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('listingPage', slots.stringsKey, pageCapsule =>
+      html.tags([
+        html.tag('dt',
+          {[html.onlyIfSiblings]: true},
+          slots.title),
 
-    return html.tags([
-      html.tag('dt', slots.title),
-      html.tag('dd',
-        html.tag('ul',
-          stitchArrays({
-            additionalFileTitle: slots.additionalFileTitles,
-            additionalFileLinks: slots.additionalFileLinks,
-            additionalFileFiles: slots.additionalFileFiles,
-          }).map(({
-              additionalFileTitle,
-              additionalFileLinks,
-              additionalFileFiles,
-            }) =>
-              language.encapsulate('listingPage', slots.stringsKey, 'file', capsule =>
-                (additionalFileLinks.length === 1
-                  ? html.tag('li',
-                      additionalFileLinks[0].slots({
-                        content:
-                          language.$(capsule, {
-                            title: additionalFileTitle,
-                          }),
-                      }))
+        html.tag('dd',
+          {[html.onlyIfContent]: true},
 
-               : additionalFileLinks.length === 0
-                  ? html.tag('li',
-                      language.$(capsule, 'withNoFiles', {
-                        title: additionalFileTitle,
-                      }))
+          html.tag('ul',
+          {[html.onlyIfContent]: true},
 
-                  : html.tag('li', {class: 'has-details'},
-                      html.tag('details', [
-                        html.tag('summary',
-                          html.tag('span',
-                            language.$(capsule, 'withMultipleFiles', {
-                              title:
-                                html.tag('b', additionalFileTitle),
+            stitchArrays({
+              title: data.titles,
+              links: relations.links,
+              filenames: data.filenames,
+            }).map(({
+                title,
+                links,
+                filenames,
+              }) =>
+                language.encapsulate(pageCapsule, 'file', capsule =>
+                  (links.length === 1
+                    ? html.tag('li',
+                        links[0].slots({
+                          content:
+                            language.$(capsule, {
+                              title: title,
+                            }),
+                        }))
 
-                              files:
-                                language.countAdditionalFiles(
-                                  additionalFileLinks.length,
-                                  {unit: true}),
-                            }))),
+                 : links.length === 0
+                    ? html.tag('li',
+                        language.$(capsule, 'withNoFiles', {
+                          title: title,
+                        }))
 
-                        html.tag('ul',
-                          stitchArrays({
-                            additionalFileLink: additionalFileLinks,
-                            additionalFileFile: additionalFileFiles,
-                          }).map(({additionalFileLink, additionalFileFile}) =>
-                              html.tag('li',
-                                additionalFileLink.slots({
-                                  content:
-                                    language.$(capsule, {
-                                      title: additionalFileFile,
-                                    }),
-                                })))),
-                      ]))))))),
-    ]);
-  },
+                    : html.tag('li', {class: 'has-details'},
+                        html.tag('details', [
+                          html.tag('summary',
+                            html.tag('span',
+                              language.$(capsule, 'withMultipleFiles', {
+                                title:
+                                  html.tag('b', title),
+
+                                files:
+                                  language.countAdditionalFiles(
+                                    links.length,
+                                    {unit: true}),
+                              }))),
+
+                          html.tag('ul',
+                            stitchArrays({
+                              link: links,
+                              filename: filenames,
+                            }).map(({link, filename}) =>
+                                html.tag('li',
+                                  link.slots({
+                                    content:
+                                      language.$(capsule, {
+                                        title: filename,
+                                      }),
+                                  })))),
+                        ]))))))),
+      ])),
 };
diff --git a/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js
new file mode 100644
index 00000000..b2e5addf
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js
@@ -0,0 +1,23 @@
+export default {
+  contentDependencies: ['generateListAllAdditionalFilesChunk', 'linkTrack'],
+  extraDependencies: ['html'],
+
+  relations: (relation, track, additionalFiles) => ({
+    trackLink:
+      relation('linkTrack', track),
+
+    chunk:
+      relation('generateListAllAdditionalFilesChunk', additionalFiles),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots) =>
+    relations.chunk.slots({
+      title: relations.trackLink,
+      stringsKey: slots.stringsKey,
+    }),
+};
+
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js
index 4f9c22f1..0c91ce0c 100644
--- a/src/content/dependencies/generateLyricsEntry.js
+++ b/src/content/dependencies/generateLyricsEntry.js
@@ -1,13 +1,33 @@
 export default {
-  contentDependencies: [
-    'transformContent',
-  ],
-
+  contentDependencies: ['linkArtist', 'linkExternal', 'transformContent'],
   extraDependencies: ['html', 'language'],
 
   relations: (relation, entry) => ({
     content:
       relation('transformContent', entry.body),
+
+    artistText:
+      relation('transformContent', entry.artistText),
+
+    artistLinks:
+      entry.artists
+        .filter(artist => artist.name !== 'HSMusic Wiki') // smh
+        .map(artist => relation('linkArtist', artist)),
+
+    sourceLinks:
+      entry.sourceURLs
+        .map(url => relation('linkExternal', url)),
+
+    originDetails:
+      relation('transformContent', entry.originDetails),
+  }),
+
+  data: (entry) => ({
+    isWikiLyrics:
+      entry.isWikiLyrics,
+
+    hasSquareBracketAnnotations:
+      entry.hasSquareBracketAnnotations,
   }),
 
   slots: {
@@ -17,9 +37,55 @@ export default {
     },
   },
 
-  generate: (relations, slots, {html}) =>
-    html.tag('div', {class: 'lyrics-entry'},
-      slots.attributes,
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('misc.lyrics', capsule =>
+      html.tag('div', {class: 'lyrics-entry'},
+        slots.attributes,
+
+        [
+          html.tag('p', {class: 'lyrics-details'},
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            [
+              language.$(capsule, 'source', {
+                [language.onlyIfOptions]: ['source'],
+
+                source:
+                  language.formatUnitList(
+                    relations.sourceLinks.map(link =>
+                      link.slots({
+                        indicateExternal: true,
+                        tab: 'separate',
+                      }))),
+              }),
+
+              data.isWikiLyrics &&
+                language.$(capsule, 'contributors', {
+                  [language.onlyIfOptions]: ['contributors'],
+
+                  contributors:
+                    (html.isBlank(relations.artistText)
+                      ? language.formatUnitList(relations.artistLinks)
+                      : relations.artistText.slot('mode', 'inline')),
+                }),
+
+              // This check is doubled up only for clarity: entries are coded
+              // in data so that `hasSquareBracketAnnotations` is only true
+              // if `isWikiLyrics` is also true.
+              data.isWikiLyrics &&
+              data.hasSquareBracketAnnotations &&
+                language.$(capsule, 'squareBracketAnnotations'),
+            ]),
+
+          html.tag('p', {class: 'origin-details'},
+            {[html.onlyIfContent]: true},
+
+            relations.originDetails.slots({
+              mode: 'inline',
+              absorbPunctuationFollowingExternalLinks: false,
+            })),
 
-      relations.content.slot('mode', 'lyrics')),
+          relations.content.slot('mode', 'lyrics'),
+        ])),
 };
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 8dad97e0..0326f415 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -3,12 +3,14 @@ import {atOffset, empty, repeat} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateColorStyleRules',
+    'generateColorStyleTag',
     'generateFooterLocalizationLinks',
     'generateImageOverlay',
     'generatePageSidebar',
     'generateSearchSidebarBox',
+    'generateStaticURLStyleTag',
     'generateStickyHeadingContainer',
+    'generateWikiWallpaperStyleTag',
     'transformContent',
   ],
 
@@ -58,8 +60,14 @@ export default {
         relation('transformContent', sprawl.footerContent);
     }
 
-    relations.colorStyleRules =
-      relation('generateColorStyleRules');
+    relations.colorStyleTag =
+      relation('generateColorStyleTag');
+
+    relations.staticURLStyleTag =
+      relation('generateStaticURLStyleTag');
+
+    relations.wikiWallpaperStyleTag =
+      relation('generateWikiWallpaperStyleTag');
 
     relations.imageOverlay =
       relation('generateImageOverlay');
@@ -107,9 +115,9 @@ export default {
 
     color: {validate: v => v.isColor},
 
-    styleRules: {
-      validate: v => v.sparseArrayOf(v.isHTML),
-      default: [],
+    styleTags: {
+      type: 'html',
+      mutable: false,
     },
 
     mainClasses: {
@@ -268,12 +276,16 @@ export default {
       const maybeTemplate =
         apparentFirst(slots.artworkColumnContent);
 
+      if (!maybeTemplate) return null;
+
       const maybeTemplateContent =
         html.resolve(maybeTemplate, {normalize: 'tag'});
 
       const maybeCoverArtwork =
         apparentFirst(maybeTemplateContent);
 
+      if (!maybeCoverArtwork) return null;
+
       if (maybeCoverArtwork.attributes.has('class', 'cover-artwork')) {
         return maybeTemplate;
       } else {
@@ -577,29 +589,33 @@ export default {
               {id: 'additional-files', string: 'additionalFiles'},
               {id: 'commentary', string: 'commentary'},
               {id: 'artist-commentary', string: 'artistCommentary'},
-              {id: 'credit-sources', string: 'creditSources'},
+              {id: 'crediting-sources', string: 'creditingSources'},
+              {id: 'referencing-sources', string: 'referencingSources'},
             ])),
         ]);
 
-    const styleRulesCSS =
-      html.resolve(slots.styleRules, {normalize: 'string'});
+    const slottedStyleTags =
+      html.smush(slots.styleTags);
+
+    const slottedWallpaperStyleTag =
+      slottedStyleTags.content
+        .find(tag => tag.attributes.has('class', 'wallpaper-style'));
 
-    const fallbackBackgroundStyleRule =
-      (styleRulesCSS.match(/body::before[^}]*background-image:/)
-        ? ''
-        : `body::before {\n` +
-          `    background-image: url("${to('media.path', 'bg.jpg')}");\n` +
-          `}`);
+    const fallbackWallpaperStyleTag =
+      (slottedWallpaperStyleTag
+        ? html.blank()
+        : relations.wikiWallpaperStyleTag);
 
-    const goshFrigginDarnitStyleRule =
-      `.image-media-link::after {\n` +
-      `    mask-image: url("${to('staticMisc.path', 'image.svg')}");\n` +
-      `}`;
+    const usingWallpaperStyleTag =
+      (slottedWallpaperStyleTag
+        ? slottedWallpaperStyleTag
+        : html.resolve(fallbackWallpaperStyleTag, {normalize: 'tag'}));
 
     const numWallpaperParts =
-      html.resolve(slots.styleRules, {normalize: 'string'})
-        .match(/\.wallpaper-part:nth-child/g)
-        ?.length ?? 0;
+      (usingWallpaperStyleTag &&
+       usingWallpaperStyleTag.attributes.has('data-wallpaper-mode', 'parts')
+        ? parseInt(usingWallpaperStyleTag.attributes.get('data-num-wallpaper-parts'))
+        : 0);
 
     const wallpaperPartsHTML =
       html.tag('div', {class: 'wallpaper-parts'},
@@ -741,14 +757,14 @@ export default {
               href: to('staticCSS.path', 'site.css'),
             }),
 
-            html.tag('style', [
-              relations.colorStyleRules
-                .slot('color', slots.color ?? data.wikiColor),
+            relations.colorStyleTag
+              .slot('color', slots.color ?? data.wikiColor),
 
-              fallbackBackgroundStyleRule,
-              goshFrigginDarnitStyleRule,
-              slots.styleRules,
-            ]),
+            relations.staticURLStyleTag,
+
+            fallbackWallpaperStyleTag,
+
+            slottedStyleTags,
 
             html.tag('script', {
               src: to('staticLib.path', 'chroma-js/chroma.min.js'),
diff --git a/src/content/dependencies/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js
index 154b4762..83451eca 100644
--- a/src/content/dependencies/generateReferencedArtworksPage.js
+++ b/src/content/dependencies/generateReferencedArtworksPage.js
@@ -47,7 +47,7 @@ export default {
   }),
 
   slots: {
-    styleRules: {type: 'html', mutable: false},
+    styleTags: {type: 'html', mutable: false},
 
     title: {type: 'html', mutable: false},
 
@@ -62,7 +62,7 @@ export default {
         subtitle: language.$(pageCapsule, 'subtitle'),
 
         color: data.color,
-        styleRules: slots.styleRules,
+        styleTags: slots.styleTags,
 
         artworkColumnContent:
           relations.cover.slots({
diff --git a/src/content/dependencies/generateReferencingArtworksPage.js b/src/content/dependencies/generateReferencingArtworksPage.js
index 55977b37..e97b01f8 100644
--- a/src/content/dependencies/generateReferencingArtworksPage.js
+++ b/src/content/dependencies/generateReferencingArtworksPage.js
@@ -47,7 +47,7 @@ export default {
   }),
 
   slots: {
-    styleRules: {type: 'html', mutable: false},
+    styleTags: {type: 'html', mutable: false},
 
     title: {type: 'html', mutable: false},
 
@@ -62,7 +62,7 @@ export default {
         subtitle: language.$(pageCapsule, 'subtitle'),
 
         color: data.color,
-        styleRules: slots.styleRules,
+        styleTags: slots.styleTags,
 
         artworkColumnContent:
           relations.cover.slots({
diff --git a/src/content/dependencies/generateReleaseInfoListenLine.js b/src/content/dependencies/generateReleaseInfoListenLine.js
new file mode 100644
index 00000000..f2a6dd29
--- /dev/null
+++ b/src/content/dependencies/generateReleaseInfoListenLine.js
@@ -0,0 +1,150 @@
+import {isExternalLinkContext} from '#external-links';
+import {empty, stitchArrays, unique} from '#sugar';
+
+function getReleaseContext(urlString, {
+  _artistURLs,
+  albumArtistURLs,
+}) {
+  const composerBandcampDomains =
+    albumArtistURLs
+      .filter(url => url.hostname.endsWith('.bandcamp.com'))
+      .map(url => url.hostname);
+
+  const url = new URL(urlString);
+
+  if (url.hostname === 'homestuck.bandcamp.com') {
+    return 'officialRelease';
+  }
+
+  if (composerBandcampDomains.includes(url.hostname)) {
+    return 'composerRelease';
+  }
+
+  return null;
+}
+
+export default {
+  contentDependencies: ['linkExternal'],
+  extraDependencies: ['html', 'language'],
+
+  query(thing) {
+    const query = {};
+
+    query.album =
+      (thing.album
+        ? thing.album
+        : thing);
+
+    query.artists =
+      thing.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.artistGroups =
+      query.artists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    query.albumArtists =
+      query.album.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.albumArtistGroups =
+      query.albumArtists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    return query;
+  },
+
+  relations: (relation, _query, thing) => ({
+    links:
+      thing.urls.map(url => relation('linkExternal', url)),
+  }),
+
+  data(query, thing) {
+    const data = {};
+
+    data.name = thing.name;
+
+    const artistURLs =
+      unique([
+        ...query.artists.flatMap(artist => artist.urls),
+        ...query.artistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const albumArtistURLs =
+      unique([
+        ...query.albumArtists.flatMap(artist => artist.urls),
+        ...query.albumArtistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const boundGetReleaseContext = urlString =>
+      getReleaseContext(urlString, {
+        artistURLs,
+        albumArtistURLs,
+      });
+
+    let releaseContexts =
+      thing.urls.map(boundGetReleaseContext);
+
+    const albumReleaseContexts =
+      query.album.urls.map(boundGetReleaseContext);
+
+    const presentReleaseContexts =
+      unique(releaseContexts.filter(Boolean));
+
+    const presentAlbumReleaseContexts =
+      unique(albumReleaseContexts.filter(Boolean));
+
+    if (
+      presentReleaseContexts.length <= 1 &&
+      presentAlbumReleaseContexts.length <= 1
+    ) {
+      releaseContexts =
+        thing.urls.map(() => null);
+    }
+
+    data.releaseContexts = releaseContexts;
+
+    return data;
+  },
+
+  slots: {
+    visibleWithoutLinks: {
+      type: 'boolean',
+      default: false,
+    },
+
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('releaseInfo.listenOn', capsule =>
+      (empty(relations.links) && slots.visibleWithoutLinks
+        ? language.$(capsule, 'noLinks', {
+            name:
+              html.tag('i', data.name),
+          })
+
+        : language.$('releaseInfo.listenOn', {
+            [language.onlyIfOptions]: ['links'],
+
+            links:
+              language.formatDisjunctionList(
+                stitchArrays({
+                  link: relations.links,
+                  releaseContext: data.releaseContexts,
+                }).map(({link, releaseContext}) =>
+                    link.slot('context', [
+                      ...
+                      (Array.isArray(slots.context)
+                        ? slots.context
+                        : [slots.context]),
+
+                      releaseContext,
+                    ]))),
+          }))),
+};
diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js
index 226152c7..931352b4 100644
--- a/src/content/dependencies/generateStaticPage.js
+++ b/src/content/dependencies/generateStaticPage.js
@@ -23,17 +23,19 @@ export default {
         title: data.name,
         headingMode: 'sticky',
 
-        styleRules:
-          (data.stylesheet
-            ? [data.stylesheet]
-            : []),
+        styleTags: [
+          html.tag('style', {class: 'static-page-style'},
+            {[html.onlyIfContent]: true},
+            data.stylesheet),
+        ],
 
         mainClasses: ['long-content'],
         mainContent: [
           relations.content,
 
-          data.script &&
-            html.tag('script', data.script),
+          html.tag('script',
+            {[html.onlyIfContent]: true},
+            data.script),
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateStaticURLStyleTag.js b/src/content/dependencies/generateStaticURLStyleTag.js
new file mode 100644
index 00000000..b927e5d6
--- /dev/null
+++ b/src/content/dependencies/generateStaticURLStyleTag.js
@@ -0,0 +1,23 @@
+export default {
+  contentDependencies: ['generateStyleTag'],
+  extraDependencies: ['to'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+  }),
+
+  generate: (relations, {to}) =>
+    relations.styleTag.slots({
+      attributes: {class: 'static-url-style'},
+
+      rules: [
+        {
+          select: '.image-media-link::after',
+          declare: [
+            `mask-image: url("${to('staticMisc.path', 'image.svg')}");`
+          ],
+        },
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateStyleTag.js b/src/content/dependencies/generateStyleTag.js
new file mode 100644
index 00000000..5ed09ae5
--- /dev/null
+++ b/src/content/dependencies/generateStyleTag.js
@@ -0,0 +1,48 @@
+import {empty} from '#sugar';
+
+const indent = text =>
+  text
+    .split('\n')
+    .map(line => ' '.repeat(4) + line)
+    .join('\n');
+
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    rules: {
+      validate: v =>
+        v.looseArrayOf(
+          v.validateProperties({
+            select: v.isString,
+            declare: v.looseArrayOf(v.isString),
+          })),
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('style', slots.attributes,
+      {[html.onlyIfContent]: true},
+
+      slots.rules
+        .filter(Boolean)
+
+        .map(rule => ({
+          select: rule.select,
+          declare: rule.declare.filter(Boolean),
+        }))
+
+        .filter(rule => !empty(rule.declare))
+
+        .map(rule =>
+          `${rule.select} {\n` +
+          indent(rule.declare.join('\n')) + '\n' +
+          `}`)
+
+        .join('\n\n')),
+};
diff --git a/src/content/dependencies/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js
index e3041d3a..c7e7f0f8 100644
--- a/src/content/dependencies/generateTrackArtistCommentarySection.js
+++ b/src/content/dependencies/generateTrackArtistCommentarySection.js
@@ -2,8 +2,8 @@ import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateContentContentHeading',
     'generateCommentaryEntry',
-    'generateContentHeading',
     'linkAlbum',
     'linkTrack',
   ],
@@ -18,8 +18,8 @@ export default {
   }),
 
   relations: (relation, query, track) => ({
-    contentHeading:
-      relation('generateContentHeading'),
+    contentContentHeading:
+      relation('generateContentContentHeading', track),
 
     mainReleaseTrackLink:
       (track.isSecondaryRelease
@@ -78,54 +78,44 @@ export default {
   generate: (data, relations, {html, language}) =>
     language.encapsulate('misc.artistCommentary', capsule =>
       html.tags([
-        relations.contentHeading.clone()
-          .slots({
-            attributes: {id: 'artist-commentary'},
-            title: language.$('misc.artistCommentary'),
-          }),
+        relations.contentContentHeading.slots({
+          attributes: {id: 'artist-commentary'},
+          string: 'misc.artistCommentary',
+        }),
+
+        relations.artistCommentaryEntries,
 
         data.isSecondaryRelease &&
-          html.tags([
-            html.tag('p', {class: ['drop', 'commentary-drop']},
-              {[html.onlyIfSiblings]: true},
-
-              language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => {
-                const workingOptions = {};
-
-                workingOptions.album =
-                  relations.mainReleaseTrackLink.slots({
-                    content:
-                      data.mainReleaseAlbumName,
-
-                    color:
-                      data.mainReleaseAlbumColor,
-                  });
-
-                if (data.name !== data.mainReleaseName) {
-                  workingCapsule += '.namedDifferently';
-                  workingOptions.name =
-                    html.tag('i', data.mainReleaseName);
-                }
-
-                return language.$(workingCapsule, workingOptions);
-              })),
-
-            relations.mainReleaseArtistCommentaryEntries,
-          ]),
-
-        html.tags([
-          data.isSecondaryRelease &&
-          !html.isBlank(relations.mainReleaseArtistCommentaryEntries) &&
-            html.tag('p', {class: ['drop', 'commentary-drop']},
-              {[html.onlyIfSiblings]: true},
-
-              language.$(capsule, 'info.releaseSpecific', {
-                album:
-                  relations.thisReleaseAlbumLink,
-              })),
-
-          relations.artistCommentaryEntries,
-        ]),
+          html.tag('div', {class: 'inherited-commentary-section'},
+            {[html.onlyIfContent]: true},
+
+            [
+              html.tag('p', {class: ['drop', 'commentary-drop']},
+                {[html.onlyIfSiblings]: true},
+
+                language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => {
+                  const workingOptions = {};
+
+                  workingOptions.album =
+                    relations.mainReleaseTrackLink.slots({
+                      content:
+                        data.mainReleaseAlbumName,
+
+                      color:
+                        data.mainReleaseAlbumColor,
+                    });
+
+                  if (data.name !== data.mainReleaseName) {
+                    workingCapsule += '.namedDifferently';
+                    workingOptions.name =
+                      html.tag('i', data.mainReleaseName);
+                  }
+
+                  return language.$(workingCapsule, workingOptions);
+                })),
+
+              relations.mainReleaseArtistCommentaryEntries,
+            ]),
 
         html.tag('p', {class: ['drop', 'commentary-drop']},
           {[html.onlyIfContent]: true},
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 11d179ad..6c16ce27 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,12 +1,13 @@
 export default {
   contentDependencies: [
+    'generateAdditionalFilesList',
     'generateAdditionalNamesBox',
-    'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generateContributionList',
     'generateLyricsSection',
@@ -38,8 +39,8 @@ export default {
     layout:
       relation('generatePageLayout'),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', track.album, track),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
 
     socialEmbed:
       relation('generateTrackSocialEmbed', track),
@@ -65,6 +66,9 @@ export default {
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', track),
+
     releaseInfo:
       relation('generateTrackReleaseInfo', track),
 
@@ -95,25 +99,23 @@ export default {
       relation('generateLyricsSection', track.lyrics),
 
     sheetMusicFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        track.album,
-        track.sheetMusicFiles),
+      relation('generateAdditionalFilesList', track.sheetMusicFiles),
 
     midiProjectFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        track.album,
-        track.midiProjectFiles),
+      relation('generateAdditionalFilesList', track.midiProjectFiles),
 
     additionalFilesList:
-      relation('generateAlbumAdditionalFilesList',
-        track.album,
-        track.additionalFiles),
+      relation('generateAdditionalFilesList', track.additionalFiles),
 
     artistCommentarySection:
       relation('generateTrackArtistCommentarySection', track),
 
-    creditSourceEntries:
-      track.creditSources
+    creditingSourceEntries:
+      track.creditingSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
+
+    referencingSourceEntries:
+      track.referencingSources
         .map(entry => relation('generateCommentaryEntry', entry)),
   }),
 
@@ -138,7 +140,7 @@ export default {
         additionalNames: relations.additionalNamesBox,
 
         color: data.color,
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         artworkColumnContent:
           relations.artworkColumn,
@@ -187,12 +189,21 @@ export default {
                         language.$(capsule, 'link')),
                   })),
 
-              !html.isBlank(relations.creditSourceEntries) &&
-                language.encapsulate(capsule, 'readCreditSources', capsule =>
+              !html.isBlank(relations.creditingSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#crediting-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.referencingSourceEntries) &&
+                language.encapsulate(capsule, 'readReferencingSources', capsule =>
                   language.$(capsule, {
                     link:
                       html.tag('a',
-                        {href: '#credit-sources'},
+                        {href: '#referencing-sources'},
                         language.$(capsule, 'link')),
                   })),
             ])),
@@ -344,13 +355,23 @@ export default {
           relations.artistCommentarySection,
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
+              .slots({
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
+              }),
+
+            relations.creditingSourceEntries,
+          ]),
+
+          html.tags([
+            relations.contentContentHeading.clone()
               .slots({
-                attributes: {id: 'credit-sources'},
-                title: language.$('misc.creditSources'),
+                attributes: {id: 'referencing-sources'},
+                string: 'misc.referencingSources',
               }),
 
-            relations.creditSourceEntries,
+            relations.referencingSourceEntries,
           ]),
         ],
 
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
index 887b6f03..3c850a18 100644
--- a/src/content/dependencies/generateTrackListItem.js
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -97,7 +97,8 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                html.metatag('chunkwrap', {split: ','},
+                // TODO: This is obviously evil.
+                html.metatag('chunkwrap', {split: /,| (?=and)/},
                   html.resolve(relations.credit)));
           }
 
diff --git a/src/content/dependencies/generateTrackReferencedArtworksPage.js b/src/content/dependencies/generateTrackReferencedArtworksPage.js
index 93438c5b..7073409e 100644
--- a/src/content/dependencies/generateTrackReferencedArtworksPage.js
+++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js
@@ -1,6 +1,6 @@
 export default {
   contentDependencies: [
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToTrackLink',
     'generateReferencedArtworksPage',
     'generateTrackNavLinks',
@@ -12,8 +12,8 @@ export default {
     page:
       relation('generateReferencedArtworksPage', track.trackArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', track.album, track),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
 
     navLinks:
       relation('generateTrackNavLinks', track),
@@ -35,7 +35,7 @@ export default {
             data.name,
         }),
 
-      styleRules: [relations.albumStyleRules],
+      styleTags: relations.albumStyleTags,
 
       navLinks:
         html.resolve(
diff --git a/src/content/dependencies/generateTrackReferencingArtworksPage.js b/src/content/dependencies/generateTrackReferencingArtworksPage.js
index e9818bad..a45144c8 100644
--- a/src/content/dependencies/generateTrackReferencingArtworksPage.js
+++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js
@@ -1,6 +1,6 @@
 export default {
   contentDependencies: [
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateBackToTrackLink',
     'generateReferencingArtworksPage',
     'generateTrackNavLinks',
@@ -12,8 +12,8 @@ export default {
     page:
       relation('generateReferencingArtworksPage', track.trackArtworks[0]),
 
-    albumStyleRules:
-      relation('generateAlbumStyleRules', track.album, track),
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
 
     navLinks:
       relation('generateTrackNavLinks', track),
@@ -35,7 +35,7 @@ export default {
             data.name,
         }),
 
-      styleRules: [relations.albumStyleRules],
+      styleTags: relations.albumStyleTags,
 
       navLinks:
         html.resolve(
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/generateTrackSocialEmbed.js b/src/content/dependencies/generateTrackSocialEmbed.js
index 7cb37af2..310816f3 100644
--- a/src/content/dependencies/generateTrackSocialEmbed.js
+++ b/src/content/dependencies/generateTrackSocialEmbed.js
@@ -26,14 +26,12 @@ export default {
     data.trackDirectory = track.directory;
     data.albumDirectory = album.directory;
 
+    data.hasImage = track.hasUniqueCoverArt || album.hasCoverArt;
+
     if (track.hasUniqueCoverArt) {
-      data.imageSource = 'track';
-      data.coverArtFileExtension = track.coverArtFileExtension;
+      data.imagePath = track.trackArtworks[0].path;
     } else if (album.hasCoverArt) {
-      data.imageSource = 'album';
-      data.coverArtFileExtension = album.coverArtFileExtension;
-    } else {
-      data.imageSource = 'none';
+      data.imagePath = album.coverArtworks[0].path;
     }
 
     return data;
@@ -59,10 +57,8 @@ export default {
           absoluteTo('localized.album', data.albumDirectory),
 
         imagePath:
-          (data.imageSource === 'album'
-            ? ['media.albumCover', data.albumDirectory, data.coverArtFileExtension]
-         : data.imageSource === 'track'
-            ? ['media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension]
+          (data.hasImage
+            ? data.imagePath
             : null),
       })),
 };
diff --git a/src/content/dependencies/generateWallpaperStyleTag.js b/src/content/dependencies/generateWallpaperStyleTag.js
new file mode 100644
index 00000000..bf094300
--- /dev/null
+++ b/src/content/dependencies/generateWallpaperStyleTag.js
@@ -0,0 +1,80 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateStyleTag'],
+  extraDependencies: ['html', 'to'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+  }),
+
+  slots: {
+    singleWallpaperPath: {
+      validate: v => v.strictArrayOf(v.isString),
+    },
+
+    singleWallpaperStyle: {
+      validate: v => v.isString,
+    },
+
+    wallpaperPartPaths: {
+      validate: v =>
+        v.strictArrayOf(v.optional(v.strictArrayOf(v.isString))),
+    },
+
+    wallpaperPartStyles: {
+      validate: v =>
+        v.strictArrayOf(v.optional(v.isString)),
+    },
+  },
+
+  generate(relations, slots, {html, to}) {
+    const attributes = html.attributes();
+    const rules = [];
+
+    attributes.add('class', 'wallpaper-style');
+
+    if (empty(slots.wallpaperPartPaths)) {
+      attributes.set('data-wallpaper-mode', 'one');
+
+      rules.push({
+        select: 'body::before',
+        declare: [
+          `background-image: url("${to(...slots.singleWallpaperPath)}");`,
+          slots.singleWallpaperStyle,
+        ],
+      });
+    } else {
+      attributes.set('data-wallpaper-mode', 'parts');
+      attributes.set('data-num-wallpaper-parts', slots.wallpaperPartPaths.length);
+
+      stitchArrays({
+        path: slots.wallpaperPartPaths,
+        style: slots.wallpaperPartStyles,
+      }).forEach(({path, style}, index) => {
+          rules.push({
+            select: `.wallpaper-part:nth-child(${index + 1})`,
+            declare: [
+              path && `background-image: url("${to(...path)}");`,
+              style,
+            ],
+          });
+        });
+
+      rules.push({
+        select: 'body::before',
+        declare: [
+          'display: none;',
+        ],
+      });
+    }
+
+    relations.styleTag.setSlots({
+      attributes,
+      rules,
+    });
+
+    return relations.styleTag;
+  },
+};
diff --git a/src/content/dependencies/generateWikiWallpaperStyleTag.js b/src/content/dependencies/generateWikiWallpaperStyleTag.js
new file mode 100644
index 00000000..12d27304
--- /dev/null
+++ b/src/content/dependencies/generateWikiWallpaperStyleTag.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateWallpaperStyleTag'],
+  extraDependencies: ['wikiData'],
+
+  sprawl: ({wikiInfo}) => ({wikiInfo}),
+
+  relations: (relation) => ({
+    wallpaperStyleTag:
+      relation('generateWallpaperStyleTag'),
+  }),
+
+  data: ({wikiInfo}) => ({
+    singleWallpaperPath: [
+      'media.path',
+      'bg.' + wikiInfo.wikiWallpaperFileExtension,
+    ],
+
+    singleWallpaperStyle:
+      wikiInfo.wikiWallpaperStyle,
+
+    wallpaperPartPaths:
+      wikiInfo.wikiWallpaperParts.map(part =>
+        (part.asset
+          ? ['media.path', part.asset]
+          : null)),
+
+    wallpaperPartStyles:
+      wikiInfo.wikiWallpaperParts.map(part => part.style),
+  }),
+
+  generate: (data, relations) =>
+    relations.wallpaperStyleTag.slots({
+      singleWallpaperPath: data.singleWallpaperPath,
+      singleWallpaperStyle: data.singleWallpaperStyle,
+      wallpaperPartPaths: data.wallpaperPartPaths,
+      wallpaperPartStyles: data.wallpaperPartStyles,
+    }),
+};
diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js
index a5009804..25d7324f 100644
--- a/src/content/dependencies/index.js
+++ b/src/content/dependencies/index.js
@@ -11,6 +11,11 @@ import {colors, logWarn} from '#cli';
 import contentFunction, {ContentFunctionSpecError} from '#content-function';
 import {annotateFunction} from '#sugar';
 
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const codeSrcPath = path.resolve(__dirname, '..');
+const codeRootPath = path.resolve(codeSrcPath, '..');
+
 function cachebust(filePath) {
   if (filePath in cachebust.cache) {
     cachebust.cache[filePath] += 1;
@@ -42,7 +47,9 @@ export function watchContentDependencies({
     close,
   });
 
-  const eslint = new ESLint();
+  const eslint = new ESLint({
+    cwd: codeRootPath,
+  });
 
   const metaPath = fileURLToPath(import.meta.url);
   const metaDirname = path.dirname(metaPath);
diff --git a/src/content/dependencies/linkAdditionalFile.js b/src/content/dependencies/linkAdditionalFile.js
new file mode 100644
index 00000000..a8a940b1
--- /dev/null
+++ b/src/content/dependencies/linkAdditionalFile.js
@@ -0,0 +1,29 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  query: (file, filename) => ({
+    index:
+      file.filenames.indexOf(filename),
+  }),
+
+  relations: (relation, _query, _file, _filename) => ({
+    linkTemplate:
+      relation('linkTemplate'),
+  }),
+
+  data: (query, file, filename) => ({
+    filename,
+
+    // Kinda jank, but eh.
+    path:
+      (query.index >= 0
+        ? file.paths.at(query.index)
+        : null),
+  }),
+
+  generate: (data, relations) =>
+    relations.linkTemplate.slots({
+      path: data.path,
+      content: data.filename,
+    }),
+};
diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js
deleted file mode 100644
index 39e7111e..00000000
--- a/src/content/dependencies/linkAlbumAdditionalFile.js
+++ /dev/null
@@ -1,24 +0,0 @@
-export default {
-  contentDependencies: ['linkTemplate'],
-
-  relations(relation) {
-    return {
-      linkTemplate: relation('linkTemplate'),
-    };
-  },
-
-  data(album, file) {
-    return {
-      albumDirectory: album.directory,
-      file,
-    };
-  },
-
-  generate(data, relations) {
-    return relations.linkTemplate
-      .slots({
-        path: ['media.albumAdditionalFile', data.albumDirectory, data.file],
-        content: data.file,
-      });
-  },
-};
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index 073c821e..45c08a08 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -39,6 +39,11 @@ export default {
       default: false,
     },
 
+    disableBrowserTooltip: {
+      type: 'boolean',
+      default: false,
+    },
+
     tab: {
       validate: v => v.is('default', 'separate'),
       default: 'default',
@@ -50,7 +55,7 @@ export default {
     try {
       new URL(data.url);
       urlIsValid = true;
-    } catch (error) {
+    } catch {
       urlIsValid = false;
     }
 
@@ -111,7 +116,9 @@ export default {
       linkAttributes.add('class', 'indicate-external');
 
       let titleText;
-      if (slots.tab === 'separate') {
+      if (slots.disableBrowserTooltip) {
+        titleText = null;
+      } else if (slots.tab === 'separate') {
         if (html.isBlank(slots.content)) {
           titleText =
             language.$('misc.external.opensInNewTab.annotation');
diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js
index e33ad7b5..8ec69f1d 100644
--- a/src/content/dependencies/listAllAdditionalFilesTemplate.js
+++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js
@@ -1,209 +1,44 @@
 import {sortChronologically} from '#sort';
-import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
     'generateListingPage',
-    'generateListAllAdditionalFilesChunk',
-    'linkAlbum',
-    'linkTrack',
-    'linkAlbumAdditionalFile',
+    'generateListAllAdditionalFilesAlbumSection',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
+  extraDependencies: ['html', 'wikiData'],
 
   sprawl: ({albumData}) => ({albumData}),
 
-  query(sprawl, spec, property) {
-    const albums =
-      sortChronologically(sprawl.albumData.slice());
+  query: (sprawl, spec, property) => ({
+    spec,
+    property,
 
-    const tracks =
-      albums
-        .map(album => album.tracks.slice());
-
-    // Get additional file objects from albums and their tracks.
-    // There's a possibility that albums and tracks don't both implement
-    // the same additional file fields - in this case, just treat them
-    // as though they do implement those fields, but don't have any
-    // additional files of that type.
-
-    const albumAdditionalFileObjects =
-      albums
-        .map(album => album[property] ?? []);
-
-    const trackAdditionalFileObjects =
-      tracks
-        .map(byAlbum => byAlbum
-          .map(track => track[property] ?? []));
-
-    // Filter out tracks that don't have any additional files.
-
-    stitchArrays({tracks, trackAdditionalFileObjects})
-      .forEach(({tracks, trackAdditionalFileObjects}) => {
-        filterMultipleArrays(tracks, trackAdditionalFileObjects,
-          (track, trackAdditionalFileObjects) => !empty(trackAdditionalFileObjects));
-      });
-
-    // Filter out albums that don't have any tracks,
-    // nor any additional files of their own.
-
-    filterMultipleArrays(albums, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects,
-      (album, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects) =>
-        !empty(albumAdditionalFileObjects) ||
-        !empty(trackAdditionalFileObjects));
-
-    // Map additional file objects into titles and lists of file names.
-
-    const albumAdditionalFileTitles =
-      albumAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(({title}) => title));
-
-    const albumAdditionalFileFiles =
-      albumAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(({files}) => files ?? []));
-
-    const trackAdditionalFileTitles =
-      trackAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(byTrack => byTrack
-            .map(({title}) => title)));
-
-    const trackAdditionalFileFiles =
-      trackAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(byTrack => byTrack
-            .map(({files}) => files ?? [])));
-
-    return {
-      spec,
-      albums,
-      tracks,
-      albumAdditionalFileTitles,
-      albumAdditionalFileFiles,
-      trackAdditionalFileTitles,
-      trackAdditionalFileFiles,
-    };
-  },
+    albums:
+      sortChronologically(sprawl.albumData.slice()),
+  }),
 
   relations: (relation, query) => ({
     page:
       relation('generateListingPage', query.spec),
 
-    albumLinks:
-      query.albums
-        .map(album => relation('linkAlbum', album)),
-
-    trackLinks:
-      query.tracks
-        .map(byAlbum => byAlbum
-          .map(track => relation('linkTrack', track))),
-
-    albumChunks:
-      query.albums
-        .map(() => relation('generateListAllAdditionalFilesChunk')),
-
-    trackChunks:
-      query.tracks
-        .map(byAlbum => byAlbum
-          .map(() => relation('generateListAllAdditionalFilesChunk'))),
-
-    albumAdditionalFileLinks:
-      stitchArrays({
-        album: query.albums,
-        files: query.albumAdditionalFileFiles,
-      }).map(({album, files: byAlbum}) =>
-          byAlbum.map(files => files
-            .map(file =>
-              relation('linkAlbumAdditionalFile', album, file)))),
-
-    trackAdditionalFileLinks:
-      stitchArrays({
-        album: query.albums,
-        files: query.trackAdditionalFileFiles,
-      }).map(({album, files: byAlbum}) =>
-          byAlbum
-            .map(byTrack => byTrack
-              .map(files => files
-                .map(file => relation('linkAlbumAdditionalFile', album, file))))),
-  }),
-
-  data: (query) => ({
-    albumAdditionalFileTitles: query.albumAdditionalFileTitles,
-    trackAdditionalFileTitles: query.trackAdditionalFileTitles,
-    albumAdditionalFileFiles: query.albumAdditionalFileFiles,
-    trackAdditionalFileFiles: query.trackAdditionalFileFiles,
+    albumSections:
+      query.albums.map(album =>
+        relation('generateListAllAdditionalFilesAlbumSection',
+          album,
+          query.property)),
   }),
 
   slots: {
     stringsKey: {type: 'string'},
   },
 
-  generate: (data, relations, slots, {html, language}) =>
+  generate: (relations, slots) =>
     relations.page.slots({
       type: 'custom',
 
       content:
-        stitchArrays({
-          albumLink: relations.albumLinks,
-          trackLinks: relations.trackLinks,
-          albumChunk: relations.albumChunks,
-          trackChunks: relations.trackChunks,
-          albumAdditionalFileTitles: data.albumAdditionalFileTitles,
-          trackAdditionalFileTitles: data.trackAdditionalFileTitles,
-          albumAdditionalFileLinks: relations.albumAdditionalFileLinks,
-          trackAdditionalFileLinks: relations.trackAdditionalFileLinks,
-          albumAdditionalFileFiles: data.albumAdditionalFileFiles,
-          trackAdditionalFileFiles: data.trackAdditionalFileFiles,
-        }).map(({
-            albumLink,
-            trackLinks,
-            albumChunk,
-            trackChunks,
-            albumAdditionalFileTitles,
-            trackAdditionalFileTitles,
-            albumAdditionalFileLinks,
-            trackAdditionalFileLinks,
-            albumAdditionalFileFiles,
-            trackAdditionalFileFiles,
-          }) => [
-            html.tag('h3', {class: 'content-heading'}, albumLink),
-
-            html.tag('dl', [
-              albumChunk.slots({
-                title:
-                  language.$('listingPage', slots.stringsKey, 'albumFiles'),
-
-                additionalFileTitles: albumAdditionalFileTitles,
-                additionalFileLinks: albumAdditionalFileLinks,
-                additionalFileFiles: albumAdditionalFileFiles,
-
-                stringsKey: slots.stringsKey,
-              }),
-
-              stitchArrays({
-                trackLink: trackLinks,
-                trackChunk: trackChunks,
-                trackAdditionalFileTitles,
-                trackAdditionalFileLinks,
-                trackAdditionalFileFiles,
-              }).map(({
-                  trackLink,
-                  trackChunk,
-                  trackAdditionalFileTitles,
-                  trackAdditionalFileLinks,
-                  trackAdditionalFileFiles,
-                }) =>
-                  trackChunk.slots({
-                    title: trackLink,
-                    additionalFileTitles: trackAdditionalFileTitles,
-                    additionalFileLinks: trackAdditionalFileLinks,
-                    additionalFileFiles: trackAdditionalFileFiles,
-                    stringsKey: slots.stringsKey,
-                  })),
-            ]),
-          ]),
+        relations.albumSections.map(section =>
+          section.slot('stringsKey', slots.stringsKey)),
     }),
 };
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
index 41944959..99f19764 100644
--- a/src/content/dependencies/listArtistsByContributions.js
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -1,13 +1,6 @@
 import {sortAlphabetically, sortByCount} from '#sort';
-
-import {
-  accumulateSum,
-  empty,
-  filterByCount,
-  filterMultipleArrays,
-  stitchArrays,
-  unique,
-} from '#sugar';
+import {empty, filterByCount, filterMultipleArrays, stitchArrays}
+  from '#sugar';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist'],
@@ -41,37 +34,46 @@ export default {
       query[countsKey] = counts;
     };
 
+    const countContributions = (artist, keys) => {
+      const contribs =
+        keys
+          .flatMap(key => artist[key])
+          .filter(contrib => contrib.countInContributionTotals);
+
+      const things =
+        new Set(contribs.map(contrib => contrib.thing));
+
+      return things.size;
+    };
+
     queryContributionInfo(
       'artistsByTrackContributions',
       'countsByTrackContributions',
       artist =>
-        (unique(
-          ([
-            artist.trackArtistContributions,
-            artist.trackContributorContributions,
-          ]).flat()
-            .map(({thing}) => thing)
-        )).length);
+        countContributions(artist, [
+          'trackArtistContributions',
+          'trackContributorContributions',
+        ]));
 
     queryContributionInfo(
       'artistsByArtworkContributions',
       'countsByArtworkContributions',
       artist =>
-        accumulateSum(
-          [
-            artist.albumCoverArtistContributions,
-            artist.albumWallpaperArtistContributions,
-            artist.albumBannerArtistContributions,
-            artist.trackCoverArtistContributions,
-          ],
-          contribs => contribs.length));
+        countContributions(artist, [
+          'albumCoverArtistContributions',
+          'albumWallpaperArtistContributions',
+          'albumBannerArtistContributions',
+          'trackCoverArtistContributions',
+        ]));
 
     if (sprawl.enableFlashesAndGames) {
       queryContributionInfo(
         'artistsByFlashContributions',
         'countsByFlashContributions',
         artist =>
-          artist.flashContributorContributions.length);
+          countContributions(artist, [
+            'flashContributorContributions',
+          ]));
     }
 
     return query;
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 1bbd45e2..e9a75744 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -1,5 +1,8 @@
+import {basename} from 'node:path';
+
+import {logWarn} from '#cli';
 import {bindFind} from '#find';
-import {replacerSpec, parseInput} from '#replacer';
+import {replacerSpec, parseContentNodes} from '#replacer';
 
 import {Marked} from 'marked';
 import striptags from 'striptags';
@@ -46,24 +49,44 @@ function getPlaceholder(node, content) {
   return {type: 'text', data: content.slice(node.i, node.iEnd)};
 }
 
+function getArg(node, argKey) {
+  return (
+    node.data.args
+      ?.find(({key}) => key.data === argKey)
+      ?.value ??
+    null);
+}
+
 export default {
   contentDependencies: [
     ...(
       Object.values(replacerSpec)
         .map(description => description.link)
         .filter(Boolean)),
+
     'image',
+    'generateTextWithTooltip',
+    'generateTooltip',
     'linkExternal',
   ],
 
-  extraDependencies: ['html', 'language', 'to', 'wikiData'],
+  extraDependencies: [
+    'html',
+    'language',
+    'niceShowAggregate',
+    'to',
+    'wikiData',
+  ],
 
   sprawl(wikiData, content) {
-    const find = bindFind(wikiData);
+    const find = bindFind(wikiData, {mode: 'quiet'});
 
-    const parsedNodes = parseInput(content ?? '');
+    const {result: parsedNodes, error} =
+      parseContentNodes(content ?? '', {errorMode: 'return'});
 
     return {
+      error,
+
       nodes: parsedNodes
         .map(node => {
           if (node.type !== 'tag') {
@@ -134,6 +157,30 @@ export default {
             return {i: node.i, iEnd: node.iEnd, type: 'internal-link', data};
           }
 
+          if (replacerKey === 'tooltip') {
+            // TODO: Again, no recursive nodes. Sorry!
+            // const enteredLabel = node.data.label && transformNode(node.data.label, opts);
+            const enteredLabel = node.data.label?.data;
+
+            return {
+              i: node.i,
+              iEnd: node.iEnd,
+              type: 'tooltip',
+              data: {
+                tooltip:
+                  replacerValue ?? '(empty tooltip...)',
+
+                label:
+                  enteredLabel ?? '(tooltip without label)',
+
+                link:
+                  (getArg(node, 'link')
+                    ? getArg(node, 'link')[0].data
+                    : null),
+              },
+            };
+          }
+
           // This will be another {type: 'tag'} node which gets processed in
           // generate. Extract replacerKey and replacerValue now, since it'd
           // be a pain to deal with later.
@@ -153,6 +200,9 @@ export default {
     return {
       content,
 
+      error:
+        sprawl.error,
+
       nodes:
         sprawl.nodes
           .map(node => {
@@ -191,6 +241,12 @@ export default {
           : getPlaceholder(node, content));
 
     return {
+      textWithTooltip:
+        relation('generateTextWithTooltip'),
+
+      tooltip:
+        relation('generateTooltip'),
+
       internalLinks:
         nodes
           .filter(({type}) => type === 'internal-link')
@@ -209,11 +265,15 @@ export default {
       externalLinks:
         nodes
           .filter(({type}) => type === 'external-link')
-          .map(node => {
-            const {href} = node.data;
+          .map(({data: {href}}) =>
+            relation('linkExternal', href)),
 
-            return relation('linkExternal', href);
-          }),
+      externalLinksForTooltipNodes:
+        nodes
+          .filter(({type}) => type === 'tooltip')
+          .filter(({data}) => data.link)
+          .map(({data: {link: href}}) =>
+            relation('linkExternal', href)),
 
       images:
         nodes
@@ -255,10 +315,16 @@ 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;
+    let externalLinkForTooltipNodeIndex = 0;
 
     let offsetTextNode = 0;
 
@@ -313,9 +379,8 @@ export default {
                   height && {height},
                   style && {style},
 
-                  align === 'center' &&
-                  !link &&
-                    {class: 'align-center'},
+                  align && !link &&
+                    {class: 'align-' + align},
 
                   pixelate &&
                     {class: 'pixelate'});
@@ -326,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 =>
@@ -377,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),
             };
@@ -390,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',
@@ -419,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});
 
@@ -435,10 +508,17 @@ export default {
               (inline
                 ? audio
                 : html.tag('div', {class: 'content-audio-container'},
-                    align === 'center' &&
-                      {class: 'align-center'},
+                    align &&
+                      {class: 'align-' + align},
+
+                    [
+                      !nameless &&
+                        html.tag('a', {class: 'filename'},
+                          src && {href: src},
+                          language.sanitize(basename(node.src))),
 
-                    audio));
+                      audio,
+                    ]));
 
             return {
               type: 'processed-audio',
@@ -484,7 +564,7 @@ export default {
             try {
               link.getSlotDescription('preferShortName');
               hasPreferShortNameSlot = true;
-            } catch (error) {
+            } catch {
               hasPreferShortNameSlot = false;
             }
 
@@ -497,7 +577,7 @@ export default {
             try {
               link.getSlotDescription('tooltipStyle');
               hasTooltipStyleSlot = true;
-            } catch (error) {
+            } catch {
               hasTooltipStyleSlot = false;
             }
 
@@ -548,6 +628,52 @@ export default {
             return {type: 'processed-external-link', data: externalLink};
           }
 
+          case 'tooltip': {
+            const {label, link, tooltip: tooltipContent} = node.data;
+
+            const externalLink =
+              (link
+                ? relations.externalLinksForTooltipNodes
+                    .at(externalLinkForTooltipNodeIndex++)
+                : null);
+
+            if (externalLink) {
+              externalLink.setSlots({
+                content: label,
+                fromContent: true,
+              });
+
+              if (slots.indicateExternalLinks) {
+                externalLink.setSlots({
+                  indicateExternal: true,
+                  disableBrowserTooltip: true,
+                  tab: 'separate',
+                  style: 'platform',
+                });
+              }
+            }
+
+            const textWithTooltip = relations.textWithTooltip.clone();
+            const tooltip = relations.tooltip.clone();
+
+            tooltip.setSlots({
+              attributes: {class: 'content-tooltip'},
+              content: tooltipContent, // Not sanitized!
+            });
+
+            textWithTooltip.setSlots({
+              attributes: [
+                {class: 'content-tooltip-guy'},
+                externalLink && {class: 'has-link'},
+              ],
+
+              text: externalLink ?? label,
+              tooltip,
+            });
+
+            return {type: 'processed-tooltip', data: textWithTooltip};
+          }
+
           case 'tag': {
             const {replacerKey, replacerValue} = node.data;
 
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index a089e325..651a61cf 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -243,13 +243,13 @@ export class CacheableObjectPropertyValueError extends Error {
 
     try {
       inspectOldValue = inspect(oldValue);
-    } catch (error) {
+    } catch {
       inspectOldValue = colors.red(`(couldn't inspect)`);
     }
 
     try {
       inspectNewValue = inspect(newValue);
-    } catch (error) {
+    } catch {
       inspectNewValue = colors.red(`(couldn't inspect)`);
     }
 
diff --git a/src/data/checks.js b/src/data/checks.js
index 52024144..3fcb6d3b 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -4,7 +4,7 @@ import {inspect as nodeInspect} from 'node:util';
 import {colors, ENABLE_COLOR} from '#cli';
 
 import CacheableObject from '#cacheable-object';
-import {replacerSpec, parseInput} from '#replacer';
+import {replacerSpec, parseContentNodes} from '#replacer';
 import {compareArrays, cut, cutStart, empty, getNestedProp, iterateMultiline}
   from '#sugar';
 import Thing from '#thing';
@@ -185,16 +185,20 @@ export function filterReferenceErrors(wikiData, {
       artTags: '_artTag',
       referencedArtworks: '_artwork',
       commentary: '_content',
-      creditSources: '_content',
+      creditingSources: '_content',
     }],
 
     ['artTagData', {
       directDescendantArtTags: 'artTag',
     }],
 
+    ['artworkData', {
+      referencedArtworks: '_artwork',
+    }],
+
     ['flashData', {
       commentary: '_content',
-      creditSources: '_content',
+      creditingSources: '_content',
     }],
 
     ['groupCategoryData', {
@@ -221,8 +225,8 @@ export function filterReferenceErrors(wikiData, {
       flashes: 'flash',
     }],
 
-    ['groupData', {
-      serieses: '_serieses',
+    ['seriesData', {
+      albums: 'album',
     }],
 
     ['trackData', {
@@ -235,7 +239,8 @@ export function filterReferenceErrors(wikiData, {
       referencedArtworks: '_artwork',
       mainReleaseTrack: '_trackMainReleasesOnly',
       commentary: '_content',
-      creditSources: '_content',
+      creditingSources: '_content',
+      referencingSources: '_content',
       lyrics: '_content',
     }],
 
@@ -290,15 +295,6 @@ export function filterReferenceErrors(wikiData, {
                 // need writing, humm...)
                 writeProperty = false;
                 break;
-
-              case '_serieses':
-                if (value) {
-                  // Doesn't report on which series has the error, but...
-                  value = value.flatMap(series => series.albums);
-                }
-
-                writeProperty = false;
-                break;
             }
 
             if (value === undefined) {
@@ -350,10 +346,6 @@ export function filterReferenceErrors(wikiData, {
                 };
                 break;
 
-              case '_serieses':
-                findFn = boundFind.album;
-                break;
-
               case '_trackArtwork':
                 findFn = ref => boundFind.track(ref.reference);
                 break;
@@ -393,7 +385,7 @@ export function filterReferenceErrors(wikiData, {
                 break;
             }
 
-            const suppress = fn => conditionallySuppressError(error => {
+            const suppress = fn => conditionallySuppressError(_error => {
               // We're not suppressing any errors at the moment.
               // An old suppression is kept below for reference.
 
@@ -565,15 +557,20 @@ export function reportContentTextErrors(wikiData, {
     description: 'description',
   };
 
+  const artworkShape = {
+    source: 'artwork source',
+    originDetails: 'artwork origin details',
+  };
+
   const commentaryShape = {
     body: 'commentary body',
-    artistDisplayText: 'commentary artist display text',
+    artistText: 'commentary artist text',
     annotation: 'commentary annotation',
   };
 
   const lyricsShape = {
     body: 'lyrics body',
-    artistDisplayText: 'lyrics artist display text',
+    artistText: 'lyrics artist text',
     annotation: 'lyrics annotation',
   };
 
@@ -581,6 +578,8 @@ export function reportContentTextErrors(wikiData, {
     ['albumData', {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
+      creditingSources: commentaryShape,
+      coverArtworks: artworkShape,
     }],
 
     ['artTagData', {
@@ -593,6 +592,8 @@ export function reportContentTextErrors(wikiData, {
 
     ['flashData', {
       commentary: commentaryShape,
+      creditingSources: commentaryShape,
+      coverArtwork: artworkShape,
     }],
 
     ['flashActData', {
@@ -622,10 +623,12 @@ export function reportContentTextErrors(wikiData, {
     ['trackData', {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
-      creditSources: commentaryShape,
+      creditingSources: commentaryShape,
+      referencingSources: commentaryShape,
       lyrics: lyricsShape,
       midiProjectFiles: additionalFileShape,
       sheetMusicFiles: additionalFileShape,
+      trackArtworks: artworkShape,
     }],
 
     ['wikiInfo', {
@@ -638,7 +641,7 @@ export function reportContentTextErrors(wikiData, {
   const findArtistOrAlias = bindFindArtistOrAlias(boundFind);
 
   function* processContent(input) {
-    const nodes = parseInput(input);
+    const nodes = parseContentNodes(input);
 
     for (const node of nodes) {
       const index = node.i;
@@ -695,7 +698,7 @@ export function reportContentTextErrors(wikiData, {
       } else if (node.type === 'external-link') {
         try {
           new URL(node.data.href);
-        } catch (error) {
+        } catch {
           yield {
             index, length,
             message:
@@ -766,6 +769,31 @@ export function reportContentTextErrors(wikiData, {
               const topMessage =
                 `Content text errors` + fieldPropertyMessage;
 
+              const checkShapeEntries = (entry, callProcessContentOpts) => {
+                for (const [key, annotation] of Object.entries(shape)) {
+                  const value = entry[key];
+
+                  // TODO: This should be an undefined/null check, like above,
+                  // but it's not, because sometimes the stuff we're checking
+                  // here isn't actually coded as a Thing - so the properties
+                  // might really be undefined instead of null. Terrifying and
+                  // awful. And most of all, citation needed.
+                  if (!value) continue;
+
+                  callProcessContent({
+                    ...callProcessContentOpts,
+
+                    // TODO: `nest` isn't provided by `callProcessContentOpts`
+                    //`but `push` is - this is to match the old code, but
+                    // what's the deal here?
+                    nest,
+
+                    value,
+                    message: `Error in ${colors.green(annotation)}`,
+                  });
+                }
+              };
+
               if (shape === '_content') {
                 callProcessContent({
                   nest,
@@ -773,26 +801,18 @@ export function reportContentTextErrors(wikiData, {
                   value,
                   message: topMessage,
                 });
-              } else {
+              } else if (Array.isArray(value)) {
                 nest({message: topMessage}, ({push}) => {
                   for (const [index, entry] of value.entries()) {
-                    for (const [key, annotation] of Object.entries(shape)) {
-                      const value = entry[key];
-
-                      // TODO: Should this check undefined/null similar to above?
-                      if (!value) continue;
-
-                      callProcessContent({
-                        nest,
-                        push,
-                        value,
-                        message: `Error in ${colors.green(annotation)}`,
-                        annotateError: error =>
-                          annotateErrorWithIndex(error, index),
-                      });
-                    }
+                    checkShapeEntries(entry, {
+                      push,
+                      annotateError: error =>
+                        annotateErrorWithIndex(error, index),
+                    });
                   }
                 });
+              } else {
+                checkShapeEntries(value, {push});
               }
             }
           });
diff --git a/src/data/composite/control-flow/flipFilter.js b/src/data/composite/control-flow/flipFilter.js
new file mode 100644
index 00000000..995bacad
--- /dev/null
+++ b/src/data/composite/control-flow/flipFilter.js
@@ -0,0 +1,36 @@
+// Flips a filter, so that each true item becomes false, and vice versa.
+// Overwrites the provided dependency.
+//
+// See also:
+//  - withAvailabilityFilter
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `flipFilter`,
+
+  inputs: {
+    filter: input({type: 'array'}),
+  },
+
+  outputs: ({
+    [input.staticDependency('filter')]: filterDependency,
+  }) => [filterDependency ?? '#flippedFilter'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input('filter'),
+        input.staticDependency('filter'),
+      ],
+
+      compute: (continuation, {
+        [input('filter')]: filter,
+        [input.staticDependency('filter')]: filterDependency,
+      }) => continuation({
+        [filterDependency ?? '#flippedFilter']:
+          filter.map(item => !item),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js
index 7e137a14..778dc66b 100644
--- a/src/data/composite/control-flow/index.js
+++ b/src/data/composite/control-flow/index.js
@@ -10,6 +10,7 @@ export {default as exposeDependency} from './exposeDependency.js';
 export {default as exposeDependencyOrContinue} from './exposeDependencyOrContinue.js';
 export {default as exposeUpdateValueOrContinue} from './exposeUpdateValueOrContinue.js';
 export {default as exposeWhetherDependencyAvailable} from './exposeWhetherDependencyAvailable.js';
+export {default as flipFilter} from './flipFilter.js';
 export {default as raiseOutputWithoutDependency} from './raiseOutputWithoutDependency.js';
 export {default as raiseOutputWithoutUpdateValue} from './raiseOutputWithoutUpdateValue.js';
 export {default as withAvailabilityFilter} from './withAvailabilityFilter.js';
diff --git a/src/data/composite/control-flow/withAvailabilityFilter.js b/src/data/composite/control-flow/withAvailabilityFilter.js
index cfea998e..fd93af71 100644
--- a/src/data/composite/control-flow/withAvailabilityFilter.js
+++ b/src/data/composite/control-flow/withAvailabilityFilter.js
@@ -4,6 +4,7 @@
 // Accepts the same mode options as withResultOfAvailabilityCheck.
 //
 // See also:
+//  - flipFilter
 //  - withFilteredList
 //  - withResultOfAvailabilityCheck
 //
diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js
index 46a3dc81..05b59445 100644
--- a/src/data/composite/data/index.js
+++ b/src/data/composite/data/index.js
@@ -20,6 +20,7 @@ export {default as withMappedList} from './withMappedList.js';
 export {default as withSortedList} from './withSortedList.js';
 export {default as withStretchedList} from './withStretchedList.js';
 
+export {default as withLengthOfList} from './withLengthOfList.js';
 export {default as withPropertyFromList} from './withPropertyFromList.js';
 export {default as withPropertiesFromList} from './withPropertiesFromList.js';
 
diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js
index 44c1661d..15ee3373 100644
--- a/src/data/composite/data/withFilteredList.js
+++ b/src/data/composite/data/withFilteredList.js
@@ -2,9 +2,6 @@
 // corresponding items in a list. Items which correspond to a truthy value
 // are kept, and the rest are excluded from the output list.
 //
-// If the flip option is set, only items corresponding with a *falsy* value in
-// the filter are kept.
-//
 // TODO: There should be two outputs - one for the items included according to
 // the filter, and one for the items excluded.
 //
@@ -22,28 +19,19 @@ export default templateCompositeFrom({
   inputs: {
     list: input({type: 'array'}),
     filter: input({type: 'array'}),
-
-    flip: input({
-      type: 'boolean',
-      defaultValue: false,
-    }),
   },
 
   outputs: ['#filteredList'],
 
   steps: () => [
     {
-      dependencies: [input('list'), input('filter'), input('flip')],
+      dependencies: [input('list'), input('filter')],
       compute: (continuation, {
         [input('list')]: list,
         [input('filter')]: filter,
-        [input('flip')]: flip,
       }) => continuation({
         '#filteredList':
-          list.filter((_item, index) =>
-            (flip
-              ? !filter[index]
-              :  filter[index])),
+          list.filter((_item, index) => filter[index]),
       }),
     },
   ],
diff --git a/src/data/composite/data/withLengthOfList.js b/src/data/composite/data/withLengthOfList.js
new file mode 100644
index 00000000..e67aa887
--- /dev/null
+++ b/src/data/composite/data/withLengthOfList.js
@@ -0,0 +1,54 @@
+import {input, templateCompositeFrom} from '#composite';
+
+function getOutputName({
+  [input.staticDependency('list')]: list,
+}) {
+  if (list && list.startsWith('#')) {
+    return `${list}.length`;
+  } else if (list) {
+    return `#${list}.length`;
+  } else {
+    return '#length';
+  }
+}
+
+export default templateCompositeFrom({
+  annotation: `withMappedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: inputs => [getOutputName(inputs)],
+
+  steps: () => [
+    {
+      dependencies: [input.staticDependency('list')],
+      compute: (continuation, inputs) =>
+        continuation({'#output': getOutputName(inputs)}),
+    },
+
+    {
+      dependencies: [input('list')],
+      compute: (continuation, {
+        [input('list')]: list,
+      }) => continuation({
+        ['#value']:
+          (list === null
+            ? null
+            : list.length),
+      }),
+    },
+
+    {
+      dependencies: ['#output', '#value'],
+
+      compute: (continuation, {
+        ['#output']: output,
+        ['#value']: value,
+      }) => continuation({
+        [output]: value,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withNearbyItemFromList.js b/src/data/composite/data/withNearbyItemFromList.js
index 83a8cc21..5e165219 100644
--- a/src/data/composite/data/withNearbyItemFromList.js
+++ b/src/data/composite/data/withNearbyItemFromList.js
@@ -9,6 +9,10 @@
 //  - If the 'valuePastEdge' input is provided, that value will be output
 //    instead of null.
 //
+//  - If the 'filter' input is provided, corresponding items will be skipped,
+//    and only (repeating `offset`) the next included in the filter will be
+//    returned.
+//
 // Both the list and item must be provided.
 //
 // See also:
@@ -16,7 +20,6 @@
 //
 
 import {input, templateCompositeFrom} from '#composite';
-import {atOffset} from '#sugar';
 
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
 
@@ -28,9 +31,12 @@ export default templateCompositeFrom({
   inputs: {
     list: input({acceptsNull: false, type: 'array'}),
     item: input({acceptsNull: false}),
-
     offset: input({type: 'number'}),
+
     wrap: input({type: 'boolean', defaultValue: false}),
+    valuePastEdge: input({defaultValue: null}),
+
+    filter: input({defaultValue: null, type: 'array'}),
   },
 
   outputs: ['#nearbyItem'],
@@ -45,29 +51,55 @@ export default templateCompositeFrom({
       dependency: '#index',
       mode: input.value('index'),
 
-      output: input.value({
-        ['#nearbyItem']:
-          null,
-      }),
+      output: input.value({'#nearbyItem': null}),
     }),
 
     {
       dependencies: [
         input('list'),
         input('offset'),
+
         input('wrap'),
+        input('valuePastEdge'),
+
+        input('filter'),
+
         '#index',
       ],
 
       compute: (continuation, {
         [input('list')]: list,
         [input('offset')]: offset,
+
         [input('wrap')]: wrap,
+        [input('valuePastEdge')]: valuePastEdge,
+
+        [input('filter')]: filter,
+
         ['#index']: index,
-      }) => continuation({
-        ['#nearbyItem']:
-          atOffset(list, index, offset, {wrap}),
-      }),
+      }) => {
+        const startIndex = index;
+
+        do {
+          index += offset;
+
+          if (wrap) {
+            index = index % list.length;
+          } else if (index < 0) {
+            return continuation({'#nearbyItem': valuePastEdge});
+          } else if (index >= list.length) {
+            return continuation({'#nearbyItem': valuePastEdge});
+          }
+
+          if (filter && !filter[index]) {
+            continue;
+          }
+
+          return continuation({'#nearbyItem': list[index]});
+        } while (index !== startIndex);
+
+        return continuation({'#nearbyItem': null});
+      },
     },
   ],
 });
diff --git a/src/data/composite/things/artwork/index.js b/src/data/composite/things/artwork/index.js
index 5a592777..3693c10f 100644
--- a/src/data/composite/things/artwork/index.js
+++ b/src/data/composite/things/artwork/index.js
@@ -1,4 +1,5 @@
+export {default as withAttachedArtwork} from './withAttachedArtwork.js';
 export {default as withContainingArtworkList} from './withContainingArtworkList.js';
-export {default as withContribsFromMainArtwork} from './withContribsFromMainArtwork.js';
+export {default as withContribsFromAttachedArtwork} from './withContribsFromAttachedArtwork.js';
 export {default as withDate} from './withDate.js';
-export {default as withPropertyFromMainArtwork} from './withPropertyFromMainArtwork.js';
+export {default as withPropertyFromAttachedArtwork} from './withPropertyFromAttachedArtwork.js';
diff --git a/src/data/composite/things/artwork/withAttachedArtwork.js b/src/data/composite/things/artwork/withAttachedArtwork.js
new file mode 100644
index 00000000..d7c0d87b
--- /dev/null
+++ b/src/data/composite/things/artwork/withAttachedArtwork.js
@@ -0,0 +1,43 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {flipFilter, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+import {withNearbyItemFromList, withPropertyFromList} from '#composite/data';
+
+import withContainingArtworkList from './withContainingArtworkList.js';
+
+export default templateCompositeFrom({
+  annotaion: `withContribsFromMainArtwork`,
+
+  outputs: ['#attachedArtwork'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'attachAbove',
+      mode: input.value('falsy'),
+      output: input.value({'#attachedArtwork': null}),
+    }),
+
+    withContainingArtworkList(),
+
+    withPropertyFromList({
+      list: '#containingArtworkList',
+      property: input.value('attachAbove'),
+    }),
+
+    flipFilter({
+      filter: '#containingArtworkList.attachAbove',
+    }).outputs({
+      '#containingArtworkList.attachAbove': '#filterNotAttached',
+    }),
+
+    withNearbyItemFromList({
+      list: '#containingArtworkList',
+      item: input.myself(),
+      offset: input.value(-1),
+      filter: '#filterNotAttached',
+    }).outputs({
+      '#nearbyItem': '#attachedArtwork',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js
new file mode 100644
index 00000000..e9425c95
--- /dev/null
+++ b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js
@@ -0,0 +1,27 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withRecontextualizedContributionList} from '#composite/wiki-data';
+
+import withPropertyFromAttachedArtwork from './withPropertyFromAttachedArtwork.js';
+
+export default templateCompositeFrom({
+  annotaion: `withContribsFromAttachedArtwork`,
+
+  outputs: ['#attachedArtwork.artistContribs'],
+
+  steps: () => [
+    withPropertyFromAttachedArtwork({
+      property: input.value('artistContribs'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#attachedArtwork.artistContribs',
+      output: input.value({'#attachedArtwork.artistContribs': null}),
+    }),
+
+    withRecontextualizedContributionList({
+      list: '#attachedArtwork.artistContribs',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/artwork/withContribsFromMainArtwork.js b/src/data/composite/things/artwork/withContribsFromMainArtwork.js
deleted file mode 100644
index 25616ad6..00000000
--- a/src/data/composite/things/artwork/withContribsFromMainArtwork.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-import {withRecontextualizedContributionList} from '#composite/wiki-data';
-
-import withPropertyFromMainArtwork from './withPropertyFromMainArtwork.js';
-
-export default templateCompositeFrom({
-  annotaion: `withContribsFromMainArtwork`,
-
-  outputs: ['#mainArtwork.artistContribs'],
-
-  steps: () => [
-    withPropertyFromMainArtwork({
-      property: input.value('artistContribs'),
-      onlyIfAttached: input.value(true),
-    }),
-
-    raiseOutputWithoutDependency({
-      dependency: '#mainArtwork.artistContribs',
-      output: input.value({'#mainArtwork.artistContribs': null}),
-    }),
-
-    withRecontextualizedContributionList({
-      list: '#mainArtwork.artistContribs',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js b/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js
new file mode 100644
index 00000000..a2f954b9
--- /dev/null
+++ b/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js
@@ -0,0 +1,65 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withAttachedArtwork from './withAttachedArtwork.js';
+
+function getOutputName({
+  [input.staticValue('property')]: property,
+}) {
+  if (property) {
+    return `#attachedArtwork.${property}`;
+  } else {
+    return '#value';
+  }
+}
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromAttachedArtwork`,
+
+  inputs: {
+    property: input({type: 'string'}),
+  },
+
+  outputs: inputs => [getOutputName(inputs)],
+
+  steps: () => [
+    {
+      dependencies: [input.staticValue('property')],
+      compute: (continuation, inputs) =>
+        continuation({'#output': getOutputName(inputs)}),
+    },
+
+    withAttachedArtwork(),
+
+    withResultOfAvailabilityCheck({
+      from: '#attachedArtwork',
+    }),
+
+    {
+      dependencies: ['#availability', '#output'],
+      compute: (continuation, {
+        ['#availability']: availability,
+        ['#output']: output,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutput({[output]: null})),
+    },
+
+    withPropertyFromObject({
+      object: '#attachedArtwork',
+      property: input('property'),
+    }),
+
+    {
+      dependencies: ['#value', '#output'],
+      compute: (continuation, {
+        ['#value']: value,
+        ['#output']: output,
+      }) =>
+        continuation.raiseOutput({[output]: value}),
+    },
+  ],
+});
diff --git a/src/data/composite/things/artwork/withPropertyFromMainArtwork.js b/src/data/composite/things/artwork/withPropertyFromMainArtwork.js
deleted file mode 100644
index a0233119..00000000
--- a/src/data/composite/things/artwork/withPropertyFromMainArtwork.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {withResultOfAvailabilityCheck} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-import withContainingArtworkList from './withContainingArtworkList.js';
-
-function getOutputName({
-  [input.staticValue('property')]: property,
-}) {
-  if (property) {
-    return `#mainArtwork.${property}`;
-  } else {
-    return '#value';
-  }
-}
-
-export default templateCompositeFrom({
-  annotation: `withPropertyFromMainArtwork`,
-
-  inputs: {
-    property: input({type: 'string'}),
-    onlyIfAttached: input({type: 'boolean', defaultValue: false}),
-  },
-
-  outputs: inputs => [getOutputName(inputs)],
-
-  steps: () => [
-    {
-      dependencies: [input.staticValue('property')],
-      compute: (continuation, inputs) =>
-        continuation({'#output': getOutputName(inputs)}),
-    },
-
-    {
-      dependencies: [input('onlyIfAttached'), 'attachAbove', '#output'],
-      compute: (continuation, {
-        [input('onlyIfAttached')]: onlyIfAttached,
-        ['attachAbove']: attachAbove,
-        ['#output']: output,
-      }) =>
-        (onlyIfAttached && attachAbove
-          ? continuation()
-       : onlyIfAttached
-          ? continuation.raiseOutput({[output]: null})
-          : continuation()),
-    },
-
-    withContainingArtworkList(),
-
-    withResultOfAvailabilityCheck({
-      from: '#containingArtworkList',
-    }),
-
-    {
-      dependencies: ['#availability', '#output'],
-      compute: (continuation, {
-        ['#availability']: availability,
-        ['#output']: output,
-      }) =>
-        (availability
-          ? continuation()
-          : continuation.raiseOutput({[output]: null})),
-    },
-
-    {
-      dependencies: ['#containingArtworkList'],
-      compute: (continuation, {
-        ['#containingArtworkList']: list,
-      }) =>
-        continuation({'#mainArtwork': list[0]}),
-    },
-
-    {
-      dependencies: [input.myself(), '#mainArtwork', '#output'],
-      compute: (continuation, {
-        [input.myself()]: myself,
-        ['#mainArtwork']: mainArtwork,
-        ['#output']: output,
-      }) =>
-        (myself === mainArtwork
-          ? continuation.raiseOutput({[output]: null})
-          : continuation()),
-    },
-
-    withPropertyFromObject({
-      object: '#mainArtwork',
-      property: input('property'),
-    }),
-
-    {
-      dependencies: ['#value', '#output'],
-      compute: (continuation, {
-        ['#value']: value,
-        ['#output']: output,
-      }) =>
-        continuation.raiseOutput({[output]: value}),
-    },
-  ],
-});
diff --git a/src/data/composite/things/commentary-entry/index.js b/src/data/composite/things/commentary-entry/index.js
deleted file mode 100644
index 091bae1a..00000000
--- a/src/data/composite/things/commentary-entry/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export {default as withWebArchiveDate} from './withWebArchiveDate.js';
diff --git a/src/data/composite/things/content/contentArtists.js b/src/data/composite/things/content/contentArtists.js
new file mode 100644
index 00000000..8d5db5a5
--- /dev/null
+++ b/src/data/composite/things/content/contentArtists.js
@@ -0,0 +1,40 @@
+import {input, templateCompositeFrom} from '#composite';
+import {validateReferenceList} from '#validators';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+import withExpressedOrImplicitArtistReferences
+  from './helpers/withExpressedOrImplicitArtistReferences.js';
+
+export default templateCompositeFrom({
+  annotation: `contentArtists`,
+
+  compose: false,
+
+  update: {
+    validate: validateReferenceList('artist'),
+  },
+
+  steps: () => [
+    withExpressedOrImplicitArtistReferences({
+      from: input.updateValue(),
+    }),
+
+    exitWithoutDependency({
+      dependency: '#artistReferences',
+      value: input.value([]),
+    }),
+
+    withResolvedReferenceList({
+      list: '#artistReferences',
+      find: soupyFind.input('artist'),
+    }),
+
+    exposeDependency({
+      dependency: '#resolvedReferenceList',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/content/hasAnnotationPart.js b/src/data/composite/things/content/hasAnnotationPart.js
new file mode 100644
index 00000000..83d175e3
--- /dev/null
+++ b/src/data/composite/things/content/hasAnnotationPart.js
@@ -0,0 +1,25 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency} from '#composite/control-flow';
+
+import withHasAnnotationPart from './withHasAnnotationPart.js';
+
+export default templateCompositeFrom({
+  annotation: `hasAnnotationPart`,
+
+  compose: false,
+
+  inputs: {
+    part: input({type: 'string'}),
+  },
+
+  steps: () => [
+    withHasAnnotationPart({
+      part: input('part'),
+    }),
+
+    exposeDependency({
+      dependency: '#hasAnnotationPart',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js b/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js
new file mode 100644
index 00000000..62799d43
--- /dev/null
+++ b/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js
@@ -0,0 +1,60 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withFilteredList, withMappedList} from '#composite/data';
+import {withContentNodes} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withExpressedOrImplicitArtistReferences`,
+
+  inputs: {
+    from: input({type: 'array', acceptsNull: true}),
+  },
+
+  outputs: ['#artistReferences'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: expressedArtistReferences,
+      }) =>
+        (expressedArtistReferences
+          ? continuation.raiseOutput({'#artistReferences': expressedArtistReferences})
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'artistText',
+      output: input.value({'#artistReferences': null}),
+    }),
+
+    withContentNodes({
+      from: 'artistText',
+    }),
+
+    withMappedList({
+      list: '#contentNodes',
+      map: input.value(node =>
+        node.type === 'tag' &&
+        node.data.replacerKey?.data === 'artist'),
+    }).outputs({
+      '#mappedList': '#artistTagFilter',
+    }),
+
+    withFilteredList({
+      list: '#contentNodes',
+      filter: '#artistTagFilter',
+    }).outputs({
+      '#filteredList': '#artistTags',
+    }),
+
+    withMappedList({
+      list: '#artistTags',
+      map: input.value(node =>
+        node.data.replacerValue[0].data),
+    }).outputs({
+      '#mappedList': '#artistReferences',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/content/index.js b/src/data/composite/things/content/index.js
new file mode 100644
index 00000000..4176337d
--- /dev/null
+++ b/src/data/composite/things/content/index.js
@@ -0,0 +1,7 @@
+export {default as contentArtists} from './contentArtists.js';
+export {default as hasAnnotationPart} from './hasAnnotationPart.js';
+export {default as withAnnotationParts} from './withAnnotationParts.js';
+export {default as withHasAnnotationPart} from './withHasAnnotationPart.js';
+export {default as withSourceText} from './withSourceText.js';
+export {default as withSourceURLs} from './withSourceURLs.js';
+export {default as withWebArchiveDate} from './withWebArchiveDate.js';
diff --git a/src/data/composite/things/content/withAnnotationParts.js b/src/data/composite/things/content/withAnnotationParts.js
new file mode 100644
index 00000000..6311b57a
--- /dev/null
+++ b/src/data/composite/things/content/withAnnotationParts.js
@@ -0,0 +1,93 @@
+import {input, templateCompositeFrom} from '#composite';
+import {transposeArrays} from '#sugar';
+import {is} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromList} from '#composite/data';
+import {splitContentNodesAround, withContentNodes} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withAnnotationParts`,
+
+  inputs: {
+    mode: input({
+      validate: is('strings', 'nodes'),
+    }),
+  },
+
+  outputs: ['#annotationParts'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'annotation',
+      output: input.value({'#annotationParts': []}),
+    }),
+
+    withContentNodes({
+      from: 'annotation',
+    }),
+
+    splitContentNodesAround({
+      nodes: '#contentNodes',
+      around: input.value(/, */g),
+    }),
+
+    {
+      dependencies: ['#contentNodeLists', input('mode')],
+      compute: (continuation, {
+        ['#contentNodeLists']: nodeLists,
+        [input('mode')]: mode,
+      }) =>
+        (mode === 'nodes'
+          ? continuation.raiseOutput({'#annotationParts': nodeLists})
+          : continuation()),
+    },
+
+    {
+      dependencies: ['#contentNodeLists'],
+
+      compute: (continuation, {
+        ['#contentNodeLists']: nodeLists,
+      }) => continuation({
+        ['#firstNodes']:
+          nodeLists.map(list => list.at(0)),
+
+        ['#lastNodes']:
+          nodeLists.map(list => list.at(-1)),
+      }),
+    },
+
+    withPropertyFromList({
+      list: '#firstNodes',
+      property: input.value('i'),
+    }).outputs({
+      '#firstNodes.i': '#startIndices',
+    }),
+
+    withPropertyFromList({
+      list: '#lastNodes',
+      property: input.value('iEnd'),
+    }).outputs({
+      '#lastNodes.iEnd': '#endIndices',
+    }),
+
+    {
+      dependencies: [
+        'annotation',
+        '#startIndices',
+        '#endIndices',
+      ],
+
+      compute: (continuation, {
+        ['annotation']: annotation,
+        ['#startIndices']: startIndices,
+        ['#endIndices']: endIndices,
+      }) => continuation({
+        ['#annotationParts']:
+          transposeArrays([startIndices, endIndices])
+            .map(([start, end]) =>
+              annotation.slice(start, end)),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/content/withHasAnnotationPart.js b/src/data/composite/things/content/withHasAnnotationPart.js
new file mode 100644
index 00000000..4af554f3
--- /dev/null
+++ b/src/data/composite/things/content/withHasAnnotationPart.js
@@ -0,0 +1,43 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withAnnotationParts from './withAnnotationParts.js';
+
+export default templateCompositeFrom({
+  annotation: `withHasAnnotationPart`,
+
+  inputs: {
+    part: input({type: 'string'}),
+  },
+
+  outputs: ['#hasAnnotationPart'],
+
+  steps: () => [
+    withAnnotationParts({
+      mode: input.value('strings'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#annotationParts',
+      output: input.value({'#hasAnnotationPart': false}),
+    }),
+
+    {
+      dependencies: [
+        input('part'),
+        '#annotationParts',
+      ],
+
+      compute: (continuation, {
+        [input('part')]: search,
+        ['#annotationParts']: parts,
+      }) => continuation({
+        ['#hasAnnotationPart']:
+          parts.some(part =>
+            part.toLowerCase() ===
+            search.toLowerCase()),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/content/withSourceText.js b/src/data/composite/things/content/withSourceText.js
new file mode 100644
index 00000000..292306b7
--- /dev/null
+++ b/src/data/composite/things/content/withSourceText.js
@@ -0,0 +1,53 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withAnnotationParts from './withAnnotationParts.js';
+
+export default templateCompositeFrom({
+  annotation: `withSourceText`,
+
+  outputs: ['#sourceText'],
+
+  steps: () => [
+    withAnnotationParts({
+      mode: input.value('nodes'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#annotationParts',
+      output: input.value({'#sourceText': null}),
+    }),
+
+    {
+      dependencies: ['#annotationParts'],
+      compute: (continuation, {
+        ['#annotationParts']: annotationParts,
+      }) => continuation({
+        ['#firstPartWithExternalLink']:
+          annotationParts
+            .find(nodes => nodes
+              .some(node => node.type === 'external-link')) ??
+          null,
+      }),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: '#firstPartWithExternalLink',
+      output: input.value({'#sourceText': null}),
+    }),
+
+    {
+      dependencies: ['annotation', '#firstPartWithExternalLink'],
+      compute: (continuation, {
+        ['annotation']: annotation,
+        ['#firstPartWithExternalLink']: nodes,
+      }) => continuation({
+        ['#sourceText']:
+          annotation.slice(
+            nodes.at(0).i,
+            nodes.at(-1).iEnd),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/content/withSourceURLs.js b/src/data/composite/things/content/withSourceURLs.js
new file mode 100644
index 00000000..f85ff9ea
--- /dev/null
+++ b/src/data/composite/things/content/withSourceURLs.js
@@ -0,0 +1,62 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withFilteredList, withMappedList} from '#composite/data';
+
+import withAnnotationParts from './withAnnotationParts.js';
+
+export default templateCompositeFrom({
+  annotation: `withSourceURLs`,
+
+  outputs: ['#sourceURLs'],
+
+  steps: () => [
+    withAnnotationParts({
+      mode: input.value('nodes'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#annotationParts',
+      output: input.value({'#sourceURLs': []}),
+    }),
+
+    {
+      dependencies: ['#annotationParts'],
+      compute: (continuation, {
+        ['#annotationParts']: annotationParts,
+      }) => continuation({
+        ['#firstPartWithExternalLink']:
+          annotationParts
+            .find(nodes => nodes
+              .some(node => node.type === 'external-link')) ??
+          null,
+      }),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: '#firstPartWithExternalLink',
+      output: input.value({'#sourceURLs': []}),
+    }),
+
+    withMappedList({
+      list: '#firstPartWithExternalLink',
+      map: input.value(node => node.type === 'external-link'),
+    }).outputs({
+      '#mappedList': '#externalLinkFilter',
+    }),
+
+    withFilteredList({
+      list: '#firstPartWithExternalLink',
+      filter: '#externalLinkFilter',
+    }).outputs({
+      '#filteredList': '#externalLinks',
+    }),
+
+    withMappedList({
+      list: '#externalLinks',
+      map: input.value(node => node.data.href),
+    }).outputs({
+      '#mappedList': '#sourceURLs',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/commentary-entry/withWebArchiveDate.js b/src/data/composite/things/content/withWebArchiveDate.js
index 3aaa4f64..3aaa4f64 100644
--- a/src/data/composite/things/commentary-entry/withWebArchiveDate.js
+++ b/src/data/composite/things/content/withWebArchiveDate.js
diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js
index 1e9019b8..a678c3f5 100644
--- a/src/data/composite/things/contribution/thingPropertyMatches.js
+++ b/src/data/composite/things/contribution/thingPropertyMatches.js
@@ -1,7 +1,6 @@
 import {input, templateCompositeFrom} from '#composite';
 
 import {exitWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
 
 export default templateCompositeFrom({
   annotation: `thingPropertyMatches`,
diff --git a/src/data/composite/things/track-section/withContinueCountingFrom.js b/src/data/composite/things/track-section/withContinueCountingFrom.js
index e034b7a5..0ca52b6c 100644
--- a/src/data/composite/things/track-section/withContinueCountingFrom.js
+++ b/src/data/composite/things/track-section/withContinueCountingFrom.js
@@ -1,4 +1,4 @@
-import {input, templateCompositeFrom} from '#composite';
+import {templateCompositeFrom} from '#composite';
 
 import withStartCountingFrom from './withStartCountingFrom.js';
 
diff --git a/src/data/composite/things/track/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js
deleted file mode 100644
index 65a2263d..00000000
--- a/src/data/composite/things/track/trackAdditionalNameList.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// Compiles additional names from various sources.
-
-import {input, templateCompositeFrom} from '#composite';
-import {isAdditionalNameList} from '#validators';
-
-import withInferredAdditionalNames from './withInferredAdditionalNames.js';
-import withSharedAdditionalNames from './withSharedAdditionalNames.js';
-
-export default templateCompositeFrom({
-  annotation: `trackAdditionalNameList`,
-
-  compose: false,
-
-  update: {validate: isAdditionalNameList},
-
-  steps: () => [
-    withInferredAdditionalNames(),
-    withSharedAdditionalNames(),
-
-    {
-      dependencies: [
-        '#inferredAdditionalNames',
-        '#sharedAdditionalNames',
-        input.updateValue(),
-      ],
-
-      compute: ({
-        ['#inferredAdditionalNames']: inferredAdditionalNames,
-        ['#sharedAdditionalNames']: sharedAdditionalNames,
-        [input.updateValue()]: providedAdditionalNames,
-      }) => [
-        ...providedAdditionalNames ?? [],
-        ...sharedAdditionalNames,
-        ...inferredAdditionalNames,
-      ],
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withAllReleases.js b/src/data/composite/things/track/withAllReleases.js
index b93bf753..891db102 100644
--- a/src/data/composite/things/track/withAllReleases.js
+++ b/src/data/composite/things/track/withAllReleases.js
@@ -8,7 +8,6 @@
 import {input, templateCompositeFrom} from '#composite';
 import {sortByDate} from '#sort';
 
-import {exitWithoutDependency} from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 
 import withMainRelease from './withMainRelease.js';
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
index 60faeaf4..87edf21e 100644
--- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -9,7 +9,6 @@ import {isBoolean} from '#validators';
 
 import {withPropertyFromObject} from '#composite/data';
 import {withResolvedReference} from '#composite/wiki-data';
-import {soupyFind} from '#composite/wiki-properties';
 
 import {
   exitWithoutDependency,
diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js
index 0639742f..bb3e8983 100644
--- a/src/data/composite/things/track/withOtherReleases.js
+++ b/src/data/composite/things/track/withOtherReleases.js
@@ -3,9 +3,6 @@
 
 import {input, templateCompositeFrom} from '#composite';
 
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
 import withAllReleases from './withAllReleases.js';
 
 export default templateCompositeFrom({
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index 005c68c0..38afc2ac 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -11,8 +11,10 @@ export {default as inputNotFoundMode} from './inputNotFoundMode.js';
 export {default as inputSoupyFind} from './inputSoupyFind.js';
 export {default as inputSoupyReverse} from './inputSoupyReverse.js';
 export {default as inputWikiData} from './inputWikiData.js';
+export {default as splitContentNodesAround} from './splitContentNodesAround.js';
 export {default as withClonedThings} from './withClonedThings.js';
 export {default as withConstitutedArtwork} from './withConstitutedArtwork.js';
+export {default as withContentNodes} from './withContentNodes.js';
 export {default as withContributionListSums} from './withContributionListSums.js';
 export {default as withCoverArtDate} from './withCoverArtDate.js';
 export {default as withDirectory} from './withDirectory.js';
@@ -22,7 +24,6 @@ export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnot
 export {default as withResolvedContribs} from './withResolvedContribs.js';
 export {default as withResolvedReference} from './withResolvedReference.js';
 export {default as withResolvedReferenceList} from './withResolvedReferenceList.js';
-export {default as withResolvedSeriesList} from './withResolvedSeriesList.js';
 export {default as withReverseReferenceList} from './withReverseReferenceList.js';
 export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js';
 export {default as withUniqueReferencingThing} from './withUniqueReferencingThing.js';
diff --git a/src/data/composite/wiki-data/splitContentNodesAround.js b/src/data/composite/wiki-data/splitContentNodesAround.js
new file mode 100644
index 00000000..6648d8e1
--- /dev/null
+++ b/src/data/composite/wiki-data/splitContentNodesAround.js
@@ -0,0 +1,87 @@
+import {input, templateCompositeFrom} from '#composite';
+import {splitContentNodesAround} from '#replacer';
+import {anyOf, isFunction, validateInstanceOf} from '#validators';
+
+import {withFilteredList, withMappedList, withUnflattenedList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `splitContentNodesAround`,
+
+  inputs: {
+    nodes: input({type: 'array'}),
+
+    around: input({
+      validate:
+        anyOf(isFunction, validateInstanceOf(RegExp)),
+    }),
+  },
+
+  outputs: ['#contentNodeLists'],
+
+  steps: () => [
+    {
+      dependencies: [input('nodes'), input('around')],
+
+      compute: (continuation, {
+        [input('nodes')]: nodes,
+        [input('around')]: splitter,
+      }) => continuation({
+        ['#nodes']:
+          Array.from(splitContentNodesAround(nodes, splitter)),
+      }),
+    },
+
+    withMappedList({
+      list: '#nodes',
+      map: input.value(node => node.type === 'separator'),
+    }).outputs({
+      '#mappedList': '#separatorFilter',
+    }),
+
+    withMappedList({
+      list: '#separatorFilter',
+      filter: '#separatorFilter',
+      map: input.value((_node, index) => index),
+    }),
+
+    withFilteredList({
+      list: '#mappedList',
+      filter: '#separatorFilter',
+    }).outputs({
+      '#filteredList': '#separatorIndices',
+    }),
+
+    {
+      dependencies: ['#nodes', '#separatorFilter'],
+
+      compute: (continuation, {
+        ['#nodes']: nodes,
+        ['#separatorFilter']: separatorFilter,
+      }) => continuation({
+        ['#nodes']:
+          nodes.map((node, index) =>
+            (separatorFilter[index]
+              ? null
+              : node)),
+      }),
+    },
+
+    {
+      dependencies: ['#separatorIndices'],
+      compute: (continuation, {
+        ['#separatorIndices']: separatorIndices,
+      }) => continuation({
+        ['#unflattenIndices']:
+          [0, ...separatorIndices],
+      }),
+    },
+
+    withUnflattenedList({
+      list: '#nodes',
+      indices: '#unflattenIndices',
+    }).outputs({
+      '#unflattenedList': '#contentNodeLists',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js
index 6187d55b..28d719e2 100644
--- a/src/data/composite/wiki-data/withConstitutedArtwork.js
+++ b/src/data/composite/wiki-data/withConstitutedArtwork.js
@@ -1,6 +1,5 @@
 import {input, templateCompositeFrom} from '#composite';
 import thingConstructors from '#things';
-import {isContributionList} from '#validators';
 
 export default templateCompositeFrom({
   annotation: `withConstitutedArtwork`,
diff --git a/src/data/composite/wiki-data/withContentNodes.js b/src/data/composite/wiki-data/withContentNodes.js
new file mode 100644
index 00000000..d014d43b
--- /dev/null
+++ b/src/data/composite/wiki-data/withContentNodes.js
@@ -0,0 +1,25 @@
+import {input, templateCompositeFrom} from '#composite';
+import {parseContentNodes} from '#replacer';
+
+export default templateCompositeFrom({
+  annotation: `withContentNodes`,
+
+  inputs: {
+    from: input({type: 'string', acceptsNull: false}),
+  },
+
+  outputs: ['#contentNodes'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+
+      compute: (continuation, {
+        [input('from')]: string,
+      }) => continuation({
+        ['#contentNodes']:
+          parseContentNodes(string),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedSeriesList.js b/src/data/composite/wiki-data/withResolvedSeriesList.js
deleted file mode 100644
index deaab466..00000000
--- a/src/data/composite/wiki-data/withResolvedSeriesList.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {stitchArrays} from '#sugar';
-import {isSeriesList, validateThing} from '#validators';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-
-import {
-  fillMissingListItems,
-  withFlattenedList,
-  withUnflattenedList,
-  withPropertiesFromList,
-} from '#composite/data';
-
-import inputSoupyFind from './inputSoupyFind.js';
-import withResolvedReferenceList from './withResolvedReferenceList.js';
-
-export default templateCompositeFrom({
-  annotation: `withResolvedSeriesList`,
-
-  inputs: {
-    group: input({
-      validate: validateThing({referenceType: 'group'}),
-    }),
-
-    list: input({
-      validate: isSeriesList,
-      acceptsNull: true,
-    }),
-  },
-
-  outputs: ['#resolvedSeriesList'],
-
-  steps: () => [
-    raiseOutputWithoutDependency({
-      dependency: input('list'),
-      mode: input.value('empty'),
-      output: input.value({
-        ['#resolvedSeriesList']: [],
-      }),
-    }),
-
-    withPropertiesFromList({
-      list: input('list'),
-      prefix: input.value('#serieses'),
-      properties: input.value([
-        'name',
-        'description',
-        'albums',
-
-        'showAlbumArtists',
-      ]),
-    }),
-
-    fillMissingListItems({
-      list: '#serieses.albums',
-      fill: input.value([]),
-    }),
-
-    withFlattenedList({
-      list: '#serieses.albums',
-    }),
-
-    withResolvedReferenceList({
-      list: '#flattenedList',
-      find: inputSoupyFind.input('album'),
-      notFoundMode: input.value('null'),
-    }),
-
-    withUnflattenedList({
-      list: '#resolvedReferenceList',
-    }).outputs({
-      '#unflattenedList': '#serieses.albums',
-    }),
-
-    fillMissingListItems({
-      list: '#serieses.description',
-      fill: input.value(null),
-    }),
-
-    fillMissingListItems({
-      list: '#serieses.showAlbumArtists',
-      fill: input.value(null),
-    }),
-
-    {
-      dependencies: [
-        '#serieses.name',
-        '#serieses.description',
-        '#serieses.albums',
-
-        '#serieses.showAlbumArtists',
-      ],
-
-      compute: (continuation, {
-        ['#serieses.name']: name,
-        ['#serieses.description']: description,
-        ['#serieses.albums']: albums,
-
-        ['#serieses.showAlbumArtists']: showAlbumArtists,
-      }) => continuation({
-        ['#seriesProperties']:
-          stitchArrays({
-            name,
-            description,
-            albums,
-
-            showAlbumArtists,
-          }).map(properties => ({
-              ...properties,
-              group: input
-            }))
-      }),
-    },
-
-    {
-      dependencies: ['#seriesProperties', input('group')],
-      compute: (continuation, {
-        ['#seriesProperties']: seriesProperties,
-        [input('group')]: group,
-      }) => continuation({
-        ['#resolvedSeriesList']:
-          seriesProperties
-            .map(properties => ({
-              ...properties,
-              group,
-            })),
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/wiki-properties/additionalFiles.js b/src/data/composite/wiki-properties/additionalFiles.js
deleted file mode 100644
index 6760527a..00000000
--- a/src/data/composite/wiki-properties/additionalFiles.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// This is a somewhat more involved data structure - it's for additional
-// or "bonus" files associated with albums or tracks (or anything else).
-// It's got this form:
-//
-//   [
-//     {title: 'Booklet', files: ['Booklet.pdf']},
-//     {
-//       title: 'Wallpaper',
-//       description: 'Cool Wallpaper!',
-//       files: ['1440x900.png', '1920x1080.png']
-//     },
-//     {title: 'Alternate Covers', description: null, files: [...]},
-//     ...
-//   ]
-//
-
-import {isAdditionalFileList} from '#validators';
-
-// TODO: Not templateCompositeFrom.
-
-export default function() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isAdditionalFileList},
-    expose: {
-      transform: (additionalFiles) =>
-        additionalFiles ?? [],
-    },
-  };
-}
diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js
deleted file mode 100644
index c5971d4a..00000000
--- a/src/data/composite/wiki-properties/additionalNameList.js
+++ /dev/null
@@ -1,14 +0,0 @@
-// A list of additional names! These can be used for a variety of purposes,
-// e.g. providing extra searchable titles, localizations, romanizations or
-// original titles, and so on. Each item has a name and, optionally, a
-// descriptive annotation.
-
-import {isAdditionalNameList} from '#validators';
-
-export default function() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isAdditionalNameList},
-    expose: {transform: value => value ?? []},
-  };
-}
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index d5e7657e..e8f109d3 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -3,8 +3,6 @@
 // Entries here may depend on entries in #composite/control-flow,
 // #composite/data, and #composite/wiki-data.
 
-export {default as additionalFiles} from './additionalFiles.js';
-export {default as additionalNameList} from './additionalNameList.js';
 export {default as annotatedReferenceList} from './annotatedReferenceList.js';
 export {default as color} from './color.js';
 export {default as commentatorArtists} from './commentatorArtists.js';
@@ -23,7 +21,6 @@ export {default as name} from './name.js';
 export {default as referenceList} from './referenceList.js';
 export {default as referencedArtworkList} from './referencedArtworkList.js';
 export {default as reverseReferenceList} from './reverseReferenceList.js';
-export {default as seriesList} from './seriesList.js';
 export {default as simpleDate} from './simpleDate.js';
 export {default as simpleString} from './simpleString.js';
 export {default as singleReference} from './singleReference.js';
diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js
index 9ba2e393..4f243493 100644
--- a/src/data/composite/wiki-properties/referencedArtworkList.js
+++ b/src/data/composite/wiki-properties/referencedArtworkList.js
@@ -1,6 +1,5 @@
 import {input, templateCompositeFrom} from '#composite';
 import find from '#find';
-import {isDate} from '#validators';
 
 import annotatedReferenceList from './annotatedReferenceList.js';
 
diff --git a/src/data/composite/wiki-properties/seriesList.js b/src/data/composite/wiki-properties/seriesList.js
deleted file mode 100644
index 2a101b45..00000000
--- a/src/data/composite/wiki-properties/seriesList.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {isSeriesList, validateThing} from '#validators';
-
-import {exposeDependency} from '#composite/control-flow';
-import {withResolvedSeriesList} from '#composite/wiki-data';
-
-export default templateCompositeFrom({
-  annotation: `seriesList`,
-
-  compose: false,
-
-  inputs: {
-    group: input({
-      validate: validateThing({referenceType: 'group'}),
-    }),
-  },
-
-  steps: () => [
-    withResolvedSeriesList({
-      group: input('group'),
-
-      list: input.updateValue({
-        validate: isSeriesList,
-      }),
-    }),
-
-    exposeDependency({
-      dependency: '#resolvedSeriesList',
-    }),
-  ],
-});
diff --git a/src/data/thing.js b/src/data/thing.js
index 66f73de5..f719224d 100644
--- a/src/data/thing.js
+++ b/src/data/thing.js
@@ -60,7 +60,7 @@ export default class Thing extends CacheableObject {
       if (this.name) {
         name = colors.green(`"${this.name}"`);
       }
-    } catch (error) {
+    } catch {
       name = colors.yellow(`couldn't get name`);
     }
 
@@ -69,7 +69,7 @@ export default class Thing extends CacheableObject {
       if (this.directory) {
         reference = colors.blue(Thing.getReference(this));
       }
-    } catch (error) {
+    } catch {
       reference = colors.yellow(`couldn't get reference`);
     }
 
diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js
new file mode 100644
index 00000000..398d0af5
--- /dev/null
+++ b/src/data/things/additional-file.js
@@ -0,0 +1,47 @@
+import {input} from '#composite';
+import Thing from '#thing';
+import {isString, validateArrayItems} from '#validators';
+
+import {contentString, simpleString, thing} from '#composite/wiki-properties';
+
+import {exposeConstant, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+
+export class AdditionalFile extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: thing(),
+
+    title: simpleString(),
+
+    description: contentString(),
+
+    filenames: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(validateArrayItems(isString)),
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Title': {property: 'title'},
+      'Description': {property: 'description'},
+      'Files': {property: 'filenames'},
+    },
+  };
+
+  get paths() {
+    if (!this.thing) return null;
+    if (!this.thing.getOwnAdditionalFilePath) return null;
+
+    return (
+      this.filenames.map(filename =>
+        this.thing.getOwnAdditionalFilePath(this, filename)));
+  }
+}
diff --git a/src/data/things/additional-name.js b/src/data/things/additional-name.js
new file mode 100644
index 00000000..4c23f291
--- /dev/null
+++ b/src/data/things/additional-name.js
@@ -0,0 +1,21 @@
+import Thing from '#thing';
+
+import {contentString, thing} from '#composite/wiki-properties';
+
+export class AdditionalName extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: thing(),
+
+    name: contentString(),
+    annotation: contentString(),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+      'Annotation': {property: 'annotation'},
+    },
+  };
+}
diff --git a/src/data/things/album.js b/src/data/things/album.js
index c71b9820..5132b962 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -8,7 +8,7 @@ import {colors} from '#cli';
 import {input} from '#composite';
 import {traverse} from '#node-utils';
 import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
-import {accumulateSum, empty} from '#sugar';
+import {empty} from '#sugar';
 import Thing from '#thing';
 import {isColor, isDate, isDirectory, isNumber} from '#validators';
 
@@ -33,8 +33,6 @@ import {exitWithoutContribs, withDirectory, withCoverArtDate}
   from '#composite/wiki-data';
 
 import {
-  additionalFiles,
-  additionalNameList,
   color,
   commentatorArtists,
   constitutibleArtwork,
@@ -49,7 +47,6 @@ import {
   name,
   referencedArtworkList,
   referenceList,
-  reverseReferenceList,
   simpleDate,
   simpleString,
   soupyFind,
@@ -69,12 +66,13 @@ export class Album extends Thing {
   static [Thing.referenceType] = 'album';
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalFile,
+    AdditionalName,
     ArtTag,
     Artwork,
     CommentaryEntry,
     CreditingSourcesEntry,
     Group,
-    Track,
     TrackSection,
     WikiInfo,
   }) => ({
@@ -99,10 +97,14 @@ export class Album extends Thing {
     alwaysReferenceTracksByDirectory: flag(false),
     suffixTrackDirectories: flag(false),
 
+    countTracksInArtistTotals: flag(true),
+
     color: color(),
     urls: urls(),
 
-    additionalNames: additionalNameList(),
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
 
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
@@ -212,11 +214,13 @@ export class Album extends Thing {
       class: input.value(CommentaryEntry),
     }),
 
-    creditSources: thingList({
+    creditingSources: thingList({
       class: input.value(CreditingSourcesEntry),
     }),
 
-    additionalFiles: additionalFiles(),
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
     trackSections: thingList({
       class: input.value(TrackSection),
@@ -500,6 +504,8 @@ export class Album extends Thing {
         transform: String,
       },
 
+      'Count Tracks In Artist Totals': {property: 'countInArtistTotals'},
+
       'Date': {
         property: 'date',
         transform: parseDate,
@@ -588,9 +594,10 @@ export class Album extends Thing {
         transform: parseContributors,
       },
 
-      'Wallpaper Style': {property: 'wallpaperStyle'},
       'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
 
+      'Wallpaper Style': {property: 'wallpaperStyle'},
+
       'Wallpaper Parts': {
         property: 'wallpaperParts',
         transform: parseWallpaperParts,
@@ -614,8 +621,8 @@ export class Album extends Thing {
         transform: parseCommentary,
       },
 
-      'Credit Sources': {
-        property: 'creditSources',
+      'Crediting Sources': {
+        property: 'creditingSources',
         transform: parseCreditingSources,
       },
 
@@ -692,6 +699,7 @@ export class Album extends Thing {
       const artworkData = [];
       const commentaryData = [];
       const creditingSourceData = [];
+      const referencingSourceData = [];
       const lyricsData = [];
 
       for (const {header: album, entries} of results) {
@@ -705,8 +713,6 @@ export class Album extends Thing {
           isDefaultTrackSection: true,
         });
 
-        const albumRef = Thing.getReference(album);
-
         const closeCurrentTrackSection = () => {
           if (
             currentTrackSection.isDefaultTrackSection &&
@@ -740,7 +746,8 @@ export class Album extends Thing {
 
           artworkData.push(...entry.trackArtworks);
           commentaryData.push(...entry.commentary);
-          creditingSourceData.push(...entry.creditSources);
+          creditingSourceData.push(...entry.creditingSources);
+          referencingSourceData.push(...entry.referencingSources);
 
           // TODO: As exposed, Track.lyrics tries to inherit from the main
           // release, which is impossible before the data's been linked.
@@ -763,7 +770,7 @@ export class Album extends Thing {
         }
 
         commentaryData.push(...album.commentary);
-        creditingSourceData.push(...album.creditSources);
+        creditingSourceData.push(...album.creditingSources);
 
         album.trackSections = trackSections;
       }
@@ -776,6 +783,7 @@ export class Album extends Thing {
         artworkData,
         commentaryData,
         creditingSourceData,
+        referencingSourceData,
         lyricsData,
       };
     },
@@ -786,6 +794,14 @@ export class Album extends Thing {
     },
   });
 
+  getOwnAdditionalFilePath(_file, filename) {
+    return [
+      'media.albumAdditionalFile',
+      this.directory,
+      filename,
+    ];
+  }
+
   getOwnArtworkPath(artwork) {
     if (artwork === this.bannerArtwork) {
       return [
@@ -823,13 +839,19 @@ export class Album extends Thing {
       artwork.fileExtension,
     ];
   }
+
+  // As of writing, albums don't even have a `duration` property...
+  // so this function will never be called... but the message stands...
+  countOwnContributionInDurationTotals(_contrib) {
+    return false;
+  }
 }
 
 export class TrackSection extends Thing {
   static [Thing.friendlyName] = `Track Section`;
   static [Thing.referenceType] = `track-section`;
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({Track}) => ({
     // Update & expose
 
     name: name('Unnamed Track Section'),
@@ -958,11 +980,13 @@ export class TrackSection extends Thing {
 
     parts.push(Thing.prototype[inspect.custom].apply(this));
 
-    if (depth >= 0) {
+    if (depth >= 0) showAlbum: {
       let album = null;
       try {
         album = this.album;
-      } catch {}
+      } catch {
+        break showAlbum;
+      }
 
       let first = null;
       try {
@@ -974,22 +998,20 @@ export class TrackSection extends Thing {
         last = this.tracks.at(-1).trackNumber;
       } catch {}
 
-      if (album) {
-        const albumName = album.name;
-        const albumIndex = album.trackSections.indexOf(this);
+      const albumName = album.name;
+      const albumIndex = album.trackSections.indexOf(this);
 
-        const num =
-          (albumIndex === -1
-            ? 'indeterminate position'
-            : `#${albumIndex + 1}`);
+      const num =
+        (albumIndex === -1
+          ? 'indeterminate position'
+          : `#${albumIndex + 1}`);
 
-        const range =
-          (albumIndex >= 0 && first !== null && last !== null
-            ? `: ${first}-${last}`
-            : '');
+      const range =
+        (albumIndex >= 0 && first !== null && last !== null
+          ? `: ${first}-${last}`
+          : '');
 
-        parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
-      }
+      parts.push(` (${colors.yellow(num + range)} in ${colors.green(`"${albumName}"`)})`);
     }
 
     return parts.join('');
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index 57e156ee..518f616b 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,8 +1,7 @@
 export const ART_TAG_DATA_FILE = 'tags.yaml';
 
 import {input} from '#composite';
-import find from '#find';
-import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort';
+import {sortAlphabetically} from '#sort';
 import Thing from '#thing';
 import {unique} from '#sugar';
 import {isName} from '#validators';
@@ -12,7 +11,6 @@ import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
 
 import {
-  additionalNameList,
   annotatedReferenceList,
   color,
   contentString,
@@ -23,8 +21,8 @@ import {
   name,
   soupyFind,
   soupyReverse,
+  thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 import {withAllDescendantArtTags, withAncestorArtTagBaobabTree}
@@ -34,7 +32,7 @@ export class ArtTag extends Thing {
   static [Thing.referenceType] = 'tag';
   static [Thing.friendlyName] = `Art Tag`;
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({
     // Update & expose
 
     name: name('Unnamed Art Tag'),
@@ -55,7 +53,9 @@ export class ArtTag extends Thing {
       },
     ],
 
-    additionalNames: additionalNameList(),
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
 
     description: contentString(),
 
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 9e329c74..5b67051c 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -26,7 +26,6 @@ import {
   soupyFind,
   soupyReverse,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 import {artistTotalDuration} from '#composite/things/artist';
@@ -35,7 +34,7 @@ export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
   static [Thing.wikiDataArray] = 'artistData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     name: name('Unnamed Artist'),
@@ -287,7 +286,7 @@ export class Artist extends Thing {
       let aliasedArtist;
       try {
         aliasedArtist = this.aliasedArtist.name;
-      } catch (_error) {
+      } catch {
         aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist');
       }
 
diff --git a/src/data/things/artwork.js b/src/data/things/artwork.js
index 8c88dea7..57c293ca 100644
--- a/src/data/things/artwork.js
+++ b/src/data/things/artwork.js
@@ -1,5 +1,6 @@
 import {inspect} from 'node:util';
 
+import {colors} from '#cli';
 import {input} from '#composite';
 import find from '#find';
 import Thing from '#thing';
@@ -24,7 +25,7 @@ import {
   parseDimensions,
 } from '#yaml';
 
-import {withIndexInList, withPropertyFromObject} from '#composite/data';
+import {withPropertyFromObject} from '#composite/data';
 
 import {
   exitWithoutDependency,
@@ -54,19 +55,17 @@ import {
 } from '#composite/wiki-properties';
 
 import {
+  withAttachedArtwork,
   withContainingArtworkList,
-  withContribsFromMainArtwork,
-  withPropertyFromMainArtwork,
+  withContribsFromAttachedArtwork,
+  withPropertyFromAttachedArtwork,
   withDate,
 } from '#composite/things/artwork';
 
 export class Artwork extends Thing {
   static [Thing.referenceType] = 'artwork';
 
-  static [Thing.getPropertyDescriptors] = ({
-    ArtTag,
-    Contribution,
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({
     // Update & expose
 
     unqualifiedDirectory: directory({
@@ -78,6 +77,7 @@ export class Artwork extends Thing {
 
     label: simpleString(),
     source: contentString(),
+    originDetails: contentString(),
 
     dateFromThingProperty: simpleString(),
 
@@ -134,7 +134,7 @@ export class Artwork extends Thing {
       }),
 
       exitWithoutDependency({
-        dependency: 'artistContribsFromThingProperty',
+        dependency: 'dimensionsFromThingProperty',
         value: input.value(null),
       }),
 
@@ -170,6 +170,7 @@ export class Artwork extends Thing {
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
         date: '#date',
+        thingProperty: input.thisProperty(),
         artistProperty: 'artistContribsArtistProperty',
       }),
 
@@ -178,10 +179,10 @@ export class Artwork extends Thing {
         mode: input.value('empty'),
       }),
 
-      withContribsFromMainArtwork(),
+      withContribsFromAttachedArtwork(),
 
       exposeDependencyOrContinue({
-        dependency: '#mainArtwork.artistContribs',
+        dependency: '#attachedArtwork.artistContribs',
       }),
 
       exitWithoutDependency({
@@ -222,13 +223,12 @@ export class Artwork extends Thing {
         mode: input.value('empty'),
       }),
 
-      withPropertyFromMainArtwork({
+      withPropertyFromAttachedArtwork({
         property: input.value('artTags'),
-        onlyIfAttached: input.value(true),
       }),
 
       exposeDependencyOrContinue({
-        dependency: '#mainArtwork.artTags',
+        dependency: '#attachedArtwork.artTags',
       }),
 
       exitWithoutDependency({
@@ -360,35 +360,31 @@ export class Artwork extends Thing {
       },
     ],
 
-    siblingArtworks: [
-      withContainingArtworkList(),
+    attachedArtwork: [
+      withAttachedArtwork(),
 
-      exitWithoutDependency({
-        dependency: '#containingArtworkList',
-        value: input.value(null),
+      exposeDependency({
+        dependency: '#attachedArtwork',
       }),
+    ],
 
-      withIndexInList({
-        list: '#containingArtworkList',
-        item: input.myself(),
+    attachingArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichAttach'),
+    }),
+
+    groups: [
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('groups'),
       }),
 
-      exitWithoutDependency({
-        dependency: '#index',
-        mode: input.value('index'),
-        value: input.value(null),
+      exposeDependencyOrContinue({
+        dependency: '#thing.groups',
       }),
 
-      {
-        dependencies: ['#containingArtworkList', '#index'],
-        compute: ({
-          ['#containingArtworkList']: list,
-          ['#index']: index,
-        }) => [
-          ...list.slice(0, index),
-          ...list.slice(index + 1),
-        ],
-      },
+      exposeConstant({
+        value: input.value([]),
+      }),
     ],
   });
 
@@ -404,6 +400,7 @@ export class Artwork extends Thing {
 
       'Label': {property: 'label'},
       'Source': {property: 'source'},
+      'Origin Details': {property: 'originDetails'},
 
       'Date': {
         property: 'date',
@@ -448,6 +445,18 @@ export class Artwork extends Thing {
       date: ({artwork}) => artwork.date,
     },
 
+    artworksWhichAttach: {
+      bindTo: 'artworkData',
+
+      referencing: referencingArtwork =>
+        (referencingArtwork.attachAbove
+          ? [referencingArtwork]
+          : []),
+
+      referenced: referencingArtwork =>
+        [referencingArtwork.attachedArtwork],
+    },
+
     artworksWhichFeature: {
       bindTo: 'artworkData',
 
@@ -463,6 +472,18 @@ export class Artwork extends Thing {
     return this.thing.getOwnArtworkPath(this);
   }
 
+  countOwnContributionInContributionTotals(contrib) {
+    if (this.attachAbove) {
+      return false;
+    }
+
+    if (contrib.annotation?.startsWith('edits for wiki')) {
+      return false;
+    }
+
+    return true;
+  }
+
   [inspect.custom](depth, options, inspect) {
     const parts = [];
 
diff --git a/src/data/things/content.js b/src/data/things/content.js
index 7f352795..ca41ccaa 100644
--- a/src/data/things/content.js
+++ b/src/data/things/content.js
@@ -1,31 +1,37 @@
 import {input} from '#composite';
-import find from '#find';
 import Thing from '#thing';
 import {is, isDate} from '#validators';
 import {parseDate} from '#yaml';
 
-import {contentString, referenceList, simpleDate, soupyFind, thing}
+import {contentString, simpleDate, soupyFind, thing}
   from '#composite/wiki-properties';
 
 import {
+  exitWithoutDependency,
   exposeConstant,
+  exposeDependency,
   exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
   withResultOfAvailabilityCheck,
 } from '#composite/control-flow';
 
-import {withWebArchiveDate} from '#composite/things/commentary-entry';
+import {
+  contentArtists,
+  hasAnnotationPart,
+  withAnnotationParts,
+  withHasAnnotationPart,
+  withSourceText,
+  withSourceURLs,
+  withWebArchiveDate,
+} from '#composite/things/content';
 
 export class ContentEntry extends Thing {
-  static [Thing.getPropertyDescriptors] = ({Artist}) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     thing: thing(),
 
-    artists: referenceList({
-      class: input.value(Artist),
-      find: soupyFind.input('artist'),
-    }),
+    artists: contentArtists(),
 
     artistText: contentString(),
 
@@ -44,6 +50,10 @@ export class ContentEntry extends Thing {
     },
 
     accessKind: [
+      exitWithoutDependency({
+        dependency: 'accessDate',
+      }),
+
       exposeUpdateValueOrContinue({
         validate: input.value(
           is(...[
@@ -67,7 +77,7 @@ export class ContentEntry extends Thing {
       },
 
       exposeConstant({
-        value: input.value(null),
+        value: input.value('accessed'),
       }),
     ],
 
@@ -96,6 +106,26 @@ export class ContentEntry extends Thing {
     // Update only
 
     find: soupyFind(),
+
+    // Expose only
+
+    annotationParts: [
+      withAnnotationParts({
+        mode: input.value('strings'),
+      }),
+
+      exposeDependency({dependency: '#annotationParts'}),
+    ],
+
+    sourceText: [
+      withSourceText(),
+      exposeDependency({dependency: '#sourceText'}),
+    ],
+
+    sourceURLs: [
+      withSourceURLs(),
+      exposeDependency({dependency: '#sourceURLs'}),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -117,6 +147,59 @@ export class ContentEntry extends Thing {
   };
 }
 
-export class CommentaryEntry extends ContentEntry {}
-export class LyricsEntry extends ContentEntry {}
+export class CommentaryEntry extends ContentEntry {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isWikiEditorCommentary: hasAnnotationPart({
+      part: input.value('wiki editor'),
+    }),
+  });
+}
+
+export class LyricsEntry extends ContentEntry {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    originDetails: contentString(),
+
+    // Expose only
+
+    isWikiLyrics: hasAnnotationPart({
+      part: input.value('wiki lyrics'),
+    }),
+
+    hasSquareBracketAnnotations: [
+      withHasAnnotationPart({
+        part: input.value('wiki lyrics'),
+      }),
+
+      exitWithoutDependency({
+        dependency: '#hasAnnotationPart',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'body',
+        value: input.value(false),
+      }),
+
+      {
+        dependencies: ['body'],
+        compute: ({body}) =>
+          /\[.*\]/m.test(body),
+      },
+    ],
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, {
+    fields: {
+      'Origin Details': {property: 'originDetails'},
+    },
+  });
+}
+
 export class CreditingSourcesEntry extends ContentEntry {}
+
+export class ReferencingSourcesEntry extends ContentEntry {}
diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js
index c92fafb4..90e8eb79 100644
--- a/src/data/things/contribution.js
+++ b/src/data/things/contribution.js
@@ -5,12 +5,20 @@ import {colors} from '#cli';
 import {input} from '#composite';
 import {empty} from '#sugar';
 import Thing from '#thing';
-import {isStringNonEmpty, isThing, validateReference} from '#validators';
+import {isBoolean, isStringNonEmpty, isThing, validateReference}
+  from '#validators';
 
-import {exitWithoutDependency, exposeDependency} from '#composite/control-flow';
 import {flag, simpleDate, soupyFind} from '#composite/wiki-properties';
 
 import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
   withFilteredList,
   withNearbyItemFromList,
   withPropertyFromList,
@@ -70,7 +78,26 @@ export class Contribution extends Thing {
         property: input.thisProperty(),
       }),
 
-      flag(true),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInContributionTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInContributionTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
 
     countInDurationTotals: [
@@ -78,7 +105,37 @@ export class Contribution extends Thing {
         property: input.thisProperty(),
       }),
 
-      flag(true),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('duration'),
+      }),
+
+      exitWithoutDependency({
+        dependency: '#thing.duration',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInDurationTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInDurationTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
 
     // Update only
@@ -238,6 +295,21 @@ export class Contribution extends Thing {
         dependency: '#nearbyItem',
       }),
     ],
+
+    groups: [
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('groups'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#thing.groups',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
   });
 
   [inspect.custom](depth, options, inspect) {
@@ -259,7 +331,7 @@ export class Contribution extends Thing {
       let artist;
       try {
         artist = this.artist;
-      } catch (_error) {
+      } catch {
         // Computing artist might crash for any reason - don't distract from
         // other errors as a result of inspecting this contribution.
       }
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index a0bcb523..160221f0 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -27,7 +27,6 @@ import {
 } from '#composite/control-flow';
 
 import {
-  additionalNameList,
   color,
   commentatorArtists,
   constitutibleArtwork,
@@ -44,7 +43,6 @@ import {
   thing,
   thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 import {withFlashAct} from '#composite/things/flash';
@@ -54,10 +52,10 @@ export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalName,
     CommentaryEntry,
     CreditingSourcesEntry,
     Track,
-    FlashAct,
     WikiInfo,
   }) => ({
     // Update & expose
@@ -127,13 +125,15 @@ export class Flash extends Thing {
 
     urls: urls(),
 
-    additionalNames: additionalNameList(),
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
 
     commentary: thingList({
       class: input.value(CommentaryEntry),
     }),
 
-    creditSources: thingList({
+    creditingSources: thingList({
       class: input.value(CreditingSourcesEntry),
     }),
 
@@ -255,8 +255,8 @@ export class Flash extends Thing {
         transform: parseCommentary,
       },
 
-      'Credit Sources': {
-        property: 'creditSources',
+      'Crediting Sources': {
+        property: 'creditingSources',
         transform: parseCreditingSources,
       },
 
@@ -459,7 +459,7 @@ export class FlashSide extends Thing {
 
       const artworkData = flashData.map(flash => flash.coverArtwork);
       const commentaryData = flashData.flatMap(flash => flash.commentary);
-      const creditingSourceData = flashData.flatMap(flash => flash.creditSources);
+      const creditingSourceData = flashData.flatMap(flash => flash.creditingSources);
 
       return {
         flashData,
diff --git a/src/data/things/group.js b/src/data/things/group.js
index b40d15b4..0262a3a5 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -1,7 +1,11 @@
 export const GROUP_DATA_FILE = 'groups.yaml';
 
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
 import {input} from '#composite';
 import Thing from '#thing';
+import {is} from '#validators';
 import {parseAnnotatedReferences, parseSerieses} from '#yaml';
 
 import {
@@ -11,16 +15,16 @@ import {
   directory,
   name,
   referenceList,
-  seriesList,
   soupyFind,
+  thing,
+  thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 export class Group extends Thing {
   static [Thing.referenceType] = 'group';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({
+  static [Thing.getPropertyDescriptors] = ({Album, Artist, Series}) => ({
     // Update & expose
 
     name: name('Unnamed Group'),
@@ -43,8 +47,8 @@ export class Group extends Thing {
       find: soupyFind.input('album'),
     }),
 
-    serieses: seriesList({
-      group: input.myself(),
+    serieses: thingList({
+      class: input.value(Series),
     }),
 
     // Update only
@@ -192,8 +196,9 @@ export class Group extends Thing {
 
       const groupData = results.filter(x => x instanceof Group);
       const groupCategoryData = results.filter(x => x instanceof GroupCategory);
+      const seriesData = groupData.flatMap(group => group.serieses);
 
-      return {groupData, groupCategoryData};
+      return {groupData, groupCategoryData, seriesData};
     },
 
     // Groups aren't sorted at all, always preserving the order in the data
@@ -240,3 +245,73 @@ export class GroupCategory extends Thing {
     },
   };
 }
+
+export class Series extends Thing {
+  static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({
+    // Update & expose
+
+    name: name('Unnamed Series'),
+
+    showAlbumArtists: {
+      flags: {update: true, expose: true},
+      update: {
+        validate:
+          is('all', 'differing', 'none'),
+      },
+    },
+
+    description: contentString(),
+
+    group: thing({
+      class: input.value(Group),
+    }),
+
+    albums: referenceList({
+      class: input.value(Album),
+      find: soupyFind.input('album'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+
+      'Description': {property: 'description'},
+
+      'Show Album Artists': {property: 'showAlbumArtists'},
+
+      'Albums': {property: 'albums'},
+    },
+  };
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (depth >= 0) showGroup: {
+      let group = null;
+      try {
+        group = this.group;
+      } catch {
+        break showGroup;
+      }
+
+      const groupName = group.name;
+      const groupIndex = group.serieses.indexOf(this);
+
+      const num =
+        (groupIndex === -1
+          ? 'indeterminate position'
+          : `#${groupIndex + 1}`);
+
+      parts.push(` (${colors.yellow(num)} in ${colors.green(`"${groupName}"`)})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index 82bad2d3..3a11c287 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -63,7 +63,6 @@ export class HomepageLayout extends Thing {
     thingConstructors: {
       HomepageLayout,
       HomepageLayoutSection,
-      HomepageLayoutAlbumsRow,
     },
   }) => ({
     title: `Process homepage layout file`,
@@ -250,7 +249,7 @@ export class HomepageLayoutActionsRow extends HomepageLayoutRow {
 export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
   static [Thing.friendlyName] = `Homepage Album Carousel Row`;
 
-  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
+  static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({
     ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
 
     // Update & expose
diff --git a/src/data/things/index.js b/src/data/things/index.js
index b832ab75..11307b50 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -9,6 +9,8 @@ import * as serialize from '#serialize';
 import {withEntries} from '#sugar';
 import Thing from '#thing';
 
+import * as additionalFileClasses from './additional-file.js';
+import * as additionalNameClasses from './additional-name.js';
 import * as albumClasses from './album.js';
 import * as artTagClasses from './art-tag.js';
 import * as artistClasses from './artist.js';
@@ -26,6 +28,8 @@ import * as trackClasses from './track.js';
 import * as wikiInfoClasses from './wiki-info.js';
 
 const allClassLists = {
+  'additional-file.js': additionalFileClasses,
+  'additional-name.js': additionalNameClasses,
   'album.js': albumClasses,
   'art-tag.js': artTagClasses,
   'artist.js': artistClasses,
diff --git a/src/data/things/language.js b/src/data/things/language.js
index a3f861bd..e3689643 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -135,6 +135,7 @@ export class Language extends Thing {
     },
 
     intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}),
+    intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}),
     intl_number: this.#intlHelper(Intl.NumberFormat),
     intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}),
     intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}),
@@ -488,22 +489,44 @@ export class Language extends Thing {
     // or both are undefined, that's just blank content.
     const hasStart = startDate !== null && startDate !== undefined;
     const hasEnd = endDate !== null && endDate !== undefined;
-    if (!hasStart || !hasEnd) {
-      if (startDate === endDate) {
-        return html.blank();
-      } else if (hasStart) {
-        throw new Error(`Expected both start and end of date range, got only start`);
-      } else if (hasEnd) {
-        throw new Error(`Expected both start and end of date range, got only end`);
-      } else {
-        throw new Error(`Got mismatched ${startDate}/${endDate} for start and end`);
-      }
+    if (!hasStart && !hasEnd) {
+      return html.blank();
+    } else if (hasStart && !hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only start`);
+    } else if (!hasStart && hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only end`);
     }
 
     this.assertIntlAvailable('intl_date');
     return this.intl_date.formatRange(startDate, endDate);
   }
 
+  formatYear(date) {
+    if (date === null || date === undefined) {
+      return html.blank();
+    }
+
+    this.assertIntlAvailable('intl_dateYear');
+    return this.intl_dateYear.format(date);
+  }
+
+  formatYearRange(startDate, endDate) {
+    // formatYearRange expects both values to be present, but if both are null
+    // or both are undefined, that's just blank content.
+    const hasStart = startDate !== null && startDate !== undefined;
+    const hasEnd = endDate !== null && endDate !== undefined;
+    if (!hasStart && !hasEnd) {
+      return html.blank();
+    } else if (hasStart && !hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only start`);
+    } else if (!hasStart && hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only end`);
+    }
+
+    this.assertIntlAvailable('intl_dateYear');
+    return this.intl_dateYear.formatRange(startDate, endDate);
+  }
+
   formatDateDuration({
     years: numYears = 0,
     months: numMonths = 0,
@@ -842,6 +865,18 @@ export class Language extends Thing {
     }
   }
 
+  typicallyLowerCase(string) {
+    // Utter nonsense implementation, so this only works on strings,
+    // not actual HTML content, and may rudely disrespect *intentful*
+    // capitalization of whatever goes into it.
+
+    if (typeof string !== 'string') return string;
+    if (string.length <= 1) return string;
+    if (/^\S+?[A-Z]/.test(string)) return string;
+
+    return string[0].toLowerCase() + string.slice(1);
+  }
+
   // Utility function to quickly provide a useful string key
   // (generally a prefix) to stuff nested beneath it.
   encapsulate(...args) {
diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js
index b169a541..ccc4ad89 100644
--- a/src/data/things/sorting-rule.js
+++ b/src/data/things/sorting-rule.js
@@ -3,7 +3,6 @@ export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml';
 import {readFile, writeFile} from 'node:fs/promises';
 import * as path from 'node:path';
 
-import {input} from '#composite';
 import {chunkByProperties, compareArrays, unique} from '#sugar';
 import Thing from '#thing';
 import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators';
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 57aaa90d..8b9420c7 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -15,6 +15,7 @@ import {
   parseCommentary,
   parseContributors,
   parseCreditingSources,
+  parseReferencingSources,
   parseDate,
   parseDimensions,
   parseDuration,
@@ -38,11 +39,8 @@ import {
 } from '#composite/wiki-data';
 
 import {
-  additionalFiles,
-  additionalNameList,
   commentatorArtists,
   constitutibleArtworkList,
-  contentString,
   contributionList,
   dimensions,
   directory,
@@ -86,14 +84,15 @@ export class Track extends Thing {
   static [Thing.referenceType] = 'track';
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalFile,
+    AdditionalName,
     Album,
     ArtTag,
     Artwork,
     CommentaryEntry,
     CreditingSourcesEntry,
-    Flash,
     LyricsEntry,
-    TrackSection,
+    ReferencingSourcesEntry,
     WikiInfo,
   }) => ({
     // Update & expose
@@ -134,7 +133,9 @@ export class Track extends Thing {
       class: input.value(Album),
     }),
 
-    additionalNames: additionalNameList(),
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
 
     bandcampTrackIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
@@ -169,6 +170,18 @@ export class Track extends Thing {
       exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
     ],
 
+    countInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('countTracksInArtistTotals'),
+      }),
+
+      exposeDependency({dependency: '#album.countTracksInArtistTotals'}),
+    ],
+
     // Disables presenting the track as though it has its own unique artwork.
     // This flag should only be used in select circumstances, i.e. to override
     // an album's trackCoverArtists. This flag supercedes that property, as well
@@ -225,10 +238,14 @@ export class Track extends Thing {
       class: input.value(CommentaryEntry),
     }),
 
-    creditSources: thingList({
+    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
@@ -240,9 +257,17 @@ export class Track extends Thing {
       }),
     ],
 
-    additionalFiles: additionalFiles(),
-    sheetMusicFiles: additionalFiles(),
-    midiProjectFiles: additionalFiles(),
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    sheetMusicFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    midiProjectFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
     mainReleaseTrack: singleReference({
       class: input.value(Track),
@@ -428,6 +453,16 @@ export class Track extends Thing {
       exposeDependency({dependency: '#otherReleases'}),
     ],
 
+    groups: [
+      withPropertyFromAlbum({
+        property: input.value('groups'),
+      }),
+
+      exposeDependency({
+        dependency: '#album.groups',
+      }),
+    ],
+
     referencedByTracks: reverseReferenceList({
       reverse: soupyReverse.input('tracksWhichReference'),
     }),
@@ -462,6 +497,8 @@ export class Track extends Thing {
         transform: String,
       },
 
+      'Count In Artist Totals': {property: 'countInArtistTotals'},
+
       'Duration': {
         property: 'duration',
         transform: parseDuration,
@@ -507,11 +544,16 @@ export class Track extends Thing {
         transform: parseCommentary,
       },
 
-      'Credit Sources': {
-        property: 'creditSources',
+      'Crediting Sources': {
+        property: 'creditingSources',
         transform: parseCreditingSources,
       },
 
+      'Referencing Sources': {
+        property: 'referencingSources',
+        transform: parseReferencingSources,
+      },
+
       'Additional Files': {
         property: 'additionalFiles',
         transform: parseAdditionalFiles,
@@ -575,6 +617,11 @@ export class Track extends Thing {
     },
 
     invalidFieldCombinations: [
+      {message: `Secondary releases never count in artist totals`, fields: [
+        'Main Release',
+        'Count In Artist Totals',
+      ]},
+
       {message: `Secondary releases inherit references from the main one`, fields: [
         'Main Release',
         'Referenced Tracks',
@@ -736,6 +783,16 @@ export class Track extends Thing {
   // Track YAML loading is handled in album.js.
   static [Thing.getYamlLoadingSpec] = null;
 
+  getOwnAdditionalFilePath(_file, filename) {
+    if (!this.album) return null;
+
+    return [
+      'media.albumAdditionalFile',
+      this.album.directory,
+      filename,
+    ];
+  }
+
   getOwnArtworkPath(artwork) {
     if (!this.album) return null;
 
@@ -751,6 +808,30 @@ export class Track extends Thing {
     ];
   }
 
+  countOwnContributionInContributionTotals(_contrib) {
+    if (!this.countInArtistTotals) {
+      return false;
+    }
+
+    if (this.isSecondaryRelease) {
+      return false;
+    }
+
+    return true;
+  }
+
+  countOwnContributionInDurationTotals(_contrib) {
+    if (!this.countInArtistTotals) {
+      return false;
+    }
+
+    if (this.isSecondaryRelease) {
+      return false;
+    }
+
+    return true;
+  }
+
   [inspect.custom](depth) {
     const parts = [];
 
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 590598be..f97f9027 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -2,7 +2,7 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml';
 
 import {input} from '#composite';
 import Thing from '#thing';
-import {parseContributionPresets} from '#yaml';
+import {parseContributionPresets, parseWallpaperParts} from '#yaml';
 
 import {
   isBoolean,
@@ -14,8 +14,17 @@ import {
 } from '#validators';
 
 import {exitWithoutDependency} from '#composite/control-flow';
-import {contentString, flag, name, referenceList, soupyFind}
-  from '#composite/wiki-properties';
+
+import {
+  contentString,
+  fileExtension,
+  flag,
+  name,
+  referenceList,
+  simpleString,
+  soupyFind,
+  wallpaperParts,
+} from '#composite/wiki-properties';
 
 export class WikiInfo extends Thing {
   static [Thing.friendlyName] = `Wiki Info`;
@@ -68,6 +77,10 @@ export class WikiInfo extends Thing {
       },
     },
 
+    wikiWallpaperFileExtension: fileExtension('jpg'),
+    wikiWallpaperStyle: simpleString(),
+    wikiWallpaperParts: wallpaperParts(),
+
     divideTrackListsByGroups: referenceList({
       class: input.value(Group),
       find: soupyFind.input('group'),
@@ -112,18 +125,34 @@ export class WikiInfo extends Thing {
     fields: {
       'Name': {property: 'name'},
       'Short Name': {property: 'nameShort'},
+
       'Color': {property: 'color'},
+
       'Description': {property: 'description'},
+
       'Footer Content': {property: 'footerContent'},
+
       'Default Language': {property: 'defaultLanguage'},
+
       'Canonical Base': {property: 'canonicalBase'},
-      'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'},
+
+      'Wiki Wallpaper File Extension': {property: 'wikiWallpaperFileExtension'},
+
+      'Wiki Wallpaper Style': {property: 'wikiWallpaperStyle'},
+
+      'Wiki Wallpaper Parts': {
+        property: 'wikiWallpaperParts',
+        transform: parseWallpaperParts,
+      },
+
       'Enable Flashes & Games': {property: 'enableFlashesAndGames'},
       'Enable Listings': {property: 'enableListings'},
       'Enable News': {property: 'enableNews'},
       'Enable Art Tag UI': {property: 'enableArtTagUI'},
       'Enable Group UI': {property: 'enableGroupUI'},
 
+      'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'},
+
       'Contribution Presets': {
         property: 'contributionPresets',
         transform: parseContributionPresets,
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 036fe8a7..9a0295b8 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -8,6 +8,7 @@ import {inspect as nodeInspect} from 'node:util';
 import yaml from 'js-yaml';
 
 import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
+import {parseContentNodes, splitContentNodesAround} from '#replacer';
 import {sortByName} from '#sort';
 import Thing from '#thing';
 import thingConstructors from '#things';
@@ -610,49 +611,39 @@ export function parseContributors(entries) {
   });
 }
 
-export function parseAdditionalFiles(entries) {
+export function parseAdditionalFiles(entries, {subdoc, AdditionalFile}) {
   return parseArrayEntries(entries, item => {
     if (typeof item !== 'object') return item;
 
-    return {
-      title: item['Title'],
-      description: item['Description'] ?? null,
-      files: item['Files'],
-    };
+    return subdoc(AdditionalFile, item, {bindInto: 'thing'});
   });
 }
 
-export function parseAdditionalNames(entries) {
+export function parseAdditionalNames(entries, {subdoc, AdditionalName}) {
   return parseArrayEntries(entries, item => {
-    if (typeof item === 'object' && typeof item['Name'] === 'string')
-      return {
-        name: item['Name'],
-        annotation: item['Annotation'] ?? null,
-      };
+    if (typeof item === 'object') {
+      return subdoc(AdditionalName, item, {bindInto: 'thing'});
+    }
 
     if (typeof item !== 'string') return item;
 
     const match = item.match(extractAccentRegex);
     if (!match) return item;
 
-    return {
-      name: match.groups.main,
-      annotation: match.groups.accent ?? null,
+    const document = {
+      ['Name']: match.groups.main,
+      ['Annotation']: match.groups.accent ?? null,
     };
+
+    return subdoc(AdditionalName, document, {bindInto: 'thing'});
   });
 }
 
-export function parseSerieses(entries) {
+export function parseSerieses(entries, {subdoc, Series}) {
   return parseArrayEntries(entries, item => {
     if (typeof item !== 'object') return item;
 
-    return {
-      name: item['Name'],
-      description: item['Description'] ?? null,
-      albums: item['Albums'] ?? null,
-
-      showAlbumArtists: item['Show Album Artists'] ?? null,
-    };
+    return subdoc(Series, item, {bindInto: 'group'});
   });
 }
 
@@ -827,37 +818,82 @@ export function parseArtwork({
   return transform;
 }
 
-export function parseContentEntries(thingClass, sourceText, {subdoc}) {
-  const map = matchEntry => ({
-    'Artists':
-      matchEntry.artistReferences
-        .split(',')
-        .map(ref => ref.trim()),
+export function parseContentEntriesFromSourceText(thingClass, sourceText, {subdoc}) {
+  function map(matchEntry) {
+    let artistText = null, artistReferences = null;
 
-    'Artist Text':
-      matchEntry.artistDisplayText,
+    const artistTextNodes =
+      Array.from(
+        splitContentNodesAround(
+          parseContentNodes(matchEntry.artistText),
+          /\|/g));
 
-    'Annotation':
-      matchEntry.annotation,
+    const separatorIndices =
+      artistTextNodes
+        .filter(node => node.type === 'separator')
+        .map(node => artistTextNodes.indexOf(node));
 
-    'Date':
-      matchEntry.date,
+    if (empty(separatorIndices)) {
+      if (artistTextNodes.length === 1 && artistTextNodes[0].type === 'text') {
+        artistReferences = matchEntry.artistText;
+      } else {
+        artistText = matchEntry.artistText;
+      }
+    } else {
+      const firstSeparatorIndex =
+        separatorIndices.at(0);
+
+      const secondSeparatorIndex =
+        separatorIndices.at(1) ??
+        artistTextNodes.length;
+
+      artistReferences =
+        matchEntry.artistText.slice(
+          artistTextNodes.at(0).i,
+          artistTextNodes.at(firstSeparatorIndex - 1).iEnd);
+
+      artistText =
+        matchEntry.artistText.slice(
+          artistTextNodes.at(firstSeparatorIndex).iEnd,
+          artistTextNodes.at(secondSeparatorIndex - 1).iEnd);
+    }
 
-    'Second Date':
-      matchEntry.secondDate,
+    if (artistReferences) {
+      artistReferences =
+        artistReferences
+          .split(',')
+          .map(ref => ref.trim());
+    }
 
-    'Date Kind':
-      matchEntry.dateKind,
+    return {
+      'Artists':
+        artistReferences,
 
-    'Access Date':
-      matchEntry.accessDate,
+      'Artist Text':
+        artistText,
 
-    'Access Kind':
-      matchEntry.accessKind,
+      'Annotation':
+        matchEntry.annotation,
 
-    'Body':
-      matchEntry.body,
-  });
+      'Date':
+        matchEntry.date,
+
+      'Second Date':
+        matchEntry.secondDate,
+
+      'Date Kind':
+        matchEntry.dateKind,
+
+      'Access Date':
+        matchEntry.accessDate,
+
+      'Access Kind':
+        matchEntry.accessKind,
+
+      'Body':
+        matchEntry.body,
+    };
+  }
 
   const documents =
     matchContentEntries(sourceText)
@@ -876,22 +912,39 @@ export function parseContentEntries(thingClass, sourceText, {subdoc}) {
   return subdocs;
 }
 
-export function parseCommentary(sourceText, {subdoc, CommentaryEntry}) {
-  return parseContentEntries(CommentaryEntry, sourceText, {subdoc});
+export function parseContentEntries(thingClass, value, {subdoc}) {
+  if (typeof value === 'string') {
+    return parseContentEntriesFromSourceText(thingClass, value, {subdoc});
+  } else if (Array.isArray(value)) {
+    return value.map(doc => subdoc(thingClass, doc, {bindInto: 'thing'}));
+  } else {
+    return value;
+  }
+}
+
+export function parseCommentary(value, {subdoc, CommentaryEntry}) {
+  return parseContentEntries(CommentaryEntry, value, {subdoc});
+}
+
+export function parseCreditingSources(value, {subdoc, CreditingSourcesEntry}) {
+  return parseContentEntries(CreditingSourcesEntry, value, {subdoc});
 }
 
-export function parseCreditingSources(sourceText, {subdoc, CreditingSourcesEntry}) {
-  return parseContentEntries(CreditingSourcesEntry, sourceText, {subdoc});
+export function parseReferencingSources(value, {subdoc, ReferencingSourcesEntry}) {
+  return parseContentEntries(ReferencingSourcesEntry, value, {subdoc});
 }
 
-export function parseLyrics(sourceText, {subdoc, LyricsEntry}) {
-  if (!multipleLyricsDetectionRegex.test(sourceText)) {
-    const document = {'Body': sourceText};
+export function parseLyrics(value, {subdoc, LyricsEntry}) {
+  if (
+    typeof value === 'string' &&
+    !multipleLyricsDetectionRegex.test(value)
+  ) {
+    const document = {'Body': value};
 
     return [subdoc(LyricsEntry, document, {bindInto: 'thing'})];
   }
 
-  return parseContentEntries(LyricsEntry, sourceText, {subdoc});
+  return parseContentEntries(LyricsEntry, value, {subdoc});
 }
 
 // documentModes: Symbols indicating sets of behavior for loading and processing
@@ -969,7 +1022,7 @@ export const documentModes = {
 export function getAllDataSteps() {
   try {
     thingConstructors;
-  } catch (error) {
+  } catch {
     throw new Error(`Thing constructors aren't ready yet, can't get all data steps`);
   }
 
@@ -1589,6 +1642,10 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
 
     ['lyricsData', [/* find */]],
 
+    ['referencingSourceData', [/* find */]],
+
+    ['seriesData', [/* find */]],
+
     ['trackData', [
       'artworkData',
       'trackData',
diff --git a/src/external-links.js b/src/external-links.js
index 1055a391..ab1555bd 100644
--- a/src/external-links.js
+++ b/src/external-links.js
@@ -4,11 +4,9 @@ import {
   anyOf,
   is,
   isBoolean,
-  isObject,
   isStringNonEmpty,
   looseArrayOf,
   optional,
-  validateAllPropertyValues,
   validateArrayItems,
   validateInstanceOf,
   validateProperties,
@@ -32,6 +30,9 @@ export const externalLinkContexts = [
   'generic',
   'group',
   'track',
+
+  'composerRelease',
+  'officialRelease',
 ];
 
 export const isExternalLinkContext =
@@ -257,6 +258,30 @@ export const externalLinkSpec = [
   },
 
   {
+    match: {
+      domain: '.bandcamp.com',
+      context: 'composerRelease',
+    },
+
+    platform: 'bandcamp.composerRelease',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'bandcamp',
+  },
+
+  {
+    match: {
+      domain: '.bandcamp.com',
+      context: 'officialRelease',
+    },
+
+    platform: 'bandcamp.officialRelease',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'bandcamp',
+  },
+
+  {
     match: {domain: '.bandcamp.com'},
 
     platform: 'bandcamp',
diff --git a/src/find-reverse.js b/src/find-reverse.js
index 6a67ac0f..c99a4a71 100644
--- a/src/find-reverse.js
+++ b/src/find-reverse.js
@@ -11,7 +11,7 @@ export function getAllSpecs({
 }) {
   try {
     thingConstructors;
-  } catch (error) {
+  } catch {
     throw new Error(`Thing constructors aren't ready yet, can't get all ${word} specs`);
   }
 
@@ -52,7 +52,7 @@ export function findSpec(key, {
 
   try {
     thingConstructors;
-  } catch (error) {
+  } catch {
     throw new Error(`Thing constructors aren't ready yet, can't check if "${word}.${key}" available`);
   }
 
diff --git a/src/find.js b/src/find.js
index e7f5cda1..8f2170d4 100644
--- a/src/find.js
+++ b/src/find.js
@@ -56,11 +56,14 @@ export function processAvailableMatchesByName(data, {
         if (normalizedName in multipleNameMatches) {
           multipleNameMatches[normalizedName].push(thing);
         } else {
-          multipleNameMatches[normalizedName] = [results[normalizedName], thing];
+          multipleNameMatches[normalizedName] = [
+            results[normalizedName].thing,
+            thing,
+          ];
           results[normalizedName] = null;
         }
       } else {
-        results[normalizedName] = thing;
+        results[normalizedName] = {thing, name};
       }
     }
   }
@@ -87,7 +90,7 @@ export function processAvailableMatchesByDirectory(data, {
         continue;
       }
 
-      results[directory] = thing;
+      results[directory] = {thing, directory};
     }
   }
 
@@ -117,13 +120,50 @@ function oopsMultipleNameMatches(mode, {
     `Returning null for this reference.`);
 }
 
+function oopsNameCapitalizationMismatch(mode, {
+  matchingName,
+  matchedName,
+}) {
+  if (matchingName.length === matchedName.length) {
+    let a = '', b = '';
+    for (let i = 0; i < matchingName.length; i++) {
+      if (
+        matchingName[i] === matchedName[i] ||
+        matchingName[i].toLowerCase() !== matchingName[i].toLowerCase()
+      ) {
+        a += matchingName[i];
+        b += matchedName[i];
+      } else {
+        a += colors.bright(colors.red(matchingName[i]));
+        b += colors.bright(colors.green(matchedName[i]));
+      }
+    }
+
+    matchingName = a;
+    matchedName = b;
+  }
+
+  return warnOrThrow(mode,
+    `Provided capitalization differs from the matched name. Please resolve:\n` +
+    `- provided: ${matchingName}\n` +
+    `- should be: ${matchedName}\n` +
+    `Returning null for this reference.`);
+}
+
 export function prepareMatchByName(mode, {byName, multipleNameMatches}) {
   return (name) => {
     const normalizedName = name.toLowerCase();
     const match = byName[normalizedName];
 
     if (match) {
-      return match;
+      if (name === match.name) {
+        return match.thing;
+      } else {
+        return oopsNameCapitalizationMismatch(mode, {
+          matchingName: name,
+          matchedName: match.name,
+        });
+      }
     } else if (multipleNameMatches[normalizedName]) {
       return oopsMultipleNameMatches(mode, {
         name,
@@ -154,7 +194,13 @@ export function prepareMatchByDirectory(mode, {referenceTypes, byDirectory}) {
       });
     }
 
-    return byDirectory[directory];
+    const match = byDirectory[directory];
+
+    if (match) {
+      return match.thing;
+    } else {
+      return null;
+    }
   };
 }
 
@@ -345,7 +391,13 @@ function findMixedHelper(config) {
           });
         }
 
-        return byDirectory[referenceType][directory];
+        const match = byDirectory[referenceType][directory];
+
+        if (match) {
+          return match.thing;
+        } else {
+          return null;
+        }
       },
 
       matchByName:
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 97cf74a9..40505189 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -162,7 +162,6 @@ import {
 
 import dimensionsOf from 'image-size';
 
-import CacheableObject from '#cacheable-object';
 import {stringifyCache} from '#cli';
 import {commandExists, isMain, promisifyProcess, traverse} from '#node-utils';
 import {sortByName} from '#sort';
@@ -447,7 +446,7 @@ async function getImageMagickVersion(binary) {
 
   try {
     await promisifyProcess(proc, false);
-  } catch (error) {
+  } catch {
     return null;
   }
 
@@ -556,42 +555,56 @@ async function determineThumbtacksNeededForFile({
   return mismatchedWithinRightSize;
 }
 
-async function generateImageThumbnail(imagePath, thumbtack, {
+// Write all requested thumbtacks for a source image in one pass
+// This saves a lot of disk reads which are probably the main bottleneck
+function prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks) {
+  const args = [filePathInMedia, '-strip'];
+
+  const basename =
+    path.basename(filePathInMedia, path.extname(filePathInMedia));
+
+  // do larger sizes first
+  thumbtacks.sort((a, b) => thumbnailSpec[b].size - thumbnailSpec[a].size);
+
+  for (const tack of thumbtacks) {
+    const {size, quality} = thumbnailSpec[tack];
+    const filename = `${basename}.${tack}.jpg`;
+    const filePathInCache = path.join(dirnameInCache, filename);
+    args.push(
+      '(', '+clone',
+      '-resize', `${size}x${size}>`,
+      '-interlace', 'Plane',
+      '-quality', `${quality}%`,
+      '-write', filePathInCache,
+      '+delete', ')',
+    );
+  }
+
+  // throw away the (already written) image stream
+  args.push('null:');
+
+  return args;
+}
+
+async function generateImageThumbnails(imagePath, thumbtacks, {
   mediaPath,
   mediaCachePath,
   spawnConvert,
 }) {
+  if (empty(thumbtacks)) return;
+
   const filePathInMedia =
     path.join(mediaPath, imagePath);
 
   const dirnameInCache =
     path.join(mediaCachePath, path.dirname(imagePath));
 
-  const filename =
-    path.basename(imagePath, path.extname(imagePath)) +
-    `.${thumbtack}.jpg`;
-
-  const filePathInCache =
-    path.join(dirnameInCache, filename);
-
   await mkdir(dirnameInCache, {recursive: true});
 
-  const specEntry = thumbnailSpec[thumbtack];
-  const {size, quality} = specEntry;
-
-  const convertProcess = spawnConvert([
-    filePathInMedia,
-    '-strip',
-    '-resize',
-    `${size}x${size}>`,
-    '-interlace',
-    'Plane',
-    '-quality',
-    `${quality}%`,
-    filePathInCache,
-  ]);
-
-  await promisifyProcess(convertProcess, false);
+  const convertArgs =
+    prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks);
+
+  await promisifyProcess(spawnConvert(convertArgs), false);
 }
 
 export async function determineMediaCachePath({
@@ -628,7 +641,7 @@ export async function determineMediaCachePath({
   try {
     const files = await readdir(mediaPath);
     mediaIncludesThumbnailCache = files.includes(CACHE_FILE);
-  } catch (error) {
+  } catch {
     mediaIncludesThumbnailCache = false;
   }
 
@@ -861,7 +874,7 @@ export async function migrateThumbsIntoDedicatedCacheDirectory({
         path.join(mediaPath, CACHE_FILE),
         path.join(mediaCachePath, CACHE_FILE));
       logInfo`Moved thumbnail cache file.`;
-    } catch (error) {
+    } catch {
       logWarn`Failed to move cache file. (${CACHE_FILE})`;
       logWarn`Check its permissions, or try copying/pasting.`;
     }
@@ -1100,33 +1113,23 @@ export default async function genThumbs({
   const writeMessageFn = () =>
     `Writing image thumbnails. [failed: ${numFailed}]`;
 
-  const generateCallImageIndices =
-    imageThumbtacksNeeded
-      .flatMap(({length}, index) =>
-        Array.from({length}, () => index));
-
-  const generateCallImagePaths =
-    generateCallImageIndices
-      .map(index => imagePaths[index]);
-
-  const generateCallThumbtacks =
-    imageThumbtacksNeeded.flat();
-
   const generateCallFns =
     stitchArrays({
-      imagePath: generateCallImagePaths,
-      thumbtack: generateCallThumbtacks,
-    }).map(({imagePath, thumbtack}) => () =>
-        generateImageThumbnail(imagePath, thumbtack, {
+      imagePath: imagePaths,
+      thumbtacks: imageThumbtacksNeeded,
+    }).map(({imagePath, thumbtacks}) => () =>
+        generateImageThumbnails(imagePath, thumbtacks, {
           mediaPath,
           mediaCachePath,
           spawnConvert,
         }).catch(error => {
             numFailed++;
-            return ({error});
+            return {error};
           }));
 
-  logInfo`Generating ${generateCallFns.length} thumbnails for ${imagePaths.length} media files.`;
+  const totalThumbs = imageThumbtacksNeeded.reduce((sum, tacks) => sum + tacks.length, 0);
+
+  logInfo`Generating ${totalThumbs} thumbnails for ${imagePaths.length} media files.`;
   if (generateCallFns.length > 500) {
     logInfo`Go get a latte - this could take a while!`;
   }
@@ -1135,37 +1138,30 @@ export default async function genThumbs({
     await progressPromiseAll(writeMessageFn,
       queue(generateCallFns, magickThreads));
 
-  let successfulIndices;
+  let successfulPaths;
 
   {
-    const erroredIndices = generateCallImageIndices.slice();
-    const erroredPaths = generateCallImagePaths.slice();
-    const erroredThumbtacks = generateCallThumbtacks.slice();
+    const erroredPaths = imagePaths.slice();
     const errors = generateCallResults.map(result => result?.error);
 
     const {removed} =
       filterMultipleArrays(
-        erroredIndices,
         erroredPaths,
-        erroredThumbtacks,
         errors,
-        (_index, _imagePath, _thumbtack, error) => error);
+        (_imagePath, error) => error);
 
-    successfulIndices = new Set(removed[0]);
-
-    const chunks =
-      chunkMultipleArrays(erroredPaths, erroredThumbtacks, errors,
-        (imagePath, lastImagePath) => imagePath !== lastImagePath);
+    ([successfulPaths] = removed);
 
     // TODO: This should obviously be an aggregate error.
     // ...Just like every other error report here, and those dang aggregates
     // should be constructable from within the queue, rather than after.
-    for (const [[imagePath], thumbtacks, errors] of chunks) {
-      logError`Failed to generate thumbnails for ${imagePath}:`;
-      for (const {thumbtack, error} of stitchArrays({thumbtack: thumbtacks, error: errors})) {
-        logError`- ${thumbtack}: ${error}`;
-      }
-    }
+    stitchArrays({
+      imagePath: erroredPaths,
+      error: errors,
+    }).forEach(({imagePath, error}) => {
+        logError`Failed to generate thumbnails for ${imagePath}:`;
+        logError`- ${error}`;
+      });
 
     if (empty(errors)) {
       logInfo`All needed thumbnails generated successfully - nice!`;
@@ -1179,8 +1175,8 @@ export default async function genThumbs({
     imagePaths,
     imageThumbtacksNeeded,
     imageDimensions,
-    (_imagePath, _thumbtacksNeeded, _dimensions, index) =>
-      successfulIndices.has(index));
+    (imagePath, _thumbtacksNeeded, _dimensions) =>
+      successfulPaths.includes(imagePath));
 
   for (const {
     imagePath,
@@ -1251,6 +1247,11 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) {
         .filter(part => part.asset)
         .map(part =>
           fromRoot.to('media.albumWallpaperPart', album.directory, part.asset))),
+
+    wikiData.wikiInfo.wikiWallpaperParts
+      .filter(part => part.asset)
+      .map(part =>
+        fromRoot.to('media.path', part.asset)),
   ].flat();
 
   sortByName(paths, {getName: path => path});
diff --git a/src/html.js b/src/html.js
index 0fe424df..dd1d1960 100644
--- a/src/html.js
+++ b/src/html.js
@@ -53,6 +53,17 @@ export const attributeSpec = {
   },
 };
 
+let disabledSlotValidation = false;
+let disabledTagTracing = false;
+
+export function disableSlotValidation() {
+  disabledSlotValidation = true;
+}
+
+export function disableTagTracing() {
+  disabledTagTracing = true;
+}
+
 // Pass to tag() as an attributes key to make tag() return a 8lank tag if the
 // provided content is empty. Useful for when you'll only 8e showing an element
 // according to the presence of content that would 8elong there.
@@ -223,7 +234,11 @@ export function isBlank(content) {
     // could include content. These need to be checked too.
     // Check each of the templates one at a time.
     for (const template of result) {
-      const content = template.content;
+      // Resolve the content all the way down to a tag -
+      // if it's a template that returns another template,
+      // that won't do, because we need to detect if its
+      // final content is a tag marked onlyIfSiblings.
+      const content = normalize(template);
 
       if (content instanceof Tag && content.onlyIfSiblings) {
         continue;
@@ -352,8 +367,10 @@ export class Tag {
     this.attributes = attributes;
     this.content = content;
 
-    this.#traceError = new Error();
-  }
+    if (!disabledTagTracing) {
+      this.#traceError = new Error();
+    }
+}
 
   clone() {
     return Reflect.construct(this.constructor, [
@@ -512,6 +529,10 @@ export class Tag {
     }
   }
 
+  #getAttributeRaw(attribute) {
+    return this.attributes.get(attribute);
+  }
+
   set onlyIfContent(value) {
     this.#setAttributeFlag(onlyIfContent, value);
   }
@@ -579,7 +600,7 @@ export class Tag {
 
     try {
       this.content = this.content;
-    } catch (error) {
+    } catch {
       this.#setAttributeFlag(imaginarySibling, false);
     }
   }
@@ -662,7 +683,7 @@ export class Tag {
 
     const chunkwrapSplitter =
       (this.chunkwrap
-        ? this.#getAttributeString('split')
+        ? this.#getAttributeRaw('split')
         : null);
 
     let seenChunkwrapSplitter =
@@ -702,17 +723,19 @@ export class Tag {
             `of ${inspect(this, {compact: true})}`,
             {cause: caughtError});
 
-        error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
-        error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
+        if (this.#traceError && !disabledTagTracing) {
+          error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
+          error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
 
-        error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
-          /content-function\.js/,
-          /util\/html\.js/,
-        ];
+          error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
+            /content-function\.js/,
+            /util\/html\.js/,
+          ];
 
-        error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
-          /content\/dependencies\/(.*\.js:.*(?=\)))/,
-        ];
+          error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
+            /content\/dependencies\/(.*\.js:.*(?=\)))/,
+          ];
+        }
 
         throw error;
       }
@@ -727,7 +750,7 @@ export class Tag {
 
       const chunkwrapChunks =
         (typeof nonTemplateItem === 'string' && chunkwrapSplitter
-          ? itemContent.split(chunkwrapSplitter)
+          ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter))
           : null);
 
       const itemIncludesChunkwrapSplit =
@@ -773,7 +796,7 @@ export class Tag {
 
       appendItemContent: {
         if (itemIncludesChunkwrapSplit) {
-          for (const [index, chunk] of chunkwrapChunks.entries()) {
+          for (const [index, {chunk, following}] of chunkwrapChunks.entries()) {
             if (index === 0) {
               // The first chunk isn't actually a chunk all on its own, it's
               // text that should be appended to the previous chunk. We will
@@ -781,12 +804,27 @@ export class Tag {
               // the next chunk.
               content += chunk;
             } else {
-              const whitespace = chunk.match(/^\s+/) ?? '';
-              content += chunkwrapSplitter;
+              const followingWhitespace = following.match(/\s+$/) ?? '';
+              const chunkWhitespace = chunk.match(/^\s+/) ?? '';
+
+              if (followingWhitespace) {
+                content += following.slice(0, -followingWhitespace.length);
+              } else {
+                content += following;
+              }
+
               content += '</span>';
-              content += whitespace;
+
+              content += followingWhitespace;
+              content += chunkWhitespace;
+
               content += '<span class="chunkwrap">';
-              content += chunk.slice(whitespace.length);
+
+              if (chunkWhitespace) {
+                content += chunk.slice(chunkWhitespace.length);
+              } else {
+                content += chunk;
+              }
             }
           }
 
@@ -1009,6 +1047,49 @@ export class Tag {
   }
 }
 
+export function* getChunkwrapChunks(content, splitter) {
+  const splitString =
+    (typeof splitter === 'string'
+      ? splitter
+      : null);
+
+  if (splitString) {
+    let following = '';
+    for (const chunk of content.split(splitString)) {
+      yield {chunk, following};
+      following = splitString;
+    }
+
+    return;
+  }
+
+  const splitRegExp =
+    (splitter instanceof RegExp
+      ? new RegExp(
+          splitter.source,
+          (splitter.flags.includes('g')
+            ? splitter.flags
+            : splitter.flags + 'g'))
+      : null);
+
+  if (splitRegExp) {
+    let following = '';
+    let prevIndex = 0;
+    for (const match of content.matchAll(splitRegExp)) {
+      const chunk = content.slice(prevIndex, match.index);
+      yield {chunk, following};
+
+      following = match[0];
+      prevIndex = match.index + match[0].length;
+    }
+
+    const chunk = content.slice(prevIndex);
+    yield {chunk, following};
+
+    return;
+  }
+}
+
 export function attributes(attributes) {
   return new Attributes(attributes);
 }
@@ -1069,6 +1150,34 @@ export class Attributes {
   }
 
   add(...args) {
+    // Very common case: add({class: 'foo', id: 'bar'})¡
+    // The argument is a plain object (no Template, no Attributes,
+    // no blessAttributes symbol). We can skip the expensive
+    // isAttributesAdditionSinglet() validation and flatten/array handling.
+    if (
+      args.length === 1 &&
+      args[0] &&
+      typeof args[0] === 'object' &&
+      !Array.isArray(args[0]) &&
+      !(args[0] instanceof Attributes) &&
+      !(args[0] instanceof Template) &&
+      !Object.hasOwn(args[0], blessAttributes)
+    ) {
+      const obj = args[0];
+
+      // Preserve existing merge semantics by funnelling each key through
+      // the internal #addOneAttribute helper (handles class/style union,
+      // unique merging, etc.) but avoid *per-object* validation overhead.
+      for (const key of Reflect.ownKeys(obj)) {
+        this.#addOneAttribute(key, obj[key]);
+      }
+
+      // Match the original return style (list of results) so callers that
+      // inspect the return continue to work.
+      return obj;
+    }
+
+    // Fall back to the original slow-but-thorough implementation
     switch (args.length) {
       case 1:
         isAttributesAdditionSinglet(args[0]);
@@ -1080,10 +1189,11 @@ export class Attributes {
 
       default:
         throw new Error(
-          `Expected array or object, or attribute and value`);
+          'Expected array or object, or attribute and value');
     }
   }
 
+
   with(...args) {
     const clone = this.clone();
     clone.add(...args);
@@ -1254,6 +1364,9 @@ export class Attributes {
           return value.some(Boolean);
         } else if (value === null) {
           return false;
+        } else if (value instanceof RegExp) {
+          // Oooooooo.
+          return true;
         } else {
           // Other objects are an error.
           break;
@@ -1285,13 +1398,16 @@ export class Attributes {
       case 'number':
         return value.toString();
 
-      // If it's a kept object, it's an array.
       case 'object': {
-        const joiner =
-          (descriptor?.arraylike && descriptor?.join)
-            ?? ' ';
+        if (Array.isArray(value)) {
+          const joiner =
+            (descriptor?.arraylike && descriptor?.join)
+              ?? ' ';
 
-        return value.filter(Boolean).join(joiner);
+          return value.filter(Boolean).join(joiner);
+        } else {
+          return value;
+        }
       }
 
       default:
@@ -1671,6 +1787,10 @@ export class Template {
   }
 
   static validateSlotValueAgainstDescription(value, description) {
+    if (disabledSlotValidation) {
+      return true;
+    }
+
     if (value === undefined) {
       throw new TypeError(`Specify value as null or don't specify at all`);
     }
@@ -1963,6 +2083,8 @@ export const isAttributeValue =
   anyOf(
     isString, isNumber, isBoolean, isArray,
     isTag, isTemplate,
+    // Evil. Ooooo
+    validateInstanceOf(RegExp),
     validateArrayItems(item => isAttributeValue(item)));
 
 export const isAttributesAdditionPair = pair => {
diff --git a/src/replacer.js b/src/replacer.js
index 32657a5a..779ee78d 100644
--- a/src/replacer.js
+++ b/src/replacer.js
@@ -8,7 +8,8 @@
 import * as marked from 'marked';
 
 import * as html from '#html';
-import {escapeRegex, typeAppearance} from '#sugar';
+import {empty, escapeRegex, typeAppearance} from '#sugar';
+import {matchMarkdownLinks} from '#wiki-data';
 
 export const replacerSpec = {
   'album': {
@@ -174,6 +175,11 @@ export const replacerSpec = {
     find: 'trackWithArtwork',
     link: 'linkTrackReferencingArtworks',
   },
+
+  'tooltip': {
+    value: (ref) => ref,
+    link: null,
+  }
 };
 
 // Syntax literals.
@@ -458,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) {
@@ -520,6 +526,7 @@ export function postprocessComments(inputNodes) {
 
 function postprocessHTMLTags(inputNodes, tagName, callback) {
   const outputNodes = [];
+  const errors = [];
 
   const lastNode = inputNodes.at(-1);
 
@@ -587,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
@@ -613,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');
@@ -642,13 +673,17 @@ 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'));
+      if (attributes.get('align')) node.align = attributes.get('align');
       if (attributes.get('pixelate')) node.pixelate = true;
 
       return node;
@@ -661,8 +696,13 @@ 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;
     });
 }
@@ -762,110 +802,234 @@ export function postprocessExternalLinks(inputNodes) {
       continue;
     }
 
-    const plausibleLinkRegexp = /\[.*?\)/g;
-
-    let textContent = '';
+    let textNode = {
+      i: node.i,
+      iEnd: null,
+      type: 'text',
+      data: '',
+    };
 
-    let plausibleMatch = null, parseFrom = 0;
-    while (plausibleMatch = plausibleLinkRegexp.exec(node.data)) {
-      textContent += node.data.slice(parseFrom, plausibleMatch.index);
-
-      // Pedantic rules use more particular parentheses detection in link
-      // destinations - they allow one level of balanced parentheses, and
-      // otherwise, parentheses must be escaped. This allows for entire links
-      // to be wrapped in parentheses, e.g below:
-      //
-      //   This is so cool. ([You know??](https://example.com))
-      //
-      const definiteMatch =
-        marked.Lexer.rules.inline.pedantic.link
-          .exec(node.data.slice(plausibleMatch.index));
-
-      if (definiteMatch) {
-        const {1: label, 2: href} = definiteMatch;
-
-        // Split the containing text node into two - the second of these will
-        // be added after iterating over matches, or by the next match.
-        if (textContent.length) {
-          outputNodes.push({type: 'text', data: textContent});
-          textContent = '';
-        }
+    let parseFrom = 0;
+    for (const match of matchMarkdownLinks(node.data, {marked})) {
+      const {label, href, index, length} = match;
 
-        const offset = plausibleMatch.index + definiteMatch.index;
-        const length = definiteMatch[0].length;
+      textNode.data += node.data.slice(parseFrom, index);
 
-        outputNodes.push({
-          i: node.i + offset,
-          iEnd: node.i + offset + length,
-          type: 'external-link',
-          data: {label, href},
-        });
+      // Split the containing text node into two - the second of these will
+      // be filled in and pushed by the next match, or after iterating over
+      // all matches.
+      if (textNode.data) {
+        textNode.iEnd = textNode.i + textNode.data.length;
+        outputNodes.push(textNode);
 
-        parseFrom = offset + length;
-      } else {
-        parseFrom = plausibleMatch.index;
+        textNode = {
+          i: node.i + index + length,
+          iEnd: null,
+          type: 'text',
+          data: '',
+        };
       }
+
+      outputNodes.push({
+        i: node.i + index,
+        iEnd: node.i + index + length,
+        type: 'external-link',
+        data: {label, href},
+      });
+
+      parseFrom = index + length;
     }
 
     if (parseFrom !== node.data.length) {
-      textContent += node.data.slice(parseFrom);
+      textNode.data += node.data.slice(parseFrom);
+      textNode.iEnd = node.iEnd;
     }
 
-    if (textContent.length) {
-      outputNodes.push({type: 'text', data: textContent});
+    if (textNode.data) {
+      outputNodes.push(textNode);
     }
   }
 
   return outputNodes;
 }
 
-export function parseInput(input) {
+export function parseContentNodes(input, {
+  errorMode = 'throw',
+} = {}) {
   if (typeof input !== 'string') {
     throw new TypeError(`Expected input to be string, got ${typeAppearance(input)}`);
   }
 
-  try {
-    let output = parseNodes(input, 0);
-    output = postprocessComments(output);
-    output = postprocessImages(output);
-    output = postprocessVideos(output);
-    output = 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;
+
+  process: {
+    try {
+      result = parseNodes(input, 0);
+    } catch (caughtError) {
+      if (caughtError.type === 'error') {
+        const {i, data: {message}} = caughtError;
+
+        let lineStart = input.slice(0, i).lastIndexOf('\n');
+        if (lineStart >= 0) {
+          lineStart += 1;
+        } else {
+          lineStart = 0;
+        }
+
+        let lineEnd = input.slice(i).indexOf('\n');
+        if (lineEnd >= 0) {
+          lineEnd += i;
+        } else {
+          lineEnd = input.length;
+        }
+
+        const line = input.slice(lineStart, lineEnd);
+
+        const cursor = i - lineStart;
+
+        error =
+          new SyntaxError(
+            `Parse error (at pos ${i}): ${message}\n` +
+            line + `\n` +
+            '-'.repeat(cursor) + '^');
+      } else {
+        error = caughtError;
+      }
+
+      // A parse error means there's no output to continue with at all,
+      // so stop here.
+      break process;
     }
 
-    const {
-      i,
-      data: {message},
-    } = errorNode;
+    const postprocessErrors = [];
+
+    for (const postprocess of [
+      postprocessComments,
+      postprocessImages,
+      postprocessVideos,
+      postprocessAudios,
+      postprocessHeadings,
+      postprocessSummaries,
+      postprocessExternalLinks,
+    ]) {
+      try {
+        result = postprocess(result);
+      } catch (caughtError) {
+        const error =
+          new Error(
+            `Error in step ${`"${postprocess.name}"`}`,
+            {cause: caughtError});
+
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+        postprocessErrors.push(error);
+      }
+    }
 
-    let lineStart = input.slice(0, i).lastIndexOf('\n');
-    if (lineStart >= 0) {
-      lineStart += 1;
-    } else {
-      lineStart = 0;
+    if (!empty(postprocessErrors)) {
+      error =
+        new AggregateError(
+          postprocessErrors,
+        `Errors postprocessing content text`);
+
+      error[Symbol.for('hsmusic.aggregate.translucent')] = 'single';
     }
+  }
 
-    let lineEnd = input.slice(i).indexOf('\n');
-    if (lineEnd >= 0) {
-      lineEnd += i;
+  if (errorMode === 'throw') {
+    if (error) {
+      throw error;
     } else {
-      lineEnd = input.length;
+      return result;
     }
+  } else if (errorMode === 'return') {
+    if (!result) {
+      result = [{
+        i: 0,
+        iEnd: input.length,
+        type: 'text',
+        data: input,
+      }];
+    }
+
+    return {error, result};
+  } else {
+    throw new Error(`Unknown errorMode ${errorMode}`);
+  }
+}
 
-    const line = input.slice(lineStart, lineEnd);
+export function* splitContentNodesAround(nodes, splitter) {
+  if (splitter instanceof RegExp) {
+    const regex = splitter;
 
-    const cursor = i - lineStart;
+    splitter = function*(text) {
+      for (const match of text.matchAll(regex)) {
+        yield {
+          index: match.index,
+          length: match[0].length,
+        };
+      }
+    };
+  }
+
+  if (typeof splitter === 'string') {
+    throw new TypeError(`Expected generator or regular expression`);
+  }
 
-    throw new SyntaxError([
-      `Parse error (at pos ${i}): ${message}`,
-      line,
-      '-'.repeat(cursor) + '^',
-    ].join('\n'));
+  function* splitTextNode(node) {
+    let textNode = {
+      i: node.i,
+      iEnd: null,
+      type: 'text',
+      data: '',
+    };
+
+    let parseFrom = 0;
+    for (const match of splitter(node.data)) {
+      const {index, length} = match;
+
+      textNode.data += node.data.slice(parseFrom, index);
+
+      if (textNode.data) {
+        textNode.iEnd = textNode.i + textNode.data.length;
+        yield textNode;
+      }
+
+      yield {
+        i: node.i + index,
+        iEnd: node.i + index + length,
+        type: 'separator',
+        data: {
+          text: node.data.slice(index, index + length),
+          match,
+        },
+      };
+
+      textNode = {
+        i: node.i + index + length,
+        iEnd: null,
+        type: 'text',
+        data: '',
+      };
+
+      parseFrom = index + length;
+    }
+
+    if (parseFrom !== node.data.length) {
+      textNode.data += node.data.slice(parseFrom);
+      textNode.iEnd = node.iEnd;
+    }
+
+    if (textNode.data) {
+      yield textNode;
+    }
+  }
+
+  for (const node of nodes) {
+    if (node.type === 'text') {
+      yield* splitTextNode(node);
+    } else {
+      yield node;
+    }
   }
 }
diff --git a/src/static/css/site.css b/src/static/css/site.css
index b087582a..a9ed90c6 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -61,7 +61,7 @@ body::before, .wallpaper-part {
 
 #page-container {
   max-width: 1100px;
-  margin: 0 auto 40px;
+  margin: 0 auto 38px;
   padding: 15px 0;
 }
 
@@ -76,10 +76,25 @@ body::before, .wallpaper-part {
   height: unset;
 }
 
+@property --banner-shine {
+  syntax: '<percentage>';
+  initial-value: 0%;
+  inherits: false;
+}
+
 #banner {
   margin: 10px 0;
   width: 100%;
   position: relative;
+
+  --banner-shine: 4%;
+  -webkit-box-reflect: below -12px linear-gradient(transparent, color-mix(in srgb, transparent, var(--banner-shine) white));
+  transition: --banner-shine 0.8s;
+}
+
+#banner:hover {
+  --banner-shine: 35%;
+  transition-delay: 0.3s;
 }
 
 #banner::after {
@@ -161,10 +176,9 @@ body::before, .wallpaper-part {
 }
 
 .sidebar-column {
-  flex: 1 1 20%;
+  flex: 1 1 35%;
   min-width: 150px;
   max-width: 250px;
-  flex-basis: 250px;
   align-self: flex-start;
 }
 
@@ -262,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 > * {
@@ -1247,7 +1265,8 @@ li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)),
 .missing-duration-tooltip,
 .commentary-date-tooltip,
 .rerelease-tooltip,
-.first-release-tooltip {
+.first-release-tooltip,
+.content-tooltip {
   padding: 3px 4px 2px 2px;
   left: -10px;
 }
@@ -1429,6 +1448,30 @@ li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)),
   font-size: 0.9em;
 }
 
+.content-tooltip-guy .hoverable a {
+  text-decoration-color: transparent;
+  text-decoration-style: dotted;
+}
+
+.content-tooltip-guy {
+  display: inline-block;
+}
+
+.content-tooltip-guy.has-link .text-with-tooltip-interaction-cue {
+  text-decoration-color: var(--primary-color);
+}
+
+.content-tooltip .tooltip-content {
+  padding: 3px 4.5px;
+  width: 240px;
+}
+
+.cover-artwork .content-tooltip {
+  font-size: 0.85rem;
+  padding: 2px 3px;
+  width: 220px;
+}
+
 .external-icon {
   display: inline-block;
   padding: 0 3px;
@@ -1479,6 +1522,38 @@ s.spoiler::-moz-selection {
   background: white;
 }
 
+span.path, code.filename {
+  font-size: 0.95em;
+  font-family: "courier new", monospace;
+  font-weight: 800;
+  background: #ccc3;
+
+  padding: 0.05em 0.5ch;
+  border: 1px solid #ccce;
+  border-radius: 2px;
+  line-height: 1.4;
+}
+
+.image-details code.filename {
+  margin-left: -0.4ch;
+  opacity: 0.8;
+}
+
+.image-details code.filename:hover {
+  opacity: 1;
+  cursor: text;
+}
+
+span.path i {
+  display: inline-block;
+  font-style: normal;
+}
+
+span.path i::before {
+  content: "\0020/\0020";
+  color: #ccc;
+}
+
 progress {
   accent-color: var(--primary-color);
 }
@@ -1546,6 +1621,13 @@ hr.cute,
   margin-right: 5px;
 }
 
+#artwork-column .cover-artwork:first-child + .cover-artwork-joiner,
+#artwork-column .cover-artwork.attached-artwork-is-main-artwork,
+#artwork-column .cover-artwork.attached-artwork-is-main-artwork + .cover-artwork-joiner {
+  margin-left: 17.5px;
+  margin-right: 17.5px;
+}
+
 .cover-artwork:where(#artwork-column .cover-artwork:not(:first-child)) {
   margin-top: 20px;
 }
@@ -1632,6 +1714,15 @@ p.image-details.origin-details {
   margin-bottom: 2px;
 }
 
+p.image-details.origin-details .origin-details {
+  display: block;
+  margin-top: 0.25em;
+}
+
+.cover-artwork-joiner {
+  z-index: -2;
+}
+
 .cover-artwork-joiner::after {
   content: "";
   display: block;
@@ -1664,40 +1755,27 @@ p.image-details.origin-details {
   margin: 0;
 }
 
-p.content-heading:has(+ .commentary-entry-heading.dated) {
-  clear: right;
-}
-
 .commentary-entry-heading {
-  display: flex;
-  flex-direction: row;
-
   margin-left: 15px;
-  padding-left: 5px;
-  max-width: 625px;
+  padding-left: calc(5px + 1.25ch);
+  text-indent: -1.25ch;
+  margin-right: min(calc(8vw - 35px), 45px);
   padding-bottom: 0.2em;
 
   border-bottom: 1px solid var(--dim-color);
 }
 
-.commentary-entry-heading-text {
-  flex-grow: 1;
-  padding-left: 1.25ch;
-  text-indent: -1.25ch;
-}
-
 .commentary-entry-accent {
   font-style: oblique;
 }
 
 .commentary-entry-heading .commentary-date {
-  flex-shrink: 0;
-
-  margin-left: 0.75ch;
-  align-self: flex-end;
+  display: inline-block;
+  text-indent: 0;
+}
 
-  padding-left: 0.5ch;
-  padding-right: 0.25ch;
+.commentary-entry-heading.dated .commentary-entry-heading-text {
+  margin-right: 0.75ch;
 }
 
 .commentary-entry-heading .hoverable {
@@ -1712,6 +1790,15 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
   color: var(--primary-color);
 }
 
+.inherited-commentary-section {
+  clear: right;
+  margin-top: 1em;
+  margin-right: min(4vw, 60px);
+  border: 2px solid var(--deep-color);
+  border-radius: 4px;
+  background: #ffffff07;
+}
+
 .commentary-art {
   float: right;
   width: 30%;
@@ -1737,7 +1824,20 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
 
 .lyrics-entry {
   padding-left: 40px;
-  max-width: 600px;
+}
+
+.lyrics-entry .lyrics-details,
+.lyrics-entry .origin-details {
+  font-size: 0.9em;
+  font-style: oblique;
+}
+
+.lyrics-entry .lyrics-details {
+  margin-bottom: 0;
+}
+
+.lyrics-entry .origin-details {
+  margin-top: 0.25em;
 }
 
 .js-hide,
@@ -1747,25 +1847,32 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
 }
 
 .content-image-container,
-.content-video-container {
+.content-video-container,
+.content-audio-container {
   margin-top: 1em;
   margin-bottom: 1em;
 }
 
-.content-image-container.align-center,
-.content-video-container.align-center,
-.content-audio-container.align-center {
+.content-image-container.align-center {
   text-align: center;
   margin-top: 1.5em;
   margin-bottom: 1.5em;
 }
 
-a.align-center, img.align-center, 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;
@@ -1880,6 +1987,50 @@ ul.quick-info li:not(:last-child)::after {
   text-align: center;
 }
 
+.gallery-view-switcher {
+  margin-left: auto;
+  margin-right: auto;
+  text-align: center;
+  line-height: 1.4;
+}
+
+#content.top-index section {
+  margin-bottom: 1.5em;
+}
+
+.expandable-gallery-section .section-expando {
+  margin-top: 1em;
+  margin-bottom: 2em;
+
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+}
+
+.expandable-gallery-section .section-expando-content {
+  text-align: center;
+  line-height: 1.5;
+}
+
+.expandable-gallery-section .section-expando-toggle {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+.expandable-gallery-section.expanded .section-content-below-cut {
+  animation: expand-gallery-section 0.8s forwards;
+}
+
+@keyframes expand-gallery-section {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
+}
+
 .quick-description:not(.has-external-links-only) {
   --clamped-padding-ratio: max(var(--responsive-padding-ratio), 0.06);
   margin-left: auto;
@@ -1910,6 +2061,7 @@ ul.quick-info li:not(:last-child)::after {
 
 .quick-description > blockquote {
   margin-left: 0 !important;
+  margin-right: 0 !important;
 }
 
 .quick-description .description-content.long hr ~ p {
@@ -1957,7 +2109,6 @@ ul.quick-info li:not(:last-child)::after {
 
 li .by {
   font-style: oblique;
-  max-width: 600px;
 }
 
 li .by a {
@@ -1973,8 +2124,8 @@ p code {
 
 #content blockquote {
   margin-left: 40px;
-  max-width: 600px;
-  margin-right: 0;
+  margin-right: min(8vw, 75px);
+  width: auto;
 }
 
 #content blockquote blockquote {
@@ -2021,7 +2172,6 @@ main.long-content > h1 {
 
 dl dt {
   padding-left: 40px;
-  max-width: 600px;
 }
 
 dl dt {
@@ -2050,6 +2200,13 @@ ul > li.has-details {
   margin-left: -17px;
 }
 
+li .origin-details {
+  display: block;
+  margin-left: 2ch;
+  font-size: 0.9em;
+  font-style: oblique;
+}
+
 .album-group-list dt,
 .group-series-list dt {
   font-style: oblique;
@@ -2061,6 +2218,15 @@ ul > li.has-details {
   margin-left: 0;
 }
 
+.album-group-list li {
+  padding-left: 1.5ch;
+  text-indent: -1.5ch;
+}
+
+.album-group-list li > * {
+  text-indent: 0;
+}
+
 .album-group-list blockquote {
   max-width: 540px;
   margin-bottom: 9px;
@@ -2091,31 +2257,54 @@ ul > li.has-details {
 
 #content hr {
   border: 1px inset #808080;
-  width: 100%;
+}
+
+#content hr.split {
+  color: #808080;
 }
 
 #content hr.split::before {
   content: "(split)";
-  color: #808080;
 }
 
-#content hr.split {
+#content hr.main-separator {
+  color: var(--dim-color);
+  clear: none;
+  margin-top: -0.25em;
+  margin-bottom: 1.75em;
+}
+
+#content hr.main-separator::before {
+  content: "♦";
+  font-size: 1.2em;
+}
+
+#content hr.split,
+#content hr.main-separator {
   position: relative;
   overflow: hidden;
   border: none;
 }
 
-#content hr.split::after {
+#content hr.split::after,
+#content hr.main-separator::after {
   display: inline-block;
   content: "";
-  border: 1px inset #808080;
-  width: 100%;
+  width: calc(100% - min(calc(8vw - 35px), 45px));
   position: absolute;
   top: 50%;
-  margin-top: -2px;
   margin-left: 10px;
 }
 
+#content hr.split::after {
+  border: 1px inset currentColor;
+  margin-top: -2px;
+}
+
+#content hr.main-separator::after {
+  border-bottom: 1px solid currentColor;
+}
+
 li > ul {
   margin-top: 5px;
 }
@@ -2429,7 +2618,33 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las
     linear-gradient(#000000bb, #000000bb),
     var(--primary-color);
 
-  box-shadow: 0 -2px 6px -1px var(--dim-color) inset;
+  --drop-shadow: 0 -2px 6px -1px var(--dim-color) inset;
+  box-shadow: var(--drop-shadow);
+}
+
+.drop.shiny {
+  cursor: default;
+}
+
+@supports (box-shadow: 1px 1px 1px color-mix(in srgb, blue, 40% red)) {
+  @property --drop-shine {
+    syntax: '<percentage>';
+    initial-value: 0%;
+    inherits: false;
+  }
+
+  .drop.shiny {
+    cursor: default;
+    transition: --drop-shine 0.2s;
+  }
+
+  .drop.shiny:hover {
+    --drop-shine: 100%;
+
+    box-shadow:
+      var(--drop-shadow),
+      0 2px 4px -0.5px color-mix(in srgb, var(--primary-color), calc(100% - var(--drop-shine)) transparent);
+  }
 }
 
 .commentary-drop {
@@ -2461,7 +2676,8 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las
 /* Videos and audios (in content) get a lite version of image-container. */
 .content-video-container,
 .content-audio-container {
-  width: min-content;
+  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;
@@ -2471,6 +2687,30 @@ 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,
+.content-audio-container.align-center {
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.content-video-container.align-full,
+.content-audio-container.align-full {
+  width: 100%;
+}
+
+.content-audio-container .filename {
+  color: white;
+  font-family: monospace;
+  display: block;
+  font-size: 0.9em;
+  padding-left: 1ch;
+  padding-right: 1ch;
+  padding-bottom: 0.25em;
+  margin-bottom: 0.5em;
+  border-bottom: 1px solid #fff4;
 }
 
 .image-text-area {
@@ -2527,6 +2767,12 @@ img {
   object-fit: cover;
 }
 
+p > img {
+  max-width: 100%;
+  object-fit: contain;
+  height: auto;
+}
+
 .image-inner-area::after {
   content: "";
   display: block;
@@ -2757,6 +3003,10 @@ video.pixelate, .pixelate video {
   max-width: 200px;
 }
 
+.grid-name-marker {
+  color: white;
+}
+
 .grid-actions {
   display: flex;
   flex-direction: row;
@@ -3550,7 +3800,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
 /* Layout - Wide (most computers) */
 
-@media (min-width: 900px) {
+@media (min-width: 850px) {
   #page-container.showing-sidebar-left:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible),
   #page-container.showing-sidebar-right:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible) {
     display: none;
@@ -3564,7 +3814,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
  * if so desired.
  */
 
-@media (min-width: 600px) and (max-width: 899.98px) {
+@media (min-width: 600px) and (max-width: 849.98px) {
   /* Medium layout is mainly defined (to the user) by hiding the sidebar, so
    * don't apply the similar layout change of widening the long-content area
    * if this page doesn't have a sidebar to hide in the first place.
@@ -3595,7 +3845,8 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   #artwork-column {
     float: right;
     width: 40%;
-    max-width: 400px;
+    min-width: 220px;
+    max-width: 280px;
     margin: -60px 0 10px 20px;
 
     position: relative;
@@ -3624,7 +3875,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
 /* Layout - Medium or Thin */
 
-@media (max-width: 899.98px) {
+@media (max-width: 849.98px) {
   .sidebar.collapsible,
   .sidebar-box-joiner.collapsible,
   .sidebar-column.all-boxes-collapsible {
@@ -3718,6 +3969,12 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
     max-width: unset;
   }
 
+  #artwork-column .cover-artwork:not(:first-child),
+  #artwork-column .cover-artwork-joiner {
+    margin-left: 30px;
+    margin-right: 30px;
+  }
+
   #additional-names-box {
     width: unset;
     max-width: unset;
diff --git a/src/static/js/client/expandable-gallery-section.js b/src/static/js/client/expandable-gallery-section.js
new file mode 100644
index 00000000..dc83e8b7
--- /dev/null
+++ b/src/static/js/client/expandable-gallery-section.js
@@ -0,0 +1,77 @@
+/* eslint-env browser */
+
+// TODO: Combine this and quick-description.js
+
+import {cssProp} from '../client-util.js';
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'expandableGallerySectionInfo',
+
+  sections: null,
+
+  sectionContentBelowCut: null,
+
+  sectionExpandoToggles: null,
+
+  sectionExpandCues: null,
+  sectionCollapseCues: null,
+};
+
+export function getPageReferences() {
+  info.sections =
+    Array.from(document.querySelectorAll('.expandable-gallery-section'))
+      .filter(section => section.querySelector('.section-expando-toggle'));
+
+  info.sectionContentBelowCut =
+    info.sections
+      .map(section => section.querySelector('.section-content-below-cut'));
+
+  info.sectionExpandoToggles =
+    info.sections
+      .map(section => section.querySelector('.section-expando-toggle'));
+
+  info.sectionExpandCues =
+    info.sections
+      .map(section => section.querySelector('.section-expand-cue'));
+
+  info.sectionCollapseCues =
+    info.sections
+      .map(section => section.querySelector('.section-collapse-cue'));
+}
+
+export function addPageListeners() {
+  for (const {
+    section,
+    contentBelowCut,
+    expandoToggle,
+    expandCue,
+    collapseCue,
+  } of stitchArrays({
+    section: info.sections,
+    contentBelowCut: info.sectionContentBelowCut,
+    expandoToggle: info.sectionExpandoToggles,
+    expandCue: info.sectionExpandCues,
+    collapseCue: info.sectionCollapseCues,
+  })) {
+    expandoToggle.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+
+      const collapsed =
+        cssProp(contentBelowCut, 'display') === 'none';
+
+      if (collapsed) {
+        section.classList.add('expanded');
+        cssProp(contentBelowCut, 'display', null);
+        cssProp(expandCue, 'display', 'none');
+        cssProp(collapseCue, 'display', null);
+      } else {
+        section.classList.remove('expanded');
+        cssProp(contentBelowCut, 'display', 'none');
+        cssProp(expandCue, 'display', null);
+        cssProp(collapseCue, 'display', 'none');
+      }
+    });
+  }
+}
diff --git a/src/static/js/client/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js
index 9569de3e..89119a47 100644
--- a/src/static/js/client/hoverable-tooltip.js
+++ b/src/static/js/client/hoverable-tooltip.js
@@ -118,17 +118,17 @@ export function registerTooltipElement(tooltip) {
     handleTooltipMouseLeft(tooltip);
   });
 
-  tooltip.addEventListener('focusin', event => {
-    handleTooltipReceivedFocus(tooltip, event.relatedTarget);
+  tooltip.addEventListener('focusin', domEvent => {
+    handleTooltipReceivedFocus(tooltip, domEvent.relatedTarget);
   });
 
-  tooltip.addEventListener('focusout', event => {
+  tooltip.addEventListener('focusout', domEvent => {
     // This event gets activated for tabbing *between* links inside the
     // tooltip, which is no good and certainly doesn't represent the focus
     // leaving the tooltip.
-    if (currentlyShownTooltipHasFocus(event.relatedTarget)) return;
+    if (currentlyShownTooltipHasFocus(domEvent.relatedTarget)) return;
 
-    handleTooltipLostFocus(tooltip, event.relatedTarget);
+    handleTooltipLostFocus(tooltip, domEvent.relatedTarget);
   });
 }
 
@@ -158,20 +158,20 @@ export function registerTooltipHoverableElement(hoverable, tooltip) {
     handleTooltipHoverableMouseLeft(hoverable);
   });
 
-  hoverable.addEventListener('focusin', event => {
-    handleTooltipHoverableReceivedFocus(hoverable, event);
+  hoverable.addEventListener('focusin', domEvent => {
+    handleTooltipHoverableReceivedFocus(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('focusout', event => {
-    handleTooltipHoverableLostFocus(hoverable, event);
+  hoverable.addEventListener('focusout', domEvent => {
+    handleTooltipHoverableLostFocus(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('touchend', event => {
-    handleTooltipHoverableTouchEnded(hoverable, event);
+  hoverable.addEventListener('touchend', domEvent => {
+    handleTooltipHoverableTouchEnded(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('click', event => {
-    handleTooltipHoverableClicked(hoverable, event);
+  hoverable.addEventListener('click', domEvent => {
+    handleTooltipHoverableClicked(hoverable, domEvent);
   });
 }
 
@@ -416,7 +416,7 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
     }, 1200);
 }
 
-function handleTooltipHoverableClicked(hoverable) {
+function handleTooltipHoverableClicked(hoverable, domEvent) {
   const {state} = info;
 
   // Don't navigate away from the page if the this hoverable was recently
@@ -426,7 +426,7 @@ function handleTooltipHoverableClicked(hoverable) {
     state.currentlyActiveHoverable === hoverable &&
     state.hoverableWasRecentlyTouched
   ) {
-    event.preventDefault();
+    domEvent.preventDefault();
   }
 }
 
diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js
index 81ea3415..aeb9264a 100644
--- a/src/static/js/client/index.js
+++ b/src/static/js/client/index.js
@@ -10,6 +10,7 @@ import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip
 import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js';
 import * as datetimestampTooltipModule from './datetimestamp-tooltip.js';
 import * as draggedLinkModule from './dragged-link.js';
+import * as expandableGallerySectionModule from './expandable-gallery-section.js';
 import * as hashLinkModule from './hash-link.js';
 import * as hoverableTooltipModule from './hoverable-tooltip.js';
 import * as imageOverlayModule from './image-overlay.js';
@@ -32,6 +33,7 @@ export const modules = [
   cssCompatibilityAssistantModule,
   datetimestampTooltipModule,
   draggedLinkModule,
+  expandableGallerySectionModule,
   hashLinkModule,
   hoverableTooltipModule,
   imageOverlayModule,
diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js
index cff82252..6a7a6023 100644
--- a/src/static/js/client/quick-description.js
+++ b/src/static/js/client/quick-description.js
@@ -1,5 +1,7 @@
 /* eslint-env browser */
 
+// TODO: Combine this and expandable-gallery-section.js
+
 import {stitchArrays} from '../../shared-util/sugar.js';
 
 export const info = {
diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js
index c8f42e91..eae1e74e 100644
--- a/src/static/js/client/sidebar-search.js
+++ b/src/static/js/client/sidebar-search.js
@@ -1305,7 +1305,7 @@ async function handleDroppedIntoSearchInput(domEvent) {
   let droppedURL;
   try {
     droppedURL = new URL(droppedText);
-  } catch (error) {
+  } catch {
     droppedURL = null;
   }
 
diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js
index c3002b18..e32b4ad5 100644
--- a/src/static/js/search-worker.js
+++ b/src/static/js/search-worker.js
@@ -130,7 +130,7 @@ async function loadDatabase() {
 
   try {
     idb = await promisifyIDBRequest(request);
-  } catch (error) {
+  } catch {
     console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`);
     console.warn(request.error);
     idb = null;
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index fe40e767..ecc0cc14 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -370,10 +370,14 @@ releaseInfo:
     _: "Read {LINK}."
     link: "artist commentary"
 
-  readCreditSources:
+  readCreditingSources:
     _: "Read {LINK}."
     link: "crediting sources"
 
+  readReferencingSources:
+    _: "Read {LINK}."
+    link: "referencing sources"
+
   additionalFiles:
     heading: "View or download additional files:"
 
@@ -488,7 +492,8 @@ misc:
   # artistCommentary:
 
   artistCommentary:
-    _: "Artist commentary:"
+    _: "Artist commentary for {THING}:"
+    sticky: "Artist commentary:"
 
     entry:
       title:
@@ -515,13 +520,10 @@ misc:
 
     info:
       fromMainRelease: >-
-        This commentary is properly placed on this track's main release, {ALBUM}.
+        The following commentary is properly placed on this track's main release, {ALBUM}.
 
       fromMainRelease.namedDifferently: >-
-        This commentary is properly placed on this track's main release, {ALBUM}, where it's named {NAME}.
-
-      releaseSpecific: >-
-        This commentary is specific to this release, {ALBUM}.
+        The following commentary is properly placed on this track's main release, {ALBUM}, where it's named {NAME}.
 
       seeSpecificReleases: >-
         For release-specific commentary, check out: {ALBUMS}.
@@ -618,8 +620,9 @@ misc:
       trackArt: "{INDEX} track art by {ARTIST}"
       onlyIndex: "Only"
 
-  creditSources:
-    _: "Crediting sources:"
+  creditingSources:
+    _: "Crediting sources for {THING}:"
+    sticky: "Crediting sources:"
 
   # external:
   #   Links which will generally bring you somewhere off of the wiki.
@@ -647,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"
@@ -711,6 +719,18 @@ misc:
       playlist: "YouTube (playlist)"
       fullAlbum: "YouTube (full album)"
 
+  # lyrics:
+
+  lyrics:
+    source: >-
+      Via {SOURCE}
+
+    contributors: >-
+      Contributions from {CONTRIBUTORS}
+
+    squareBracketAnnotations: >-
+      Mind parts marked in [square brackets]
+
   # missingImage:
   #   Fallback text displayed in an image when it's sourced to a file
   #   that isn't available under the wiki's media directory. While it
@@ -826,6 +846,10 @@ misc:
       group: "Groups"
       track: "Tracks"
 
+  referencingSources:
+    _: "Referencing sources for {THING}:"
+    sticky: "Referencing sources:"
+
   # skippers:
   #
   #   These are navigational links that only show up when you're
@@ -853,7 +877,7 @@ misc:
     # Displayed on various info pages.
 
     artistCommentary: "Artist commentary"
-    creditSources: "Crediting sources"
+    creditingSources: "Crediting sources"
 
     # Displayed on artist info page.
 
@@ -874,6 +898,7 @@ misc:
     sampledBy: "Sampled by..."
     features: "Features..."
     featuredIn: "Featured in..."
+    referencingSources: "Referencing sources"
 
     lyrics: "Lyrics"
 
@@ -931,9 +956,21 @@ misc:
     source.customLabel: >-
       {LABEL} via {SOURCE}
 
-    customLabelAlone: >-
+    source.withYear: >-
+      Via {SOURCE} ({YEAR})
+
+    source.customLabel.withYear: >-
+      {LABEL} ({YEAR}) via {SOURCE}
+
+    customLabel: >-
       {LABEL}
 
+    customLabel.withYear: >-
+      {LABEL} ({YEAR})
+
+    year: >-
+      Released {YEAR}
+
     trackArtFromAlbum: "Album cover for {ALBUM}"
 
     sameTagsAsMainArtwork: "Same tags as main artwork"
@@ -949,6 +986,9 @@ misc:
     noCoverArt: "{ALBUM}"
 
     details:
+      notFromThisGroup: "{NAME}{MARKER}"
+      notFromThisGroup.marker: "*"
+
       accent: "({DETAILS})"
 
       albumLength: "{TRACKS}, {TIME}"
@@ -958,6 +998,8 @@ misc:
 
       otherCoverArtists: "With {ARTISTS}"
 
+    expandCollapseCue: "({CUE})"
+
   albumGalleryGrid:
     noCoverArt: "{NAME}"
 
@@ -1293,6 +1335,16 @@ artistPage:
       flash:
         _: "{FLASH}"
 
+      artwork.accent:
+        withLabel: >-
+          {LABEL}
+
+        withAnnotation: >-
+          {ANNOTATION}
+
+        withLabel.withAnnotation: >-
+          {LABEL}: {ANNOTATION}
+
   # contributedDurationLine:
   #   This is shown at the top of the artist's track list, provided
   #   any of their tracks have durations at all.
@@ -1641,6 +1693,37 @@ groupGalleryPage:
   infoLine: >-
     {TRACKS} across {ALBUMS}, totaling {TIME}.
 
+  albumViewSwitcher:
+    _: "Showing albums:"
+
+    bySeries: "By series"
+    byDate: "By date"
+
+  albumsByDate:
+    title: "All albums"
+
+  albumSection:
+    caption: >-
+      {TRACKS} across {ALBUMS}.
+
+    caption.withDate: >-
+      {TRACKS} across {ALBUMS}, released {DATE}.
+
+    caption.withYear: >-
+      {TRACKS} across {ALBUMS}, released during {YEAR}.
+
+    caption.withYearRange: >-
+      {TRACKS} across {ALBUMS}, released {YEAR_RANGE}.
+
+    caption.seriesAlbumsNotFromGroup: >-
+      Albums marked {MARKER} are part of {SERIES}, but not from {GROUP}.
+
+    expand: >-
+      Show the rest!
+
+    collapse: >-
+      Collapse these
+
 #
 # listingIndex:
 #   The listing index page shows all available listings on the wiki,
diff --git a/src/upd8.js b/src/upd8.js
index 86ecab69..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';
@@ -117,7 +118,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
 let COMMIT;
 try {
   COMMIT = execSync('git log --format="%h %B" -n 1 HEAD', {cwd: __dirname}).toString().trim();
-} catch (error) {
+} catch {
   COMMIT = '(failed to detect)';
 }
 
@@ -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,
     });
@@ -2419,7 +2438,7 @@ async function main() {
       });
 
       internalDefaultLanguage = internalDefaultLanguageWatcher.language;
-    } catch (_error) {
+    } catch {
       // No need to display the error here - it's already printed by
       // watchLanguageFile.
       errorLoadingInternalDefaultLanguage = true;
@@ -3207,6 +3226,7 @@ async function main() {
     developersComment,
     languages,
     missingImagePaths,
+    niceShowAggregate,
     thumbsCache,
     urlSpec,
     urls,
@@ -3269,7 +3289,7 @@ async function main() {
 }
 
 // TODO: isMain detection isn't consistent across platforms here
-/* eslint-disable-next-line no-constant-condition */
+// eslint-disable-next-line no-constant-binary-expression
 if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmusic') {
   (async () => {
     let result;
@@ -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/urls.js b/src/urls.js
index 9cc4a554..b51ea459 100644
--- a/src/urls.js
+++ b/src/urls.js
@@ -129,6 +129,9 @@ export function generateURLs(urlSpec) {
         if (template === null) {
           // Self-diagnose, brutally.
 
+          // TODO: This variable isn't used, and has never been used,
+          // but it might have been *meant* to be used?
+          // eslint-disable-next-line no-unused-vars
           const otherTemplateKey = (device ? 'posix' : 'device');
 
           const {value: {[templateKey]: otherTemplate}} =
diff --git a/src/validators.js b/src/validators.js
index 5b8227fb..59df80d4 100644
--- a/src/validators.js
+++ b/src/validators.js
@@ -738,14 +738,6 @@ export const isContributionPreset = validateProperties({
 
 export const isContributionPresetList = validateArrayItems(isContributionPreset);
 
-export const isAdditionalFile = validateProperties({
-  title: isName,
-  description: optional(isContentString),
-  files: optional(validateArrayItems(isString)),
-});
-
-export const isAdditionalFileList = validateArrayItems(isAdditionalFile);
-
 export const isTrackSection = validateProperties({
   name: optional(isName),
   color: optional(isColor),
@@ -756,17 +748,6 @@ export const isTrackSection = validateProperties({
 
 export const isTrackSectionList = validateArrayItems(isTrackSection);
 
-export const isSeries = validateProperties({
-  name: isName,
-  description: optional(isContentString),
-  albums: optional(validateReferenceList('album')),
-
-  showAlbumArtists:
-    optional(is('all', 'differing', 'none')),
-});
-
-export const isSeriesList = validateArrayItems(isSeries);
-
 export const isWallpaperPart = validateProperties({
   asset: optional(isString),
   style: optional(isString),
@@ -987,13 +968,6 @@ export function validateWikiData({
   };
 }
 
-export const isAdditionalName = validateProperties({
-  name: isContentString,
-  annotation: optional(isContentString),
-});
-
-export const isAdditionalNameList = validateArrayItems(isAdditionalName);
-
 // Compositional utilities
 
 export function anyOf(...validators) {
diff --git a/src/web-routes.js b/src/web-routes.js
index b68dccbf..a7115bbd 100644
--- a/src/web-routes.js
+++ b/src/web-routes.js
@@ -4,8 +4,10 @@ import {fileURLToPath} from 'node:url';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
+/* eslint-disable no-unused-vars */
 const codeSrcPath = __dirname;
 const codeRootPath = path.resolve(codeSrcPath, '..');
+/* eslint-enable no-unused-vars */
 
 function getNodeDependencyRootPath(dependencyName) {
   return (
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index d55ab215..afbf8b2f 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -24,6 +24,7 @@ export function bindUtilities({
   language,
   languages,
   missingImagePaths,
+  niceShowAggregate,
   pagePath,
   pagePathStringFromRoot,
   thumbsCache,
@@ -42,6 +43,7 @@ export function bindUtilities({
     language,
     languages,
     missingImagePaths,
+    niceShowAggregate,
     pagePath,
     pagePathStringFromRoot,
     thumb,
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index ecb9df21..5dece8d0 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -253,7 +253,7 @@ export async function go({
     let url;
     try {
       url = new URL(request.url, `http://${request.headers.host}`);
-    } catch (error) {
+    } catch {
       response.writeHead(500, contentTypePlain);
       response.end('Failed to parse request URL\n');
       return;
@@ -300,7 +300,7 @@ export async function go({
       let filePath;
       try {
         filePath = path.resolve(localDirectory, decodeURI(safePath.split('/').join(path.sep)));
-      } catch (error) {
+      } catch {
         response.writeHead(404, contentTypePlain);
         response.end(`File not found for: ${safePath}`);
         console.log(`${requestHead} [404] ${pathname}`);
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index d363811d..b5ded04c 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -106,8 +106,6 @@ export async function go({
 
   universalUtilities,
 
-  mediaPath,
-
   defaultLanguage,
   languages,
   urls,