« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data
diff options
context:
space:
mode:
Diffstat (limited to 'src/data')
-rw-r--r--src/data/cacheable-object.js162
-rw-r--r--src/data/checks.js335
-rw-r--r--src/data/composite.js284
-rw-r--r--src/data/composite/control-flow/exitWithoutDependency.js2
-rw-r--r--src/data/composite/control-flow/exitWithoutUpdateValue.js16
-rw-r--r--src/data/composite/control-flow/flipFilter.js36
-rw-r--r--src/data/composite/control-flow/index.js2
-rw-r--r--src/data/composite/control-flow/withAvailabilityFilter.js1
-rw-r--r--src/data/composite/data/helpers/property-from-helpers.js14
-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.js56
-rw-r--r--src/data/composite/data/withNearbyItemFromList.js52
-rw-r--r--src/data/composite/data/withPropertiesFromList.js14
-rw-r--r--src/data/composite/data/withPropertiesFromObject.js14
-rw-r--r--src/data/composite/data/withPropertyFromList.js18
-rw-r--r--src/data/composite/data/withPropertyFromObject.js19
-rw-r--r--src/data/composite/things/album/index.js2
-rw-r--r--src/data/composite/things/album/withHasCoverArt.js64
-rw-r--r--src/data/composite/things/album/withTracks.js29
-rw-r--r--src/data/composite/things/art-tag/index.js2
-rw-r--r--src/data/composite/things/art-tag/withAllDescendantArtTags.js44
-rw-r--r--src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js46
-rw-r--r--src/data/composite/things/artist/artistTotalDuration.js69
-rw-r--r--src/data/composite/things/artist/index.js1
-rw-r--r--src/data/composite/things/artwork/index.js2
-rw-r--r--src/data/composite/things/artwork/withContainingArtworkList.js46
-rw-r--r--src/data/composite/things/artwork/withDate.js41
-rw-r--r--src/data/composite/things/commentary-entry/index.js1
-rw-r--r--src/data/composite/things/content/hasAnnotationPart.js25
-rw-r--r--src/data/composite/things/content/index.js4
-rw-r--r--src/data/composite/things/content/withAnnotationPartNodeLists.js28
-rw-r--r--src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js61
-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/index.js4
-rw-r--r--src/data/composite/things/contribution/inheritFromContributionPresets.js17
-rw-r--r--src/data/composite/things/contribution/thingPropertyMatches.js46
-rw-r--r--src/data/composite/things/contribution/thingReferenceTypeMatches.js66
-rw-r--r--src/data/composite/things/contribution/withContainingReverseContributionList.js8
-rw-r--r--src/data/composite/things/contribution/withContributionArtist.js26
-rw-r--r--src/data/composite/things/contribution/withMatchingContributionPresets.js70
-rw-r--r--src/data/composite/things/flash-act/index.js1
-rw-r--r--src/data/composite/things/flash-act/withFlashSide.js22
-rw-r--r--src/data/composite/things/flash/index.js1
-rw-r--r--src/data/composite/things/flash/withFlashAct.js22
-rw-r--r--src/data/composite/things/track-section/index.js3
-rw-r--r--src/data/composite/things/track-section/withAlbum.js20
-rw-r--r--src/data/composite/things/track-section/withContinueCountingFrom.js25
-rw-r--r--src/data/composite/things/track-section/withStartCountingFrom.js64
-rw-r--r--src/data/composite/things/track/exitWithoutUniqueCoverArt.js26
-rw-r--r--src/data/composite/things/track/index.js15
-rw-r--r--src/data/composite/things/track/inheritContributionListFromMainRelease.js29
-rw-r--r--src/data/composite/things/track/inheritFromMainRelease.js28
-rw-r--r--src/data/composite/things/track/trackAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/withAllReleases.js47
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js97
-rw-r--r--src/data/composite/things/track/withContainingTrackSection.js20
-rw-r--r--src/data/composite/things/track/withCoverArtistContribs.js73
-rw-r--r--src/data/composite/things/track/withDate.js34
-rw-r--r--src/data/composite/things/track/withDirectorySuffix.js36
-rw-r--r--src/data/composite/things/track/withHasUniqueCoverArt.js108
-rw-r--r--src/data/composite/things/track/withMainRelease.js70
-rw-r--r--src/data/composite/things/track/withOtherReleases.js30
-rw-r--r--src/data/composite/things/track/withPropertyFromAlbum.js48
-rw-r--r--src/data/composite/things/track/withPropertyFromMainRelease.js86
-rw-r--r--src/data/composite/things/track/withSuffixDirectoryFromAlbum.js53
-rw-r--r--src/data/composite/things/track/withTrackArtDate.js60
-rw-r--r--src/data/composite/things/track/withTrackNumber.js50
-rw-r--r--src/data/composite/wiki-data/constituteFrom.js31
-rw-r--r--src/data/composite/wiki-data/constituteOrContinue.js34
-rw-r--r--src/data/composite/wiki-data/gobbleSoupyFind.js2
-rw-r--r--src/data/composite/wiki-data/gobbleSoupyReverse.js2
-rw-r--r--src/data/composite/wiki-data/helpers/withSimpleDirectory.js2
-rw-r--r--src/data/composite/wiki-data/index.js7
-rw-r--r--src/data/composite/wiki-data/inputFindOptions.js5
-rw-r--r--src/data/composite/wiki-data/splitContentNodesAround.js100
-rw-r--r--src/data/composite/wiki-data/withConstitutedArtwork.js5
-rw-r--r--src/data/composite/wiki-data/withContentNodes.js25
-rw-r--r--src/data/composite/wiki-data/withCoverArtDate.js51
-rw-r--r--src/data/composite/wiki-data/withDirectory.js4
-rw-r--r--src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js9
-rw-r--r--src/data/composite/wiki-data/withResolvedContribs.js5
-rw-r--r--src/data/composite/wiki-data/withResolvedReference.js22
-rw-r--r--src/data/composite/wiki-data/withResolvedReferenceList.js21
-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/annotatedReferenceList.js17
-rw-r--r--src/data/composite/wiki-properties/canonicalBase.js16
-rw-r--r--src/data/composite/wiki-properties/color.js28
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js5
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtwork.js2
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtworkList.js2
-rw-r--r--src/data/composite/wiki-properties/contribsPresent.js30
-rw-r--r--src/data/composite/wiki-properties/contributionList.js3
-rw-r--r--src/data/composite/wiki-properties/fileExtension.js29
-rw-r--r--src/data/composite/wiki-properties/flag.js30
-rw-r--r--src/data/composite/wiki-properties/hasArtwork.js90
-rw-r--r--src/data/composite/wiki-properties/index.js6
-rw-r--r--src/data/composite/wiki-properties/name.js28
-rw-r--r--src/data/composite/wiki-properties/referenceList.js11
-rw-r--r--src/data/composite/wiki-properties/referencedArtworkList.js3
-rw-r--r--src/data/composite/wiki-properties/seriesList.js31
-rw-r--r--src/data/composite/wiki-properties/singleReference.js31
-rw-r--r--src/data/language.js17
-rw-r--r--src/data/thing.js16
-rw-r--r--src/data/things/additional-file.js54
-rw-r--r--src/data/things/additional-name.js31
-rw-r--r--src/data/things/album.js908
-rw-r--r--src/data/things/art-tag.js96
-rw-r--r--src/data/things/artist.js192
-rw-r--r--src/data/things/artwork.js291
-rw-r--r--src/data/things/content.js243
-rw-r--r--src/data/things/contribution.js239
-rw-r--r--src/data/things/flash.js211
-rw-r--r--src/data/things/group.js160
-rw-r--r--src/data/things/homepage-layout.js77
-rw-r--r--src/data/things/index.js100
-rw-r--r--src/data/things/language.js277
-rw-r--r--src/data/things/news-entry.js9
-rw-r--r--src/data/things/sorting-rule.js36
-rw-r--r--src/data/things/static-page.js13
-rw-r--r--src/data/things/track.js1094
-rw-r--r--src/data/things/wiki-info.js109
-rw-r--r--src/data/yaml.js550
125 files changed, 4567 insertions, 3867 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index a089e325..9c655823 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -12,28 +12,15 @@ export default class CacheableObject {
   static propertyDependants = Symbol.for('CacheableObject.propertyDependants');
 
   static cacheValid = Symbol.for('CacheableObject.cacheValid');
+  static cachedValue = Symbol.for('CacheableObject.cachedValue');
   static updateValue = Symbol.for('CacheableObject.updateValues');
 
   constructor({seal = true} = {}) {
-    this[CacheableObject.updateValue] = Object.create(null);
     this[CacheableObject.cachedValue] = Object.create(null);
     this[CacheableObject.cacheValid] = Object.create(null);
 
-    const propertyDescriptors = this.constructor[CacheableObject.propertyDescriptors];
-    for (const property of Reflect.ownKeys(propertyDescriptors)) {
-      const {flags, update} = propertyDescriptors[property];
-      if (!flags.update) continue;
-
-      if (
-        typeof update === 'object' &&
-        update !== null &&
-        'default' in update
-      ) {
-        this[property] = update?.default;
-      } else {
-        this[property] = null;
-      }
-    }
+    this[CacheableObject.updateValue] =
+      Object.create(this[CacheableObject.updateValue]);
 
     if (seal) {
       Object.seal(this);
@@ -49,9 +36,31 @@ export default class CacheableObject {
       throw new Error(`Expected constructor ${this.name} to provide CacheableObject.propertyDescriptors`);
     }
 
+    const propertyDescriptors = this[CacheableObject.propertyDescriptors];
+
+    // Finalize prototype update value
+
+    this.prototype[CacheableObject.updateValue] =
+      Object.create(
+        Object.getPrototypeOf(this.prototype)[CacheableObject.updateValue] ??
+        null);
+
+    for (const property of Reflect.ownKeys(propertyDescriptors)) {
+      const {flags, update} = propertyDescriptors[property];
+      if (!flags.update) continue;
+
+      if (typeof update === 'object' && update !== null && 'default' in update) {
+        validatePropertyValue(property, null, update.default, update);
+        this.prototype[CacheableObject.updateValue][property] = update.default;
+      } else {
+        this.prototype[CacheableObject.updateValue][property] = null;
+      }
+    }
+
+    // Finalize prototype property descriptors
+
     this[CacheableObject.propertyDependants] = Object.create(null);
 
-    const propertyDescriptors = this[CacheableObject.propertyDescriptors];
     for (const property of Reflect.ownKeys(propertyDescriptors)) {
       const {flags, update, expose} = propertyDescriptors[property];
 
@@ -73,17 +82,7 @@ export default class CacheableObject {
           }
 
           if (newValue !== null && update?.validate) {
-            try {
-              const result = update.validate(newValue);
-              if (result === undefined) {
-                throw new TypeError(`Validate function returned undefined`);
-              } else if (result !== true) {
-                throw new TypeError(`Validation failed for value ${newValue}`);
-              }
-            } catch (caughtError) {
-              throw new CacheableObjectPropertyValueError(
-                property, oldValue, newValue, {cause: caughtError});
-            }
+            validatePropertyValue(property, oldValue, newValue, update);
           }
 
           this[CacheableObject.updateValue][property] = newValue;
@@ -121,18 +120,14 @@ export default class CacheableObject {
 
           const dependencies = Object.create(null);
           for (const key of expose.dependencies ?? []) {
-            switch (key) {
-              case 'this':
-                dependencies.this = this;
-                break;
-
-              case 'thisProperty':
-                dependencies.thisProperty = property;
-                break;
-
-              default:
-                dependencies[key] = this[CacheableObject.updateValue][key];
-                break;
+            if (key === 'this') {
+              dependencies.this = this;
+            } else if (key === 'thisProperty') {
+              dependencies.thisProperty = property;
+            } else if (key.startsWith('_')) {
+              dependencies[key] = this[CacheableObject.updateValue][key.slice(1)];
+            } else {
+              dependencies[key] = this[key];
             }
           }
 
@@ -151,27 +146,11 @@ export default class CacheableObject {
       if (flags.expose) recordAsDependant: {
         const dependantsMap = this[CacheableObject.propertyDependants];
 
-        if (flags.update && expose?.transform) {
-          if (dependantsMap[property]) {
-            dependantsMap[property].push(property);
+        for (const dependency of dependenciesOf(property, propertyDescriptors)) {
+          if (dependantsMap[dependency]) {
+            dependantsMap[dependency].push(property);
           } else {
-            dependantsMap[property] = [property];
-          }
-        }
-
-        for (const dependency of expose?.dependencies ?? []) {
-          switch (dependency) {
-            case 'this':
-            case 'thisProperty':
-              continue;
-
-            default: {
-              if (dependantsMap[dependency]) {
-                dependantsMap[dependency].push(property);
-              } else {
-                dependantsMap[dependency] = [property];
-              }
-            }
+            dependantsMap[dependency] = [property];
           }
         }
       }
@@ -187,7 +166,7 @@ export default class CacheableObject {
   }
 
   static hasPropertyDescriptor(property) {
-    return Object.hasOwn(this[CacheableObject.propertyDescriptors], property);
+    return property in this[CacheableObject.propertyDescriptors];
   }
 
   static cacheAllExposedProperties(obj) {
@@ -243,13 +222,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)`);
     }
 
@@ -260,3 +239,60 @@ export class CacheableObjectPropertyValueError extends Error {
     this.property = property;
   }
 }
+
+// good ol' module-scope utility functions
+
+function validatePropertyValue(property, oldValue, newValue, update) {
+  try {
+    const result = update.validate(newValue);
+    if (result === undefined) {
+      throw new TypeError(`Validate function returned undefined`);
+    } else if (result !== true) {
+      throw new TypeError(`Validation failed for value ${newValue}`);
+    }
+  } catch (caughtError) {
+    throw new CacheableObjectPropertyValueError(
+      property, oldValue, newValue, {cause: caughtError});
+  }
+}
+
+function* dependenciesOf(property, propertyDescriptors, cycle = []) {
+  const descriptor = propertyDescriptors[property];
+
+  if (descriptor?.flags?.update && descriptor?.expose?.transform) {
+    yield property;
+  }
+
+  const dependencies = descriptor?.expose?.dependencies;
+  if (!dependencies) return;
+
+  for (const dependency of dependencies) {
+    if (dependency === 'this') continue;
+    if (dependency === 'thisProperty') continue;
+
+    if (dependency.startsWith('_')) {
+      yield dependency.slice(1);
+      continue;
+    }
+
+    if (dependency === property) {
+      throw new Error(
+        `property ${dependency} directly depends on its own computed value`);
+    }
+
+    if (cycle.includes(dependency)) {
+      const subcycle = cycle.slice(cycle.indexOf(dependency));
+      const supercycle = cycle.slice(0, cycle.indexOf(dependency));
+      throw new Error(
+        `property ${dependency} indirectly depends on its own computed value\n` +
+        `  via: ` + subcycle.map(p => p + ' -> ').join('') + property + ' -> ' + dependency +
+        (supercycle.length
+          ? '\n   in: ' + supercycle.join(' -> ')
+          : ''));
+    }
+
+    cycle.push(property);
+    yield* dependenciesOf(dependency, propertyDescriptors, cycle);
+    cycle.pop();
+  }
+}
diff --git a/src/data/checks.js b/src/data/checks.js
index 52024144..ac1b6257 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -4,13 +4,14 @@ 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';
 import thingConstructors from '#things';
 
 import {
+  annotateError,
   annotateErrorWithIndex,
   conditionallySuppressError,
   decorateErrorWithIndex,
@@ -59,7 +60,7 @@ export function reportDirectoryErrors(wikiData, {
           : [thing.directory]);
 
       for (const directory of directories) {
-        if (directory === null || directory === undefined) {
+        if (directory === '' || directory === null || directory === undefined) {
           missingDirectoryThings.add(thing);
           continue;
         }
@@ -165,6 +166,69 @@ function getFieldPropertyMessage(yamlDocumentSpec, property) {
   return fieldPropertyMessage;
 }
 
+function decoAnnotateFindErrors(findFn) {
+  function annotateMultipleNameMatchesIncludingUnfortunatelyUnsecondary(error) {
+    const matches = error[Symbol.for('hsmusic.find.multipleNameMatches')];
+    if (!matches) return;
+
+    const notSoSecondary =
+      matches
+        .map(match => match.thing ?? match)
+        .filter(match =>
+          match.isTrack &&
+          match.isMainRelease &&
+          CacheableObject.getUpdateValue(match, 'mainRelease'));
+
+    if (empty(notSoSecondary)) return;
+
+    let {message} = error;
+    message += (message.includes('\n') ? '\n\n' : '\n');
+    message += colors.bright(colors.yellow('<!>')) + ' ';
+    message += colors.yellow(`Some of these tracks are meant to be secondary releases,`) + '\n';
+    message += ' '.repeat(4);
+    message += colors.yellow(`but another error is keeping that from processing correctly!`) + '\n';
+    message += ' '.repeat(4);
+    message += colors.yellow(`Probably look for an error to do with "Main Release", first.`);
+    Object.assign(error, {message});
+  }
+
+  return (...args) => {
+    try {
+      return findFn(...args);
+    } catch (caughtError) {
+      throw annotateError(caughtError, ...[
+        annotateMultipleNameMatchesIncludingUnfortunatelyUnsecondary,
+      ]);
+    }
+  };
+}
+
+function decoSuppressFindErrors(findFn, {property}) {
+  void property;
+
+  return conditionallySuppressError(_error => {
+    // We're not suppressing any errors at the moment.
+    // An old suppression is kept below for reference.
+
+    /*
+    if (property === 'sampledTracks') {
+      // Suppress "didn't match anything" errors in particular, just for samples.
+      // In hsmusic-data we have a lot of "stub" sample data which don't have
+      // corresponding tracks yet, so it won't be useful to report such reference
+      // errors until we take the time to address that. But other errors, like
+      // malformed reference strings or miscapitalized existing tracks, should
+      // still be reported, as samples of existing tracks *do* display on the
+      // website!
+      if (error.message.includes(`Didn't match anything`)) {
+        return true;
+      }
+    }
+    */
+
+    return false;
+  }, findFn);
+}
+
 // Warn about references across data which don't match anything.  This involves
 // using the find() functions on all references, setting it to 'error' mode, and
 // collecting everything in a structured logged (which gets logged if there are
@@ -185,16 +249,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', {
@@ -217,25 +285,23 @@ export function filterReferenceErrors(wikiData, {
       featuredTracks: 'track',
     }],
 
-    ['flashActData', {
-      flashes: 'flash',
-    }],
-
-    ['groupData', {
-      serieses: '_serieses',
+    ['seriesData', {
+      albums: 'album',
     }],
 
     ['trackData', {
       artistContribs: '_contrib',
       contributorContribs: '_contrib',
       coverArtistContribs: '_contrib',
+      previousProductionTracks: '_trackMainReleasesOnly',
       referencedTracks: '_trackMainReleasesOnly',
       sampledTracks: '_trackMainReleasesOnly',
       artTags: '_artTag',
       referencedArtworks: '_artwork',
-      mainReleaseTrack: '_trackMainReleasesOnly',
+      mainRelease: '_mainRelease',
       commentary: '_content',
-      creditSources: '_content',
+      creditingSources: '_content',
+      referencingSources: '_content',
       lyrics: '_content',
     }],
 
@@ -290,15 +356,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,8 +407,94 @@ export function filterReferenceErrors(wikiData, {
                 };
                 break;
 
-              case '_serieses':
-                findFn = boundFind.album;
+              case '_mainRelease':
+                findFn = ref => {
+                  // Mocking what's going on in `withMainRelease`.
+
+                  if (ref === 'same name single') {
+                    // Accessing the current thing here.
+                    try {
+                      return boundFind.albumSinglesOnly(thing.name, {
+                        fuzz: {
+                          capitalization: true,
+                          kebab: true,
+                        },
+                      });
+                    } catch (caughtError) {
+                      throw new Error(
+                        `Didn't match a single with the same name`,
+                        {cause: caughtError});
+                    }
+                  }
+
+                  let track, trackError;
+                  let album, albumError;
+
+                  try {
+                    track = boundFind.trackMainReleasesOnly(ref);
+                  } catch (caughtError) {
+                    trackError = new Error(
+                      `Didn't match a track`, {cause: caughtError});
+                  }
+
+                  try {
+                    album = boundFind.album(ref);
+                  } catch (caughtError) {
+                    albumError = new Error(
+                      `Didn't match an album`, {cause: caughtError});
+                  }
+
+                  if (track && album) {
+                    if (album.tracks.includes(track)) {
+                      return track;
+                    } else {
+                      throw new Error(
+                        `Unrelated album and track matched for reference "${ref}". Please resolve:\n` +
+                        `- ${inspect(track)}\n` +
+                        `- ${inspect(album)}\n` +
+                        `Returning null for this reference.`);
+                    }
+                  }
+
+                  if (track) {
+                    return track;
+                  }
+
+                  if (album) {
+                    // At this point verification depends on the thing itself,
+                    // which is currently in lexical scope, but if this code
+                    // gets refactored, there might be trouble here...
+
+                    if (thing.mainReleaseTrack === null) {
+                      if (album === thing.album) {
+                        throw new Error(
+                          `Matched album for reference "${ref}":\n` +
+                          `- ` + inspect(album) + `\n` +
+                          `...but this is the album that includes this secondary release, itself.\n` +
+                          `Please resolve by pointing to aonther album here, or by removing this\n` +
+                          `Main Release field, if this track is meant to be the main release.`);
+                      } else {
+                        throw new Error(
+                          `Matched album for reference "${ref}":\n` +
+                          `- ` + inspect(album) + `\n` +
+                          `...but none of its tracks automatically match this secondary release.\n` +
+                          `Please resolve by specifying the track here, instead of the album.`);
+                      }
+                    } else {
+                      return album;
+                    }
+                  }
+
+                  const aggregateCause =
+                    new AggregateError([albumError, trackError]);
+
+                  aggregateCause[Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+                  throw new Error(`Trouble matching "${ref}"`, {
+                    cause: aggregateCause,
+                  });
+                }
+
                 break;
 
               case '_trackArtwork':
@@ -360,9 +503,16 @@ export function filterReferenceErrors(wikiData, {
 
               case '_trackMainReleasesOnly':
                 findFn = trackRef => {
-                  const track = boundFind.track(trackRef);
-                  const mainRef = track && CacheableObject.getUpdateValue(track, 'mainReleaseTrack');
+                  let track = boundFind.trackMainReleasesOnly(trackRef, {mode: 'quiet'});
+                  if (track) {
+                    return track;
+                  }
 
+                  // Will error normally, if this can't unambiguously resolve
+                  // or doesn't match any track.
+                  track = boundFind.track(trackRef);
+
+                  const mainRef = CacheableObject.getUpdateValue(track, 'mainRelease');
                   if (mainRef) {
                     // It's possible for the main release to not actually exist, in this case.
                     // It should still be reported since the 'Main Release' field was present.
@@ -393,27 +543,8 @@ export function filterReferenceErrors(wikiData, {
                 break;
             }
 
-            const suppress = fn => conditionallySuppressError(error => {
-              // We're not suppressing any errors at the moment.
-              // An old suppression is kept below for reference.
-
-              /*
-              if (property === 'sampledTracks') {
-                // Suppress "didn't match anything" errors in particular, just for samples.
-                // In hsmusic-data we have a lot of "stub" sample data which don't have
-                // corresponding tracks yet, so it won't be useful to report such reference
-                // errors until we take the time to address that. But other errors, like
-                // malformed reference strings or miscapitalized existing tracks, should
-                // still be reported, as samples of existing tracks *do* display on the
-                // website!
-                if (error.message.includes(`Didn't match anything`)) {
-                  return true;
-                }
-              }
-              */
-
-              return false;
-            }, fn);
+            findFn = decoSuppressFindErrors(findFn, {property});
+            findFn = decoAnnotateFindErrors(findFn);
 
             const fieldPropertyMessage =
               getFieldPropertyMessage(
@@ -469,10 +600,10 @@ export function filterReferenceErrors(wikiData, {
                   value, {message: errorMessage},
                   decorateErrorWithIndex(refs =>
                     (refs.length === 1
-                      ? suppress(findFn)(refs[0])
+                      ? findFn(refs[0])
                       : filterAggregate(
                           refs, {message: `Errors in entry's artist references`},
-                          decorateErrorWithIndex(suppress(findFn)))
+                          decorateErrorWithIndex(findFn))
                             .aggregate
                             .close())));
 
@@ -484,19 +615,18 @@ export function filterReferenceErrors(wikiData, {
               if (Array.isArray(value)) {
                 newPropertyValue = filter(
                   value, {message: errorMessage},
-                  decorateErrorWithIndex(suppress(findFn)));
+                  decorateErrorWithIndex(findFn));
                 break determineNewPropertyValue;
               }
 
-              nest({message: errorMessage},
-                suppress(({call}) => {
-                  try {
-                    call(findFn, value);
-                  } catch (error) {
-                    newPropertyValue = null;
-                    throw error;
-                  }
-                }));
+              nest({message: errorMessage}, ({call}) => {
+                try {
+                  call(findFn, value);
+                } catch (error) {
+                  newPropertyValue = null;
+                  throw error;
+                }
+              });
             }
 
             if (writeProperty) {
@@ -520,7 +650,11 @@ export class ContentNodeError extends Error {
     message,
   }) {
     const headingLine =
-      `(${where}) ${message}`;
+      (message.includes('\n\n')
+        ? `(${where})\n\n` + message + '\n'
+     : message.includes('\n')
+        ? `(${where})\n` + message
+        : `(${where}) ${message}`);
 
     const textUpToNode =
       containingLine.slice(0, columnNumber);
@@ -565,15 +699,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 +720,8 @@ export function reportContentTextErrors(wikiData, {
     ['albumData', {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
+      creditingSources: commentaryShape,
+      coverArtworks: artworkShape,
     }],
 
     ['artTagData', {
@@ -593,6 +734,8 @@ export function reportContentTextErrors(wikiData, {
 
     ['flashData', {
       commentary: commentaryShape,
+      creditingSources: commentaryShape,
+      coverArtwork: artworkShape,
     }],
 
     ['flashActData', {
@@ -622,10 +765,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', {
@@ -634,11 +779,19 @@ export function reportContentTextErrors(wikiData, {
     }],
   ];
 
-  const boundFind = bindFind(wikiData, {mode: 'error'});
+  const boundFind =
+    bindFind(wikiData, {
+      mode: 'error',
+      fuzz: {
+        capitalization: true,
+        kebab: true,
+      },
+    });
+
   const findArtistOrAlias = bindFindArtistOrAlias(boundFind);
 
   function* processContent(input) {
-    const nodes = parseInput(input);
+    const nodes = parseContentNodes(input);
 
     for (const node of nodes) {
       const index = node.i;
@@ -675,6 +828,9 @@ export function reportContentTextErrors(wikiData, {
               break;
           }
 
+          findFn = decoSuppressFindErrors(findFn, {property: null});
+          findFn = decoAnnotateFindErrors(findFn);
+
           const findRef =
             (replacerKeyImplied
               ? replacerValue
@@ -695,7 +851,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 +922,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 +954,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.js b/src/data/composite.js
index f31c4069..8ac906c7 100644
--- a/src/data/composite.js
+++ b/src/data/composite.js
@@ -17,7 +17,7 @@ const _valueIntoToken = shape =>
    : typeof value === 'string'
       ? Symbol.for(`hsmusic.composite.${shape}:${value}`)
       : {
-          symbol: Symbol.for(`hsmusic.composite.input`),
+          symbol: Symbol.for(`hsmusic.composite.${shape.split('.')[0]}`),
           shape,
           value,
         });
@@ -36,6 +36,10 @@ input.updateValue = _valueIntoToken('input.updateValue');
 input.staticDependency = _valueIntoToken('input.staticDependency');
 input.staticValue = _valueIntoToken('input.staticValue');
 
+// Only valid in positional inputs. This is replaced with
+// equivalent input.value() token in prepared inputs.
+export const V = _valueIntoToken('V');
+
 function isInputToken(token) {
   if (token === null) {
     return false;
@@ -48,27 +52,39 @@ function isInputToken(token) {
   }
 }
 
+function isConciseInputToken(token) {
+  if (token === null) {
+    return false;
+  } else if (typeof token === 'object') {
+    return token.symbol === Symbol.for('hsmusic.composite.V');
+  } else if (typeof token === 'symbol') {
+    return token.description.startsWith('hsmusic.composite.V');
+  } else {
+    return false;
+  }
+}
+
 function getInputTokenShape(token) {
-  if (!isInputToken(token)) {
+  if (!isInputToken(token) && !isConciseInputToken(token)) {
     throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`);
   }
 
   if (typeof token === 'object') {
     return token.shape;
   } else {
-    return token.description.match(/hsmusic\.composite\.(input.*?)(:|$)/)[1];
+    return token.description.match(/hsmusic\.composite\.(input.*?|V)(:|$)/)[1];
   }
 }
 
 function getInputTokenValue(token) {
-  if (!isInputToken(token)) {
+  if (!isInputToken(token) && !isConciseInputToken(token)) {
     throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`);
   }
 
   if (typeof token === 'object') {
     return token.value;
   } else {
-    return token.description.match(/hsmusic\.composite\.input.*?:(.*)/)?.[1] ?? null;
+    return token.description.match(/hsmusic\.composite\.(?:input.*?|V):(.*)/)?.[1] ?? null;
   }
 }
 
@@ -214,76 +230,161 @@ export function templateCompositeFrom(description) {
       ? Object.keys(description.inputs)
       : []);
 
-  const instantiate = (inputOptions = {}) => {
+  const optionalInputNames =
+    expectedInputNames.filter(name => {
+      const inputDescription = getInputTokenValue(description.inputs[name]);
+      if (!inputDescription) return false;
+      if ('defaultValue' in inputDescription) return true;
+      if ('defaultDependency' in inputDescription) return true;
+      return false;
+    });
+
+  const instantiate = (...args) => {
+    const preparedInputs = {};
+
     withAggregate({message: `Errors in input options passed to ${compositionName}`}, ({push}) => {
-      const providedInputNames = Object.keys(inputOptions);
+      const [positionalInputs, namedInputs] =
+        (typeof args.at(-1) === 'object' &&
+         !isInputToken(args.at(-1)) &&
+         !isConciseInputToken(args.at(-1))
+          ? [args.slice(0, -1), args.at(-1)]
+          : [args, {}]);
 
-      const misplacedInputNames =
-        providedInputNames
-          .filter(name => !expectedInputNames.includes(name));
+      const expresslyProvidedInputNames = Object.keys(namedInputs);
+      const positionallyProvidedInputNames = [];
+      const remainingInputNames = expectedInputNames.slice();
 
-      const missingInputNames =
-        expectedInputNames
-          .filter(name => !providedInputNames.includes(name))
-          .filter(name => {
-            const inputDescription = getInputTokenValue(description.inputs[name]);
-            if (!inputDescription) return true;
-            if ('defaultValue' in inputDescription) return false;
-            if ('defaultDependency' in inputDescription) return false;
-            return true;
-          });
+      const apparentInputRoutes = {};
 
-      const wrongTypeInputNames = [];
+      const wrongTypeInputPositions = [];
+      const namedAndPositionalConflictInputPositions = [];
 
-      const expectedStaticValueInputNames = [];
-      const expectedStaticDependencyInputNames = [];
-      const expectedValueProvidingTokenInputNames = [];
+      const maximumPositionalInputs = expectedInputNames.length;
+      const lastPossiblePositionalIndex = maximumPositionalInputs - 1;
 
-      const validateFailedErrors = [];
+      for (const [index, value] of positionalInputs.entries()) {
+        if (!isInputToken(value) && !isConciseInputToken(value)) {
+          if (typeof value === 'object' && value !== null) {
+            wrongTypeInputPositions.push(index);
+            continue;
+          } else if (typeof value !== 'string') {
+            wrongTypeInputPositions.push(index);
+            continue;
+          }
+        }
+
+        if (index > lastPossiblePositionalIndex) {
+          continue;
+        }
+
+        const correspondingName = remainingInputNames.shift();
+        if (expresslyProvidedInputNames.includes(correspondingName)) {
+          namedAndPositionalConflictInputPositions.push(index);
+          continue;
+        }
+
+        preparedInputs[correspondingName] =
+          (isConciseInputToken(value)
+            ? input.value(getInputTokenValue(value))
+            : value);
+
+        apparentInputRoutes[correspondingName] = `${correspondingName} (i = ${index})`;
+        positionallyProvidedInputNames.push(correspondingName);
+      }
 
-      for (const [name, value] of Object.entries(inputOptions)) {
+      const misplacedInputNames =
+        expresslyProvidedInputNames
+          .filter(name => !expectedInputNames.includes(name));
+
+      const wrongTypeInputNames = [];
+      const skippedInputNames = [];
+      const passedInputNames = [];
+      const nameProvidedInputNames = [];
+
+      for (const [name, value] of Object.entries(namedInputs)) {
         if (misplacedInputNames.includes(name)) {
           continue;
         }
 
+        // Concise input tokens, V(...), end up here too.
         if (typeof value !== 'string' && !isInputToken(value)) {
           wrongTypeInputNames.push(name);
           continue;
         }
 
+        const index = remainingInputNames.indexOf(name);
+        if (index === 0) {
+          passedInputNames.push(remainingInputNames.shift());
+        } else if (index === -1) {
+          // This input isn't misplaced, so it's an expected name,
+          // and SHOULD be in the list of remaining input names.
+          // But it isn't if it itself has already been skipped!
+          // And if so, that's already been tracked.
+        } else {
+          const til = remainingInputNames.splice(0, index);
+          passedInputNames.push(...til);
+
+          const skipped =
+            til.filter(name =>
+              !optionalInputNames.includes(name) ||
+              expresslyProvidedInputNames.includes(name));
+
+          if (!empty(skipped)) {
+            skippedInputNames.push({skipped, before: name});
+          }
+
+          passedInputNames.push(remainingInputNames.shift());
+        }
+
+        preparedInputs[name] = value;
+        apparentInputRoutes[name] = name;
+        nameProvidedInputNames.push(name);
+      }
+
+      const totalProvidedInputNames =
+        unique([
+          ...expresslyProvidedInputNames,
+          ...positionallyProvidedInputNames,
+        ]);
+
+      const missingInputNames =
+        expectedInputNames
+          .filter(name => !totalProvidedInputNames.includes(name))
+          .filter(name => !optionalInputNames.includes(name));
+
+      const expectedStaticValueInputNames = [];
+      const expectedStaticDependencyInputNames = [];
+      const expectedValueProvidingTokenInputNames = [];
+      const validateFailedErrors = [];
+
+      for (const [name, value] of Object.entries(preparedInputs)) {
         const descriptionShape = getInputTokenShape(description.inputs[name]);
 
         const tokenShape = (isInputToken(value) ? getInputTokenShape(value) : null);
         const tokenValue = (isInputToken(value) ? getInputTokenValue(value) : null);
 
-        switch (descriptionShape) {
-          case'input.staticValue':
-            if (tokenShape !== 'input.value') {
-              expectedStaticValueInputNames.push(name);
-              continue;
-            }
-            break;
-
-          case 'input.staticDependency':
-            if (typeof value !== 'string' && tokenShape !== 'input.dependency') {
-              expectedStaticDependencyInputNames.push(name);
-              continue;
-            }
-            break;
-
-          case 'input':
-            if (typeof value !== 'string' && ![
-              'input',
-              'input.value',
-              'input.dependency',
-              'input.myself',
-              'input.thisProperty',
-              'input.updateValue',
-            ].includes(tokenShape)) {
-              expectedValueProvidingTokenInputNames.push(name);
-              continue;
-            }
-            break;
+        if (descriptionShape === 'input.staticValue') {
+          if (tokenShape !== 'input.value') {
+            expectedStaticValueInputNames.push(name);
+            continue;
+          }
+        } else if (descriptionShape === 'input.staticDependency') {
+          if (typeof value !== 'string' && tokenShape !== 'input.dependency') {
+            expectedStaticDependencyInputNames.push(name);
+            continue;
+          }
+        } else {
+          if (typeof value !== 'string' && ![
+            'input',
+            'input.value',
+            'input.dependency',
+            'input.myself',
+            'input.thisProperty',
+            'input.updateValue',
+          ].includes(tokenShape)) {
+            expectedValueProvidingTokenInputNames.push(name);
+            continue;
+          }
         }
 
         if (tokenShape === 'input.value') {
@@ -296,6 +397,11 @@ export function templateCompositeFrom(description) {
         }
       }
 
+      const inputAppearance = name =>
+        (isInputToken(preparedInputs[name])
+          ? `${getInputTokenShape(preparedInputs[name])}() call`
+          : `dependency name`);
+
       if (!empty(misplacedInputNames)) {
         push(new Error(`Unexpected input names: ${misplacedInputNames.join(', ')}`));
       }
@@ -304,29 +410,53 @@ export function templateCompositeFrom(description) {
         push(new Error(`Required these inputs: ${missingInputNames.join(', ')}`));
       }
 
-      const inputAppearance = name =>
-        (isInputToken(inputOptions[name])
-          ? `${getInputTokenShape(inputOptions[name])}() call`
-          : `dependency name`);
+      if (positionalInputs.length > maximumPositionalInputs) {
+        push(new Error(`Too many positional inputs provided (${positionalInputs.length} > ${maximumPositionalInputs}`));
+      }
+
+      for (const index of namedAndPositionalConflictInputPositions) {
+        const conflictingName = positionalInputNames[index];
+        push(new Error(`${name}: Provided as both named and positional (i = ${index}) input`));
+      }
+
+      for (const {skipped, before} of skippedInputNames) {
+        push(new Error(`Expected ${skipped.join(', ')} before ${before}`));
+      }
 
       for (const name of expectedStaticDependencyInputNames) {
-        const appearance = inputAppearance(name);
-        push(new Error(`${name}: Expected dependency name, got ${appearance}`));
+        const appearance = inputAppearance(preparedInputs[name]);
+        const route = apparentInputRoutes[name];
+        push(new Error(`${route}: Expected dependency name, got ${appearance}`));
       }
 
       for (const name of expectedStaticValueInputNames) {
-        const appearance = inputAppearance(name)
-        push(new Error(`${name}: Expected input.value() call, got ${appearance}`));
+        const appearance = inputAppearance(preparedInputs[name]);
+        const route = apparentInputRoutes[name];
+        push(new Error(`${route}: Expected input.value() call, got ${appearance}`));
       }
 
       for (const name of expectedValueProvidingTokenInputNames) {
-        const appearance = getInputTokenShape(inputOptions[name]);
-        push(new Error(`${name}: Expected dependency name or value-providing input() call, got ${appearance}`));
+        const appearance = getInputTokenShape(preparedInputs[name]);
+        const route = apparentInputRoutes[name];
+        push(new Error(`${route}: Expected dependency name or value-providing input() call, got ${appearance}`));
       }
 
       for (const name of wrongTypeInputNames) {
-        const type = typeAppearance(inputOptions[name]);
-        push(new Error(`${name}: Expected dependency name or input() call, got ${type}`));
+        if (isConciseInputToken(namedInputs[name])) {
+          push(new Error(`${name}: Use input.value() instead of V() for named inputs`));
+        } else {
+          const type = typeAppearance(namedInputs[name]);
+          push(new Error(`${name}: Expected dependency name or input() call, got ${type}`));
+        }
+      }
+
+      for (const index of wrongTypeInputPositions) {
+        const type = typeAppearance(positionalInputs[index]);
+        if (type === 'object') {
+          push(new Error(`i = ${index}: Got object - all named dependencies must be passed together, in last argument`));
+        } else {
+          push(new Error(`i = ${index}: Expected dependency name or input() call, got ${type}`));
+        }
       }
 
       for (const error of validateFailedErrors) {
@@ -338,13 +468,13 @@ export function templateCompositeFrom(description) {
     if ('inputs' in description) {
       for (const [name, token] of Object.entries(description.inputs)) {
         const tokenValue = getInputTokenValue(token);
-        if (name in inputOptions) {
-          if (typeof inputOptions[name] === 'string') {
-            inputMapping[name] = input.dependency(inputOptions[name]);
+        if (name in preparedInputs) {
+          if (typeof preparedInputs[name] === 'string') {
+            inputMapping[name] = input.dependency(preparedInputs[name]);
           } else {
             // This is always an input token, since only a string or
             // an input token is a valid input option (asserted above).
-            inputMapping[name] = inputOptions[name];
+            inputMapping[name] = preparedInputs[name];
           }
         } else if (tokenValue.defaultValue) {
           inputMapping[name] = input.value(tokenValue.defaultValue);
@@ -713,8 +843,9 @@ export function compositeFrom(description) {
       stepExposeDescriptions
         .flatMap(expose => expose?.dependencies ?? [])
         .map(dependency => {
-          if (typeof dependency === 'string')
+          if (typeof dependency === 'string') {
             return (dependency.startsWith('#') ? null : dependency);
+          }
 
           const tokenShape = getInputTokenShape(dependency);
           const tokenValue = getInputTokenValue(dependency);
@@ -886,7 +1017,7 @@ export function compositeFrom(description) {
           }
         });
 
-    withAggregate({message: `Errors in input values provided to ${compositionName}`}, ({push}) => {
+    withAggregate({message: `Errors validating input values provided to ${compositionName}`}, ({push}) => {
       for (const {dynamic, name, value, description} of stitchArrays({
         dynamic: inputsMayBeDynamicValue,
         name: inputNames,
@@ -896,9 +1027,10 @@ export function compositeFrom(description) {
         if (!dynamic) continue;
         try {
           validateInputValue(value, description);
-        } catch (error) {
-          error.message = `${name}: ${error.message}`;
-          push(error);
+        } catch (caughtError) {
+          push(new Error(
+            `Error validating input ${name}: ` + inspect(value, {compact: true}),
+            {cause: caughtError}));
         }
       }
     });
@@ -1416,7 +1548,7 @@ export function compositeFrom(description) {
 
 export function displayCompositeCacheAnalysis() {
   const showTimes = (cache, key) => {
-    const times = cache.times[key].slice().sort();
+    const times = cache.times[key].toSorted();
 
     const all = times;
     const worst10pc = times.slice(-times.length / 10);
diff --git a/src/data/composite/control-flow/exitWithoutDependency.js b/src/data/composite/control-flow/exitWithoutDependency.js
index c660a7ef..598f2ec2 100644
--- a/src/data/composite/control-flow/exitWithoutDependency.js
+++ b/src/data/composite/control-flow/exitWithoutDependency.js
@@ -11,8 +11,8 @@ export default templateCompositeFrom({
 
   inputs: {
     dependency: input({acceptsNull: true}),
-    mode: inputAvailabilityCheckMode(),
     value: input({defaultValue: null}),
+    mode: inputAvailabilityCheckMode(),
   },
 
   steps: () => [
diff --git a/src/data/composite/control-flow/exitWithoutUpdateValue.js b/src/data/composite/control-flow/exitWithoutUpdateValue.js
index 244b3233..5104a8c0 100644
--- a/src/data/composite/control-flow/exitWithoutUpdateValue.js
+++ b/src/data/composite/control-flow/exitWithoutUpdateValue.js
@@ -10,15 +10,27 @@ export default templateCompositeFrom({
   annotation: `exitWithoutUpdateValue`,
 
   inputs: {
-    mode: inputAvailabilityCheckMode(),
     value: input({defaultValue: null}),
+    mode: inputAvailabilityCheckMode(),
+
+    validate: input({
+      type: 'function',
+      defaultValue: null,
+    }),
   },
 
+  update: ({
+    [input.staticValue('validate')]: validate,
+  }) =>
+    (validate
+      ? {validate}
+      : {}),
+
   steps: () => [
     exitWithoutDependency({
       dependency: input.updateValue(),
-      mode: input('mode'),
       value: input('value'),
+      mode: input('mode'),
     }),
   ],
 });
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..61bfa08e 100644
--- a/src/data/composite/control-flow/index.js
+++ b/src/data/composite/control-flow/index.js
@@ -10,6 +10,8 @@ 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 inputAvailabilityCheckMode} from './inputAvailabilityCheckMode.js'; // A helper, technically...
 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/helpers/property-from-helpers.js b/src/data/composite/data/helpers/property-from-helpers.js
new file mode 100644
index 00000000..00251f3b
--- /dev/null
+++ b/src/data/composite/data/helpers/property-from-helpers.js
@@ -0,0 +1,14 @@
+export function getOutputName({property, from, prefix = null}) {
+  if (property && prefix) {
+    return `${prefix}.${property}`;
+  } else if (property && from) {
+    if (from.startsWith('_')) {
+      return `${from.slice(1)}.${property}`;
+    } else {
+      return `${from}.${property}`;
+    }
+  } else {
+    if (!property) throw new Error(`guard property outside getOutputName(), c'mon`);
+    if (!from) throw new Error(`guard from in getOutputName(), c'mon`);
+  }
+}
\ No newline at end of file
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..7e8fd17f
--- /dev/null
+++ b/src/data/composite/data/withLengthOfList.js
@@ -0,0 +1,56 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {getOutputName} from './helpers/property-from-helpers.js';
+
+export default templateCompositeFrom({
+  annotation: `withMappedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [
+    (list
+      ? getOutputName({property: 'length', from: list})
+      : '#length'),
+  ],
+
+  steps: () => [
+    {
+      dependencies: [input.staticDependency('list')],
+      compute: (continuation, {
+        [input.staticDependency('list')]: list,
+      }) => continuation({
+        '#output':
+          (list
+            ? getOutputName({property: 'length', from: list})
+            : '#length'),
+      }),
+    },
+
+    {
+      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/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js
index fb4134bc..791165b3 100644
--- a/src/data/composite/data/withPropertiesFromList.js
+++ b/src/data/composite/data/withPropertiesFromList.js
@@ -12,6 +12,8 @@
 import {input, templateCompositeFrom} from '#composite';
 import {isString, validateArrayItems} from '#validators';
 
+import {getOutputName} from './helpers/property-from-helpers.js';
+
 export default templateCompositeFrom({
   annotation: `withPropertiesFromList`,
 
@@ -32,11 +34,7 @@ export default templateCompositeFrom({
   }) =>
     (properties
       ? properties.map(property =>
-          (prefix
-            ? `${prefix}.${property}`
-         : list
-            ? `${list}.${property}`
-            : `#list.${property}`))
+          getOutputName({property, from: list || '#list', prefix}))
       : ['#lists']),
 
   steps: () => [
@@ -73,11 +71,7 @@ export default templateCompositeFrom({
           ? continuation(
               Object.fromEntries(
                 properties.map(property => [
-                  (prefix
-                    ? `${prefix}.${property}`
-                 : list
-                    ? `${list}.${property}`
-                    : `#list.${property}`),
+                  getOutputName({property, from: list || '#list', prefix}),
                   lists[property],
                 ])))
           : continuation({'#lists': lists})),
diff --git a/src/data/composite/data/withPropertiesFromObject.js b/src/data/composite/data/withPropertiesFromObject.js
index 21726b58..f600df0d 100644
--- a/src/data/composite/data/withPropertiesFromObject.js
+++ b/src/data/composite/data/withPropertiesFromObject.js
@@ -11,6 +11,8 @@
 import {input, templateCompositeFrom} from '#composite';
 import {isString, validateArrayItems} from '#validators';
 
+import {getOutputName} from './helpers/property-from-helpers.js';
+
 export default templateCompositeFrom({
   annotation: `withPropertiesFromObject`,
 
@@ -32,11 +34,7 @@ export default templateCompositeFrom({
   }) =>
     (properties
       ? properties.map(property =>
-          (prefix
-            ? `${prefix}.${property}`
-         : object
-            ? `${object}.${property}`
-            : `#object.${property}`))
+          getOutputName({property, from: object || '#object', prefix}))
       : ['#object']),
 
   steps: () => [
@@ -71,11 +69,7 @@ export default templateCompositeFrom({
           ? continuation(
               Object.fromEntries(
                 entries.map(([property, value]) => [
-                  (prefix
-                    ? `${prefix}.${property}`
-                 : object
-                    ? `${object}.${property}`
-                    : `#object.${property}`),
+                  getOutputName({property, from: object || '#object', prefix}),
                   value ?? null,
                 ])))
           : continuation({
diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js
index 760095c2..485dd197 100644
--- a/src/data/composite/data/withPropertyFromList.js
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -16,12 +16,7 @@
 import CacheableObject from '#cacheable-object';
 import {input, templateCompositeFrom} from '#composite';
 
-function getOutputName({list, property, prefix}) {
-  if (!property) return `#values`;
-  if (prefix) return `${prefix}.${property}`;
-  if (list) return `${list}.${property}`;
-  return `#list.${property}`;
-}
+import {getOutputName} from './helpers/property-from-helpers.js';
 
 export default templateCompositeFrom({
   annotation: `withPropertyFromList`,
@@ -37,8 +32,11 @@ export default templateCompositeFrom({
     [input.staticDependency('list')]: list,
     [input.staticValue('property')]: property,
     [input.staticValue('prefix')]: prefix,
-  }) =>
-    [getOutputName({list, property, prefix})],
+  }) => [
+    (property
+      ? getOutputName({property, from: list || '#list', prefix})
+      : '#values'),
+  ],
 
   steps: () => [
     {
@@ -78,7 +76,9 @@ export default templateCompositeFrom({
         [input.staticValue('prefix')]: prefix,
       }) => continuation({
         ['#outputName']:
-          getOutputName({list, property, prefix}),
+          (property
+            ? getOutputName({property, from: list || '#list', prefix})
+            : '#values'),
       }),
     },
 
diff --git a/src/data/composite/data/withPropertyFromObject.js b/src/data/composite/data/withPropertyFromObject.js
index 4f240506..7f8c4449 100644
--- a/src/data/composite/data/withPropertyFromObject.js
+++ b/src/data/composite/data/withPropertyFromObject.js
@@ -13,6 +13,8 @@
 import CacheableObject from '#cacheable-object';
 import {input, templateCompositeFrom} from '#composite';
 
+import {getOutputName} from './helpers/property-from-helpers.js';
+
 export default templateCompositeFrom({
   annotation: `withPropertyFromObject`,
 
@@ -25,12 +27,11 @@ export default templateCompositeFrom({
   outputs: ({
     [input.staticDependency('object')]: object,
     [input.staticValue('property')]: property,
-  }) =>
-    (object && property
-      ? (object.startsWith('#')
-          ? [`${object}.${property}`]
-          : [`#${object}.${property}`])
-      : ['#value']),
+  }) => [
+    (property
+      ? getOutputName({property, from: object || '#object'})
+      : '#value'),
+  ],
 
   steps: () => [
     {
@@ -44,10 +45,8 @@ export default templateCompositeFrom({
         [input.staticValue('property')]: property,
       }) => continuation({
         '#output':
-          (object && property
-            ? (object.startsWith('#')
-                ? `${object}.${property}`
-                : `#${object}.${property}`)
+          (property
+            ? getOutputName({property, from: object || '#object'})
             : '#value'),
       }),
     },
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
deleted file mode 100644
index dfc6864f..00000000
--- a/src/data/composite/things/album/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export {default as withHasCoverArt} from './withHasCoverArt.js';
-export {default as withTracks} from './withTracks.js';
diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/things/album/withHasCoverArt.js
deleted file mode 100644
index fd3f2894..00000000
--- a/src/data/composite/things/album/withHasCoverArt.js
+++ /dev/null
@@ -1,64 +0,0 @@
-// TODO: This shouldn't be coded as an Album-specific thing,
-// or even really to do with cover artworks in particular, either.
-
-import {input, templateCompositeFrom} from '#composite';
-
-import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
-  from '#composite/control-flow';
-import {fillMissingListItems, withFlattenedList, withPropertyFromList}
-  from '#composite/data';
-
-export default templateCompositeFrom({
-  annotation: 'withHasCoverArt',
-
-  outputs: ['#hasCoverArt'],
-
-  steps: () => [
-    withResultOfAvailabilityCheck({
-      from: 'coverArtistContribs',
-      mode: input.value('empty'),
-    }),
-
-    {
-      dependencies: ['#availability'],
-      compute: (continuation, {
-        ['#availability']: availability,
-      }) =>
-        (availability
-          ? continuation.raiseOutput({
-              ['#hasCoverArt']: true,
-            })
-          : continuation()),
-    },
-
-    raiseOutputWithoutDependency({
-      dependency: 'coverArtworks',
-      mode: input.value('empty'),
-      output: input.value({'#hasCoverArt': false}),
-    }),
-
-    withPropertyFromList({
-      list: 'coverArtworks',
-      property: input.value('artistContribs'),
-      internal: input.value(true),
-    }),
-
-    // Since we're getting the update value for each artwork's artistContribs,
-    // it may not be set at all, and in that case won't be exposing as [].
-    fillMissingListItems({
-      list: '#coverArtworks.artistContribs',
-      fill: input.value([]),
-    }),
-
-    withFlattenedList({
-      list: '#coverArtworks.artistContribs',
-    }),
-
-    withResultOfAvailabilityCheck({
-      from: '#flattenedList',
-      mode: input.value('empty'),
-    }).outputs({
-      '#availability': '#hasCoverArt',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js
deleted file mode 100644
index 835ee570..00000000
--- a/src/data/composite/things/album/withTracks.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {withFlattenedList, withPropertyFromList} from '#composite/data';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-
-export default templateCompositeFrom({
-  annotation: `withTracks`,
-
-  outputs: ['#tracks'],
-
-  steps: () => [
-    raiseOutputWithoutDependency({
-      dependency: 'trackSections',
-      output: input.value({'#tracks': []}),
-    }),
-
-    withPropertyFromList({
-      list: 'trackSections',
-      property: input.value('tracks'),
-    }),
-
-    withFlattenedList({
-      list: '#trackSections.tracks',
-    }).outputs({
-      ['#flattenedList']: '#tracks',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/art-tag/index.js b/src/data/composite/things/art-tag/index.js
deleted file mode 100644
index bbd38293..00000000
--- a/src/data/composite/things/art-tag/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export {default as withAllDescendantArtTags} from './withAllDescendantArtTags.js';
-export {default as withAncestorArtTagBaobabTree} from './withAncestorArtTagBaobabTree.js';
diff --git a/src/data/composite/things/art-tag/withAllDescendantArtTags.js b/src/data/composite/things/art-tag/withAllDescendantArtTags.js
deleted file mode 100644
index 795f96cd..00000000
--- a/src/data/composite/things/art-tag/withAllDescendantArtTags.js
+++ /dev/null
@@ -1,44 +0,0 @@
-// Gets all the art tags which descend from this one - that means its own direct
-// descendants, but also all the direct and indirect desceands of each of those!
-// The results aren't specially sorted, but they won't contain any duplicates
-// (for example if two descendant tags both route deeper to end up including
-// some of the same tags).
-
-import {input, templateCompositeFrom} from '#composite';
-import {unique} from '#sugar';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-import {withResolvedReferenceList} from '#composite/wiki-data';
-import {soupyFind} from '#composite/wiki-properties';
-
-export default templateCompositeFrom({
-  annotation: `withAllDescendantArtTags`,
-
-  outputs: ['#allDescendantArtTags'],
-
-  steps: () => [
-    raiseOutputWithoutDependency({
-      dependency: 'directDescendantArtTags',
-      mode: input.value('empty'),
-      output: input.value({'#allDescendantArtTags': []})
-    }),
-
-    withResolvedReferenceList({
-      list: 'directDescendantArtTags',
-      find: soupyFind.input('artTag'),
-    }),
-
-    {
-      dependencies: ['#resolvedReferenceList'],
-      compute: (continuation, {
-        ['#resolvedReferenceList']: directDescendantArtTags,
-      }) => continuation({
-        ['#allDescendantArtTags']:
-          unique([
-            ...directDescendantArtTags,
-            ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags),
-          ]),
-      }),
-    },
-  ],
-})
diff --git a/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js
deleted file mode 100644
index e084a42b..00000000
--- a/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js
+++ /dev/null
@@ -1,46 +0,0 @@
-// Gets all the art tags which are ancestors of this one as a "baobab tree" -
-// what you'd typically think of as roots are all up in the air! Since this
-// really is backwards from the way that the art tag tree is written in data,
-// chances are pretty good that there will be many of the exact same "leaf"
-// nodes - art tags which don't themselves have any ancestors. In the actual
-// data structure, each node is a Map, with keys for each ancestor and values
-// for each ancestor's own baobab (thus a branching structure, just like normal
-// trees in this regard).
-
-import {input, templateCompositeFrom} from '#composite';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-import {withReverseReferenceList} from '#composite/wiki-data';
-import {soupyReverse} from '#composite/wiki-properties';
-
-export default templateCompositeFrom({
-  annotation: `withAncestorArtTagBaobabTree`,
-
-  outputs: ['#ancestorArtTagBaobabTree'],
-
-  steps: () => [
-    withReverseReferenceList({
-      reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'),
-    }).outputs({
-      ['#reverseReferenceList']: '#directAncestorArtTags',
-    }),
-
-    raiseOutputWithoutDependency({
-      dependency: '#directAncestorArtTags',
-      mode: input.value('empty'),
-      output: input.value({'#ancestorArtTagBaobabTree': new Map()}),
-    }),
-
-    {
-      dependencies: ['#directAncestorArtTags'],
-      compute: (continuation, {
-        ['#directAncestorArtTags']: directAncestorArtTags,
-      }) => continuation({
-        ['#ancestorArtTagBaobabTree']:
-          new Map(
-            directAncestorArtTags
-              .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])),
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/things/artist/artistTotalDuration.js b/src/data/composite/things/artist/artistTotalDuration.js
deleted file mode 100644
index b8a205fe..00000000
--- a/src/data/composite/things/artist/artistTotalDuration.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {exposeDependency} from '#composite/control-flow';
-import {withFilteredList, withPropertyFromList} from '#composite/data';
-import {withContributionListSums, withReverseReferenceList}
-  from '#composite/wiki-data';
-import {soupyReverse} from '#composite/wiki-properties';
-
-export default templateCompositeFrom({
-  annotation: `artistTotalDuration`,
-
-  compose: false,
-
-  steps: () => [
-    withReverseReferenceList({
-      reverse: soupyReverse.input('trackArtistContributionsBy'),
-    }).outputs({
-      '#reverseReferenceList': '#contributionsAsArtist',
-    }),
-
-    withReverseReferenceList({
-      reverse: soupyReverse.input('trackContributorContributionsBy'),
-    }).outputs({
-      '#reverseReferenceList': '#contributionsAsContributor',
-    }),
-
-    {
-      dependencies: [
-        '#contributionsAsArtist',
-        '#contributionsAsContributor',
-      ],
-
-      compute: (continuation, {
-        ['#contributionsAsArtist']: artistContribs,
-        ['#contributionsAsContributor']: contributorContribs,
-      }) => continuation({
-        ['#allContributions']: [
-          ...artistContribs,
-          ...contributorContribs,
-        ],
-      }),
-    },
-
-    withPropertyFromList({
-      list: '#allContributions',
-      property: input.value('thing'),
-    }),
-
-    withPropertyFromList({
-      list: '#allContributions.thing',
-      property: input.value('isMainRelease'),
-    }),
-
-    withFilteredList({
-      list: '#allContributions',
-      filter: '#allContributions.thing.isMainRelease',
-    }).outputs({
-      '#filteredList': '#mainReleaseContributions',
-    }),
-
-    withContributionListSums({
-      list: '#mainReleaseContributions',
-    }),
-
-    exposeDependency({
-      dependency: '#contributionListDuration',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/artist/index.js b/src/data/composite/things/artist/index.js
deleted file mode 100644
index 55514c71..00000000
--- a/src/data/composite/things/artist/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export {default as artistTotalDuration} from './artistTotalDuration.js';
diff --git a/src/data/composite/things/artwork/index.js b/src/data/composite/things/artwork/index.js
index b92bff72..2cd3c388 100644
--- a/src/data/composite/things/artwork/index.js
+++ b/src/data/composite/things/artwork/index.js
@@ -1 +1 @@
-export {default as withDate} from './withDate.js';
+export {default as withContainingArtworkList} from './withContainingArtworkList.js';
diff --git a/src/data/composite/things/artwork/withContainingArtworkList.js b/src/data/composite/things/artwork/withContainingArtworkList.js
new file mode 100644
index 00000000..9c928ffd
--- /dev/null
+++ b/src/data/composite/things/artwork/withContainingArtworkList.js
@@ -0,0 +1,46 @@
+// Gets the list of artworks which contains this one, which is functionally
+// equivalent to `this.thing[this.thingProperty]`. If the exposed value is not
+// a list at all (i.e. the property holds a single artwork), this composition
+// outputs null.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withContainingArtworkList`,
+
+  outputs: ['#containingArtworkList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'thing',
+      output: input.value({'#containingArtworkList': null}),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: 'thingProperty',
+      output: input.value({'#containingArtworkList': null}),
+    }),
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: 'thingProperty',
+    }).outputs({
+      '#value': '#containingValue',
+    }),
+
+    {
+      dependencies: ['#containingValue'],
+      compute: (continuation, {
+        ['#containingValue']: containingValue,
+      }) => continuation({
+        ['#containingArtworkList']:
+          (Array.isArray(containingValue)
+            ? containingValue
+            : null),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/artwork/withDate.js b/src/data/composite/things/artwork/withDate.js
deleted file mode 100644
index 5e05b814..00000000
--- a/src/data/composite/things/artwork/withDate.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-export default templateCompositeFrom({
-  annotation: `withDate`,
-
-  inputs: {
-    from: input({
-      defaultDependency: 'date',
-      acceptsNull: true,
-    }),
-  },
-
-  outputs: ['#date'],
-
-  steps: () => [
-    {
-      dependencies: [input('from')],
-      compute: (continuation, {
-        [input('from')]: date,
-      }) =>
-        (date
-          ? continuation.raiseOutput({'#date': date})
-          : continuation()),
-    },
-
-    raiseOutputWithoutDependency({
-      dependency: 'dateFromThingProperty',
-      output: input.value({'#date': null}),
-    }),
-
-    withPropertyFromObject({
-      object: 'thing',
-      property: 'dateFromThingProperty',
-    }).outputs({
-      ['#value']: '#date',
-    }),
-  ],
-})
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/hasAnnotationPart.js b/src/data/composite/things/content/hasAnnotationPart.js
new file mode 100644
index 00000000..93aaf5e5
--- /dev/null
+++ b/src/data/composite/things/content/hasAnnotationPart.js
@@ -0,0 +1,25 @@
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `hasAnnotationPart`,
+
+  compose: false,
+
+  inputs: {
+    part: input({type: 'string'}),
+  },
+
+  steps: () => [
+    {
+      dependencies: [input('part'), 'annotationParts'],
+
+      compute: ({
+        [input('part')]: search,
+        ['annotationParts']: parts,
+      }) =>
+          parts.some(part =>
+            part.toLowerCase() ===
+            search.toLowerCase()),
+    },
+  ],
+});
diff --git a/src/data/composite/things/content/index.js b/src/data/composite/things/content/index.js
new file mode 100644
index 00000000..27bf7c53
--- /dev/null
+++ b/src/data/composite/things/content/index.js
@@ -0,0 +1,4 @@
+export {default as hasAnnotationPart} from './hasAnnotationPart.js';
+export {default as withAnnotationPartNodeLists} from './withAnnotationPartNodeLists.js';
+export {default as withExpressedOrImplicitArtistReferences} from './withExpressedOrImplicitArtistReferences.js';
+export {default as withWebArchiveDate} from './withWebArchiveDate.js';
diff --git a/src/data/composite/things/content/withAnnotationPartNodeLists.js b/src/data/composite/things/content/withAnnotationPartNodeLists.js
new file mode 100644
index 00000000..fc304594
--- /dev/null
+++ b/src/data/composite/things/content/withAnnotationPartNodeLists.js
@@ -0,0 +1,28 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {splitContentNodesAround, withContentNodes} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withAnnotationPartNodeLists`,
+
+  outputs: ['#annotationPartNodeLists'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'annotation',
+      output: input.value({'#annotationPartNodeLists': []}),
+    }),
+
+    withContentNodes({
+      from: 'annotation',
+    }),
+
+    splitContentNodesAround({
+      nodes: '#contentNodes',
+      around: input.value(/, */g),
+    }).outputs({
+      '#contentNodeLists': '#annotationPartNodeLists',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js b/src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js
new file mode 100644
index 00000000..69da8c75
--- /dev/null
+++ b/src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js
@@ -0,0 +1,61 @@
+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 =>
+        'artist:' +
+        node.data.replacerValue[0].data),
+    }).outputs({
+      '#mappedList': '#artistReferences',
+    }),
+  ],
+});
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/index.js b/src/data/composite/things/contribution/index.js
index 9b22be2e..2bbf994d 100644
--- a/src/data/composite/things/contribution/index.js
+++ b/src/data/composite/things/contribution/index.js
@@ -1,7 +1,3 @@
 export {default as inheritFromContributionPresets} from './inheritFromContributionPresets.js';
-export {default as thingPropertyMatches} from './thingPropertyMatches.js';
-export {default as thingReferenceTypeMatches} from './thingReferenceTypeMatches.js';
 export {default as withContainingReverseContributionList} from './withContainingReverseContributionList.js';
-export {default as withContributionArtist} from './withContributionArtist.js';
 export {default as withContributionContext} from './withContributionContext.js';
-export {default as withMatchingContributionPresets} from './withMatchingContributionPresets.js';
diff --git a/src/data/composite/things/contribution/inheritFromContributionPresets.js b/src/data/composite/things/contribution/inheritFromContributionPresets.js
index a74e6db3..17387404 100644
--- a/src/data/composite/things/contribution/inheritFromContributionPresets.js
+++ b/src/data/composite/things/contribution/inheritFromContributionPresets.js
@@ -3,29 +3,18 @@ import {input, templateCompositeFrom} from '#composite';
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
 import {withPropertyFromList} from '#composite/data';
 
-import withMatchingContributionPresets
-  from './withMatchingContributionPresets.js';
-
 export default templateCompositeFrom({
   annotation: `inheritFromContributionPresets`,
 
-  inputs: {
-    property: input({type: 'string'}),
-  },
-
   steps: () => [
-    withMatchingContributionPresets().outputs({
-      '#matchingContributionPresets': '#presets',
-    }),
-
     raiseOutputWithoutDependency({
-      dependency: '#presets',
+      dependency: 'matchingPresets',
       mode: input.value('empty'),
     }),
 
     withPropertyFromList({
-      list: '#presets',
-      property: input('property'),
+      list: 'matchingPresets',
+      property: input.thisProperty(),
     }),
 
     {
diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js
deleted file mode 100644
index 1e9019b8..00000000
--- a/src/data/composite/things/contribution/thingPropertyMatches.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-export default templateCompositeFrom({
-  annotation: `thingPropertyMatches`,
-
-  compose: false,
-
-  inputs: {
-    value: input({type: 'string'}),
-  },
-
-  steps: () => [
-    {
-      dependencies: ['thing', 'thingProperty'],
-
-      compute: (continuation, {thing, thingProperty}) =>
-        continuation({
-          ['#thingProperty']:
-            (thing.constructor[Symbol.for('Thing.referenceType')] === 'artwork'
-              ? thing.artistContribsFromThingProperty
-              : thingProperty),
-        }),
-    },
-
-    exitWithoutDependency({
-      dependency: '#thingProperty',
-      value: input.value(false),
-    }),
-
-    {
-      dependencies: [
-        '#thingProperty',
-        input('value'),
-      ],
-
-      compute: ({
-        ['#thingProperty']: thingProperty,
-        [input('value')]: value,
-      }) =>
-        thingProperty === value,
-    },
-  ],
-});
diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
deleted file mode 100644
index 4042e78f..00000000
--- a/src/data/composite/things/contribution/thingReferenceTypeMatches.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-export default templateCompositeFrom({
-  annotation: `thingReferenceTypeMatches`,
-
-  compose: false,
-
-  inputs: {
-    value: input({type: 'string'}),
-  },
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: 'thing',
-      value: input.value(false),
-    }),
-
-    withPropertyFromObject({
-      object: 'thing',
-      property: input.value('constructor'),
-    }),
-
-    {
-      dependencies: [
-        '#thing.constructor',
-        input('value'),
-      ],
-
-      compute: (continuation, {
-        ['#thing.constructor']: constructor,
-        [input('value')]: value,
-      }) =>
-        (constructor[Symbol.for('Thing.referenceType')] === value
-          ? continuation.exit(true)
-       : constructor[Symbol.for('Thing.referenceType')] === 'artwork'
-          ? continuation()
-          : continuation.exit(false)),
-    },
-
-    withPropertyFromObject({
-      object: 'thing',
-      property: input.value('thing'),
-    }),
-
-    withPropertyFromObject({
-      object: '#thing.thing',
-      property: input.value('constructor'),
-    }),
-
-    {
-      dependencies: [
-        '#thing.thing.constructor',
-        input('value'),
-      ],
-
-      compute: ({
-        ['#thing.thing.constructor']: constructor,
-        [input('value')]: value,
-      }) =>
-        constructor[Symbol.for('Thing.referenceType')] === value,
-    },
-  ],
-});
diff --git a/src/data/composite/things/contribution/withContainingReverseContributionList.js b/src/data/composite/things/contribution/withContainingReverseContributionList.js
index 175d6cbb..a9ba31c9 100644
--- a/src/data/composite/things/contribution/withContainingReverseContributionList.js
+++ b/src/data/composite/things/contribution/withContainingReverseContributionList.js
@@ -9,14 +9,12 @@ import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
   from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 
-import withContributionArtist from './withContributionArtist.js';
-
 export default templateCompositeFrom({
   annotation: `withContainingReverseContributionList`,
 
   inputs: {
     artistProperty: input({
-      defaultDependency: 'artistProperty',
+      defaultDependency: '_artistProperty',
       acceptsNull: true,
     }),
   },
@@ -32,10 +30,8 @@ export default templateCompositeFrom({
       }),
     }),
 
-    withContributionArtist(),
-
     withPropertyFromObject({
-      object: '#artist',
+      object: 'artist',
       property: input('artistProperty'),
     }).outputs({
       ['#value']: '#list',
diff --git a/src/data/composite/things/contribution/withContributionArtist.js b/src/data/composite/things/contribution/withContributionArtist.js
deleted file mode 100644
index 5f81c716..00000000
--- a/src/data/composite/things/contribution/withContributionArtist.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {withResolvedReference} from '#composite/wiki-data';
-import {soupyFind} from '#composite/wiki-properties';
-
-export default templateCompositeFrom({
-  annotation: `withContributionArtist`,
-
-  inputs: {
-    ref: input({
-      type: 'string',
-      defaultDependency: 'artist',
-    }),
-  },
-
-  outputs: ['#artist'],
-
-  steps: () => [
-    withResolvedReference({
-      ref: input('ref'),
-      find: soupyFind.input('artist'),
-    }).outputs({
-      '#resolvedReference': '#artist',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/contribution/withMatchingContributionPresets.js b/src/data/composite/things/contribution/withMatchingContributionPresets.js
deleted file mode 100644
index 09454164..00000000
--- a/src/data/composite/things/contribution/withMatchingContributionPresets.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-import withContributionContext from './withContributionContext.js';
-
-export default templateCompositeFrom({
-  annotation: `withMatchingContributionPresets`,
-
-  outputs: ['#matchingContributionPresets'],
-
-  steps: () => [
-    withPropertyFromObject({
-      object: 'thing',
-      property: input.value('wikiInfo'),
-      internal: input.value(true),
-    }),
-
-    raiseOutputWithoutDependency({
-      dependency: '#thing.wikiInfo',
-      output: input.value({
-        '#matchingContributionPresets': null,
-      }),
-    }),
-
-    withPropertyFromObject({
-      object: '#thing.wikiInfo',
-      property: input.value('contributionPresets'),
-    }).outputs({
-      '#thing.wikiInfo.contributionPresets': '#contributionPresets',
-    }),
-
-    raiseOutputWithoutDependency({
-      dependency: '#contributionPresets',
-      mode: input.value('empty'),
-      output: input.value({
-        '#matchingContributionPresets': [],
-      }),
-    }),
-
-    withContributionContext(),
-
-    {
-      dependencies: [
-        '#contributionPresets',
-        '#contributionTarget',
-        '#contributionProperty',
-        'annotation',
-      ],
-
-      compute: (continuation, {
-        ['#contributionPresets']: presets,
-        ['#contributionTarget']: target,
-        ['#contributionProperty']: property,
-        ['annotation']: annotation,
-      }) => continuation({
-        ['#matchingContributionPresets']:
-          presets
-            .filter(preset =>
-              preset.context[0] === target &&
-              preset.context.slice(1).includes(property) &&
-              // For now, only match if the annotation is a complete match.
-              // Partial matches (e.g. because the contribution includes "two"
-              // annotations, separated by commas) don't count.
-              preset.annotation === annotation),
-      })
-    },
-  ],
-});
diff --git a/src/data/composite/things/flash-act/index.js b/src/data/composite/things/flash-act/index.js
deleted file mode 100644
index 40fecd2f..00000000
--- a/src/data/composite/things/flash-act/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export {default as withFlashSide} from './withFlashSide.js';
diff --git a/src/data/composite/things/flash-act/withFlashSide.js b/src/data/composite/things/flash-act/withFlashSide.js
deleted file mode 100644
index e09f06e6..00000000
--- a/src/data/composite/things/flash-act/withFlashSide.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// Gets the flash act's side. This will early exit if flashSideData is missing.
-// If there's no side whose list of flash acts includes this act, the output
-// dependency will be null.
-
-import {templateCompositeFrom} from '#composite';
-
-import {withUniqueReferencingThing} from '#composite/wiki-data';
-import {soupyReverse} from '#composite/wiki-properties';
-
-export default templateCompositeFrom({
-  annotation: `withFlashSide`,
-
-  outputs: ['#flashSide'],
-
-  steps: () => [
-    withUniqueReferencingThing({
-      reverse: soupyReverse.input('flashSidesWhoseActsInclude'),
-    }).outputs({
-      ['#uniqueReferencingThing']: '#flashSide',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/flash/index.js b/src/data/composite/things/flash/index.js
deleted file mode 100644
index 63ac13da..00000000
--- a/src/data/composite/things/flash/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export {default as withFlashAct} from './withFlashAct.js';
diff --git a/src/data/composite/things/flash/withFlashAct.js b/src/data/composite/things/flash/withFlashAct.js
deleted file mode 100644
index 87922aff..00000000
--- a/src/data/composite/things/flash/withFlashAct.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// Gets the flash's act. This will early exit if flashActData is missing.
-// If there's no flash whose list of flashes includes this flash, the output
-// dependency will be null.
-
-import {templateCompositeFrom} from '#composite';
-
-import {withUniqueReferencingThing} from '#composite/wiki-data';
-import {soupyReverse} from '#composite/wiki-properties';
-
-export default templateCompositeFrom({
-  annotation: `withFlashAct`,
-
-  outputs: ['#flashAct'],
-
-  steps: () => [
-    withUniqueReferencingThing({
-      reverse: soupyReverse.input('flashActsWhoseFlashesInclude'),
-    }).outputs({
-      ['#uniqueReferencingThing']: '#flashAct',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/track-section/index.js b/src/data/composite/things/track-section/index.js
deleted file mode 100644
index f11a2ab5..00000000
--- a/src/data/composite/things/track-section/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export {default as withAlbum} from './withAlbum.js';
-export {default as withContinueCountingFrom} from './withContinueCountingFrom.js';
-export {default as withStartCountingFrom} from './withStartCountingFrom.js';
diff --git a/src/data/composite/things/track-section/withAlbum.js b/src/data/composite/things/track-section/withAlbum.js
deleted file mode 100644
index e257062e..00000000
--- a/src/data/composite/things/track-section/withAlbum.js
+++ /dev/null
@@ -1,20 +0,0 @@
-// Gets the track section's album.
-
-import {templateCompositeFrom} from '#composite';
-
-import {withUniqueReferencingThing} from '#composite/wiki-data';
-import {soupyReverse} from '#composite/wiki-properties';
-
-export default templateCompositeFrom({
-  annotation: `withAlbum`,
-
-  outputs: ['#album'],
-
-  steps: () => [
-    withUniqueReferencingThing({
-      reverse: soupyReverse.input('albumsWhoseTrackSectionsInclude'),
-    }).outputs({
-      ['#uniqueReferencingThing']: '#album',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/track-section/withContinueCountingFrom.js b/src/data/composite/things/track-section/withContinueCountingFrom.js
deleted file mode 100644
index e034b7a5..00000000
--- a/src/data/composite/things/track-section/withContinueCountingFrom.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import withStartCountingFrom from './withStartCountingFrom.js';
-
-export default templateCompositeFrom({
-  annotation: `withContinueCountingFrom`,
-
-  outputs: ['#continueCountingFrom'],
-
-  steps: () => [
-    withStartCountingFrom(),
-
-    {
-      dependencies: ['#startCountingFrom', 'tracks'],
-      compute: (continuation, {
-        ['#startCountingFrom']: startCountingFrom,
-        ['tracks']: tracks,
-      }) => continuation({
-        ['#continueCountingFrom']:
-          startCountingFrom +
-          tracks.length,
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/things/track-section/withStartCountingFrom.js b/src/data/composite/things/track-section/withStartCountingFrom.js
deleted file mode 100644
index ef345327..00000000
--- a/src/data/composite/things/track-section/withStartCountingFrom.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-import {withNearbyItemFromList, withPropertyFromObject} from '#composite/data';
-
-import withAlbum from './withAlbum.js';
-
-export default templateCompositeFrom({
-  annotation: `withStartCountingFrom`,
-
-  inputs: {
-    from: input({
-      type: 'number',
-      defaultDependency: 'startCountingFrom',
-      acceptsNull: true,
-    }),
-  },
-
-  outputs: ['#startCountingFrom'],
-
-  steps: () => [
-    {
-      dependencies: [input('from')],
-      compute: (continuation, {
-        [input('from')]: from,
-      }) =>
-        (from === null
-          ? continuation()
-          : continuation.raiseOutput({'#startCountingFrom': from})),
-    },
-
-    withAlbum(),
-
-    raiseOutputWithoutDependency({
-      dependency: '#album',
-      output: input.value({'#startCountingFrom': 1}),
-    }),
-
-    withPropertyFromObject({
-      object: '#album',
-      property: input.value('trackSections'),
-    }),
-
-    withNearbyItemFromList({
-      list: '#album.trackSections',
-      item: input.myself(),
-      offset: input.value(-1),
-    }).outputs({
-      '#nearbyItem': '#previousTrackSection',
-    }),
-
-    raiseOutputWithoutDependency({
-      dependency: '#previousTrackSection',
-      output: input.value({'#startCountingFrom': 1}),
-    }),
-
-    withPropertyFromObject({
-      object: '#previousTrackSection',
-      property: input.value('continueCountingFrom'),
-    }).outputs({
-      '#previousTrackSection.continueCountingFrom': '#startCountingFrom',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/track/exitWithoutUniqueCoverArt.js b/src/data/composite/things/track/exitWithoutUniqueCoverArt.js
deleted file mode 100644
index f47086d9..00000000
--- a/src/data/composite/things/track/exitWithoutUniqueCoverArt.js
+++ /dev/null
@@ -1,26 +0,0 @@
-// Shorthand for checking if the track has unique cover art and exposing a
-// fallback value if it isn't.
-
-import {input, templateCompositeFrom} from '#composite';
-
-import {exitWithoutDependency} from '#composite/control-flow';
-
-import withHasUniqueCoverArt from './withHasUniqueCoverArt.js';
-
-export default templateCompositeFrom({
-  annotation: `exitWithoutUniqueCoverArt`,
-
-  inputs: {
-    value: input({defaultValue: null}),
-  },
-
-  steps: () => [
-    withHasUniqueCoverArt(),
-
-    exitWithoutDependency({
-      dependency: '#hasUniqueCoverArt',
-      mode: input.value('falsy'),
-      value: input('value'),
-    }),
-  ],
-});
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
index e789e736..c200df19 100644
--- a/src/data/composite/things/track/index.js
+++ b/src/data/composite/things/track/index.js
@@ -1,17 +1,2 @@
-export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js';
 export {default as inheritContributionListFromMainRelease} from './inheritContributionListFromMainRelease.js';
 export {default as inheritFromMainRelease} from './inheritFromMainRelease.js';
-export {default as withAllReleases} from './withAllReleases.js';
-export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js';
-export {default as withContainingTrackSection} from './withContainingTrackSection.js';
-export {default as withCoverArtistContribs} from './withCoverArtistContribs.js';
-export {default as withDate} from './withDate.js';
-export {default as withDirectorySuffix} from './withDirectorySuffix.js';
-export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js';
-export {default as withMainRelease} from './withMainRelease.js';
-export {default as withOtherReleases} from './withOtherReleases.js';
-export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js';
-export {default as withPropertyFromMainRelease} from './withPropertyFromMainRelease.js';
-export {default as withSuffixDirectoryFromAlbum} from './withSuffixDirectoryFromAlbum.js';
-export {default as withTrackArtDate} from './withTrackArtDate.js';
-export {default as withTrackNumber} from './withTrackNumber.js';
diff --git a/src/data/composite/things/track/inheritContributionListFromMainRelease.js b/src/data/composite/things/track/inheritContributionListFromMainRelease.js
index 89252feb..8db50060 100644
--- a/src/data/composite/things/track/inheritContributionListFromMainRelease.js
+++ b/src/data/composite/things/track/inheritContributionListFromMainRelease.js
@@ -5,40 +5,37 @@ import {input, templateCompositeFrom} from '#composite';
 
 import {exposeDependency, raiseOutputWithoutDependency}
   from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
 import {withRecontextualizedContributionList, withRedatedContributionList}
   from '#composite/wiki-data';
 
-import withDate from './withDate.js';
-import withPropertyFromMainRelease
-  from './withPropertyFromMainRelease.js';
-
 export default templateCompositeFrom({
   annotation: `inheritContributionListFromMainRelease`,
 
   steps: () => [
-    withPropertyFromMainRelease({
-      property: input.thisProperty(),
-      notFoundValue: input.value([]),
-    }),
-
     raiseOutputWithoutDependency({
-      dependency: '#isSecondaryRelease',
+      dependency: 'isSecondaryRelease',
       mode: input.value('falsy'),
     }),
 
-    withRecontextualizedContributionList({
-      list: '#mainReleaseValue',
+    withPropertyFromObject({
+      object: 'mainReleaseTrack',
+      property: input.thisProperty(),
+    }).outputs({
+      '#value': '#contributions',
     }),
 
-    withDate(),
+    withRecontextualizedContributionList({
+      list: '#contributions',
+    }),
 
     withRedatedContributionList({
-      list: '#mainReleaseValue',
-      date: '#date',
+      list: '#contributions',
+      date: 'date',
     }),
 
     exposeDependency({
-      dependency: '#mainReleaseValue',
+      dependency: '#contributions',
     }),
   ],
 });
diff --git a/src/data/composite/things/track/inheritFromMainRelease.js b/src/data/composite/things/track/inheritFromMainRelease.js
index b1cbb65e..ca532bc7 100644
--- a/src/data/composite/things/track/inheritFromMainRelease.js
+++ b/src/data/composite/things/track/inheritFromMainRelease.js
@@ -1,41 +1,29 @@
 // Early exits with the value for the same property as specified on the
 // main release, if this track is a secondary release, and otherwise continues
 // without providing any further dependencies.
-//
-// Like withMainRelease, this will early exit (with notFoundValue) if the
-// main release is specified by reference and that reference doesn't
-// resolve to anything.
 
 import {input, templateCompositeFrom} from '#composite';
 
 import {exposeDependency, raiseOutputWithoutDependency}
   from '#composite/control-flow';
-
-import withPropertyFromMainRelease
-  from './withPropertyFromMainRelease.js';
+import {withPropertyFromObject} from '#composite/data';
 
 export default templateCompositeFrom({
   annotation: `inheritFromMainRelease`,
 
-  inputs: {
-    notFoundValue: input({
-      defaultValue: null,
-    }),
-  },
-
   steps: () => [
-    withPropertyFromMainRelease({
-      property: input.thisProperty(),
-      notFoundValue: input('notFoundValue'),
-    }),
-
     raiseOutputWithoutDependency({
-      dependency: '#isSecondaryRelease',
+      dependency: 'isSecondaryRelease',
       mode: input.value('falsy'),
     }),
 
+    withPropertyFromObject({
+      object: 'mainReleaseTrack',
+      property: input.thisProperty(),
+    }),
+
     exposeDependency({
-      dependency: '#mainReleaseValue',
+      dependency: '#value',
     }),
   ],
 });
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
deleted file mode 100644
index b93bf753..00000000
--- a/src/data/composite/things/track/withAllReleases.js
+++ /dev/null
@@ -1,47 +0,0 @@
-// Gets all releases of the current track. All items of the outputs are
-// distinct Track objects; one track is the main release; all else are
-// secondary releases of that main release; and one item, which may be
-// the main release or one of the secondary releases, is the current
-// track. The results are sorted by date, and it is possible that the
-// main release is not actually the earliest/first.
-
-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';
-
-export default templateCompositeFrom({
-  annotation: `withAllReleases`,
-
-  outputs: ['#allReleases'],
-
-  steps: () => [
-    withMainRelease({
-      selfIfMain: input.value(true),
-      notFoundValue: input.value([]),
-    }),
-
-    // We don't talk about bruno no no
-    // Yes, this can perform a normal access equivalent to
-    // `this.secondaryReleases` from within a data composition.
-    // Oooooooooooooooooooooooooooooooooooooooooooooooo
-    withPropertyFromObject({
-      object: '#mainRelease',
-      property: input.value('secondaryReleases'),
-    }),
-
-    {
-      dependencies: ['#mainRelease', '#mainRelease.secondaryReleases'],
-      compute: (continuation, {
-        ['#mainRelease']: mainRelease,
-        ['#mainRelease.secondaryReleases']: secondaryReleases,
-      }) => continuation({
-        ['#allReleases']:
-          sortByDate([mainRelease, ...secondaryReleases]),
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
deleted file mode 100644
index 60faeaf4..00000000
--- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
+++ /dev/null
@@ -1,97 +0,0 @@
-// Controls how find.track works - it'll never be matched by a reference
-// just to the track's name, which means you don't have to always reference
-// some *other* (much more commonly referenced) track by directory instead
-// of more naturally by name.
-
-import {input, templateCompositeFrom} from '#composite';
-import find from '#find';
-import {isBoolean} from '#validators';
-
-import {withPropertyFromObject} from '#composite/data';
-import {withResolvedReference} from '#composite/wiki-data';
-import {soupyFind} from '#composite/wiki-properties';
-
-import {
-  exitWithoutDependency,
-  exposeDependencyOrContinue,
-  exposeUpdateValueOrContinue,
-} from '#composite/control-flow';
-
-import withPropertyFromAlbum from './withPropertyFromAlbum.js';
-
-export default templateCompositeFrom({
-  annotation: `withAlwaysReferenceByDirectory`,
-
-  outputs: ['#alwaysReferenceByDirectory'],
-
-  steps: () => [
-    exposeUpdateValueOrContinue({
-      validate: input.value(isBoolean),
-    }),
-
-    withPropertyFromAlbum({
-      property: input.value('alwaysReferenceTracksByDirectory'),
-    }),
-
-    // Falsy mode means this exposes true if the album's property is true,
-    // but continues if the property is false (which is also the default).
-    exposeDependencyOrContinue({
-      dependency: '#album.alwaysReferenceTracksByDirectory',
-      mode: input.value('falsy'),
-    }),
-
-    // Remaining code is for defaulting to true if this track is a rerelease of
-    // another with the same name, so everything further depends on access to
-    // trackData as well as mainReleaseTrack.
-
-    exitWithoutDependency({
-      dependency: 'trackData',
-      mode: input.value('empty'),
-      value: input.value(false),
-    }),
-
-    exitWithoutDependency({
-      dependency: 'mainReleaseTrack',
-      value: input.value(false),
-    }),
-
-    // It's necessary to use the custom trackMainReleasesOnly find function
-    // here, so as to avoid recursion issues - the find.track() function depends
-    // on accessing each track's alwaysReferenceByDirectory, which means it'll
-    // hit *this track* - and thus this step - and end up recursing infinitely.
-    // By definition, find.trackMainReleasesOnly excludes tracks which have
-    // an mainReleaseTrack update value set, which means even though it does
-    // still access each of tracks' `alwaysReferenceByDirectory` property, it
-    // won't access that of *this* track - it will never proceed past the
-    // `exitWithoutDependency` step directly above, so there's no opportunity
-    // for recursion.
-    withResolvedReference({
-      ref: 'mainReleaseTrack',
-      data: 'trackData',
-      find: input.value(find.trackMainReleasesOnly),
-    }).outputs({
-      '#resolvedReference': '#mainRelease',
-    }),
-
-    exitWithoutDependency({
-      dependency: '#mainRelease',
-      value: input.value(false),
-    }),
-
-    withPropertyFromObject({
-      object: '#mainRelease',
-      property: input.value('name'),
-    }),
-
-    {
-      dependencies: ['name', '#mainRelease.name'],
-      compute: (continuation, {
-        name,
-        ['#mainRelease.name']: mainReleaseName,
-      }) => continuation({
-        ['#alwaysReferenceByDirectory']:
-          name === mainReleaseName,
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js
deleted file mode 100644
index 3d4d081e..00000000
--- a/src/data/composite/things/track/withContainingTrackSection.js
+++ /dev/null
@@ -1,20 +0,0 @@
-// Gets the track section containing this track from its album's track list.
-
-import {templateCompositeFrom} from '#composite';
-
-import {withUniqueReferencingThing} from '#composite/wiki-data';
-import {soupyReverse} from '#composite/wiki-properties';
-
-export default templateCompositeFrom({
-  annotation: `withContainingTrackSection`,
-
-  outputs: ['#trackSection'],
-
-  steps: () => [
-    withUniqueReferencingThing({
-      reverse: soupyReverse.input('trackSectionsWhichInclude'),
-    }).outputs({
-      ['#uniqueReferencingThing']: '#trackSection',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/track/withCoverArtistContribs.js b/src/data/composite/things/track/withCoverArtistContribs.js
deleted file mode 100644
index 9057cfeb..00000000
--- a/src/data/composite/things/track/withCoverArtistContribs.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {isContributionList} from '#validators';
-
-import {exposeDependencyOrContinue} from '#composite/control-flow';
-
-import {
-  withRecontextualizedContributionList,
-  withRedatedContributionList,
-  withResolvedContribs,
-} from '#composite/wiki-data';
-
-import exitWithoutUniqueCoverArt from './exitWithoutUniqueCoverArt.js';
-import withPropertyFromAlbum from './withPropertyFromAlbum.js';
-import withTrackArtDate from './withTrackArtDate.js';
-
-export default templateCompositeFrom({
-  annotation: `withCoverArtistContribs`,
-
-  inputs: {
-    from: input({
-      defaultDependency: 'coverArtistContribs',
-      validate: isContributionList,
-      acceptsNull: true,
-    }),
-  },
-
-  outputs: ['#coverArtistContribs'],
-
-  steps: () => [
-    exitWithoutUniqueCoverArt({
-      value: input.value([]),
-    }),
-
-    withTrackArtDate(),
-
-    withResolvedContribs({
-      from: input('from'),
-      thingProperty: input.value('coverArtistContribs'),
-      artistProperty: input.value('trackCoverArtistContributions'),
-      date: '#trackArtDate',
-    }).outputs({
-      '#resolvedContribs': '#coverArtistContribs',
-    }),
-
-    exposeDependencyOrContinue({
-      dependency: '#coverArtistContribs',
-      mode: input.value('empty'),
-    }),
-
-    withPropertyFromAlbum({
-      property: input.value('trackCoverArtistContribs'),
-    }),
-
-    withRecontextualizedContributionList({
-      list: '#album.trackCoverArtistContribs',
-      artistProperty: input.value('trackCoverArtistContributions'),
-    }),
-
-    withRedatedContributionList({
-      list: '#album.trackCoverArtistContribs',
-      date: '#trackArtDate',
-    }),
-
-    {
-      dependencies: ['#album.trackCoverArtistContribs'],
-      compute: (continuation, {
-        ['#album.trackCoverArtistContribs']: coverArtistContribs,
-      }) => continuation({
-        ['#coverArtistContribs']: coverArtistContribs,
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withDate.js b/src/data/composite/things/track/withDate.js
deleted file mode 100644
index b5a770e9..00000000
--- a/src/data/composite/things/track/withDate.js
+++ /dev/null
@@ -1,34 +0,0 @@
-// Gets the track's own date. This is either its dateFirstReleased property
-// or, if unset, the album's date.
-
-import {input, templateCompositeFrom} from '#composite';
-
-import withPropertyFromAlbum from './withPropertyFromAlbum.js';
-
-export default templateCompositeFrom({
-  annotation: `withDate`,
-
-  outputs: ['#date'],
-
-  steps: () => [
-    {
-      dependencies: ['dateFirstReleased'],
-      compute: (continuation, {dateFirstReleased}) =>
-        (dateFirstReleased
-          ? continuation.raiseOutput({'#date': dateFirstReleased})
-          : continuation()),
-    },
-
-    withPropertyFromAlbum({
-      property: input.value('date'),
-    }),
-
-    {
-      dependencies: ['#album.date'],
-      compute: (continuation, {['#album.date']: albumDate}) =>
-        (albumDate
-          ? continuation.raiseOutput({'#date': albumDate})
-          : continuation.raiseOutput({'#date': null})),
-    },
-  ],
-})
diff --git a/src/data/composite/things/track/withDirectorySuffix.js b/src/data/composite/things/track/withDirectorySuffix.js
deleted file mode 100644
index c063e158..00000000
--- a/src/data/composite/things/track/withDirectorySuffix.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-
-import withPropertyFromAlbum from './withPropertyFromAlbum.js';
-import withSuffixDirectoryFromAlbum from './withSuffixDirectoryFromAlbum.js';
-
-export default templateCompositeFrom({
-  annotation: `withDirectorySuffix`,
-
-  outputs: ['#directorySuffix'],
-
-  steps: () => [
-    withSuffixDirectoryFromAlbum(),
-
-    raiseOutputWithoutDependency({
-      dependency: '#suffixDirectoryFromAlbum',
-      mode: input.value('falsy'),
-      output: input.value({['#directorySuffix']: null}),
-    }),
-
-    withPropertyFromAlbum({
-      property: input.value('directorySuffix'),
-    }),
-
-    {
-      dependencies: ['#album.directorySuffix'],
-      compute: (continuation, {
-        ['#album.directorySuffix']: directorySuffix,
-      }) => continuation({
-        ['#directorySuffix']:
-          directorySuffix,
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js
deleted file mode 100644
index 85d3b92a..00000000
--- a/src/data/composite/things/track/withHasUniqueCoverArt.js
+++ /dev/null
@@ -1,108 +0,0 @@
-// Whether or not the track has "unique" cover artwork - a cover which is
-// specifically associated with this track in particular, rather than with
-// the track's album as a whole. This is typically used to select between
-// displaying the track artwork and a fallback, such as the album artwork
-// or a placeholder. (This property is named hasUniqueCoverArt instead of
-// the usual hasCoverArt to emphasize that it does not inherit from the
-// album.)
-//
-// withHasUniqueCoverArt is based only around the presence of *specified*
-// cover artist contributions, not whether the references to artists on those
-// contributions actually resolve to anything. It completely evades interacting
-// with find/replace.
-
-import {input, templateCompositeFrom} from '#composite';
-
-import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
-  from '#composite/control-flow';
-import {fillMissingListItems, withFlattenedList, withPropertyFromList}
-  from '#composite/data';
-
-import withPropertyFromAlbum from './withPropertyFromAlbum.js';
-
-export default templateCompositeFrom({
-  annotation: 'withHasUniqueCoverArt',
-
-  outputs: ['#hasUniqueCoverArt'],
-
-  steps: () => [
-    {
-      dependencies: ['disableUniqueCoverArt'],
-      compute: (continuation, {disableUniqueCoverArt}) =>
-        (disableUniqueCoverArt
-          ? continuation.raiseOutput({
-              ['#hasUniqueCoverArt']: false,
-            })
-          : continuation()),
-    },
-
-    withResultOfAvailabilityCheck({
-      from: 'coverArtistContribs',
-      mode: input.value('empty'),
-    }),
-
-    {
-      dependencies: ['#availability'],
-      compute: (continuation, {
-        ['#availability']: availability,
-      }) =>
-        (availability
-          ? continuation.raiseOutput({
-              ['#hasUniqueCoverArt']: true,
-            })
-          : continuation()),
-    },
-
-    withPropertyFromAlbum({
-      property: input.value('trackCoverArtistContribs'),
-      internal: input.value(true),
-    }),
-
-    withResultOfAvailabilityCheck({
-      from: '#album.trackCoverArtistContribs',
-      mode: input.value('empty'),
-    }),
-
-    {
-      dependencies: ['#availability'],
-      compute: (continuation, {
-        ['#availability']: availability,
-      }) =>
-        (availability
-          ? continuation.raiseOutput({
-              ['#hasUniqueCoverArt']: true,
-            })
-          : continuation()),
-    },
-
-    raiseOutputWithoutDependency({
-      dependency: 'trackArtworks',
-      mode: input.value('empty'),
-      output: input.value({'#hasUniqueCoverArt': false}),
-    }),
-
-    withPropertyFromList({
-      list: 'trackArtworks',
-      property: input.value('artistContribs'),
-      internal: input.value(true),
-    }),
-
-    // Since we're getting the update value for each artwork's artistContribs,
-    // it may not be set at all, and in that case won't be exposing as [].
-    fillMissingListItems({
-      list: '#trackArtworks.artistContribs',
-      fill: input.value([]),
-    }),
-
-    withFlattenedList({
-      list: '#trackArtworks.artistContribs',
-    }),
-
-    withResultOfAvailabilityCheck({
-      from: '#flattenedList',
-      mode: input.value('empty'),
-    }).outputs({
-      '#availability': '#hasUniqueCoverArt',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/track/withMainRelease.js b/src/data/composite/things/track/withMainRelease.js
deleted file mode 100644
index 3a91edae..00000000
--- a/src/data/composite/things/track/withMainRelease.js
+++ /dev/null
@@ -1,70 +0,0 @@
-// Just includes the main release of this track as a dependency.
-// If this track isn't a secondary release, then it'll provide null, unless
-// the {selfIfMain} option is set, in which case it'll provide this track
-// itself. This will early exit (with notFoundValue) if the main release
-// is specified by reference and that reference doesn't resolve to anything.
-
-import {input, templateCompositeFrom} from '#composite';
-
-import {exitWithoutDependency, withResultOfAvailabilityCheck}
-  from '#composite/control-flow';
-import {withResolvedReference} from '#composite/wiki-data';
-import {soupyFind} from '#composite/wiki-properties';
-
-export default templateCompositeFrom({
-  annotation: `withMainRelease`,
-
-  inputs: {
-    selfIfMain: input({type: 'boolean', defaultValue: false}),
-    notFoundValue: input({defaultValue: null}),
-  },
-
-  outputs: ['#mainRelease'],
-
-  steps: () => [
-    withResultOfAvailabilityCheck({
-      from: 'mainReleaseTrack',
-    }),
-
-    {
-      dependencies: [
-        input.myself(),
-        input('selfIfMain'),
-        '#availability',
-      ],
-
-      compute: (continuation, {
-        [input.myself()]: track,
-        [input('selfIfMain')]: selfIfMain,
-        '#availability': availability,
-      }) =>
-        (availability
-          ? continuation()
-          : continuation.raiseOutput({
-              ['#mainRelease']:
-                (selfIfMain ? track : null),
-            })),
-    },
-
-    withResolvedReference({
-      ref: 'mainReleaseTrack',
-      find: soupyFind.input('track'),
-    }),
-
-    exitWithoutDependency({
-      dependency: '#resolvedReference',
-      value: input('notFoundValue'),
-    }),
-
-    {
-      dependencies: ['#resolvedReference'],
-
-      compute: (continuation, {
-        ['#resolvedReference']: resolvedReference,
-      }) =>
-        continuation({
-          ['#mainRelease']: resolvedReference,
-        }),
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js
deleted file mode 100644
index 0639742f..00000000
--- a/src/data/composite/things/track/withOtherReleases.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// Gets all releases of the current track *except* this track itself;
-// in other words, all other releases of the current track.
-
-import {input, templateCompositeFrom} from '#composite';
-
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-import withAllReleases from './withAllReleases.js';
-
-export default templateCompositeFrom({
-  annotation: `withOtherReleases`,
-
-  outputs: ['#otherReleases'],
-
-  steps: () => [
-    withAllReleases(),
-
-    {
-      dependencies: [input.myself(), '#allReleases'],
-      compute: (continuation, {
-        [input.myself()]: thisTrack,
-        ['#allReleases']: allReleases,
-      }) => continuation({
-        ['#otherReleases']:
-          allReleases.filter(track => track !== thisTrack),
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js
deleted file mode 100644
index a203c2e7..00000000
--- a/src/data/composite/things/track/withPropertyFromAlbum.js
+++ /dev/null
@@ -1,48 +0,0 @@
-// Gets a single property from this track's album, providing it as the same
-// property name prefixed with '#album.' (by default).
-
-import {input, templateCompositeFrom} from '#composite';
-
-import {withPropertyFromObject} from '#composite/data';
-
-export default templateCompositeFrom({
-  annotation: `withPropertyFromAlbum`,
-
-  inputs: {
-    property: input.staticValue({type: 'string'}),
-    internal: input({type: 'boolean', defaultValue: false}),
-  },
-
-  outputs: ({
-    [input.staticValue('property')]: property,
-  }) => ['#album.' + property],
-
-  steps: () => [
-    // XXX: This is a ridiculous hack considering `defaultValue` above.
-    // If we were certain what was up, we'd just get around to fixing it LOL
-    {
-      dependencies: [input('internal')],
-      compute: (continuation, {
-        [input('internal')]: internal,
-      }) => continuation({
-        ['#internal']: internal ?? false,
-      }),
-    },
-
-    withPropertyFromObject({
-      object: 'album',
-      property: input('property'),
-      internal: '#internal',
-    }),
-
-    {
-      dependencies: ['#value', input.staticValue('property')],
-      compute: (continuation, {
-        ['#value']: value,
-        [input.staticValue('property')]: property,
-      }) => continuation({
-        ['#album.' + property]: value,
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withPropertyFromMainRelease.js b/src/data/composite/things/track/withPropertyFromMainRelease.js
deleted file mode 100644
index 393a4c63..00000000
--- a/src/data/composite/things/track/withPropertyFromMainRelease.js
+++ /dev/null
@@ -1,86 +0,0 @@
-// Provides a value inherited from the main release, if applicable, and a
-// flag indicating if this track is a secondary release or not.
-//
-// Like withMainRelease, this will early exit (with notFoundValue) if the
-// main release is specified by reference and that reference doesn't
-// resolve to anything.
-
-import {input, templateCompositeFrom} from '#composite';
-
-import {withResultOfAvailabilityCheck} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-import withMainRelease from './withMainRelease.js';
-
-export default templateCompositeFrom({
-  annotation: `inheritFromMainRelease`,
-
-  inputs: {
-    property: input({type: 'string'}),
-
-    notFoundValue: input({
-      defaultValue: null,
-    }),
-  },
-
-  outputs: ({
-    [input.staticValue('property')]: property,
-  }) =>
-    ['#isSecondaryRelease'].concat(
-      (property
-        ? ['#mainRelease.' + property]
-        : ['#mainReleaseValue'])),
-
-  steps: () => [
-    withMainRelease({
-      notFoundValue: input('notFoundValue'),
-    }),
-
-    withResultOfAvailabilityCheck({
-      from: '#mainRelease',
-    }),
-
-    {
-      dependencies: [
-        '#availability',
-        input.staticValue('property'),
-      ],
-
-      compute: (continuation, {
-        ['#availability']: availability,
-        [input.staticValue('property')]: property,
-      }) =>
-        (availability
-          ? continuation()
-          : continuation.raiseOutput(
-              Object.assign(
-                {'#isSecondaryRelease': false},
-                (property
-                  ? {['#mainRelease.' + property]: null}
-                  : {'#mainReleaseValue': null})))),
-    },
-
-    withPropertyFromObject({
-      object: '#mainRelease',
-      property: input('property'),
-    }),
-
-    {
-      dependencies: [
-        '#value',
-        input.staticValue('property'),
-      ],
-
-      compute: (continuation, {
-        ['#value']: value,
-        [input.staticValue('property')]: property,
-      }) =>
-        continuation.raiseOutput(
-          Object.assign(
-            {'#isSecondaryRelease': true},
-            (property
-              ? {['#mainRelease.' + property]: value}
-              : {'#mainReleaseValue': value}))),
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js b/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js
deleted file mode 100644
index 7159a3f4..00000000
--- a/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {withResultOfAvailabilityCheck} from '#composite/control-flow';
-
-import withPropertyFromAlbum from './withPropertyFromAlbum.js';
-
-export default templateCompositeFrom({
-  annotation: `withSuffixDirectoryFromAlbum`,
-
-  inputs: {
-    flagValue: input({
-      defaultDependency: 'suffixDirectoryFromAlbum',
-      acceptsNull: true,
-    }),
-  },
-
-  outputs: ['#suffixDirectoryFromAlbum'],
-
-  steps: () => [
-    withResultOfAvailabilityCheck({
-      from: 'suffixDirectoryFromAlbum',
-    }),
-
-    {
-      dependencies: [
-        '#availability',
-        'suffixDirectoryFromAlbum'
-      ],
-
-      compute: (continuation, {
-        ['#availability']: availability,
-        ['suffixDirectoryFromAlbum']: flagValue,
-      }) =>
-        (availability
-          ? continuation.raiseOutput({['#suffixDirectoryFromAlbum']: flagValue})
-          : continuation()),
-    },
-
-    withPropertyFromAlbum({
-      property: input.value('suffixTrackDirectories'),
-    }),
-
-    {
-      dependencies: ['#album.suffixTrackDirectories'],
-      compute: (continuation, {
-        ['#album.suffixTrackDirectories']: suffixTrackDirectories,
-      }) => continuation({
-        ['#suffixDirectoryFromAlbum']:
-          suffixTrackDirectories,
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withTrackArtDate.js b/src/data/composite/things/track/withTrackArtDate.js
deleted file mode 100644
index 9b7b61c7..00000000
--- a/src/data/composite/things/track/withTrackArtDate.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {isDate} from '#validators';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-
-import withDate from './withDate.js';
-import withHasUniqueCoverArt from './withHasUniqueCoverArt.js';
-import withPropertyFromAlbum from './withPropertyFromAlbum.js';
-
-export default templateCompositeFrom({
-  annotation: `withTrackArtDate`,
-
-  inputs: {
-    from: input({
-      validate: isDate,
-      defaultDependency: 'coverArtDate',
-      acceptsNull: true,
-    }),
-  },
-
-  outputs: ['#trackArtDate'],
-
-  steps: () => [
-    withHasUniqueCoverArt(),
-
-    raiseOutputWithoutDependency({
-      dependency: '#hasUniqueCoverArt',
-      mode: input.value('falsy'),
-      output: input.value({'#trackArtDate': null}),
-    }),
-
-    {
-      dependencies: [input('from')],
-      compute: (continuation, {
-        [input('from')]: from,
-      }) =>
-        (from
-          ? continuation.raiseOutput({'#trackArtDate': from})
-          : continuation()),
-    },
-
-    withPropertyFromAlbum({
-      property: input.value('trackArtDate'),
-    }),
-
-    {
-      dependencies: ['#album.trackArtDate'],
-      compute: (continuation, {
-        ['#album.trackArtDate']: albumTrackArtDate,
-      }) =>
-        (albumTrackArtDate
-          ? continuation.raiseOutput({'#trackArtDate': albumTrackArtDate})
-          : continuation()),
-    },
-
-    withDate().outputs({
-      '#date': '#trackArtDate',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/track/withTrackNumber.js b/src/data/composite/things/track/withTrackNumber.js
deleted file mode 100644
index 61428e8c..00000000
--- a/src/data/composite/things/track/withTrackNumber.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-import {withIndexInList, withPropertiesFromObject} from '#composite/data';
-
-import withContainingTrackSection from './withContainingTrackSection.js';
-
-export default templateCompositeFrom({
-  annotation: `withTrackNumber`,
-
-  outputs: ['#trackNumber'],
-
-  steps: () => [
-    withContainingTrackSection(),
-
-    // Zero is the fallback, not one, but in most albums the first track
-    // (and its intended output by this composition) will be one.
-    raiseOutputWithoutDependency({
-      dependency: '#trackSection',
-      output: input.value({'#trackNumber': 0}),
-    }),
-
-    withPropertiesFromObject({
-      object: '#trackSection',
-      properties: input.value(['tracks', 'startCountingFrom']),
-    }),
-
-    withIndexInList({
-      list: '#trackSection.tracks',
-      item: input.myself(),
-    }),
-
-    raiseOutputWithoutDependency({
-      dependency: '#index',
-      output: input.value({'#trackNumber': 0}),
-    }),
-
-    {
-      dependencies: ['#trackSection.startCountingFrom', '#index'],
-      compute: (continuation, {
-        ['#trackSection.startCountingFrom']: startCountingFrom,
-        ['#index']: index,
-      }) => continuation({
-        ['#trackNumber']:
-          startCountingFrom +
-          index,
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/wiki-data/constituteFrom.js b/src/data/composite/wiki-data/constituteFrom.js
new file mode 100644
index 00000000..b919d5cd
--- /dev/null
+++ b/src/data/composite/wiki-data/constituteFrom.js
@@ -0,0 +1,31 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {inputAvailabilityCheckMode,} from '#composite/control-flow';
+
+import constituteOrContinue from './constituteOrContinue.js';
+
+export default templateCompositeFrom({
+  annotation: `constituteFrom`,
+
+  inputs: {
+    object: input({type: 'object', acceptsNull: true}),
+    property: input({type: 'string', acceptsNull: true}),
+    else: input({defaultValue: null}),
+    mode: inputAvailabilityCheckMode(),
+  },
+
+  compose: false,
+
+  steps: () => [
+    constituteOrContinue({
+      object: input('object'),
+      property: input('property'),
+      mode: input('mode'),
+    }),
+
+    {
+      dependencies: [input('else')],
+      compute: ({[input('else')]: fallback}) => fallback,
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/constituteOrContinue.js b/src/data/composite/wiki-data/constituteOrContinue.js
new file mode 100644
index 00000000..92b941ba
--- /dev/null
+++ b/src/data/composite/wiki-data/constituteOrContinue.js
@@ -0,0 +1,34 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import {
+  exposeDependencyOrContinue,
+  inputAvailabilityCheckMode,
+  raiseOutputWithoutDependency,
+} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `constituteFrom`,
+
+  inputs: {
+    object: input({type: 'object', acceptsNull: true}),
+    property: input({type: 'string', acceptsNull: true}),
+    mode: inputAvailabilityCheckMode(),
+  },
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('property'),
+    }),
+
+    withPropertyFromObject({
+      object: input('object'),
+      property: input('property'),
+    }),
+
+    exposeDependencyOrContinue({
+      dependency: '#value',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/gobbleSoupyFind.js b/src/data/composite/wiki-data/gobbleSoupyFind.js
index aec3f5b1..98d5f5c9 100644
--- a/src/data/composite/wiki-data/gobbleSoupyFind.js
+++ b/src/data/composite/wiki-data/gobbleSoupyFind.js
@@ -30,7 +30,7 @@ export default templateCompositeFrom({
     },
 
     withPropertyFromObject({
-      object: 'find',
+      object: '_find',
       property: '#key',
     }).outputs({
       '#value': '#find',
diff --git a/src/data/composite/wiki-data/gobbleSoupyReverse.js b/src/data/composite/wiki-data/gobbleSoupyReverse.js
index 86a1061c..26052f28 100644
--- a/src/data/composite/wiki-data/gobbleSoupyReverse.js
+++ b/src/data/composite/wiki-data/gobbleSoupyReverse.js
@@ -30,7 +30,7 @@ export default templateCompositeFrom({
     },
 
     withPropertyFromObject({
-      object: 'reverse',
+      object: '_reverse',
       property: '#key',
     }).outputs({
       '#value': '#reverse',
diff --git a/src/data/composite/wiki-data/helpers/withSimpleDirectory.js b/src/data/composite/wiki-data/helpers/withSimpleDirectory.js
index 08ca3bfc..0b225847 100644
--- a/src/data/composite/wiki-data/helpers/withSimpleDirectory.js
+++ b/src/data/composite/wiki-data/helpers/withSimpleDirectory.js
@@ -15,7 +15,7 @@ export default templateCompositeFrom({
   inputs: {
     directory: input({
       validate: isDirectory,
-      defaultDependency: 'directory',
+      defaultDependency: '_directory',
       acceptsNull: true,
     }),
 
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index 005c68c0..41f34d21 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -4,17 +4,21 @@
 // #composite/data.
 //
 
+export {default as constituteFrom} from './constituteFrom.js';
+export {default as constituteOrContinue} from './constituteOrContinue.js';
 export {default as exitWithoutContribs} from './exitWithoutContribs.js';
 export {default as gobbleSoupyFind} from './gobbleSoupyFind.js';
 export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js';
+export {default as inputFindOptions} from './inputFindOptions.js';
 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';
 export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js';
 export {default as withRedatedContributionList} from './withRedatedContributionList.js';
@@ -22,7 +26,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/inputFindOptions.js b/src/data/composite/wiki-data/inputFindOptions.js
new file mode 100644
index 00000000..07ed4bce
--- /dev/null
+++ b/src/data/composite/wiki-data/inputFindOptions.js
@@ -0,0 +1,5 @@
+import {input} from '#composite';
+
+export default function inputFindOptions() {
+  return input({type: 'object', defaultValue: null});
+}
diff --git a/src/data/composite/wiki-data/splitContentNodesAround.js b/src/data/composite/wiki-data/splitContentNodesAround.js
new file mode 100644
index 00000000..f12bd8fc
--- /dev/null
+++ b/src/data/composite/wiki-data/splitContentNodesAround.js
@@ -0,0 +1,100 @@
+import {input, templateCompositeFrom} from '#composite';
+import {splitContentNodesAround} from '#replacer';
+import {anyOf, isFunction, validateInstanceOf} from '#validators';
+
+import {withAvailabilityFilter} from '#composite/control-flow';
+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',
+      map: input.value((_node, index) => index),
+      filter: '#separatorFilter',
+    }),
+
+    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',
+    }),
+
+    withAvailabilityFilter({
+      from: '#contentNodeLists',
+      mode: input.value('empty'),
+    }),
+
+    withFilteredList({
+      list: '#contentNodeLists',
+      filter: '#availabilityFilter',
+    }).outputs({
+      '#filteredList': '#contentNodeLists',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js
index 9e260abf..28d719e2 100644
--- a/src/data/composite/wiki-data/withConstitutedArtwork.js
+++ b/src/data/composite/wiki-data/withConstitutedArtwork.js
@@ -1,11 +1,11 @@
 import {input, templateCompositeFrom} from '#composite';
 import thingConstructors from '#things';
-import {isContributionList} from '#validators';
 
 export default templateCompositeFrom({
   annotation: `withConstitutedArtwork`,
 
   inputs: {
+    thingProperty: input({type: 'string', acceptsNull: true}),
     dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
     fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
     dateFromThingProperty: input({type: 'string', acceptsNull: true}),
@@ -21,6 +21,7 @@ export default templateCompositeFrom({
     {
       dependencies: [
         input.myself(),
+        input('thingProperty'),
         input('dimensionsFromThingProperty'),
         input('fileExtensionFromThingProperty'),
         input('dateFromThingProperty'),
@@ -32,6 +33,7 @@ export default templateCompositeFrom({
 
       compute: (continuation, {
         [input.myself()]: myself,
+        [input('thingProperty')]: thingProperty,
         [input('dimensionsFromThingProperty')]: dimensionsFromThingProperty,
         [input('fileExtensionFromThingProperty')]: fileExtensionFromThingProperty,
         [input('dateFromThingProperty')]: dateFromThingProperty,
@@ -43,6 +45,7 @@ export default templateCompositeFrom({
         ['#constitutedArtwork']:
           Object.assign(new thingConstructors.Artwork, {
             thing: myself,
+            thingProperty,
             dimensionsFromThingProperty,
             fileExtensionFromThingProperty,
             artistContribsFromThingProperty,
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/withCoverArtDate.js b/src/data/composite/wiki-data/withCoverArtDate.js
deleted file mode 100644
index a114d5ff..00000000
--- a/src/data/composite/wiki-data/withCoverArtDate.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {isDate} from '#validators';
-
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-
-import withResolvedContribs from './withResolvedContribs.js';
-
-export default templateCompositeFrom({
-  annotation: `withCoverArtDate`,
-
-  inputs: {
-    from: input({
-      validate: isDate,
-      defaultDependency: 'coverArtDate',
-      acceptsNull: true,
-    }),
-  },
-
-  outputs: ['#coverArtDate'],
-
-  steps: () => [
-    withResolvedContribs({
-      from: 'coverArtistContribs',
-      date: input.value(null),
-    }),
-
-    raiseOutputWithoutDependency({
-      dependency: '#resolvedContribs',
-      mode: input.value('empty'),
-      output: input.value({'#coverArtDate': null}),
-    }),
-
-    {
-      dependencies: [input('from')],
-      compute: (continuation, {
-        [input('from')]: from,
-      }) =>
-        (from
-          ? continuation.raiseOutput({'#coverArtDate': from})
-          : continuation()),
-    },
-
-    {
-      dependencies: ['date'],
-      compute: (continuation, {date}) =>
-        (date
-          ? continuation({'#coverArtDate': date})
-          : continuation({'#coverArtDate': null})),
-    },
-  ],
-});
diff --git a/src/data/composite/wiki-data/withDirectory.js b/src/data/composite/wiki-data/withDirectory.js
index f3bedf2e..e7c3960e 100644
--- a/src/data/composite/wiki-data/withDirectory.js
+++ b/src/data/composite/wiki-data/withDirectory.js
@@ -17,13 +17,13 @@ export default templateCompositeFrom({
   inputs: {
     directory: input({
       validate: isDirectory,
-      defaultDependency: 'directory',
+      defaultDependency: '_directory',
       acceptsNull: true,
     }),
 
     name: input({
       validate: isName,
-      defaultDependency: 'name',
+      defaultDependency: '_name',
       acceptsNull: true,
     }),
 
diff --git a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
index 9cc52f29..71bc56ac 100644
--- a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
+++ b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
@@ -7,6 +7,7 @@ import {withPropertyFromList} from '#composite/data';
 import {raiseOutputWithoutDependency, withAvailabilityFilter}
   from '#composite/control-flow';
 
+import inputFindOptions from './inputFindOptions.js';
 import inputSoupyFind from './inputSoupyFind.js';
 import inputNotFoundMode from './inputNotFoundMode.js';
 import inputWikiData from './inputWikiData.js';
@@ -22,13 +23,14 @@ export default templateCompositeFrom({
       acceptsNull: true,
     }),
 
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
+    findOptions: inputFindOptions(),
+
     reference: input({type: 'string', defaultValue: 'reference'}),
     annotation: input({type: 'string', defaultValue: 'annotation'}),
     thing: input({type: 'string', defaultValue: 'thing'}),
 
-    data: inputWikiData({allowMixedTypes: true}),
-    find: inputSoupyFind(),
-
     notFoundMode: inputNotFoundMode(),
   },
 
@@ -61,6 +63,7 @@ export default templateCompositeFrom({
       list: '#references',
       data: input('data'),
       find: input('find'),
+      findOptions: input('findOptions'),
       notFoundMode: input.value('null'),
     }),
 
diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js
index 838c991f..7729d5b6 100644
--- a/src/data/composite/wiki-data/withResolvedContribs.js
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -28,6 +28,7 @@ export default templateCompositeFrom({
     date: input({
       validate: isDate,
       acceptsNull: true,
+      defaultDependency: 'date',
     }),
 
     notFoundMode: inputNotFoundMode(),
@@ -110,7 +111,7 @@ export default templateCompositeFrom({
         '#thingProperty',
         input('artistProperty'),
         input.myself(),
-        'find',
+        '_find',
       ],
 
       compute: (continuation, {
@@ -118,7 +119,7 @@ export default templateCompositeFrom({
         ['#thingProperty']: thingProperty,
         [input('artistProperty')]: artistProperty,
         [input.myself()]: myself,
-        ['find']: find,
+        ['_find']: find,
       }) => continuation({
         ['#contributions']:
           details.map(details => {
diff --git a/src/data/composite/wiki-data/withResolvedReference.js b/src/data/composite/wiki-data/withResolvedReference.js
index 6f422194..d9a05367 100644
--- a/src/data/composite/wiki-data/withResolvedReference.js
+++ b/src/data/composite/wiki-data/withResolvedReference.js
@@ -8,6 +8,7 @@ import {input, templateCompositeFrom} from '#composite';
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
 
 import gobbleSoupyFind from './gobbleSoupyFind.js';
+import inputFindOptions from './inputFindOptions.js';
 import inputSoupyFind from './inputSoupyFind.js';
 import inputWikiData from './inputWikiData.js';
 
@@ -17,8 +18,9 @@ export default templateCompositeFrom({
   inputs: {
     ref: input({type: 'string', acceptsNull: true}),
 
-    data: inputWikiData({allowMixedTypes: false}),
+    data: inputWikiData({allowMixedTypes: true}),
     find: inputSoupyFind(),
+    findOptions: inputFindOptions(),
   },
 
   outputs: ['#resolvedReference'],
@@ -36,21 +38,35 @@ export default templateCompositeFrom({
     }),
 
     {
+      dependencies: [input('findOptions')],
+      compute: (continuation, {
+        [input('findOptions')]: findOptions,
+      }) => continuation({
+        ['#findOptions']:
+          (findOptions
+            ? {...findOptions, mode: 'quiet'}
+            : {mode: 'quiet'}),
+      }),
+    },
+
+    {
       dependencies: [
         input('ref'),
         input('data'),
         '#find',
+        '#findOptions',
       ],
 
       compute: (continuation, {
         [input('ref')]: ref,
         [input('data')]: data,
         ['#find']: findFunction,
+        ['#findOptions']: findOptions,
       }) => continuation({
         ['#resolvedReference']:
           (data
-            ? findFunction(ref, data, {mode: 'quiet'}) ?? null
-            : findFunction(ref, {mode: 'quiet'}) ?? null),
+            ? findFunction(ref, data, findOptions) ?? null
+            : findFunction(ref, findOptions) ?? null),
       }),
     },
   ],
diff --git a/src/data/composite/wiki-data/withResolvedReferenceList.js b/src/data/composite/wiki-data/withResolvedReferenceList.js
index 9dc960dd..14ce6919 100644
--- a/src/data/composite/wiki-data/withResolvedReferenceList.js
+++ b/src/data/composite/wiki-data/withResolvedReferenceList.js
@@ -11,6 +11,7 @@ import {raiseOutputWithoutDependency, withAvailabilityFilter}
 import {withMappedList} from '#composite/data';
 
 import gobbleSoupyFind from './gobbleSoupyFind.js';
+import inputFindOptions from './inputFindOptions.js';
 import inputNotFoundMode from './inputNotFoundMode.js';
 import inputSoupyFind from './inputSoupyFind.js';
 import inputWikiData from './inputWikiData.js';
@@ -27,6 +28,7 @@ export default templateCompositeFrom({
 
     data: inputWikiData({allowMixedTypes: true}),
     find: inputSoupyFind(),
+    findOptions: inputFindOptions(),
 
     notFoundMode: inputNotFoundMode(),
   },
@@ -47,15 +49,28 @@ export default templateCompositeFrom({
     }),
 
     {
-      dependencies: [input('data'), '#find'],
+      dependencies: [input('findOptions')],
+      compute: (continuation, {
+        [input('findOptions')]: findOptions,
+      }) => continuation({
+        ['#findOptions']:
+          (findOptions
+            ? {...findOptions, mode: 'quiet'}
+            : {mode: 'quiet'}),
+      }),
+    },
+
+    {
+      dependencies: [input('data'), '#find', '#findOptions'],
       compute: (continuation, {
         [input('data')]: data,
         ['#find']: findFunction,
+        ['#findOptions']: findOptions,
       }) => continuation({
         ['#map']:
           (data
-            ? ref => findFunction(ref, data, {mode: 'quiet'})
-            : ref => findFunction(ref, {mode: 'quiet'})),
+            ? ref => findFunction(ref, data, findOptions)
+            : ref => findFunction(ref, findOptions)),
       }),
     },
 
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/annotatedReferenceList.js b/src/data/composite/wiki-properties/annotatedReferenceList.js
index 8e6c96a1..918f8567 100644
--- a/src/data/composite/wiki-properties/annotatedReferenceList.js
+++ b/src/data/composite/wiki-properties/annotatedReferenceList.js
@@ -9,8 +9,13 @@ import {
 } from '#validators';
 
 import {exposeDependency} from '#composite/control-flow';
-import {inputSoupyFind, inputWikiData, withResolvedAnnotatedReferenceList}
-  from '#composite/wiki-data';
+
+import {
+  inputFindOptions,
+  inputSoupyFind,
+  inputWikiData,
+  withResolvedAnnotatedReferenceList,
+} from '#composite/wiki-data';
 
 import {referenceListInputDescriptions, referenceListUpdateDescription}
   from './helpers/reference-list-helpers.js';
@@ -25,6 +30,7 @@ export default templateCompositeFrom({
 
     data: inputWikiData({allowMixedTypes: true}),
     find: inputSoupyFind(),
+    findOptions: inputFindOptions(),
 
     reference: input.staticValue({type: 'string', defaultValue: 'reference'}),
     annotation: input.staticValue({type: 'string', defaultValue: 'annotation'}),
@@ -51,12 +57,13 @@ export default templateCompositeFrom({
     withResolvedAnnotatedReferenceList({
       list: input.updateValue(),
 
+      data: input('data'),
+      find: input('find'),
+      findOptions: input('findOptions'),
+
       reference: input('reference'),
       annotation: input('annotation'),
       thing: input('thing'),
-
-      data: input('data'),
-      find: input('find'),
     }),
 
     exposeDependency({dependency: '#resolvedAnnotatedReferenceList'}),
diff --git a/src/data/composite/wiki-properties/canonicalBase.js b/src/data/composite/wiki-properties/canonicalBase.js
new file mode 100644
index 00000000..81740d6c
--- /dev/null
+++ b/src/data/composite/wiki-properties/canonicalBase.js
@@ -0,0 +1,16 @@
+import {isURL} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isURL},
+    expose: {
+      transform: (value) =>
+        (value === null
+          ? null
+       : value.endsWith('/')
+          ? value
+          : value + '/'),
+    },
+  };
+}
diff --git a/src/data/composite/wiki-properties/color.js b/src/data/composite/wiki-properties/color.js
index 1bc9888b..e7fe472a 100644
--- a/src/data/composite/wiki-properties/color.js
+++ b/src/data/composite/wiki-properties/color.js
@@ -1,12 +1,26 @@
 // A color! This'll be some CSS-ready value.
 
+import {input, templateCompositeFrom} from '#composite';
 import {isColor} from '#validators';
 
-// TODO: Not templateCompositeFrom.
+export default templateCompositeFrom({
+  annotation: 'color',
 
-export default function() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isColor},
-  };
-}
+  compose: false,
+
+  inputs: {
+    default: input({validate: isColor, defaultValue: null}),
+  },
+
+  update: {
+    validate: isColor,
+  },
+
+  steps: () => [
+    {
+      dependencies: [input('default')],
+      transform: (value, {[input('default')]: defaultValue}) =>
+        value ?? defaultValue,
+    },
+  ],
+});
\ No newline at end of file
diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js
index 54d3e1a5..44dee028 100644
--- a/src/data/composite/wiki-properties/commentatorArtists.js
+++ b/src/data/composite/wiki-properties/commentatorArtists.js
@@ -14,10 +14,9 @@ export default templateCompositeFrom({
   compose: false,
 
   steps: () => [
-    exitWithoutDependency({
-      dependency: 'commentary',
-      mode: input.value('falsy'),
+    exitWithoutDependency('commentary', {
       value: input.value([]),
+      mode: input.value('falsy'),
     }),
 
     withPropertyFromList({
diff --git a/src/data/composite/wiki-properties/constitutibleArtwork.js b/src/data/composite/wiki-properties/constitutibleArtwork.js
index 0ee3bfcd..48f4211a 100644
--- a/src/data/composite/wiki-properties/constitutibleArtwork.js
+++ b/src/data/composite/wiki-properties/constitutibleArtwork.js
@@ -17,6 +17,7 @@ const template = templateCompositeFrom({
   compose: false,
 
   inputs: {
+    thingProperty: input({type: 'string', acceptsNull: true}),
     dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
     fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
     dateFromThingProperty: input({type: 'string', acceptsNull: true}),
@@ -35,6 +36,7 @@ const template = templateCompositeFrom({
     }),
 
     withConstitutedArtwork({
+      thingProperty: input('thingProperty'),
       dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
       fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
       dateFromThingProperty: input('dateFromThingProperty'),
diff --git a/src/data/composite/wiki-properties/constitutibleArtworkList.js b/src/data/composite/wiki-properties/constitutibleArtworkList.js
index 246c08b5..dad3a957 100644
--- a/src/data/composite/wiki-properties/constitutibleArtworkList.js
+++ b/src/data/composite/wiki-properties/constitutibleArtworkList.js
@@ -16,6 +16,7 @@ const template = templateCompositeFrom({
   compose: false,
 
   inputs: {
+    thingProperty: input({type: 'string', acceptsNull: true}),
     dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
     fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
     dateFromThingProperty: input({type: 'string', acceptsNull: true}),
@@ -34,6 +35,7 @@ const template = templateCompositeFrom({
     }),
 
     withConstitutedArtwork({
+      thingProperty: input('thingProperty'),
       dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
       fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
       dateFromThingProperty: input('dateFromThingProperty'),
diff --git a/src/data/composite/wiki-properties/contribsPresent.js b/src/data/composite/wiki-properties/contribsPresent.js
deleted file mode 100644
index 24f302a5..00000000
--- a/src/data/composite/wiki-properties/contribsPresent.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// Nice 'n simple shorthand for an exposed-only flag which is true when any
-// contributions are present in the specified property.
-
-import {input, templateCompositeFrom} from '#composite';
-import {isContributionList} from '#validators';
-
-import {exposeDependency, withResultOfAvailabilityCheck}
-  from '#composite/control-flow';
-
-export default templateCompositeFrom({
-  annotation: `contribsPresent`,
-
-  compose: false,
-
-  inputs: {
-    contribs: input.staticDependency({
-      validate: isContributionList,
-      acceptsNull: true,
-    }),
-  },
-
-  steps: () => [
-    withResultOfAvailabilityCheck({
-      from: input('contribs'),
-      mode: input.value('empty'),
-    }),
-
-    exposeDependency({dependency: '#availability'}),
-  ],
-});
diff --git a/src/data/composite/wiki-properties/contributionList.js b/src/data/composite/wiki-properties/contributionList.js
index d9a6b417..04f93b52 100644
--- a/src/data/composite/wiki-properties/contributionList.js
+++ b/src/data/composite/wiki-properties/contributionList.js
@@ -29,6 +29,7 @@ export default templateCompositeFrom({
     date: input({
       validate: isDate,
       acceptsNull: true,
+      defaultDependency: 'date',
     }),
 
     artistProperty: input({
@@ -42,9 +43,9 @@ export default templateCompositeFrom({
   steps: () => [
     withResolvedContribs({
       from: input.updateValue(),
+      date: input('date'),
       thingProperty: input.thisProperty(),
       artistProperty: input('artistProperty'),
-      date: input('date'),
     }),
 
     exposeDependencyOrContinue({
diff --git a/src/data/composite/wiki-properties/fileExtension.js b/src/data/composite/wiki-properties/fileExtension.js
index c926fa8b..fa933f56 100644
--- a/src/data/composite/wiki-properties/fileExtension.js
+++ b/src/data/composite/wiki-properties/fileExtension.js
@@ -1,13 +1,26 @@
 // A file extension! Or the default, if provided when calling this.
 
+import {input, templateCompositeFrom} from '#composite';
 import {isFileExtension} from '#validators';
 
-// TODO: Not templateCompositeFrom.
+export default templateCompositeFrom({
+  annotation: 'name',
 
-export default function(defaultFileExtension = null) {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isFileExtension},
-    expose: {transform: (value) => value ?? defaultFileExtension},
-  };
-}
+  compose: false,
+
+  inputs: {
+    default: input({validate: isFileExtension, acceptsNull: true}),
+  },
+
+  update: {
+    validate: isFileExtension,
+  },
+
+  steps: () => [
+    {
+      dependencies: [input('default')],
+      transform: (value, {[input('default')]: defaultValue}) =>
+        value ?? defaultValue,
+    },
+  ],
+});
\ No newline at end of file
diff --git a/src/data/composite/wiki-properties/flag.js b/src/data/composite/wiki-properties/flag.js
index 076e663f..fa787f92 100644
--- a/src/data/composite/wiki-properties/flag.js
+++ b/src/data/composite/wiki-properties/flag.js
@@ -1,19 +1,27 @@
 // Straightforward flag descriptor for a variety of property purposes.
 // Provide a default value, true or false!
 
+import {input, templateCompositeFrom} from '#composite';
 import {isBoolean} from '#validators';
 
-// TODO: Not templateCompositeFrom.
+export default templateCompositeFrom({
+  annotation: 'flag',
 
-// TODO: The description is a lie. This defaults to false. Bad.
+  compose: false,
 
-export default function(defaultValue = false) {
-  if (typeof defaultValue !== 'boolean') {
-    throw new TypeError(`Always set explicit defaults for flags!`);
-  }
+  inputs: {
+    default: input({type: 'boolean'}),
+  },
 
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isBoolean, default: defaultValue},
-  };
-}
+  update: {
+    validate: isBoolean,
+  },
+
+  steps: () => [
+    {
+      dependencies: [input('default')],
+      transform: (value, {[input('default')]: defaultValue}) =>
+        value ?? defaultValue,
+    },
+  ],
+});
\ No newline at end of file
diff --git a/src/data/composite/wiki-properties/hasArtwork.js b/src/data/composite/wiki-properties/hasArtwork.js
new file mode 100644
index 00000000..e403a7e2
--- /dev/null
+++ b/src/data/composite/wiki-properties/hasArtwork.js
@@ -0,0 +1,90 @@
+import {input, templateCompositeFrom, V} from '#composite';
+import {isContributionList, isThing, strictArrayOf} from '#validators';
+
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/data';
+
+import {
+  exitWithoutDependency,
+  exposeWhetherDependencyAvailable,
+  withResultOfAvailabilityCheck,
+} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: 'hasArtwork',
+
+  inputs: {
+    contribs: input({
+      validate: isContributionList,
+      defaultValue: null,
+    }),
+
+    artwork: input({
+      validate: isThing,
+      defaultValue: null,
+    }),
+
+    artworks: input({
+      validate: strictArrayOf(isThing),
+      defaultValue: null,
+    }),
+  },
+
+  compose: false,
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('contribs'),
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability'],
+      compute: (continuation, {
+        ['#availability']: availability,
+      }) =>
+        (availability
+          ? true
+          : continuation()),
+    },
+
+    {
+      dependencies: [input('artwork'), input('artworks')],
+      compute: (continuation, {
+        [input('artwork')]: artwork,
+        [input('artworks')]: artworks,
+      }) =>
+        continuation({
+          ['#artworks']:
+            (artwork && artworks
+              ? [artwork, ...artworks]
+           : artwork
+              ? [artwork]
+           : artworks
+              ? artworks
+              : []),
+        }),
+    },
+
+    exitWithoutDependency('#artworks', {
+      value: input.value(false),
+      mode: input.value('empty'),
+    }),
+
+    withPropertyFromList('#artworks', {
+      property: input.value('artistContribs'),
+      internal: input.value(true),
+    }),
+
+    // Since we're getting the update value for each artwork's artistContribs,
+    // it may not be set at all, and in that case won't be exposing as [].
+    fillMissingListItems('#artworks.artistContribs', V([])),
+
+    withFlattenedList('#artworks.artistContribs'),
+
+    exposeWhetherDependencyAvailable({
+      dependency: '#flattenedList',
+      mode: input.value('empty'),
+    }),
+  ],
+});
\ No newline at end of file
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index d5e7657e..9ef7ccc4 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -3,15 +3,13 @@
 // 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 canonicalBase} from './canonicalBase.js';
 export {default as color} from './color.js';
 export {default as commentatorArtists} from './commentatorArtists.js';
 export {default as constitutibleArtwork} from './constitutibleArtwork.js';
 export {default as constitutibleArtworkList} from './constitutibleArtworkList.js';
 export {default as contentString} from './contentString.js';
-export {default as contribsPresent} from './contribsPresent.js';
 export {default as contributionList} from './contributionList.js';
 export {default as dimensions} from './dimensions.js';
 export {default as directory} from './directory.js';
@@ -19,11 +17,11 @@ export {default as duration} from './duration.js';
 export {default as externalFunction} from './externalFunction.js';
 export {default as fileExtension} from './fileExtension.js';
 export {default as flag} from './flag.js';
+export {default as hasArtwork} from './hasArtwork.js';
 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/name.js b/src/data/composite/wiki-properties/name.js
index 5146488b..e4a28860 100644
--- a/src/data/composite/wiki-properties/name.js
+++ b/src/data/composite/wiki-properties/name.js
@@ -1,11 +1,27 @@
 // A wiki data object's name! Its directory (i.e. unique identifier) will be
 // computed based on this value if not otherwise specified.
 
+import {input, templateCompositeFrom} from '#composite';
 import {isName} from '#validators';
 
-export default function(defaultName) {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isName, default: defaultName},
-  };
-}
+export default templateCompositeFrom({
+  annotation: 'name',
+
+  compose: false,
+
+  inputs: {
+    default: input({type: 'string'}),
+  },
+
+  update: {
+    validate: isName,
+  },
+
+  steps: () => [
+    {
+      dependencies: [input('default')],
+      transform: (value, {[input('default')]: defaultValue}) =>
+        value ?? defaultValue,
+    },
+  ],
+});
\ No newline at end of file
diff --git a/src/data/composite/wiki-properties/referenceList.js b/src/data/composite/wiki-properties/referenceList.js
index 4f8207b5..663349ee 100644
--- a/src/data/composite/wiki-properties/referenceList.js
+++ b/src/data/composite/wiki-properties/referenceList.js
@@ -11,8 +11,13 @@ import {input, templateCompositeFrom} from '#composite';
 import {validateReferenceList} from '#validators';
 
 import {exposeDependency} from '#composite/control-flow';
-import {inputSoupyFind, inputWikiData, withResolvedReferenceList}
-  from '#composite/wiki-data';
+
+import {
+  inputFindOptions,
+  inputSoupyFind,
+  inputWikiData,
+  withResolvedReferenceList,
+} from '#composite/wiki-data';
 
 import {referenceListInputDescriptions, referenceListUpdateDescription}
   from './helpers/reference-list-helpers.js';
@@ -27,6 +32,7 @@ export default templateCompositeFrom({
 
     data: inputWikiData({allowMixedTypes: true}),
     find: inputSoupyFind(),
+    findOptions: inputFindOptions(),
   },
 
   update:
@@ -39,6 +45,7 @@ export default templateCompositeFrom({
       list: input.updateValue(),
       data: input('data'),
       find: input('find'),
+      findOptions: input('findOptions'),
     }),
 
     exposeDependency({dependency: '#resolvedReferenceList'}),
diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js
index 9ba2e393..278f063d 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';
 
@@ -23,7 +22,7 @@ export default templateCompositeFrom({
     annotatedReferenceList({
       referenceType: input.value(['album', 'track']),
 
-      data: 'artworkData',
+      data: '_artworkData',
       find: '#find',
 
       thing: input.value('artwork'),
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/composite/wiki-properties/singleReference.js b/src/data/composite/wiki-properties/singleReference.js
index f532ebbe..25b97907 100644
--- a/src/data/composite/wiki-properties/singleReference.js
+++ b/src/data/composite/wiki-properties/singleReference.js
@@ -8,11 +8,19 @@
 //
 
 import {input, templateCompositeFrom} from '#composite';
-import {isThingClass, validateReference} from '#validators';
+import {validateReference} from '#validators';
 
 import {exposeDependency} from '#composite/control-flow';
-import {inputSoupyFind, inputWikiData, withResolvedReference}
-  from '#composite/wiki-data';
+
+import {
+  inputFindOptions,
+  inputSoupyFind,
+  inputWikiData,
+  withResolvedReference,
+} from '#composite/wiki-data';
+
+import {referenceListInputDescriptions, referenceListUpdateDescription}
+  from './helpers/reference-list-helpers.js';
 
 export default templateCompositeFrom({
   annotation: `singleReference`,
@@ -20,25 +28,24 @@ export default templateCompositeFrom({
   compose: false,
 
   inputs: {
-    class: input.staticValue({validate: isThingClass}),
+    ...referenceListInputDescriptions(),
 
+    data: inputWikiData({allowMixedTypes: true}),
     find: inputSoupyFind(),
-    data: inputWikiData({allowMixedTypes: false}),
+    findOptions: inputFindOptions(),
   },
 
-  update: ({
-    [input.staticValue('class')]: thingClass,
-  }) => ({
-    validate:
-      validateReference(
-        thingClass[Symbol.for('Thing.referenceType')]),
-  }),
+  update:
+    referenceListUpdateDescription({
+      validateReferenceList: validateReference,
+    }),
 
   steps: () => [
     withResolvedReference({
       ref: input.updateValue(),
       data: input('data'),
       find: input('find'),
+      findOptions: input('findOptions'),
     }),
 
     exposeDependency({dependency: '#resolvedReference'}),
diff --git a/src/data/language.js b/src/data/language.js
index 3edf7e51..e97267c0 100644
--- a/src/data/language.js
+++ b/src/data/language.js
@@ -4,12 +4,10 @@ import path from 'node:path';
 import {fileURLToPath} from 'node:url';
 
 import chokidar from 'chokidar';
-import he from 'he'; // It stands for "HTML Entities", apparently. Cursed.
 import yaml from 'js-yaml';
 
 import {annotateError, annotateErrorWithFile, showAggregate, withAggregate}
   from '#aggregate';
-import {externalLinkSpec} from '#external-links';
 import {colors, logWarn} from '#cli';
 import {empty, splitKeys, withEntries} from '#sugar';
 import T from '#things';
@@ -248,19 +246,8 @@ async function processLanguageSpecFromFile(file, processLanguageSpecOpts) {
   }
 }
 
-export function initializeLanguageObject() {
-  const language = new Language();
-
-  language.escapeHTML = string =>
-    he.encode(string, {useNamedReferences: true});
-
-  language.externalLinkSpec = externalLinkSpec;
-
-  return language;
-}
-
 export async function processLanguageFile(file) {
-  const language = initializeLanguageObject();
+  const language = new Language()
   const properties = await processLanguageSpecFromFile(file);
   return Object.assign(language, properties);
 }
@@ -271,7 +258,7 @@ export function watchLanguageFile(file, {
   const basename = path.basename(file);
 
   const events = new EventEmitter();
-  const language = initializeLanguageObject();
+  const language = new Language();
 
   let emittedReady = false;
   let successfullyAppliedLanguage = false;
diff --git a/src/data/thing.js b/src/data/thing.js
index 66f73de5..32eff4d1 100644
--- a/src/data/thing.js
+++ b/src/data/thing.js
@@ -10,6 +10,10 @@ export default class Thing extends CacheableObject {
   static referenceType = Symbol.for('Thing.referenceType');
   static friendlyName = Symbol.for('Thing.friendlyName');
 
+  static wikiData = Symbol.for('Thing.wikiData');
+  static oneInstancePerWiki = Symbol.for('Thing.oneThingPerWiki');
+  static constitutibleProperties = Symbol.for('Thing.constitutibleProperties');
+
   static getPropertyDescriptors = Symbol.for('Thing.getPropertyDescriptors');
   static getSerializeDescriptors = Symbol.for('Thing.getSerializeDescriptors');
 
@@ -60,7 +64,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 +73,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`);
     }
 
@@ -84,7 +88,13 @@ export default class Thing extends CacheableObject {
     }
 
     if (!thing.directory) {
-      throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
+      if (thing.name) {
+        throw TypeError(
+          `Passed ${thing.constructor.name} (named ${inspect(thing.name)}) ` +
+          `is missing its directory`);
+      } else {
+        throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
+      }
     }
 
     return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js
new file mode 100644
index 00000000..b15f62e0
--- /dev/null
+++ b/src/data/things/additional-file.js
@@ -0,0 +1,54 @@
+import {input} from '#composite';
+import Thing from '#thing';
+import {isString, validateArrayItems} from '#validators';
+
+import {exposeConstant, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {contentString, simpleString, thing} from '#composite/wiki-properties';
+
+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([]),
+      }),
+    ],
+
+    // Expose only
+
+    isAdditionalFile: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+  });
+
+  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..99f3ee46
--- /dev/null
+++ b/src/data/things/additional-name.js
@@ -0,0 +1,31 @@
+import {input} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+import {contentString, thing} from '#composite/wiki-properties';
+
+export class AdditionalName extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: thing(),
+
+    name: contentString(),
+    annotation: contentString(),
+
+    // Expose only
+
+    isAdditionalName: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+  });
+
+  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 8a25a8ac..31d94ef1 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -3,14 +3,22 @@ export const DATA_ALBUM_DIRECTORY = 'album';
 import * as path from 'node:path';
 import {inspect} from 'node:util';
 
-import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
-import {input} from '#composite';
+import {input, V} 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';
+
+import {
+  is,
+  isBoolean,
+  isColor,
+  isContributionList,
+  isDate,
+  isDirectory,
+  isNumber,
+} from '#validators';
 
 import {
   parseAdditionalFiles,
@@ -25,31 +33,40 @@ import {
   parseWallpaperParts,
 } from '#yaml';
 
-import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-import {exitWithoutContribs, withDirectory, withCoverArtDate}
+import {withRecontextualizedContributionList, withResolvedContribs}
   from '#composite/wiki-data';
 
 import {
-  additionalFiles,
-  additionalNameList,
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  withFlattenedList,
+  withLengthOfList,
+  withNearbyItemFromList,
+  withPropertyFromList,
+  withPropertyFromObject,
+} from '#composite/data';
+
+import {
   color,
   commentatorArtists,
   constitutibleArtwork,
   constitutibleArtworkList,
   contentString,
-  contribsPresent,
   contributionList,
   dimensions,
   directory,
   fileExtension,
   flag,
+  hasArtwork,
   name,
   referencedArtworkList,
   referenceList,
-  reverseReferenceList,
   simpleDate,
   simpleString,
   soupyFind,
@@ -61,26 +78,34 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withHasCoverArt, withTracks} from '#composite/things/album';
-import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
-  from '#composite/things/track-section';
-
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
+  static [Thing.wikiData] = 'albumData';
+
+  static [Thing.constitutibleProperties] = [
+    'coverArtworks',
+    'wallpaperArtwork',
+    'bannerArtwork',
+  ];
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalFile,
+    AdditionalName,
     ArtTag,
     Artwork,
     CommentaryEntry,
     CreditingSourcesEntry,
     Group,
-    Track,
     TrackSection,
     WikiInfo,
   }) => ({
-    // Update & expose
+    // > Update & expose - Internal relationships
+
+    trackSections: thingList(V(TrackSection)),
 
-    name: name('Unnamed Album'),
+    // > Update & expose - Identifying metadata
+
+    name: name(V('Unnamed Album')),
     directory: directory(),
 
     directorySuffix: [
@@ -88,152 +113,143 @@ export class Album extends Thing {
         validate: input.value(isDirectory),
       }),
 
-      withDirectory(),
-
-      exposeDependency({
-        dependency: '#directory',
-      }),
+      exposeDependency('directory'),
     ],
 
-    alwaysReferenceByDirectory: flag(false),
-    alwaysReferenceTracksByDirectory: flag(false),
-    suffixTrackDirectories: flag(false),
+    alwaysReferenceByDirectory: flag(V(false)),
+    alwaysReferenceTracksByDirectory: flag(V(false)),
+    suffixTrackDirectories: flag(V(false)),
 
-    color: color(),
-    urls: urls(),
+    style: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(is(...[
+          'album',
+          'single',
+        ])),
+      }),
 
-    additionalNames: additionalNameList(),
+      exposeConstant(V('album')),
+    ],
 
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
 
+    additionalNames: thingList(V(AdditionalName)),
+
     date: simpleDate(),
-    trackArtDate: simpleDate(),
     dateAddedToWiki: simpleDate(),
 
-    coverArtDate: [
-      withCoverArtDate({
-        from: input.updateValue({
-          validate: isDate,
-        }),
+    // > Update & expose - Credits and contributors
+
+    artistContribs: contributionList({
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    trackArtistText: contentString(),
+
+    trackArtistContribs: [
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('albumTrackArtistContributions'),
+      }).outputs({
+        '#resolvedContribs': '#trackArtistContribs',
       }),
 
-      exposeDependency({dependency: '#coverArtDate'}),
-    ],
+      exposeDependencyOrContinue('#trackArtistContribs', V('empty')),
 
-    coverArtFileExtension: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      fileExtension('jpg'),
+      withRecontextualizedContributionList('artistContribs', {
+        artistProperty: input.value('albumTrackArtistContributions'),
+      }),
+
+      exposeDependency('#artistContribs'),
     ],
 
-    trackCoverArtFileExtension: fileExtension('jpg'),
+    // > Update & expose - General configuration
 
-    wallpaperFileExtension: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      fileExtension('jpg'),
-    ],
+    countTracksInArtistTotals: flag(V(true)),
 
-    bannerFileExtension: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      fileExtension('jpg'),
-    ],
+    showAlbumInTracksWithoutArtists: flag(V(false)),
 
-    wallpaperStyle: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      simpleString(),
-    ],
+    hasTrackNumbers: flag(V(true)),
+    isListedOnHomepage: flag(V(true)),
+    isListedInGalleries: flag(V(true)),
 
-    wallpaperParts: [
-      exitWithoutContribs({
-        contribs: 'wallpaperArtistContribs',
+    hideDuration: flag(V(false)),
+
+    // > Update & expose - General metadata
+
+    color: color(),
+
+    urls: urls(),
+
+    // > Update & expose - Artworks
+
+    coverArtworks: [
+      exitWithoutDependency('hasCoverArt', {
         value: input.value([]),
+        mode: input.value('falsy'),
       }),
 
-      wallpaperParts(),
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
     ],
 
-    bannerStyle: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      simpleString(),
-    ],
+    coverArtistContribs: contributionList({
+      date: 'coverArtDate',
+      artistProperty: input.value('albumCoverArtistContributions'),
+    }),
 
-    coverArtDimensions: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      dimensions(),
-    ],
+    coverArtDate: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
 
-    trackDimensions: dimensions(),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
 
-    bannerDimensions: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      dimensions(),
+      exposeDependency('date'),
     ],
 
-    wallpaperArtwork: [
-      exitWithoutDependency({
-        dependency: 'wallpaperArtistContribs',
-        mode: input.value('empty'),
+    coverArtFileExtension: [
+      exitWithoutDependency('hasCoverArt', {
         value: input.value(null),
+        mode: input.value('falsy'),
       }),
 
-      constitutibleArtwork.fromYAMLFieldSpec
-        .call(this, 'Wallpaper Artwork'),
+      fileExtension(V('jpg')),
     ],
 
-    bannerArtwork: [
-      exitWithoutDependency({
-        dependency: 'bannerArtistContribs',
-        mode: input.value('empty'),
+    coverArtDimensions: [
+      exitWithoutDependency('hasCoverArt', {
         value: input.value(null),
+        mode: input.value('falsy'),
       }),
 
-      constitutibleArtwork.fromYAMLFieldSpec
-        .call(this, 'Banner Artwork'),
+      dimensions(),
     ],
 
-    coverArtworks: [
-      withHasCoverArt(),
-
-      exitWithoutDependency({
-        dependency: '#hasCoverArt',
-        mode: input.value('falsy'),
+    artTags: [
+      exitWithoutDependency('hasCoverArt', {
         value: input.value([]),
+        mode: input.value('falsy'),
       }),
 
-      constitutibleArtworkList.fromYAMLFieldSpec
-        .call(this, 'Cover Artwork'),
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
     ],
 
-    hasTrackNumbers: flag(true),
-    isListedOnHomepage: flag(true),
-    isListedInGalleries: flag(true),
-
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
-
-    creditSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
-
-    additionalFiles: additionalFiles(),
-
-    trackSections: thingList({
-      class: input.value(TrackSection),
-    }),
-
-    artistContribs: contributionList({
-      date: 'date',
-      artistProperty: input.value('albumArtistContributions'),
-    }),
-
-    coverArtistContribs: [
-      withCoverArtDate(),
-
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumCoverArtistContributions'),
+    referencedArtworks: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
       }),
+
+      referencedArtworkList(),
     ],
 
     trackCoverArtistContribs: contributionList({
@@ -246,80 +262,150 @@ export class Album extends Thing {
       artistProperty: input.value('trackCoverArtistContributions'),
     }),
 
-    wallpaperArtistContribs: [
-      withCoverArtDate(),
+    trackArtDate: simpleDate(),
+
+    trackCoverArtFileExtension: fileExtension(V('jpg')),
+
+    trackDimensions: dimensions(),
 
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumWallpaperArtistContributions'),
+    wallpaperArtwork: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
       }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Wallpaper Artwork'),
     ],
 
-    bannerArtistContribs: [
-      withCoverArtDate(),
+    wallpaperArtistContribs: contributionList({
+      date: 'coverArtDate',
+      artistProperty: input.value('albumWallpaperArtistContributions'),
+    }),
 
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumBannerArtistContributions'),
+    wallpaperFileExtension: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
       }),
+
+      fileExtension(V('jpg')),
     ],
 
-    groups: referenceList({
-      class: input.value(Group),
-      find: soupyFind.input('group'),
-    }),
+    wallpaperStyle: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
 
-    artTags: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
+      simpleString(),
+    ],
+
+    wallpaperParts: [
+      exitWithoutDependency('hasWallpaperArt', {
         value: input.value([]),
+        mode: input.value('falsy'),
       }),
 
-      referenceList({
-        class: input.value(ArtTag),
-        find: soupyFind.input('artTag'),
+      wallpaperParts(),
+    ],
+
+    bannerArtwork: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
       }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
     ],
 
-    referencedArtworks: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
+    bannerArtistContribs: contributionList({
+      date: 'coverArtDate',
+      artistProperty: input.value('albumBannerArtistContributions'),
+    }),
+
+    bannerFileExtension: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
       }),
 
-      referencedArtworkList(),
+      fileExtension(V('jpg')),
     ],
 
-    // Update only
+    bannerDimensions: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      dimensions(),
+    ],
+
+    bannerStyle: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      simpleString(),
+    ],
+
+    // > Update & expose - Groups
+
+    groups: referenceList({
+      class: input.value(Group),
+      find: soupyFind.input('group'),
+    }),
+
+    // > Update & expose - Content entries
+
+    commentary: thingList(V(CommentaryEntry)),
+    creditingSources: thingList(V(CreditingSourcesEntry)),
+
+    // > Update & expose - Additional files
+
+    additionalFiles: thingList(V(AdditionalFile)),
+
+    // > Update only
 
     find: soupyFind(),
     reverse: soupyReverse(),
 
     // used for referencedArtworkList (mixedFind)
-    artworkData: wikiData({
-      class: input.value(Artwork),
-    }),
+    artworkData: wikiData(V(Artwork)),
 
     // used for withMatchingContributionPresets (indirectly by Contribution)
-    wikiInfo: thing({
-      class: input.value(WikiInfo),
-    }),
+    wikiInfo: thing(V(WikiInfo)),
 
-    // Expose only
+    // > Expose only
+
+    isAlbum: exposeConstant(V(true)),
 
     commentatorArtists: commentatorArtists(),
 
-    hasCoverArt: [
-      withHasCoverArt(),
-      exposeDependency({dependency: '#hasCoverArt'}),
-    ],
+    hasCoverArt: hasArtwork({
+      contribs: '_coverArtistContribs',
+      artworks: '_coverArtworks',
+    }),
+
+    hasWallpaperArt: hasArtwork({
+      contribs: '_wallpaperArtistContribs',
+      artwork: '_wallpaperArtwork',
+    }),
 
-    hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
-    hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
+    hasBannerArt: hasArtwork({
+      contribs: '_bannerArtistContribs',
+      artwork: '_bannerArtwork',
+    }),
 
     tracks: [
-      withTracks(),
-      exposeDependency({dependency: '#tracks'}),
+      exitWithoutDependency('trackSections', V([])),
+
+      withPropertyFromList('trackSections', V('tracks')),
+      withFlattenedList('#trackSections.tracks'),
+      exposeDependency('#flattenedList'),
     ],
   });
 
@@ -374,8 +460,22 @@ export class Album extends Thing {
       bindTo: 'albumData',
 
       getMatchableNames: album =>
-        (album.alwaysReferenceByDirectory 
-          ? [] 
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+    },
+
+    albumSinglesOnly: {
+      referencing: ['album'],
+
+      bindTo: 'albumData',
+
+      incldue: album =>
+        album.style === 'single',
+
+      getMatchableNames: album =>
+        (album.alwaysReferenceByDirectory
+          ? []
           : [album.name]),
     },
 
@@ -392,8 +492,8 @@ export class Album extends Thing {
         album.hasCoverArt,
 
       getMatchableNames: album =>
-        (album.alwaysReferenceByDirectory 
-          ? [] 
+        (album.alwaysReferenceByDirectory
+          ? []
           : [album.name]),
     },
 
@@ -424,20 +524,6 @@ export class Album extends Thing {
   };
 
   static [Thing.reverseSpecs] = {
-    albumsWhoseTracksInclude: {
-      bindTo: 'albumData',
-
-      referencing: album => [album],
-      referenced: album => album.tracks,
-    },
-
-    albumsWhoseTrackSectionsInclude: {
-      bindTo: 'albumData',
-
-      referencing: album => [album],
-      referenced: album => album.trackSections,
-    },
-
     albumsWhoseArtworksFeature: {
       bindTo: 'albumData',
 
@@ -455,6 +541,9 @@ export class Album extends Thing {
     albumArtistContributionsBy:
       soupyReverse.contributionsBy('albumData', 'artistContribs'),
 
+    albumTrackArtistContributionsBy:
+      soupyReverse.contributionsBy('albumData', 'trackArtistContribs'),
+
     albumCoverArtistContributionsBy:
       soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'),
 
@@ -474,21 +563,15 @@ export class Album extends Thing {
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
-      'Album': {property: 'name'},
+      // Identifying metadata
 
+      'Album': {property: 'name'},
       'Directory': {property: 'directory'},
       'Directory Suffix': {property: 'directorySuffix'},
       'Suffix Track Directories': {property: 'suffixTrackDirectories'},
-
       'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
-      'Always Reference Tracks By Directory': {
-        property: 'alwaysReferenceTracksByDirectory',
-      },
-
-      'Additional Names': {
-        property: 'additionalNames',
-        transform: parseAdditionalNames,
-      },
+      'Always Reference Tracks By Directory': {property: 'alwaysReferenceTracksByDirectory'},
+      'Style': {property: 'style'},
 
       'Bandcamp Album ID': {
         property: 'bandcampAlbumIdentifier',
@@ -500,22 +583,66 @@ export class Album extends Thing {
         transform: String,
       },
 
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
       'Date': {
         property: 'date',
         transform: parseDate,
       },
 
-      'Color': {property: 'color'},
-      'URLs': {property: 'urls'},
+      'Date Added': {
+        property: 'dateAddedToWiki',
+        transform: parseDate,
+      },
+
+      // Credits and contributors
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Track Artist Text': {
+        property: 'trackArtistText',
+      },
+
+      'Track Artists': {
+        property: 'trackArtistContribs',
+        transform: parseContributors,
+      },
+
+      // General configuration
+
+      'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'},
+
+      'Show Album In Tracks Without Artists': {
+        property: 'showAlbumInTracksWithoutArtists',
+      },
 
       'Has Track Numbers': {property: 'hasTrackNumbers'},
       'Listed on Homepage': {property: 'isListedOnHomepage'},
       'Listed in Galleries': {property: 'isListedInGalleries'},
 
+      'Hide Duration': {property: 'hideDuration'},
+
+      // General metadata
+
+      'Color': {property: 'color'},
+
+      'URLs': {property: 'urls'},
+
+      // Artworks
+      //  (Note - this YAML section is deliberately ordered differently
+      //   than the corresponding property descriptors.)
+
       'Cover Artwork': {
         property: 'coverArtworks',
         transform:
           parseArtwork({
+            thingProperty: 'coverArtworks',
             dimensionsFromThingProperty: 'coverArtDimensions',
             fileExtensionFromThingProperty: 'coverArtFileExtension',
             dateFromThingProperty: 'coverArtDate',
@@ -531,6 +658,7 @@ export class Album extends Thing {
         transform:
           parseArtwork({
             single: true,
+            thingProperty: 'bannerArtwork',
             dimensionsFromThingProperty: 'bannerDimensions',
             fileExtensionFromThingProperty: 'bannerFileExtension',
             dateFromThingProperty: 'date',
@@ -544,6 +672,7 @@ export class Album extends Thing {
         transform:
           parseArtwork({
             single: true,
+            thingProperty: 'wallpaperArtwork',
             dimensionsFromThingProperty: null,
             fileExtensionFromThingProperty: 'wallpaperFileExtension',
             dateFromThingProperty: 'date',
@@ -552,27 +681,29 @@ export class Album extends Thing {
           }),
       },
 
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
+
       'Cover Art Date': {
         property: 'coverArtDate',
         transform: parseDate,
       },
 
-      'Default Track Cover Art Date': {
-        property: 'trackArtDate',
-        transform: parseDate,
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
       },
 
-      'Date Added': {
-        property: 'dateAddedToWiki',
-        transform: parseDate,
+      'Default Track Cover Artists': {
+        property: 'trackCoverArtistContribs',
+        transform: parseContributors,
       },
 
-      'Cover Art File Extension': {property: 'coverArtFileExtension'},
-      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
-
-      'Cover Art Dimensions': {
-        property: 'coverArtDimensions',
-        transform: parseDimensions,
+      'Default Track Cover Art Date': {
+        property: 'trackArtDate',
+        transform: parseDate,
       },
 
       'Default Track Dimensions': {
@@ -586,7 +717,6 @@ export class Album extends Thing {
       },
 
       'Wallpaper Style': {property: 'wallpaperStyle'},
-      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
 
       'Wallpaper Parts': {
         property: 'wallpaperParts',
@@ -598,58 +728,70 @@ export class Album extends Thing {
         transform: parseContributors,
       },
 
-      'Banner Style': {property: 'bannerStyle'},
-      'Banner File Extension': {property: 'bannerFileExtension'},
-
       'Banner Dimensions': {
         property: 'bannerDimensions',
         transform: parseDimensions,
       },
 
-      'Commentary': {
-        property: 'commentary',
-        transform: parseCommentary,
-      },
+      'Banner Style': {property: 'bannerStyle'},
 
-      'Credit Sources': {
-        property: 'creditSources',
-        transform: parseCreditingSources,
-      },
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
+      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
+      'Banner File Extension': {property: 'bannerFileExtension'},
 
-      'Additional Files': {
-        property: 'additionalFiles',
-        transform: parseAdditionalFiles,
-      },
+      'Art Tags': {property: 'artTags'},
 
       'Referenced Artworks': {
         property: 'referencedArtworks',
         transform: parseAnnotatedReferences,
       },
 
-      'Franchises': {ignore: true},
+      // Groups
 
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
+      'Groups': {property: 'groups'},
+
+      // Content entries
+
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
       },
 
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
       },
 
-      'Default Track Cover Artists': {
-        property: 'trackCoverArtistContribs',
-        transform: parseContributors,
+      // Additional files
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
       },
 
-      'Groups': {property: 'groups'},
-      'Art Tags': {property: 'artTags'},
+      // Shenanigans
 
+      'Franchises': {ignore: true},
       'Review Points': {ignore: true},
     },
 
     invalidFieldCombinations: [
+      {message: `Move commentary on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Commentary',
+      ]},
+
+      {message: `Move crediting sources on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Crediting Sources',
+      ]},
+
+      {message: `Move additional names on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Additional Names',
+      ]},
+
       {message: `Specify one wallpaper style or multiple wallpaper parts, not both`, fields: [
         'Wallpaper Parts',
         'Wallpaper Style',
@@ -681,100 +823,48 @@ export class Album extends Thing {
         ? TrackSection
         : Track),
 
-    save(results) {
-      const albumData = [];
-      const trackSectionData = [];
-      const trackData = [];
-
-      const artworkData = [];
-      const commentaryData = [];
-      const creditingSourceData = [];
-      const lyricsData = [];
-
-      for (const {header: album, entries} of results) {
-        const trackSections = [];
-
-        let currentTrackSection = new TrackSection();
-        let currentTrackSectionTracks = [];
-
-        Object.assign(currentTrackSection, {
-          name: `Default Track Section`,
-          isDefaultTrackSection: true,
-        });
-
-        const albumRef = Thing.getReference(album);
-
-        const closeCurrentTrackSection = () => {
-          if (
-            currentTrackSection.isDefaultTrackSection &&
-            empty(currentTrackSectionTracks)
-          ) {
-            return;
-          }
-
-          currentTrackSection.tracks =
-            currentTrackSectionTracks;
-
-          trackSections.push(currentTrackSection);
-          trackSectionData.push(currentTrackSection);
-        };
-
-        for (const entry of entries) {
-          if (entry instanceof TrackSection) {
-            closeCurrentTrackSection();
-            currentTrackSection = entry;
-            currentTrackSectionTracks = [];
-            continue;
-          }
-
-          currentTrackSectionTracks.push(entry);
-          trackData.push(entry);
-
-          // Set the track's album before accessing its list of artworks.
-          // The existence of its artwork objects may depend on access to
-          // its album's 'Default Track Cover Artists'.
-          entry.album = album;
-
-          artworkData.push(...entry.trackArtworks);
-          commentaryData.push(...entry.commentary);
-          creditingSourceData.push(...entry.creditSources);
-
-          // TODO: As exposed, Track.lyrics tries to inherit from the main
-          // release, which is impossible before the data's been linked.
-          // We just use the update value here. But it's icky!
-          lyricsData.push(...CacheableObject.getUpdateValue(entry, 'lyrics') ?? []);
-        }
-
-        closeCurrentTrackSection();
+    connect({header: album, entries}) {
+      const trackSections = [];
 
-        albumData.push(album);
+      let currentTrackSection = new TrackSection();
+      let currentTrackSectionTracks = [];
 
-        artworkData.push(...album.coverArtworks);
+      Object.assign(currentTrackSection, {
+        name: `Default Track Section`,
+        isDefaultTrackSection: true,
+      });
 
-        if (album.bannerArtwork) {
-          artworkData.push(album.bannerArtwork);
+      const closeCurrentTrackSection = () => {
+        if (
+          currentTrackSection.isDefaultTrackSection &&
+          empty(currentTrackSectionTracks)
+        ) {
+          return;
         }
 
-        if (album.wallpaperArtwork) {
-          artworkData.push(album.wallpaperArtwork);
+        currentTrackSection.tracks = currentTrackSectionTracks;
+        currentTrackSection.album = album;
+
+        trackSections.push(currentTrackSection);
+      };
+
+      for (const entry of entries) {
+        if (entry instanceof TrackSection) {
+          closeCurrentTrackSection();
+          currentTrackSection = entry;
+          currentTrackSectionTracks = [];
+          continue;
         }
 
-        commentaryData.push(...album.commentary);
-        creditingSourceData.push(...album.creditSources);
+        entry.album = album;
+        entry.trackSection = currentTrackSection;
 
-        album.trackSections = trackSections;
+        currentTrackSectionTracks.push(entry);
       }
 
-      return {
-        albumData,
-        trackSectionData,
-        trackData,
+      closeCurrentTrackSection();
 
-        artworkData,
-        commentaryData,
-        creditingSourceData,
-        lyricsData,
-      };
+      album.trackSections = trackSections;
     },
 
     sort({albumData, trackData}) {
@@ -783,6 +873,14 @@ export class Album extends Thing {
     },
   });
 
+  getOwnAdditionalFilePath(_file, filename) {
+    return [
+      'media.albumAdditionalFile',
+      this.directory,
+      filename,
+    ];
+  }
+
   getOwnArtworkPath(artwork) {
     if (artwork === this.bannerArtwork) {
       return [
@@ -820,56 +918,120 @@ 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.wikiData] = 'trackSectionData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({Track}) => ({
     // Update & expose
 
-    name: name('Unnamed Track Section'),
+    album: thing(V(Album)),
+
+    name: name(V('Unnamed Track Section')),
 
     unqualifiedDirectory: directory(),
 
+    directorySuffix: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDirectory),
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('directorySuffix'),
+      }),
+
+      exposeDependency({dependency: '#album.directorySuffix'}),
+    ],
+
+    suffixTrackDirectories: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('suffixTrackDirectories'),
+      }),
+
+      exposeDependency({dependency: '#album.suffixTrackDirectories'}),
+    ],
+
     color: [
       exposeUpdateValueOrContinue({
         validate: input.value(isColor),
       }),
 
-      withAlbum(),
-
       withPropertyFromObject({
-        object: '#album',
+        object: 'album',
         property: input.value('color'),
       }),
 
       exposeDependency({dependency: '#album.color'}),
     ],
 
+    hasTrackNumbers: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject('album', V('hasTrackNumbers')),
+      exposeDependency('#album.hasTrackNumbers'),
+    ],
+
     startCountingFrom: [
-      withStartCountingFrom({
-        from: input.updateValue({validate: isNumber}),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isNumber),
+      }),
+
+      withPropertyFromObject('album', V('hasTrackNumbers')),
+      exitWithoutDependency('#album.hasTrackNumbers', V(1), V('falsy')),
+
+      withPropertyFromObject('album', V('trackSections')),
+
+      withNearbyItemFromList({
+        list: '#album.trackSections',
+        item: input.myself(),
+        offset: input.value(-1),
+      }).outputs({
+        '#nearbyItem': '#previousTrackSection',
       }),
 
-      exposeDependency({dependency: '#startCountingFrom'}),
+      exitWithoutDependency('#previousTrackSection', V(1)),
+
+      withPropertyFromObject('#previousTrackSection', V('continueCountingFrom')),
+      exposeDependency('#previousTrackSection.continueCountingFrom'),
     ],
 
     dateOriginallyReleased: simpleDate(),
 
-    isDefaultTrackSection: flag(false),
+    countTracksInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
 
-    description: contentString(),
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('countTracksInArtistTotals'),
+      }),
 
-    album: [
-      withAlbum(),
-      exposeDependency({dependency: '#album'}),
+      exposeDependency({dependency: '#album.countTracksInArtistTotals'}),
     ],
 
-    tracks: thingList({
-      class: input.value(Track),
-    }),
+    isDefaultTrackSection: flag(V(false)),
+
+    description: contentString(),
+
+    tracks: thingList(V(Track)),
 
     // Update only
 
@@ -877,38 +1039,51 @@ export class TrackSection extends Thing {
 
     // Expose only
 
-    directory: [
-      withAlbum(),
+    isTrackSection: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
 
+    directory: [
       exitWithoutDependency({
-        dependency: '#album',
+        dependency: 'album',
       }),
 
       withPropertyFromObject({
-        object: '#album',
+        object: 'album',
         property: input.value('directory'),
       }),
 
-      withDirectory({
-        directory: 'unqualifiedDirectory',
-      }).outputs({
-        '#directory': '#unqualifiedDirectory',
-      }),
-
       {
-        dependencies: ['#album.directory', '#unqualifiedDirectory'],
+        dependencies: ['#album.directory', 'unqualifiedDirectory'],
         compute: ({
           ['#album.directory']: albumDirectory,
-          ['#unqualifiedDirectory']: unqualifiedDirectory,
+          ['unqualifiedDirectory']: unqualifiedDirectory,
         }) =>
           albumDirectory + '/' + unqualifiedDirectory,
       },
     ],
 
     continueCountingFrom: [
-      withContinueCountingFrom(),
+      withPropertyFromObject('album', V('hasTrackNumbers')),
+      exitWithoutDependency('#album.hasTrackNumbers', V(null), V('falsy')),
 
-      exposeDependency({dependency: '#continueCountingFrom'}),
+      {
+        dependencies: ['hasTrackNumbers', 'startCountingFrom'],
+        compute: (continuation, {hasTrackNumbers, startCountingFrom}) =>
+          (hasTrackNumbers
+            ? continuation()
+            : continuation.exit(startCountingFrom)),
+      },
+
+      withLengthOfList('tracks'),
+
+      {
+        dependencies: ['startCountingFrom', '#tracks.length'],
+        compute: ({startCountingFrom, '#tracks.length': tracks}) =>
+          startCountingFrom + tracks,
+      },
     ],
   });
 
@@ -926,19 +1101,14 @@ export class TrackSection extends Thing {
     },
   };
 
-  static [Thing.reverseSpecs] = {
-    trackSectionsWhichInclude: {
-      bindTo: 'trackSectionData',
-
-      referencing: trackSection => [trackSection],
-      referenced: trackSection => trackSection.tracks,
-    },
-  };
-
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Section': {property: 'name'},
+      'Directory Suffix': {property: 'directorySuffix'},
+      'Suffix Track Directories': {property: 'suffixTrackDirectories'},
+
       'Color': {property: 'color'},
+      'Has Track Numbers': {property: 'hasTrackNumbers'},
       'Start Counting From': {property: 'startCountingFrom'},
 
       'Date Originally Released': {
@@ -946,6 +1116,8 @@ export class TrackSection extends Thing {
         transform: parseDate,
       },
 
+      'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'},
+
       'Description': {property: 'description'},
     },
   };
@@ -955,11 +1127,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 {
@@ -971,22 +1145,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..f4fedf49 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,18 +1,24 @@
+export const DATA_ART_TAGS_DIRECTORY = 'art-tags';
 export const ART_TAG_DATA_FILE = 'tags.yaml';
 
-import {input} from '#composite';
-import find from '#find';
-import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort';
+import {readFile} from 'node:fs/promises';
+import * as path from 'node:path';
+
+import {input, V} from '#composite';
+import {traverse} from '#node-utils';
+import {sortAlphabetically} from '#sort';
 import Thing from '#thing';
 import {unique} from '#sugar';
 import {isName} from '#validators';
 import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml';
 
-import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
 
 import {
-  additionalNameList,
   annotatedReferenceList,
   color,
   contentString,
@@ -23,24 +29,22 @@ import {
   name,
   soupyFind,
   soupyReverse,
+  thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
-import {withAllDescendantArtTags, withAncestorArtTagBaobabTree}
-  from '#composite/things/art-tag';
-
 export class ArtTag extends Thing {
   static [Thing.referenceType] = 'tag';
   static [Thing.friendlyName] = `Art Tag`;
+  static [Thing.wikiData] = 'artTagData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({
     // Update & expose
 
-    name: name('Unnamed Art Tag'),
+    name: name(V('Unnamed Art Tag')),
     directory: directory(),
     color: color(),
-    isContentWarning: flag(false),
+    isContentWarning: flag(V(false)),
     extraReadingURLs: urls(),
 
     nameShort: [
@@ -55,7 +59,7 @@ export class ArtTag extends Thing {
       },
     ],
 
-    additionalNames: additionalNameList(),
+    additionalNames: thingList(V(AdditionalName)),
 
     description: contentString(),
 
@@ -79,9 +83,11 @@ export class ArtTag extends Thing {
 
     // Expose only
 
+    isArtTag: exposeConstant(V(true)),
+
     descriptionShort: [
-      exitWithoutDependency({
-        dependency: 'description',
+      exitWithoutDependency('description', {
+        value: input.value(null),
         mode: input.value('falsy'),
       }),
 
@@ -97,29 +103,51 @@ export class ArtTag extends Thing {
     }),
 
     indirectlyFeaturedInArtworks: [
-      withAllDescendantArtTags(),
-
       {
-        dependencies: ['#allDescendantArtTags'],
-        compute: ({'#allDescendantArtTags': allDescendantArtTags}) =>
+        dependencies: ['allDescendantArtTags'],
+        compute: ({allDescendantArtTags}) =>
           unique(
             allDescendantArtTags
               .flatMap(artTag => artTag.directlyFeaturedInArtworks)),
       },
     ],
 
+    // All the art tags which descend from this one - that means its own direct
+    // descendants, plus all the direct and indirect descendants of each of those!
+    // The results aren't specially sorted, but they won't contain any duplicates
+    // (for example if two descendant tags both route deeper to end up including
+    // some of the same tags).
     allDescendantArtTags: [
-      withAllDescendantArtTags(),
-      exposeDependency({dependency: '#allDescendantArtTags'}),
+      {
+        dependencies: ['directDescendantArtTags'],
+        compute: ({directDescendantArtTags}) =>
+          unique([
+            ...directDescendantArtTags,
+            ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags),
+          ]),
+      },
     ],
 
     directAncestorArtTags: reverseReferenceList({
       reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'),
     }),
 
+    // All the art tags which are ancestors of this one as a "baobab tree" -
+    // what you'd typically think of as roots are all up in the air! Since this
+    // really is backwards from the way that the art tag tree is written in data,
+    // chances are pretty good that there will be many of the exact same "leaf"
+    // nodes - art tags which don't themselves have any ancestors. In the actual
+    // data structure, each node is a Map, with keys for each ancestor and values
+    // for each ancestor's own baobab (thus a branching structure, just like normal
+    // trees in this regard).
     ancestorArtTagBaobabTree: [
-      withAncestorArtTagBaobabTree(),
-      exposeDependency({dependency: '#ancestorArtTagBaobabTree'}),
+      {
+        dependencies: ['directAncestorArtTags'],
+        compute: ({directAncestorArtTags}) =>
+          new Map(
+            directAncestorArtTags
+              .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])),
+      },
     ],
   });
 
@@ -174,16 +202,26 @@ export class ArtTag extends Thing {
   };
 
   static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {allInOne},
+    documentModes: {allTogether},
     thingConstructors: {ArtTag},
   }) => ({
     title: `Process art tags file`,
-    file: ART_TAG_DATA_FILE,
 
-    documentMode: allInOne,
-    documentThing: ArtTag,
+    files: dataPath =>
+      Promise.allSettled([
+        readFile(path.join(dataPath, ART_TAG_DATA_FILE))
+          .then(() => [ART_TAG_DATA_FILE]),
+
+        traverse(path.join(dataPath, DATA_ART_TAGS_DIRECTORY), {
+          filterFile: name => path.extname(name) === '.yaml',
+          prefixPath: DATA_ART_TAGS_DIRECTORY,
+        }),
+      ]).then(results => results
+          .filter(({status}) => status === 'fulfilled')
+          .flatMap(({value}) => value)),
 
-    save: (results) => ({artTagData: results}),
+    documentMode: allTogether,
+    documentThing: ArtTag,
 
     sort({artTagData}) {
       sortAlphabetically(artTagData);
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 87e1c563..a2ed0b74 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -4,15 +4,21 @@ import {inspect} from 'node:util';
 
 import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
-import {input} from '#composite';
-import {sortAlphabetically} from '#sort';
-import {stitchArrays} from '#sugar';
+import {input, V} from '#composite';
 import Thing from '#thing';
-import {isName, validateArrayItems} from '#validators';
-import {getKebabCase} from '#wiki-data';
-import {parseArtwork} from '#yaml';
+import {parseArtistAliases, parseArtwork} from '#yaml';
 
-import {exitWithoutDependency} from '#composite/control-flow';
+import {
+  sortAlbumsTracksChronologically,
+  sortArtworksChronologically,
+  sortAlphabetically,
+  sortContributionsChronologically,
+} from '#sort';
+
+import {exitWithoutDependency, exposeConstant, exposeDependency}
+  from '#composite/control-flow';
+import {withFilteredList, withPropertyFromList} from '#composite/data';
+import {withContributionListSums} from '#composite/wiki-data';
 
 import {
   constitutibleArtwork,
@@ -22,53 +28,46 @@ import {
   flag,
   name,
   reverseReferenceList,
-  singleReference,
   soupyFind,
   soupyReverse,
+  thing,
+  thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
-import {artistTotalDuration} from '#composite/things/artist';
-
 export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
-  static [Thing.wikiDataArray] = 'artistData';
+  static [Thing.wikiData] = 'artistData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({
+  static [Thing.constitutibleProperties] = [
+    'avatarArtwork', // from inline fields
+  ];
+
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
-    name: name('Unnamed Artist'),
+    name: name(V('Unnamed Artist')),
     directory: directory(),
     urls: urls(),
 
     contextNotes: contentString(),
 
-    hasAvatar: flag(false),
-    avatarFileExtension: fileExtension('jpg'),
+    hasAvatar: flag(V(false)),
+    avatarFileExtension: fileExtension(V('jpg')),
 
     avatarArtwork: [
-      exitWithoutDependency({
-        dependency: 'hasAvatar',
+      exitWithoutDependency('hasAvatar', {
         value: input.value(null),
+        mode: input.value('falsy'),
       }),
 
       constitutibleArtwork.fromYAMLFieldSpec
         .call(this, 'Avatar Artwork'),
     ],
 
-    aliasNames: {
-      flags: {update: true, expose: true},
-      update: {validate: validateArrayItems(isName)},
-      expose: {transform: (names) => names ?? []},
-    },
-
-    isAlias: flag(),
-
-    aliasedArtist: singleReference({
-      class: input.value(Artist),
-      find: soupyFind.input('artist'),
-    }),
+    isAlias: flag(V(false)),
+    artistAliases: thingList(V(Artist)),
+    aliasedArtist: thing(V(Artist)),
 
     // Update only
 
@@ -77,6 +76,8 @@ export class Artist extends Thing {
 
     // Expose only
 
+    isArtist: exposeConstant(V(true)),
+
     trackArtistContributions: reverseReferenceList({
       reverse: soupyReverse.input('trackArtistContributionsBy'),
     }),
@@ -97,6 +98,10 @@ export class Artist extends Thing {
       reverse: soupyReverse.input('albumArtistContributionsBy'),
     }),
 
+    albumTrackArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumTrackArtistContributionsBy'),
+    }),
+
     albumCoverArtistContributions: reverseReferenceList({
       reverse: soupyReverse.input('albumCoverArtistContributionsBy'),
     }),
@@ -125,7 +130,76 @@ export class Artist extends Thing {
       reverse: soupyReverse.input('groupsCloselyLinkedTo'),
     }),
 
-    totalDuration: artistTotalDuration(),
+    musicContributions: [
+      {
+        dependencies: [
+          'trackArtistContributions',
+          'trackContributorContributions',
+        ],
+
+        compute: (continuation, {
+          trackArtistContributions,
+          trackContributorContributions,
+        }) => continuation({
+          ['#contributions']: [
+            ...trackArtistContributions,
+            ...trackContributorContributions,
+          ],
+        }),
+      },
+
+      {
+        dependencies: ['#contributions'],
+        compute: ({'#contributions': contributions}) =>
+          sortContributionsChronologically(
+            contributions,
+            sortAlbumsTracksChronologically),
+      },
+    ],
+
+    artworkContributions: [
+      {
+        dependencies: [
+          'trackCoverArtistContributions',
+          'albumCoverArtistContributions',
+          'albumWallpaperArtistContributions',
+          'albumBannerArtistContributions',
+        ],
+
+        compute: (continuation, {
+          trackCoverArtistContributions,
+          albumCoverArtistContributions,
+          albumWallpaperArtistContributions,
+          albumBannerArtistContributions,
+        }) => continuation({
+          ['#contributions']: [
+            ...trackCoverArtistContributions,
+            ...albumCoverArtistContributions,
+            ...albumWallpaperArtistContributions,
+            ...albumBannerArtistContributions,
+          ],
+        }),
+      },
+
+      {
+        dependencies: ['#contributions'],
+        compute: ({'#contributions': contributions}) =>
+          sortContributionsChronologically(
+            contributions,
+            sortArtworksChronologically),
+      },
+    ],
+
+    totalDuration: [
+      withPropertyFromList('musicContributions', V('thing')),
+      withPropertyFromList('#musicContributions.thing', V('isMainRelease')),
+
+      withFilteredList('musicContributions', '#musicContributions.thing.isMainRelease')
+        .outputs({'#filteredList': '#mainReleaseContributions'}),
+
+      withContributionListSums('#mainReleaseContributions'),
+      exposeDependency('#contributionListDuration'),
+    ],
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -139,8 +213,6 @@ export class Artist extends Thing {
     hasAvatar: S.id,
     avatarFileExtension: S.id,
 
-    aliasNames: S.id,
-
     tracksAsCommentator: S.toRefs,
     albumsAsCommentator: S.toRefs,
   });
@@ -171,17 +243,9 @@ export class Artist extends Thing {
         // in the original's alias list. This is honestly a bit awkward, but it
         // avoids artist aliases conflicting with each other when checking for
         // duplicate directories.
-        for (const aliasName of originalArtist.aliasNames) {
-          // These are trouble. We should be accessing aliases' directories
-          // directly, but artists currently don't expose a reverse reference
-          // list for aliases. (This is pending a cleanup of "reverse reference"
-          // behavior in general.) It doesn't actually cause problems *here*
-          // because alias directories are computed from their names 100% of the
-          // time, but that *is* an assumption this code makes.
-          if (aliasName === artist.name) continue;
-          if (artist.directory === getKebabCase(aliasName)) {
-            return [];
-          }
+        for (const alias of originalArtist.artistAliases) {
+          if (alias === artist) break;
+          if (alias.directory === artist.directory) return [];
         }
 
         // And, aliases never return just a blank string. This part is pretty
@@ -213,6 +277,7 @@ export class Artist extends Thing {
         transform:
           parseArtwork({
             single: true,
+            thingProperty: 'avatarArtwork',
             fileExtensionFromThingProperty: 'avatarFileExtension',
           }),
       },
@@ -220,7 +285,10 @@ export class Artist extends Thing {
       'Has Avatar': {property: 'hasAvatar'},
       'Avatar File Extension': {property: 'avatarFileExtension'},
 
-      'Aliases': {property: 'aliasNames'},
+      'Aliases': {
+        property: 'artistAliases',
+        transform: parseArtistAliases,
+      },
 
       'Dead URLs': {ignore: true},
 
@@ -238,38 +306,6 @@ export class Artist extends Thing {
     documentMode: allInOne,
     documentThing: Artist,
 
-    save(results) {
-      const artists = results;
-
-      const artistRefs =
-        artists.map(artist => Thing.getReference(artist));
-
-      const artistAliasNames =
-        artists.map(artist => artist.aliasNames);
-
-      const artistAliases =
-        stitchArrays({
-          originalArtistRef: artistRefs,
-          aliasNames: artistAliasNames,
-        }).flatMap(({originalArtistRef, aliasNames}) =>
-            aliasNames.map(name => {
-              const alias = new Artist();
-              alias.name = name;
-              alias.isAlias = true;
-              alias.aliasedArtist = originalArtistRef;
-              return alias;
-            }));
-
-      const artistData = [...artists, ...artistAliases];
-
-      const artworkData =
-        artistData
-          .filter(artist => artist.hasAvatar)
-          .map(artist => artist.avatarArtwork);
-
-      return {artistData, artworkData};
-    },
-
     sort({artistData}) {
       sortAlphabetically(artistData);
     },
@@ -286,7 +322,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 2a97fd6d..c1aafa8f 100644
--- a/src/data/things/artwork.js
+++ b/src/data/things/artwork.js
@@ -1,6 +1,7 @@
 import {inspect} from 'node:util';
 
-import {input} from '#composite';
+import {colors} from '#cli';
+import {input, V} from '#composite';
 import find from '#find';
 import Thing from '#thing';
 
@@ -24,17 +25,25 @@ import {
   parseDimensions,
 } from '#yaml';
 
-import {withPropertyFromObject} from '#composite/data';
-
 import {
   exitWithoutDependency,
   exposeConstant,
   exposeDependency,
   exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
+  flipFilter,
 } from '#composite/control-flow';
 
 import {
+  withFilteredList,
+  withNearbyItemFromList,
+  withPropertyFromList,
+  withPropertyFromObject,
+} from '#composite/data';
+
+import {
+  constituteFrom,
+  constituteOrContinue,
   withRecontextualizedContributionList,
   withResolvedAnnotatedReferenceList,
   withResolvedContribs,
@@ -44,6 +53,7 @@ import {
 import {
   contentString,
   directory,
+  flag,
   reverseReferenceList,
   simpleString,
   soupyFind,
@@ -52,15 +62,18 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withDate} from '#composite/things/artwork';
+import {withContainingArtworkList} from '#composite/things/artwork';
 
 export class Artwork extends Thing {
   static [Thing.referenceType] = 'artwork';
+  static [Thing.wikiData] = 'artworkData';
+
+  static [Thing.constitutibleProperties] = [
+    // Contributions currently aren't being observed for constitution.
+    // 'artistContribs', // from attached artwork or thing
+  ];
 
-  static [Thing.getPropertyDescriptors] = ({
-    ArtTag,
-    Contribution,
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({
     // Update & expose
 
     unqualifiedDirectory: directory({
@@ -68,54 +81,32 @@ export class Artwork extends Thing {
     }),
 
     thing: thing(),
+    thingProperty: simpleString(),
 
     label: simpleString(),
     source: contentString(),
+    originDetails: contentString(),
+    showFilename: simpleString(),
 
     dateFromThingProperty: simpleString(),
 
     date: [
-      withDate({
-        from: input.updateValue({validate: isDate}),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
       }),
 
-      exposeDependency({dependency: '#date'}),
+      constituteFrom('thing', 'dateFromThingProperty'),
     ],
 
     fileExtensionFromThingProperty: simpleString(),
 
     fileExtension: [
-      {
-        compute: (continuation) => continuation({
-          ['#default']: 'jpg',
-        }),
-      },
-
       exposeUpdateValueOrContinue({
         validate: input.value(isFileExtension),
       }),
 
-      exitWithoutDependency({
-        dependency: 'thing',
-        value: '#default',
-      }),
-
-      exitWithoutDependency({
-        dependency: 'fileExtensionFromThingProperty',
-        value: '#default',
-      }),
-
-      withPropertyFromObject({
-        object: 'thing',
-        property: 'fileExtensionFromThingProperty',
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#value',
-      }),
-
-      exposeDependency({
-        dependency: '#default',
+      constituteFrom('thing', 'fileExtensionFromThingProperty', {
+        else: input.value('jpg'),
       }),
     ],
 
@@ -126,70 +117,40 @@ export class Artwork extends Thing {
         validate: input.value(isDimensions),
       }),
 
-      exitWithoutDependency({
-        dependency: 'artistContribsFromThingProperty',
-        value: input.value(null),
-      }),
-
-      withPropertyFromObject({
-        object: 'thing',
-        property: 'dimensionsFromThingProperty',
-      }).outputs({
-        ['#value']: '#dimensionsFromThing',
-      }),
-
-      exitWithoutDependency({
-        dependency: 'dimensionsFromThingProperty',
-        value: input.value(null),
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#dimensionsFromThing',
-      }),
-
-      exposeConstant({
-        value: input.value(null),
-      }),
+      constituteFrom('thing', 'dimensionsFromThingProperty'),
     ],
 
+    attachAbove: flag(V(false)),
+
     artistContribsFromThingProperty: simpleString(),
     artistContribsArtistProperty: simpleString(),
 
     artistContribs: [
-      withDate(),
-
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
-        date: '#date',
+        date: 'date',
+        thingProperty: input.thisProperty(),
         artistProperty: 'artistContribsArtistProperty',
       }),
 
-      exposeDependencyOrContinue({
-        dependency: '#resolvedContribs',
-        mode: input.value('empty'),
-      }),
+      exposeDependencyOrContinue('#resolvedContribs', V('empty')),
 
-      exitWithoutDependency({
-        dependency: 'artistContribsFromThingProperty',
-        value: input.value([]),
-      }),
+      withPropertyFromObject('attachedArtwork', V('artistContribs')),
 
-      withPropertyFromObject({
-        object: 'thing',
-        property: 'artistContribsFromThingProperty',
-      }).outputs({
-        ['#value']: '#artistContribs',
-      }),
+      withRecontextualizedContributionList('#attachedArtwork.artistContribs'),
+      exposeDependencyOrContinue('#attachedArtwork.artistContribs'),
 
-      withRecontextualizedContributionList({
-        list: '#artistContribs',
-      }),
+      exitWithoutDependency('artistContribsFromThingProperty', V([])),
 
-      exposeDependency({
-        dependency: '#artistContribs',
-      }),
+      withPropertyFromObject('thing', 'artistContribsFromThingProperty')
+        .outputs({'#value': '#artistContribsFromThing'}),
+
+      withRecontextualizedContributionList('#artistContribsFromThing'),
+      exposeDependency('#artistContribsFromThing'),
     ],
 
+    style: simpleString(),
+
     artTagsFromThingProperty: simpleString(),
 
     artTags: [
@@ -198,34 +159,14 @@ export class Artwork extends Thing {
           validate:
             validateReferenceList(ArtTag[Thing.referenceType]),
         }),
-
         find: soupyFind.input('artTag'),
       }),
 
-      exposeDependencyOrContinue({
-        dependency: '#resolvedReferenceList',
-        mode: input.value('empty'),
-      }),
-
-      exitWithoutDependency({
-        dependency: 'artTagsFromThingProperty',
-        value: input.value([]),
-      }),
-
-      withPropertyFromObject({
-        object: 'thing',
-        property: 'artTagsFromThingProperty',
-      }).outputs({
-        ['#value']: '#artTags',
-      }),
+      exposeDependencyOrContinue('#resolvedReferenceList', V('empty')),
 
-      exposeDependencyOrContinue({
-        dependency: '#artTags',
-      }),
+      constituteOrContinue('attachedArtwork', V('artTags'), V('empty')),
 
-      exposeConstant({
-        value: input.value([]),
-      }),
+      constituteFrom('thing', 'artTagsFromThingProperty', V([])),
     ],
 
     referencedArtworksFromThingProperty: simpleString(),
@@ -255,35 +196,16 @@ export class Artwork extends Thing {
               })),
         }),
 
-        data: 'artworkData',
+        data: '_artworkData',
         find: '#find',
 
         thing: input.value('artwork'),
       }),
 
-      exposeDependencyOrContinue({
-        dependency: '#resolvedAnnotatedReferenceList',
-        mode: input.value('empty'),
-      }),
-
-      exitWithoutDependency({
-        dependency: 'referencedArtworksFromThingProperty',
-        value: input.value([]),
-      }),
-
-      withPropertyFromObject({
-        object: 'thing',
-        property: 'referencedArtworksFromThingProperty',
-      }).outputs({
-        ['#value']: '#referencedArtworks',
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#referencedArtworks',
-      }),
+      exposeDependencyOrContinue('#resolvedAnnotatedReferenceList', V('empty')),
 
-      exposeConstant({
-        value: input.value([]),
+      constituteFrom('thing', 'referencedArtworksFromThingProperty', {
+        else: input.value([]),
       }),
     ],
 
@@ -293,15 +215,86 @@ export class Artwork extends Thing {
     reverse: soupyReverse(),
 
     // used for referencedArtworks (mixedFind)
-    artworkData: wikiData({
-      class: input.value(Artwork),
-    }),
+    artworkData: wikiData(V(Artwork)),
 
     // Expose only
 
+    isArtwork: exposeConstant(V(true)),
+
     referencedByArtworks: reverseReferenceList({
       reverse: soupyReverse.input('artworksWhichReference'),
     }),
+
+    isMainArtwork: [
+      withContainingArtworkList(),
+      exitWithoutDependency('#containingArtworkList'),
+
+      {
+        dependencies: [input.myself(), '#containingArtworkList'],
+        compute: ({
+          [input.myself()]: myself,
+          ['#containingArtworkList']: list,
+        }) =>
+          list[0] === myself,
+      },
+    ],
+
+    mainArtwork: [
+      withContainingArtworkList(),
+      exitWithoutDependency('#containingArtworkList'),
+
+      {
+        dependencies: ['#containingArtworkList'],
+        compute: ({'#containingArtworkList': list}) =>
+          list[0],
+      },
+    ],
+
+    attachedArtwork: [
+      exitWithoutDependency('attachAbove', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      withContainingArtworkList(),
+
+      withPropertyFromList('#containingArtworkList', V('attachAbove')),
+
+      flipFilter('#containingArtworkList.attachAbove')
+        .outputs({'#containingArtworkList.attachAbove': '#filterNotAttached'}),
+
+      withNearbyItemFromList({
+        list: '#containingArtworkList',
+        item: input.myself(),
+        offset: input.value(-1),
+        filter: '#filterNotAttached',
+      }),
+
+      exposeDependency('#nearbyItem'),
+    ],
+
+    attachingArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichAttach'),
+    }),
+
+    groups: [
+      withPropertyFromObject('thing', V('groups')),
+      exposeDependencyOrContinue('#thing.groups'),
+
+      exposeConstant(V([])),
+    ],
+
+    contentWarningArtTags: [
+      withPropertyFromList('artTags', V('isContentWarning')),
+      withFilteredList('artTags', '#artTags.isContentWarning'),
+      exposeDependency('#filteredList'),
+    ],
+
+    contentWarnings: [
+      withPropertyFromList('contentWarningArtTags', V('name')),
+      exposeDependency('#contentWarningArtTags.name'),
+    ],
+
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -316,17 +309,23 @@ export class Artwork extends Thing {
 
       'Label': {property: 'label'},
       'Source': {property: 'source'},
+      'Origin Details': {property: 'originDetails'},
+      'Show Filename': {property: 'showFilename'},
 
       'Date': {
         property: 'date',
         transform: parseDate,
       },
 
+      'Attach Above': {property: 'attachAbove'},
+
       'Artists': {
         property: 'artistContribs',
         transform: parseContributors,
       },
 
+      'Style': {property: 'style'},
+
       'Tags': {property: 'artTags'},
 
       'Referenced Artworks': {
@@ -358,6 +357,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',
 
@@ -373,6 +384,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..64d03e69 100644
--- a/src/data/things/content.js
+++ b/src/data/things/content.js
@@ -1,31 +1,53 @@
-import {input} from '#composite';
-import find from '#find';
+import {input, V} from '#composite';
+import {transposeArrays} from '#sugar';
 import Thing from '#thing';
-import {is, isDate} from '#validators';
+import {is, isDate, validateReferenceList} from '#validators';
 import {parseDate} from '#yaml';
 
-import {contentString, referenceList, simpleDate, soupyFind, thing}
+import {withFilteredList, withMappedList, withPropertyFromList}
+  from '#composite/data';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+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 {
+  hasAnnotationPart,
+  withAnnotationPartNodeLists,
+  withExpressedOrImplicitArtistReferences,
+  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: [
+      withExpressedOrImplicitArtistReferences({
+        from: input.updateValue({
+          validate: validateReferenceList('artist'),
+        }),
+      }),
+
+      exitWithoutDependency('#artistReferences', V([])),
+
+      withResolvedReferenceList({
+        list: '#artistReferences',
+        find: soupyFind.input('artist'),
+      }),
+
+      exposeDependency('#resolvedReferenceList'),
+    ],
 
     artistText: contentString(),
 
@@ -44,6 +66,8 @@ export class ContentEntry extends Thing {
     },
 
     accessKind: [
+      exitWithoutDependency('_accessDate'),
+
       exposeUpdateValueOrContinue({
         validate: input.value(
           is(...[
@@ -54,9 +78,7 @@ export class ContentEntry extends Thing {
 
       withWebArchiveDate(),
 
-      withResultOfAvailabilityCheck({
-        from: '#webArchiveDate',
-      }),
+      withResultOfAvailabilityCheck({from: '#webArchiveDate'}),
 
       {
         dependencies: ['#availability'],
@@ -66,13 +88,10 @@ export class ContentEntry extends Thing {
             : continuation()),
       },
 
-      exposeConstant({
-        value: input.value(null),
-      }),
+      exposeConstant(V('accessed')),
     ],
 
     date: simpleDate(),
-
     secondDate: simpleDate(),
 
     accessDate: [
@@ -86,9 +105,7 @@ export class ContentEntry extends Thing {
         dependency: '#webArchiveDate',
       }),
 
-      exposeConstant({
-        value: input.value(null),
-      }),
+      exposeConstant(V(null)),
     ],
 
     body: contentString(),
@@ -96,6 +113,118 @@ export class ContentEntry extends Thing {
     // Update only
 
     find: soupyFind(),
+
+    // Expose only
+
+    isContentEntry: exposeConstant(V(true)),
+
+    annotationParts: [
+      withAnnotationPartNodeLists(),
+
+      {
+        dependencies: ['#annotationPartNodeLists'],
+        compute: (continuation, {
+          ['#annotationPartNodeLists']: nodeLists,
+        }) => continuation({
+          ['#firstNodes']:
+            nodeLists.map(list => list.at(0)),
+
+          ['#lastNodes']:
+            nodeLists.map(list => list.at(-1)),
+        }),
+      },
+
+      withPropertyFromList('#firstNodes', V('i'))
+        .outputs({'#firstNodes.i': '#startIndices'}),
+
+      withPropertyFromList('#lastNodes', V('iEnd'))
+        .outputs({'#lastNodes.iEnd': '#endIndices'}),
+
+      {
+        dependencies: [
+          'annotation',
+          '#startIndices',
+          '#endIndices',
+        ],
+
+        compute: ({
+          ['annotation']: annotation,
+          ['#startIndices']: startIndices,
+          ['#endIndices']: endIndices,
+        }) =>
+          transposeArrays([startIndices, endIndices])
+            .map(([start, end]) =>
+              annotation.slice(start, end)),
+      },
+    ],
+
+    sourceText: [
+      withAnnotationPartNodeLists(),
+
+      {
+        dependencies: ['#annotationPartNodeLists'],
+        compute: (continuation, {
+          ['#annotationPartNodeLists']: nodeLists,
+        }) => continuation({
+          ['#firstPartWithExternalLink']:
+            nodeLists
+              .find(nodes => nodes
+                .some(node => node.type === 'external-link')) ??
+            null,
+        }),
+      },
+
+      exitWithoutDependency('#firstPartWithExternalLink'),
+
+      {
+        dependencies: ['annotation', '#firstPartWithExternalLink'],
+        compute: ({
+          ['annotation']: annotation,
+          ['#firstPartWithExternalLink']: nodes,
+        }) =>
+          annotation.slice(
+            nodes.at(0).i,
+            nodes.at(-1).iEnd),
+      },
+    ],
+
+    sourceURLs: [
+      withAnnotationPartNodeLists(),
+
+      {
+        dependencies: ['#annotationPartNodeLists'],
+        compute: (continuation, {
+          ['#annotationPartNodeLists']: nodeLists,
+        }) => continuation({
+          ['#firstPartWithExternalLink']:
+            nodeLists
+              .find(nodes => nodes
+                .some(node => node.type === 'external-link')) ??
+            null,
+        }),
+      },
+
+      exitWithoutDependency('#firstPartWithExternalLink', V([])),
+
+      withMappedList({
+        list: '#firstPartWithExternalLink',
+        map: input.value(node => node.type === 'external-link'),
+      }).outputs({
+        '#mappedList': '#externalLinkFilter',
+      }),
+
+      withFilteredList({
+        list: '#firstPartWithExternalLink',
+        filter: '#externalLinkFilter',
+      }),
+
+      withMappedList({
+        list: '#filteredList',
+        map: input.value(node => node.data.href),
+      }),
+
+      exposeDependency('#mappedList'),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -117,6 +246,74 @@ export class ContentEntry extends Thing {
   };
 }
 
-export class CommentaryEntry extends ContentEntry {}
-export class LyricsEntry extends ContentEntry {}
-export class CreditingSourcesEntry extends ContentEntry {}
+export class CommentaryEntry extends ContentEntry {
+  static [Thing.wikiData] = 'commentaryData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isCommentaryEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
+    isWikiEditorCommentary: hasAnnotationPart({
+      part: input.value('wiki editor'),
+    }),
+  });
+}
+
+export class LyricsEntry extends ContentEntry {
+  static [Thing.wikiData] = 'lyricsData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    originDetails: contentString(),
+
+    // Expose only
+
+    isLyricsEntry: exposeConstant(V(true)),
+
+    isWikiLyrics: hasAnnotationPart(V('wiki lyrics')),
+    helpNeeded: hasAnnotationPart(V('help needed')),
+
+    hasSquareBracketAnnotations: [
+      exitWithoutDependency('isWikiLyrics', V(false), V('falsy')),
+      exitWithoutDependency('body', V(false)),
+
+      {
+        dependencies: ['body'],
+        compute: ({body}) =>
+          /\[.*\]/m.test(body),
+      },
+    ],
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, {
+    fields: {
+      'Origin Details': {property: 'originDetails'},
+    },
+  });
+}
+
+export class CreditingSourcesEntry extends ContentEntry {
+  static [Thing.wikiData] = 'creditingSourceData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isCreditingSourcesEntry: exposeConstant(V(true)),
+  });
+}
+
+export class ReferencingSourcesEntry extends ContentEntry {
+  static [Thing.wikiData] = 'referencingSourceData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isReferencingSourceEntry: exposeConstant(V(true)),
+  });
+}
diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js
index c92fafb4..393a60b4 100644
--- a/src/data/things/contribution.js
+++ b/src/data/things/contribution.js
@@ -2,13 +2,21 @@ import {inspect} from 'node:util';
 
 import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
-import {input} from '#composite';
+import {input, V} from '#composite';
 import {empty} from '#sugar';
 import Thing from '#thing';
-import {isStringNonEmpty, isThing, validateReference} from '#validators';
+import {isBoolean, isStringNonEmpty, isThing} from '#validators';
 
-import {exitWithoutDependency, exposeDependency} from '#composite/control-flow';
-import {flag, simpleDate, soupyFind} from '#composite/wiki-properties';
+import {simpleDate, singleReference, soupyFind}
+  from '#composite/wiki-properties';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
 
 import {
   withFilteredList,
@@ -19,12 +27,8 @@ import {
 
 import {
   inheritFromContributionPresets,
-  thingPropertyMatches,
-  thingReferenceTypeMatches,
   withContainingReverseContributionList,
-  withContributionArtist,
   withContributionContext,
-  withMatchingContributionPresets,
 } from '#composite/things/contribution';
 
 export class Contribution extends Thing {
@@ -48,17 +52,9 @@ export class Contribution extends Thing {
 
     date: simpleDate(),
 
-    artist: [
-      withContributionArtist({
-        ref: input.updateValue({
-          validate: validateReference('artist'),
-        }),
-      }),
-
-      exposeDependency({
-        dependency: '#artist',
-      }),
-    ],
+    artist: singleReference({
+      find: soupyFind.input('artist'),
+    }),
 
     annotation: {
       flags: {update: true, expose: true},
@@ -66,19 +62,55 @@ export class Contribution extends Thing {
     },
 
     countInContributionTotals: [
-      inheritFromContributionPresets({
-        property: input.thisProperty(),
+      inheritFromContributionPresets(),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
       }),
 
-      flag(true),
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInContributionTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInContributionTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant(V(true)),
     ],
 
     countInDurationTotals: [
-      inheritFromContributionPresets({
-        property: input.thisProperty(),
+      inheritFromContributionPresets(),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
       }),
 
-      flag(true),
+      withPropertyFromObject('thing', V('duration')),
+      exitWithoutDependency('#thing.duration', {
+        value: input.value(false),
+        mode: input.value('falsy'),
+      }),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInDurationTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInDurationTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant(V(true)),
     ],
 
     // Update only
@@ -87,6 +119,8 @@ export class Contribution extends Thing {
 
     // Expose only
 
+    isContribution: exposeConstant(V(true)),
+
     context: [
       withContributionContext(),
 
@@ -107,11 +141,43 @@ export class Contribution extends Thing {
     ],
 
     matchingPresets: [
-      withMatchingContributionPresets(),
-
-      exposeDependency({
-        dependency: '#matchingContributionPresets',
+      withPropertyFromObject('thing', {
+        property: input.value('wikiInfo'),
+        internal: input.value(true),
       }),
+
+      exitWithoutDependency('#thing.wikiInfo', V([])),
+
+      withPropertyFromObject('#thing.wikiInfo', V('contributionPresets'))
+        .outputs({'#thing.wikiInfo.contributionPresets': '#contributionPresets'}),
+
+      exitWithoutDependency('#contributionPresets', V([]), V('empty')),
+
+      withContributionContext(),
+
+      // TODO: implementing this with compositional filters would be fun
+      {
+        dependencies: [
+          '#contributionPresets',
+          '#contributionTarget',
+          '#contributionProperty',
+          'annotation',
+        ],
+
+        compute: ({
+          ['#contributionPresets']: presets,
+          ['#contributionTarget']: target,
+          ['#contributionProperty']: property,
+          ['annotation']: annotation,
+        }) =>
+          presets.filter(preset =>
+            preset.context[0] === target &&
+            preset.context.slice(1).includes(property) &&
+            // For now, only match if the annotation is a complete match.
+            // Partial matches (e.g. because the contribution includes "two"
+            // annotations, separated by commas) don't count.
+            preset.annotation === annotation),
+      },
     ],
 
     // All the contributions from the list which includes this contribution.
@@ -119,27 +185,13 @@ export class Contribution extends Thing {
     // artist, but also this very contribution. It doesn't mix contributions
     // exposed on different properties.
     associatedContributions: [
-      exitWithoutDependency({
-        dependency: 'thing',
-        value: input.value([]),
-      }),
+      exitWithoutDependency('thing', V([])),
+      exitWithoutDependency('thingProperty', V([])),
 
-      exitWithoutDependency({
-        dependency: 'thingProperty',
-        value: input.value([]),
-      }),
+      withPropertyFromObject('thing', 'thingProperty')
+        .outputs({'#value': '#contributions'}),
 
-      withPropertyFromObject({
-        object: 'thing',
-        property: 'thingProperty',
-      }).outputs({
-        '#value': '#contributions',
-      }),
-
-      withPropertyFromList({
-        list: '#contributions',
-        property: input.value('annotation'),
-      }),
+      withPropertyFromList('#contributions', V('annotation')),
 
       {
         dependencies: ['#contributions.annotation', 'annotation'],
@@ -155,88 +207,37 @@ export class Contribution extends Thing {
         }),
       },
 
-      withFilteredList({
-        list: '#contributions',
-        filter: '#likeContributionsFilter',
-      }).outputs({
-        '#filteredList': '#contributions',
-      }),
+      withFilteredList('#contributions', '#likeContributionsFilter')
+        .outputs({'#filteredList': '#contributions'}),
 
-      exposeDependency({
-        dependency: '#contributions',
-      }),
+      exposeDependency('#contributions'),
     ],
 
-    isArtistContribution: thingPropertyMatches({
-      value: input.value('artistContribs'),
-    }),
-
-    isContributorContribution: thingPropertyMatches({
-      value: input.value('contributorContribs'),
-    }),
-
-    isCoverArtistContribution: thingPropertyMatches({
-      value: input.value('coverArtistContribs'),
-    }),
-
-    isBannerArtistContribution: thingPropertyMatches({
-      value: input.value('bannerArtistContribs'),
-    }),
-
-    isWallpaperArtistContribution: thingPropertyMatches({
-      value: input.value('wallpaperArtistContribs'),
-    }),
-
-    isForTrack: thingReferenceTypeMatches({
-      value: input.value('track'),
-    }),
-
-    isForAlbum: thingReferenceTypeMatches({
-      value: input.value('album'),
-    }),
-
-    isForFlash: thingReferenceTypeMatches({
-      value: input.value('flash'),
-    }),
-
     previousBySameArtist: [
-      withContainingReverseContributionList().outputs({
-        '#containingReverseContributionList': '#list',
-      }),
-
-      exitWithoutDependency({
-        dependency: '#list',
-      }),
+      withContainingReverseContributionList()
+        .outputs({'#containingReverseContributionList': '#list'}),
 
-      withNearbyItemFromList({
-        list: '#list',
-        item: input.myself(),
-        offset: input.value(-1),
-      }),
+      exitWithoutDependency('#list'),
 
-      exposeDependency({
-        dependency: '#nearbyItem',
-      }),
+      withNearbyItemFromList('#list', input.myself(), V(-1)),
+      exposeDependency('#nearbyItem'),
     ],
 
     nextBySameArtist: [
-      withContainingReverseContributionList().outputs({
-        '#containingReverseContributionList': '#list',
-      }),
+      withContainingReverseContributionList()
+        .outputs({'#containingReverseContributionList': '#list'}),
 
-      exitWithoutDependency({
-        dependency: '#list',
-      }),
+      exitWithoutDependency('#list'),
 
-      withNearbyItemFromList({
-        list: '#list',
-        item: input.myself(),
-        offset: input.value(+1),
-      }),
+      withNearbyItemFromList('#list', input.myself(), V(+1)),
+      exposeDependency('#nearbyItem'),
+    ],
 
-      exposeDependency({
-        dependency: '#nearbyItem',
-      }),
+    groups: [
+      withPropertyFromObject('thing', V('groups')),
+      exposeDependencyOrContinue('#thing.groups'),
+
+      exposeConstant(V([])),
     ],
   });
 
@@ -259,7 +260,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 dac674dd..b595ec58 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -1,7 +1,6 @@
 export const FLASH_DATA_FILE = 'flashes.yaml';
 
-import {input} from '#composite';
-import {empty} from '#sugar';
+import {input, V} from '#composite';
 import {sortFlashesChronologically} from '#sort';
 import Thing from '#thing';
 import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
@@ -22,12 +21,10 @@ import {withPropertyFromObject} from '#composite/data';
 import {
   exposeConstant,
   exposeDependency,
-  exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
 } from '#composite/control-flow';
 
 import {
-  additionalNameList,
   color,
   commentatorArtists,
   constitutibleArtwork,
@@ -44,25 +41,29 @@ import {
   thing,
   thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
-import {withFlashAct} from '#composite/things/flash';
-import {withFlashSide} from '#composite/things/flash-act';
-
 export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
+  static [Thing.wikiData] = 'flashData';
+
+  static [Thing.constitutibleProperties] = [
+    'coverArtwork', // from inline fields
+  ];
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalName,
     CommentaryEntry,
     CreditingSourcesEntry,
-    Track,
     FlashAct,
+    Track,
     WikiInfo,
   }) => ({
     // Update & expose
 
-    name: name('Unnamed Flash'),
+    act: thing(V(FlashAct)),
+
+    name: name(V('Unnamed Flash')),
 
     directory: {
       flags: {update: true, expose: true},
@@ -95,19 +96,13 @@ export class Flash extends Thing {
         validate: input.value(isColor),
       }),
 
-      withFlashAct(),
-
-      withPropertyFromObject({
-        object: '#flashAct',
-        property: input.value('color'),
-      }),
-
-      exposeDependency({dependency: '#flashAct.color'}),
+      withPropertyFromObject('act', V('color')),
+      exposeDependency('#act.color'),
     ],
 
     date: simpleDate(),
 
-    coverArtFileExtension: fileExtension('jpg'),
+    coverArtFileExtension: fileExtension(V('jpg')),
 
     coverArtDimensions: dimensions(),
 
@@ -116,7 +111,6 @@ export class Flash extends Thing {
         .call(this, 'Cover Artwork'),
 
     contributorContribs: contributionList({
-      date: 'date',
       artistProperty: input.value('flashContributorContributions'),
     }),
 
@@ -127,15 +121,10 @@ export class Flash extends Thing {
 
     urls: urls(),
 
-    additionalNames: additionalNameList(),
-
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
+    additionalNames: thingList(V(AdditionalName)),
 
-    creditSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
+    commentary: thingList(V(CommentaryEntry)),
+    creditingSources: thingList(V(CreditingSourcesEntry)),
 
     // Update only
 
@@ -143,28 +132,17 @@ export class Flash extends Thing {
     reverse: soupyReverse(),
 
     // used for withMatchingContributionPresets (indirectly by Contribution)
-    wikiInfo: thing({
-      class: input.value(WikiInfo),
-    }),
+    wikiInfo: thing(V(WikiInfo)),
 
     // Expose only
 
-    commentatorArtists: commentatorArtists(),
+    isFlash: exposeConstant(V(true)),
 
-    act: [
-      withFlashAct(),
-      exposeDependency({dependency: '#flashAct'}),
-    ],
+    commentatorArtists: commentatorArtists(),
 
     side: [
-      withFlashAct(),
-
-      withPropertyFromObject({
-        object: '#flashAct',
-        property: input.value('side'),
-      }),
-
-      exposeDependency({dependency: '#flashAct.side'}),
+      withPropertyFromObject('act', V('side')),
+      exposeDependency('#act.side'),
     ],
   });
 
@@ -230,6 +208,7 @@ export class Flash extends Thing {
         transform:
           parseArtwork({
             single: true,
+            thingProperty: 'coverArtwork',
             fileExtensionFromThingProperty: 'coverArtFileExtension',
             dimensionsFromThingProperty: 'coverArtDimensions',
           }),
@@ -254,8 +233,8 @@ export class Flash extends Thing {
         transform: parseCommentary,
       },
 
-      'Credit Sources': {
-        property: 'creditSources',
+      'Crediting Sources': {
+        property: 'creditingSources',
         transform: parseCreditingSources,
       },
 
@@ -275,11 +254,14 @@ export class Flash extends Thing {
 export class FlashAct extends Thing {
   static [Thing.referenceType] = 'flash-act';
   static [Thing.friendlyName] = `Flash Act`;
+  static [Thing.wikiData] = 'flashActData';
 
-  static [Thing.getPropertyDescriptors] = () => ({
+  static [Thing.getPropertyDescriptors] = ({Flash, FlashSide}) => ({
     // Update & expose
 
-    name: name('Unnamed Flash Act'),
+    side: thing(V(FlashSide)),
+
+    name: name(V('Unnamed Flash Act')),
     directory: directory(),
     color: color(),
 
@@ -288,26 +270,11 @@ export class FlashAct extends Thing {
         validate: input.value(isContentString),
       }),
 
-      withFlashSide(),
-
-      withPropertyFromObject({
-        object: '#flashSide',
-        property: input.value('listTerminology'),
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#flashSide.listTerminology',
-      }),
-
-      exposeConstant({
-        value: input.value(null),
-      }),
+      withPropertyFromObject('side', V('listTerminology')),
+      exposeDependency('#side.listTerminology'),
     ],
 
-    flashes: referenceList({
-      class: input.value(Flash),
-      find: soupyFind.input('flash'),
-    }),
+    flashes: thingList(V(Flash)),
 
     // Update only
 
@@ -316,10 +283,7 @@ export class FlashAct extends Thing {
 
     // Expose only
 
-    side: [
-      withFlashSide(),
-      exposeDependency({dependency: '#flashSide'}),
-    ],
+    isFlashAct: exposeConstant(V(true)),
   });
 
   static [Thing.findSpecs] = {
@@ -354,23 +318,25 @@ export class FlashAct extends Thing {
 export class FlashSide extends Thing {
   static [Thing.referenceType] = 'flash-side';
   static [Thing.friendlyName] = `Flash Side`;
+  static [Thing.wikiData] = 'flashSideData';
 
-  static [Thing.getPropertyDescriptors] = () => ({
+  static [Thing.getPropertyDescriptors] = ({FlashAct}) => ({
     // Update & expose
 
-    name: name('Unnamed Flash Side'),
+    name: name(V('Unnamed Flash Side')),
     directory: directory(),
     color: color(),
     listTerminology: contentString(),
 
-    acts: referenceList({
-      class: input.value(FlashAct),
-      find: soupyFind.input('flashAct'),
-    }),
+    acts: thingList(V(FlashAct)),
 
     // Update only
 
     find: soupyFind(),
+
+    // Expose only
+
+    isFlashSide: exposeConstant(V(true)),
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -413,62 +379,61 @@ export class FlashSide extends Thing {
         ? FlashAct
         : Flash),
 
-    save(results) {
-      // JavaScript likes you.
+    connect(results) {
+      let thing, i;
 
-      if (!empty(results) && !(results[0] instanceof FlashSide)) {
-        throw new Error(`Expected a side at top of flash data file`);
-      }
+      for (i = 0; thing = results[i]; i++) {
+        if (thing.isFlashSide) {
+          const side = thing;
+          const acts = [];
 
-      let index = 0;
-      let thing;
-      for (; thing = results[index]; index++) {
-        const flashSide = thing;
-        const flashActRefs = [];
+          for (i++; thing = results[i]; i++) {
+            if (thing.isFlashAct) {
+              const act = thing;
+              const flashes = [];
 
-        if (results[index + 1] instanceof Flash) {
-          throw new Error(`Expected an act to immediately follow a side`);
-        }
+              for (i++; thing = results[i]; i++) {
+                if (thing.isFlash) {
+                  const flash = thing;
+
+                  flash.act = act;
+                  flashes.push(flash);
 
-        for (
-          index++;
-          (thing = results[index]) && thing instanceof FlashAct;
-          index++
-        ) {
-          const flashAct = thing;
-          const flashRefs = [];
-          for (
-            index++;
-            (thing = results[index]) && thing instanceof Flash;
-            index++
-          ) {
-            flashRefs.push(Thing.getReference(thing));
+                  continue;
+                }
+
+                i--;
+                break;
+              }
+
+              act.side = side;
+              act.flashes = flashes;
+              acts.push(act);
+
+              continue;
+            }
+
+            if (thing.isFlash) {
+              throw new Error(`Flashes must be under an act`);
+            }
+
+            i--;
+            break;
           }
-          index--;
-          flashAct.flashes = flashRefs;
-          flashActRefs.push(Thing.getReference(flashAct));
-        }
-        index--;
-        flashSide.acts = flashActRefs;
-      }
 
-      const flashData = results.filter(x => x instanceof Flash);
-      const flashActData = results.filter(x => x instanceof FlashAct);
-      const flashSideData = results.filter(x => x instanceof FlashSide);
+          side.acts = acts;
 
-      const artworkData = flashData.map(flash => flash.coverArtwork);
-      const commentaryData = flashData.flatMap(flash => flash.commentary);
-      const creditingSourceData = flashData.flatMap(flash => flash.creditSources);
+          continue;
+        }
 
-      return {
-        flashData,
-        flashActData,
-        flashSideData,
+        if (thing.isFlashAct) {
+          throw new Error(`Acts must be under a side`);
+        }
 
-        artworkData,
-        commentaryData,
-        creditingSourceData,
-      };
+        if (thing.isFlash) {
+          throw new Error(`Flashes must be under a side and act`);
+        }
+      }
     },
 
     sort({flashData}) {
diff --git a/src/data/things/group.js b/src/data/things/group.js
index b40d15b4..cc5f4cb8 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -1,31 +1,66 @@
 export const GROUP_DATA_FILE = 'groups.yaml';
 
-import {input} from '#composite';
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {input, V} from '#composite';
 import Thing from '#thing';
+import {is, isBoolean} from '#validators';
 import {parseAnnotatedReferences, parseSerieses} from '#yaml';
 
+import {withPropertyFromObject} from '#composite/data';
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+
+import {
+  exposeConstant,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
 import {
   annotatedReferenceList,
   color,
   contentString,
   directory,
+  flag,
   name,
   referenceList,
-  seriesList,
   soupyFind,
+  soupyReverse,
+  thing,
+  thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 export class Group extends Thing {
   static [Thing.referenceType] = 'group';
+  static [Thing.wikiData] = 'groupData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({
+  static [Thing.getPropertyDescriptors] = ({Album, Artist, Series}) => ({
     // Update & expose
 
-    name: name('Unnamed Group'),
+    name: name(V('Unnamed Group')),
     directory: directory(),
 
+    excludeFromGalleryTabs: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withUniqueReferencingThing({
+        reverse: soupyReverse.input('groupCategoriesWhichInclude'),
+      }).outputs({
+        '#uniqueReferencingThing': '#category',
+      }),
+
+      withPropertyFromObject('#category', V('excludeGroupsFromGalleryTabs')),
+      exposeDependencyOrContinue('#category.excludeGroupsFromGalleryTabs'),
+
+      exposeConstant(V(false)),
+    ],
+
+    divideAlbumsByStyle: flag(V(false)),
+
     description: contentString(),
 
     urls: urls(),
@@ -43,17 +78,17 @@ export class Group extends Thing {
       find: soupyFind.input('album'),
     }),
 
-    serieses: seriesList({
-      group: input.myself(),
-    }),
+    serieses: thingList(V(Series)),
 
     // Update only
 
     find: soupyFind(),
-    reverse: soupyFind(),
+    reverse: soupyReverse(),
 
     // Expose only
 
+    isGroup: exposeConstant(V(true)),
+
     descriptionShort: {
       flags: {expose: true},
 
@@ -70,8 +105,8 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['this', 'reverse'],
-        compute: ({this: group, reverse}) =>
+        dependencies: ['this', '_reverse'],
+        compute: ({this: group, _reverse: reverse}) =>
           reverse.albumsWhoseGroupsInclude(group),
       },
     },
@@ -80,8 +115,8 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['this', 'reverse'],
-        compute: ({this: group, reverse}) =>
+        dependencies: ['this', '_reverse'],
+        compute: ({this: group, _reverse: reverse}) =>
           reverse.groupCategoriesWhichInclude(group, {unique: true})
             ?.color,
       },
@@ -91,8 +126,8 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['this', 'reverse'],
-        compute: ({this: group, reverse}) =>
+        dependencies: ['this', '_reverse'],
+        compute: ({this: group, _reverse: reverse}) =>
           reverse.groupCategoriesWhichInclude(group, {unique: true}) ??
           null,
       },
@@ -129,6 +164,10 @@ export class Group extends Thing {
     fields: {
       'Group': {property: 'name'},
       'Directory': {property: 'directory'},
+
+      'Exclude From Gallery Tabs': {property: 'excludeFromGalleryTabs'},
+      'Divide Albums By Style': {property: 'divideAlbumsByStyle'},
+
       'Description': {property: 'description'},
       'URLs': {property: 'urls'},
 
@@ -165,7 +204,7 @@ export class Group extends Thing {
         ? GroupCategory
         : Group),
 
-    save(results) {
+    connect(results) {
       let groupCategory;
       let groupRefs = [];
 
@@ -189,11 +228,6 @@ export class Group extends Thing {
       if (groupCategory) {
         Object.assign(groupCategory, {groups: groupRefs});
       }
-
-      const groupData = results.filter(x => x instanceof Group);
-      const groupCategoryData = results.filter(x => x instanceof GroupCategory);
-
-      return {groupData, groupCategoryData};
     },
 
     // Groups aren't sorted at all, always preserving the order in the data
@@ -205,13 +239,16 @@ export class Group extends Thing {
 export class GroupCategory extends Thing {
   static [Thing.referenceType] = 'group-category';
   static [Thing.friendlyName] = `Group Category`;
+  static [Thing.wikiData] = 'groupCategoryData';
 
   static [Thing.getPropertyDescriptors] = ({Group}) => ({
     // Update & expose
 
-    name: name('Unnamed Group Category'),
+    name: name(V('Unnamed Group Category')),
     directory: directory(),
 
+    excludeGroupsFromGalleryTabs: flag(V(false)),
+
     color: color(),
 
     groups: referenceList({
@@ -222,6 +259,10 @@ export class GroupCategory extends Thing {
     // Update only
 
     find: soupyFind(),
+
+    // Expose only
+
+    isGroupCategory: exposeConstant(V(true)),
   });
 
   static [Thing.reverseSpecs] = {
@@ -236,7 +277,82 @@ export class GroupCategory extends Thing {
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Category': {property: 'name'},
+
       'Color': {property: 'color'},
+
+      'Exclude Groups From Gallery Tabs': {
+        property: 'excludeGroupsFromGalleryTabs',
+      },
     },
   };
 }
+
+export class Series extends Thing {
+  static [Thing.wikiData] = 'seriesData';
+
+  static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({
+    // Update & expose
+
+    name: name(V('Unnamed Series')),
+
+    showAlbumArtists: {
+      flags: {update: true, expose: true},
+      update: {
+        validate:
+          is('all', 'differing', 'none'),
+      },
+    },
+
+    description: contentString(),
+
+    group: thing(V(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..c4dc2812 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -3,7 +3,7 @@ export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml';
 import {inspect} from 'node:util';
 
 import {colors} from '#cli';
-import {input} from '#composite';
+import {input, V} from '#composite';
 import Thing from '#thing';
 import {empty} from '#sugar';
 
@@ -17,7 +17,7 @@ import {
   validateReference,
 } from '#validators';
 
-import {exposeDependency} from '#composite/control-flow';
+import {exposeConstant, exposeDependency} from '#composite/control-flow';
 import {withResolvedReference} from '#composite/wiki-data';
 
 import {
@@ -32,6 +32,8 @@ import {
 
 export class HomepageLayout extends Thing {
   static [Thing.friendlyName] = `Homepage Layout`;
+  static [Thing.wikiData] = 'homepageLayout';
+  static [Thing.oneInstancePerWiki] = true;
 
   static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({
     // Update & expose
@@ -44,9 +46,11 @@ export class HomepageLayout extends Thing {
       expose: {transform: value => value ?? []},
     },
 
-    sections: thingList({
-      class: input.value(HomepageLayoutSection),
-    }),
+    sections: thingList(V(HomepageLayoutSection)),
+
+    // Expose only
+
+    isHomepageLayout: exposeConstant(V(true)),
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -63,7 +67,6 @@ export class HomepageLayout extends Thing {
     thingConstructors: {
       HomepageLayout,
       HomepageLayoutSection,
-      HomepageLayoutAlbumsRow,
     },
   }) => ({
     title: `Process homepage layout file`,
@@ -95,7 +98,7 @@ export class HomepageLayout extends Thing {
       return null;
     },
 
-    save(results) {
+    connect(results) {
       if (!empty(results) && !(results[0] instanceof HomepageLayout)) {
         throw new Error(`Expected 'Homepage' document at top of homepage layout file`);
       }
@@ -138,8 +141,6 @@ export class HomepageLayout extends Thing {
       closeCurrentSection();
 
       homepageLayout.sections = sections;
-
-      return {homepageLayout};
     },
   });
 }
@@ -150,13 +151,15 @@ export class HomepageLayoutSection extends Thing {
   static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({
     // Update & expose
 
-    name: name(`Unnamed Homepage Section`),
+    name: name(V(`Unnamed Homepage Section`)),
 
     color: color(),
 
-    rows: thingList({
-      class: input.value(HomepageLayoutRow),
-    }),
+    rows: thingList(V(HomepageLayoutRow)),
+
+    // Expose only
+
+    isHomepageLayoutSection: exposeConstant(V(true)),
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -173,9 +176,7 @@ export class HomepageLayoutRow extends Thing {
   static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({
     // Update & expose
 
-    section: thing({
-      class: input.value(HomepageLayoutSection),
-    }),
+    section: thing(V(HomepageLayoutSection)),
 
     // Update only
 
@@ -183,6 +184,8 @@ export class HomepageLayoutRow extends Thing {
 
     // Expose only
 
+    isHomepageLayoutRow: exposeConstant(V(true)),
+
     type: {
       flags: {expose: true},
 
@@ -222,9 +225,7 @@ export class HomepageLayoutRow extends Thing {
 export class HomepageLayoutActionsRow extends HomepageLayoutRow {
   static [Thing.friendlyName] = `Homepage Actions Row`;
 
-  static [Thing.getPropertyDescriptors] = (opts) => ({
-    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
-
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     actionLinks: {
@@ -234,25 +235,21 @@ export class HomepageLayoutActionsRow extends HomepageLayoutRow {
 
     // Expose only
 
-    type: {
-      flags: {expose: true},
-      expose: {compute: () => 'actions'},
-    },
+    isHomepageLayoutActionsRow: exposeConstant(V(true)),
+    type: exposeConstant(V('actions')),
   });
 
-  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+  static [Thing.yamlDocumentSpec] = {
     fields: {
       'Actions': {property: 'actionLinks'},
     },
-  });
+  };
 }
 
 export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
   static [Thing.friendlyName] = `Homepage Album Carousel Row`;
 
-  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
-    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
-
+  static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({
     // Update & expose
 
     albums: referenceList({
@@ -262,25 +259,21 @@ export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
 
     // Expose only
 
-    type: {
-      flags: {expose: true},
-      expose: {compute: () => 'album carousel'},
-    },
+    isHomepageLayoutAlbumCarouselRow: exposeConstant(V(true)),
+    type: exposeConstant(V('album carousel')),
   });
 
-  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+  static [Thing.yamlDocumentSpec] = {
     fields: {
       'Albums': {property: 'albums'},
     },
-  });
+  };
 }
 
 export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow {
   static [Thing.friendlyName] = `Homepage Album Grid Row`;
 
   static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
-    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
-
     // Update & expose
 
     sourceGroup: [
@@ -307,7 +300,7 @@ export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow {
         find: soupyFind.input('group'),
       }),
 
-      exposeDependency({dependency: '#resolvedReference'}),
+      exposeDependency('#resolvedReference'),
     ],
 
     sourceAlbums: referenceList({
@@ -322,17 +315,15 @@ export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow {
 
     // Expose only
 
-    type: {
-      flags: {expose: true},
-      expose: {compute: () => 'album grid'},
-    },
+    isHomepageLayoutAlbumGridRow: exposeConstant(V(true)),
+    type: exposeConstant(V('album grid')),
   });
 
-  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+  static [Thing.yamlDocumentSpec] = {
     fields: {
       'Group': {property: 'sourceGroup'},
       'Count': {property: 'countAlbumsFromGroup'},
       'Albums': {property: 'sourceAlbums'},
     },
-  });
+  };
 }
diff --git a/src/data/things/index.js b/src/data/things/index.js
index b832ab75..09765fd2 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -6,9 +6,11 @@ import CacheableObject from '#cacheable-object';
 import {logError} from '#cli';
 import {compositeFrom} from '#composite';
 import * as serialize from '#serialize';
-import {withEntries} from '#sugar';
+import {empty} 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,
@@ -54,6 +58,7 @@ const __dirname = path.dirname(
 function niceShowAggregate(error, ...opts) {
   showAggregate(error, {
     pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
+    showClasses: false,
     ...opts,
   });
 }
@@ -86,25 +91,39 @@ function errorDuplicateClassNames() {
 }
 
 function flattenClassLists() {
-  let allClassesUnsorted = Object.create(null);
-
+  let remaining = [];
   for (const classes of Object.values(allClassLists)) {
-    for (const [name, constructor] of Object.entries(classes)) {
+    for (const constructor of Object.values(classes)) {
       if (typeof constructor !== 'function') continue;
       if (!(constructor.prototype instanceof Thing)) continue;
-      allClassesUnsorted[name] = constructor;
+      remaining.push(constructor);
+    }
+  }
+
+  let sorted = [];
+  while (true) {
+    if (sorted[0]) {
+      const superclass = Object.getPrototypeOf(sorted[0]);
+      if (superclass !== Thing) {
+        if (sorted.includes(superclass)) {
+          sorted.unshift(...sorted.splice(sorted.indexOf(superclass), 1));
+        } else {
+          sorted.unshift(superclass);
+        }
+        continue;
+      }
+    }
+
+    if (!empty(remaining)) {
+      sorted.unshift(remaining.shift());
+    } else {
+      break;
     }
   }
 
-  // Sort subclasses after their superclasses.
-  Object.assign(allClasses,
-    withEntries(allClassesUnsorted, entries =>
-      entries.sort(({[1]: A}, {[1]: B}) =>
-        (A.prototype instanceof B
-          ? +1
-       : B.prototype instanceof A
-          ? -1
-          :  0))));
+  for (const constructor of sorted) {
+    allClasses[constructor.name] = constructor;
+  }
 }
 
 function descriptorAggregateHelper({
@@ -134,6 +153,15 @@ function descriptorAggregateHelper({
   } catch (error) {
     niceShowAggregate(error);
     showFailedClasses(failedClasses);
+
+    /*
+    if (error.errors) {
+      for (const sub of error.errors) {
+        console.error(sub);
+      }
+    }
+    */
+
     return false;
   }
 }
@@ -163,10 +191,10 @@ function evaluatePropertyDescriptors() {
         }
       }
 
-      constructor[CacheableObject.propertyDescriptors] = {
-        ...constructor[CacheableObject.propertyDescriptors] ?? {},
-        ...results,
-      };
+      constructor[CacheableObject.propertyDescriptors] =
+        Object.create(constructor[CacheableObject.propertyDescriptors] ?? null);
+
+      Object.assign(constructor[CacheableObject.propertyDescriptors], results);
     },
 
     showFailedClasses(failedClasses) {
@@ -196,6 +224,27 @@ function evaluateSerializeDescriptors() {
   });
 }
 
+function finalizeYamlDocumentSpecs() {
+  return descriptorAggregateHelper({
+    message: `Errors finalizing Thing YAML document specs`,
+
+    op(constructor) {
+      const superclass = Object.getPrototypeOf(constructor);
+      if (
+        constructor[Thing.yamlDocumentSpec] &&
+        superclass[Thing.yamlDocumentSpec]
+      ) {
+        constructor[Thing.yamlDocumentSpec] =
+          Thing.extendDocumentSpec(superclass, constructor[Thing.yamlDocumentSpec]);
+      }
+    },
+
+    showFailedClasses(failedClasses) {
+      logError`Failed to finalize YAML document specs for classes: ${failedClasses.join(', ')}`;
+    },
+  });
+}
+
 function finalizeCacheableObjectPrototypes() {
   return descriptorAggregateHelper({
     message: `Errors finalizing Thing class prototypes`,
@@ -210,19 +259,14 @@ function finalizeCacheableObjectPrototypes() {
   });
 }
 
-if (!errorDuplicateClassNames())
-  process.exit(1);
+if (!errorDuplicateClassNames()) process.exit(1);
 
 flattenClassLists();
 
-if (!evaluatePropertyDescriptors())
-  process.exit(1);
-
-if (!evaluateSerializeDescriptors())
-  process.exit(1);
-
-if (!finalizeCacheableObjectPrototypes())
-  process.exit(1);
+if (!evaluatePropertyDescriptors()) process.exit(1);
+if (!evaluateSerializeDescriptors()) process.exit(1);
+if (!finalizeYamlDocumentSpecs()) process.exit(1);
+if (!finalizeCacheableObjectPrototypes()) process.exit(1);
 
 Object.assign(allClasses, {Thing});
 
diff --git a/src/data/things/language.js b/src/data/things/language.js
index a3f861bd..7f3f43de 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,24 +1,25 @@
-import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
+import {Temporal, toTemporalInstant} from '@js-temporal/polyfill';
 
 import {withAggregate} from '#aggregate';
-import CacheableObject from '#cacheable-object';
 import {logWarn} from '#cli';
+import {input, V} from '#composite';
 import * as html from '#html';
-import {empty} from '#sugar';
-import {isLanguageCode} from '#validators';
+import {accumulateSum, empty, withEntries} from '#sugar';
+import {isLanguageCode, isObject} from '#validators';
 import Thing from '#thing';
+import {languageOptionRegex} from '#wiki-data';
 
 import {
+  externalLinkSpec,
   getExternalLinkStringOfStyleFromDescriptors,
   getExternalLinkStringsFromDescriptors,
   isExternalLinkContext,
-  isExternalLinkSpec,
   isExternalLinkStyle,
 } from '#external-links';
 
-import {externalFunction, flag, name} from '#composite/wiki-properties';
-
-export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g;
+import {exitWithoutDependency, exposeConstant}
+  from '#composite/control-flow';
+import {flag, name} from '#composite/wiki-properties';
 
 export class Language extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
@@ -34,7 +35,7 @@ export class Language extends Thing {
 
     // Human-readable name. This should be the language's own native name, not
     // localized to any other language.
-    name: name(`Unnamed Language`),
+    name: name(V(`Unnamed Language`)),
 
     // Language code specific to JavaScript's Internationalization (Intl) API.
     // Usually this will be the same as the language's general code, but it
@@ -56,20 +57,29 @@ export class Language extends Thing {
     // with languages that are currently in development and not ready for
     // formal release, or which are just kept hidden as "experimental zones"
     // for wiki development or content testing.
-    hidden: flag(false),
+    hidden: flag(V(false)),
 
     // Mapping of translation keys to values (strings). Generally, don't
     // access this object directly - use methods instead.
-    strings: {
-      flags: {update: true, expose: true},
-      update: {validate: (t) => typeof t === 'object'},
+    strings: [
+      {
+        dependencies: [
+          input.updateValue({validate: isObject}),
+          'inheritedStrings',
+        ],
+
+        compute: (continuation, {
+          [input.updateValue()]: strings,
+          ['inheritedStrings']: inheritedStrings,
+        }) =>
+          (strings && inheritedStrings
+            ? continuation()
+            : strings ?? inheritedStrings),
+      },
 
-      expose: {
+      {
         dependencies: ['inheritedStrings', 'code'],
         transform(strings, {inheritedStrings, code}) {
-          if (!strings && !inheritedStrings) return null;
-          if (!inheritedStrings) return strings;
-
           const validStrings = {
             ...inheritedStrings,
             ...strings,
@@ -98,6 +108,7 @@ export class Language extends Thing {
                 logWarn`- Missing options: ${missingOptionNames.join(', ')}`;
               if (!empty(misplacedOptionNames))
                 logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`;
+
               validStrings[key] = inheritedStrings[key];
             }
           }
@@ -105,7 +116,7 @@ export class Language extends Thing {
           return validStrings;
         },
       },
-    },
+    ],
 
     // May be provided to specify "default" strings, generally (but not
     // necessarily) inherited from another Language object.
@@ -114,33 +125,22 @@ export class Language extends Thing {
       update: {validate: (t) => typeof t === 'object'},
     },
 
-    // List of descriptors for providing to external link utilities when using
-    // language.formatExternalLink - refer to #external-links for info.
-    externalLinkSpec: {
-      flags: {update: true, expose: true},
-      update: {validate: isExternalLinkSpec},
-    },
-
-    // Update only
-
-    escapeHTML: externalFunction(),
-
     // Expose only
 
-    onlyIfOptions: {
-      flags: {expose: true},
-      expose: {
-        compute: () => Symbol.for(`language.onlyIfOptions`),
-      },
-    },
+    isLanguage: exposeConstant(V(true)),
+
+    onlyIfOptions: exposeConstant(V(Symbol.for(`language.onlyIfOptions`))),
 
     intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}),
+    intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}),
+    intl_dateMonthDay: this.#intlHelper(Intl.DateTimeFormat, {month: 'numeric', day: 'numeric'}),
     intl_number: this.#intlHelper(Intl.NumberFormat),
     intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}),
     intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}),
     intl_listUnit: this.#intlHelper(Intl.ListFormat, {type: 'unit'}),
     intl_pluralCardinal: this.#intlHelper(Intl.PluralRules, {type: 'cardinal'}),
     intl_pluralOrdinal: this.#intlHelper(Intl.PluralRules, {type: 'ordinal'}),
+    intl_wordSegmenter: this.#intlHelper(Intl.Segmenter, {granularity: 'word'}),
 
     validKeys: {
       flags: {expose: true},
@@ -158,19 +158,16 @@ export class Language extends Thing {
     },
 
     // TODO: This currently isn't used. Is it still needed?
-    strings_htmlEscaped: {
-      flags: {expose: true},
-      expose: {
-        dependencies: ['strings', 'inheritedStrings', 'escapeHTML'],
-        compute({strings, inheritedStrings, escapeHTML}) {
-          if (!(strings || inheritedStrings) || !escapeHTML) return null;
-          const allStrings = {...inheritedStrings, ...strings};
-          return Object.fromEntries(
-            Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)])
-          );
-        },
+    strings_htmlEscaped: [
+      exitWithoutDependency('strings'),
+
+      {
+        dependencies: ['strings'],
+        compute: ({strings}) =>
+          withEntries(strings, entries => entries
+            .map(([key, value]) => [key, html.escape(value)])),
       },
-    },
+    ],
   });
 
   static #intlHelper (constructor, opts) {
@@ -191,18 +188,35 @@ export class Language extends Thing {
     return this.formatString(...args);
   }
 
+  $order(...args) {
+    return this.orderStringOptions(...args);
+  }
+
   assertIntlAvailable(property) {
     if (!this[property]) {
       throw new Error(`Intl API ${property} unavailable`);
     }
   }
 
+  countWords(text) {
+    this.assertIntlAvailable('intl_wordSegmenter');
+
+    const string = html.resolve(text, {normalize: 'plain'});
+    const segments = this.intl_wordSegmenter.segment(string);
+
+    return accumulateSum(segments, segment => segment.isWordLike ? 1 : 0);
+  }
+
   getUnitForm(value) {
     this.assertIntlAvailable('intl_pluralCardinal');
     return this.intl_pluralCardinal.select(value);
   }
 
   formatString(...args) {
+    if (typeof args.at(-1) === 'function') {
+      throw new Error(`Passed function - did you mean language.encapsulate() instead?`);
+    }
+
     const hasOptions =
       typeof args.at(-1) === 'object' &&
       args.at(-1) !== null;
@@ -210,19 +224,14 @@ export class Language extends Thing {
     const key =
       this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args);
 
+    const template =
+      this.#getStringTemplateFromFormedKey(key);
+
     const options =
       (hasOptions
         ? args.at(-1)
         : {});
 
-    if (!this.strings) {
-      throw new Error(`Strings unavailable`);
-    }
-
-    if (!this.validKeys.includes(key)) {
-      throw new Error(`Invalid key ${key} accessed`);
-    }
-
     const constantCasify = name =>
       name
         .replace(/[A-Z]/g, '_$&')
@@ -263,8 +272,7 @@ export class Language extends Thing {
         ]));
 
     const output = this.#iterateOverTemplate({
-      template: this.strings[key],
-
+      template,
       match: languageOptionRegex,
 
       insert: ({name: optionName}, canceledForming) => {
@@ -309,7 +317,7 @@ export class Language extends Thing {
           return undefined;
         }
 
-        return optionValue;
+        return this.sanitize(optionValue);
       },
     });
 
@@ -344,6 +352,46 @@ export class Language extends Thing {
     return output;
   }
 
+  orderStringOptions(...args) {
+    let slice = null, at = null, parts = null;
+    if (args.length >= 2 && typeof args.at(-1) === 'number') {
+      if (args.length >= 3 && typeof args.at(-2) === 'number') {
+        slice = [args.at(-2), args.at(-1)];
+        parts = args.slice(0, -2);
+      } else {
+        at = args.at(-1);
+        parts = args.slice(0, -1);
+      }
+    } else {
+      parts = args;
+    }
+
+    const template = this.getStringTemplate(...parts);
+    const matches = Array.from(template.matchAll(languageOptionRegex));
+    const options = matches.map(({groups}) => groups.name);
+
+    if (slice !== null) return options.slice(...slice);
+    if (at !== null) return options.at(at);
+    return options;
+  }
+
+  getStringTemplate(...args) {
+    const key = this.#joinKeyParts(args);
+    return this.#getStringTemplateFromFormedKey(key);
+  }
+
+  #getStringTemplateFromFormedKey(key) {
+    if (!this.strings) {
+      throw new Error(`Strings unavailable`);
+    }
+
+    if (!this.validKeys.includes(key)) {
+      throw new Error(`Invalid key ${key} accessed`);
+    }
+
+    return this.strings[key];
+  }
+
   #iterateOverTemplate({
     template,
     match: regexp,
@@ -374,26 +422,22 @@ export class Language extends Thing {
 
       partInProgress += template.slice(lastIndex, match.index);
 
-      // Sanitize string arguments in particular. These are taken to come from
-      // (raw) data and may include special characters that aren't meant to be
-      // rendered as HTML markup.
-      const sanitizedInsertion =
-        this.#sanitizeValueForInsertion(insertion);
-
-      if (typeof sanitizedInsertion === 'string') {
-        // Join consecutive strings together.
-        partInProgress += sanitizedInsertion;
-      } else if (
-        sanitizedInsertion instanceof html.Tag &&
-        sanitizedInsertion.contentOnly
-      ) {
-        // Collapse string-only tag contents onto the current string part.
-        partInProgress += sanitizedInsertion.toString();
-      } else {
-        // Push the string part in progress, then the insertion as-is.
-        outputParts.push(partInProgress);
-        outputParts.push(sanitizedInsertion);
+      const insertionItems = html.smush(insertion).content;
+      if (insertionItems.length === 1 && typeof insertionItems[0] !== 'string') {
+        // Push the insertion exactly as it is, rather than manipulating.
+        if (partInProgress) outputParts.push(partInProgress);
+        outputParts.push(insertion);
         partInProgress = '';
+      } else for (const insertionItem of insertionItems) {
+        if (typeof insertionItem === 'string') {
+          // Join consecutive strings together.
+          partInProgress += insertionItem;
+        } else {
+          // Push the string part in progress, then the insertion as-is.
+          if (partInProgress) outputParts.push(partInProgress);
+          outputParts.push(insertionItem);
+          partInProgress = '';
+        }
       }
 
       lastIndex = match.index + match[0].length;
@@ -425,14 +469,9 @@ export class Language extends Thing {
   // html.Tag objects - gets left as-is, preserving the value exactly as it's
   // provided.
   #sanitizeValueForInsertion(value) {
-    const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML');
-    if (!escapeHTML) {
-      throw new Error(`escapeHTML unavailable`);
-    }
-
     switch (typeof value) {
       case 'string':
-        return escapeHTML(value);
+        return html.escape(value);
 
       case 'number':
       case 'boolean':
@@ -488,22 +527,53 @@ 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);
+  }
+
+  formatMonthDay(date) {
+    if (date === null || date === undefined) {
+      return html.blank();
+    }
+
+    this.assertIntlAvailable('intl_dateMonthDay');
+    return this.intl_dateMonthDay.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,
@@ -665,10 +735,6 @@ export class Language extends Thing {
     style = 'platform',
     context = 'generic',
   } = {}) {
-    if (!this.externalLinkSpec) {
-      throw new TypeError(`externalLinkSpec unavailable`);
-    }
-
     // Null or undefined url is blank content.
     if (url === null || url === undefined) {
       return html.blank();
@@ -677,7 +743,7 @@ export class Language extends Thing {
     isExternalLinkContext(context);
 
     if (style === 'all') {
-      return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, {
+      return getExternalLinkStringsFromDescriptors(url, externalLinkSpec, {
         language: this,
         context,
       });
@@ -686,7 +752,7 @@ export class Language extends Thing {
     isExternalLinkStyle(style);
 
     const result =
-      getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+      getExternalLinkStringOfStyleFromDescriptors(url, style, externalLinkSpec, {
         language: this,
         context,
       });
@@ -842,6 +908,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) {
@@ -900,7 +978,6 @@ Object.assign(Language.prototype, {
   countArtworks: countHelper('artworks'),
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
-  countCoverArts: countHelper('coverArts'),
   countDays: countHelper('days'),
   countFlashes: countHelper('flashes'),
   countMonths: countHelper('months'),
diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js
index 43d1638e..bb35d11b 100644
--- a/src/data/things/news-entry.js
+++ b/src/data/things/news-entry.js
@@ -1,20 +1,23 @@
 export const NEWS_DATA_FILE = 'news.yaml';
 
+import {V} from '#composite';
 import {sortChronologically} from '#sort';
 import Thing from '#thing';
 import {parseDate} from '#yaml';
 
+import {exposeConstant} from '#composite/control-flow';
 import {contentString, directory, name, simpleDate}
   from '#composite/wiki-properties';
 
 export class NewsEntry extends Thing {
   static [Thing.referenceType] = 'news-entry';
   static [Thing.friendlyName] = `News Entry`;
+  static [Thing.wikiData] = 'newsData';
 
   static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
-    name: name('Unnamed News Entry'),
+    name: name(V('Unnamed News Entry')),
     directory: directory(),
     date: simpleDate(),
 
@@ -22,6 +25,8 @@ export class NewsEntry extends Thing {
 
     // Expose only
 
+    isNewsEntry: exposeConstant(V(true)),
+
     contentShort: {
       flags: {expose: true},
 
@@ -64,8 +69,6 @@ export class NewsEntry extends Thing {
     documentMode: allInOne,
     documentThing: NewsEntry,
 
-    save: (results) => ({newsData: results}),
-
     sort({newsData}) {
       sortChronologically(newsData, {latestFirst: true});
     },
diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js
index b169a541..101a4966 100644
--- a/src/data/things/sorting-rule.js
+++ b/src/data/things/sorting-rule.js
@@ -3,7 +3,7 @@ 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 {V} from '#composite';
 import {chunkByProperties, compareArrays, unique} from '#sugar';
 import Thing from '#thing';
 import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators';
@@ -22,6 +22,7 @@ import {
   reorderDocumentsInYAMLSourceText,
 } from '#yaml';
 
+import {exposeConstant} from '#composite/control-flow';
 import {flag} from '#composite/wiki-properties';
 
 function isSelectFollowingEntry(value) {
@@ -37,16 +38,21 @@ function isSelectFollowingEntry(value) {
 
 export class SortingRule extends Thing {
   static [Thing.friendlyName] = `Sorting Rule`;
+  static [Thing.wikiData] = 'sortingRules';
 
   static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
-    active: flag(true),
+    active: flag(V(true)),
 
     message: {
       flags: {update: true, expose: true},
       update: {validate: isStringNonEmpty},
     },
+
+    // Expose only
+
+    isSortingRule: exposeConstant(V(true)),
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -68,8 +74,6 @@ export class SortingRule extends Thing {
       (document['Sort Documents']
         ? DocumentSortingRule
         : null),
-
-    save: (results) => ({sortingRules: results}),
   });
 
   check(opts) {
@@ -119,17 +123,21 @@ export class ThingSortingRule extends SortingRule {
         validate: strictArrayOf(isStringNonEmpty),
       },
     },
+
+    // Expose only
+
+    isThingSortingRule: exposeConstant(V(true)),
   });
 
-  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(SortingRule, {
+  static [Thing.yamlDocumentSpec] = {
     fields: {
       'By Properties': {property: 'properties'},
     },
-  });
+  };
 
   sort(sortable) {
     if (this.properties) {
-      for (const property of this.properties.slice().reverse()) {
+      for (const property of this.properties.toReversed()) {
         const get = thing => thing[property];
         const lc = property.toLowerCase();
 
@@ -218,9 +226,13 @@ export class DocumentSortingRule extends ThingSortingRule {
       flags: {update: true, expose: true},
       update: {validate: isStringNonEmpty},
     },
+
+    // Expose only
+
+    isDocumentSortingRule: exposeConstant(V(true)),
   });
 
-  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ThingSortingRule, {
+  static [Thing.yamlDocumentSpec] = {
     fields: {
       'Sort Documents': {property: 'filename'},
       'Select Documents Following': {property: 'selectDocumentsFollowing'},
@@ -233,7 +245,7 @@ export class DocumentSortingRule extends ThingSortingRule {
         'Select Documents Under',
       ]},
     ],
-  });
+  };
 
   static async apply(rule, {wikiData, dataPath, dry}) {
     const oldLayout = getThingLayoutForFilename(rule.filename, wikiData);
@@ -261,10 +273,8 @@ export class DocumentSortingRule extends ThingSortingRule {
   }
 
   static async* applyAll(rules, {wikiData, dataPath, dry}) {
-    rules =
-      rules
-        .slice()
-        .sort((a, b) => a.filename.localeCompare(b.filename, 'en'));
+    rules = rules
+      .toSorted((a, b) => a.filename.localeCompare(b.filename, 'en'));
 
     for (const {chunk, filename} of chunkByProperties(rules, ['filename'])) {
       const initialLayout = getThingLayoutForFilename(filename, wikiData);
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
index 52a09c31..999072d3 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -2,22 +2,25 @@ export const DATA_STATIC_PAGE_DIRECTORY = 'static-page';
 
 import * as path from 'node:path';
 
+import {V} from '#composite';
 import {traverse} from '#node-utils';
 import {sortAlphabetically} from '#sort';
 import Thing from '#thing';
 import {isName} from '#validators';
 
+import {exposeConstant} from '#composite/control-flow';
 import {contentString, directory, flag, name, simpleString}
   from '#composite/wiki-properties';
 
 export class StaticPage extends Thing {
   static [Thing.referenceType] = 'static';
   static [Thing.friendlyName] = `Static Page`;
+  static [Thing.wikiData] = 'staticPageData';
 
   static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
-    name: name('Unnamed Static Page'),
+    name: name(V('Unnamed Static Page')),
 
     nameShort: {
       flags: {update: true, expose: true},
@@ -35,7 +38,11 @@ export class StaticPage extends Thing {
     script: simpleString(),
     content: contentString(),
 
-    absoluteLinks: flag(),
+    absoluteLinks: flag(V(false)),
+
+    // Expose only
+
+    isStaticPage: exposeConstant(V(true)),
   });
 
   static [Thing.findSpecs] = {
@@ -76,8 +83,6 @@ export class StaticPage extends Thing {
     documentMode: onePerFile,
     documentThing: StaticPage,
 
-    save: (results) => ({staticPageData: results}),
-
     sort({staticPageData}) {
       sortAlphabetically(staticPageData);
     },
diff --git a/src/data/things/track.js b/src/data/things/track.js
index ae7be170..ab7511a8 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -2,10 +2,21 @@ import {inspect} from 'node:util';
 
 import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
-import {input} from '#composite';
+import {input, V} from '#composite';
+import {onlyItem} from '#sugar';
+import {sortByDate} from '#sort';
 import Thing from '#thing';
-import {isBoolean, isColor, isContributionList, isDate, isFileExtension}
-  from '#validators';
+import {getKebabCase} from '#wiki-data';
+
+import {
+  isBoolean,
+  isColor,
+  isContentString,
+  isContributionList,
+  isDate,
+  isFileExtension,
+  validateReference,
+} from '#validators';
 
 import {
   parseAdditionalFiles,
@@ -15,31 +26,44 @@ import {
   parseCommentary,
   parseContributors,
   parseCreditingSources,
+  parseReferencingSources,
   parseDate,
   parseDimensions,
   parseDuration,
   parseLyrics,
 } from '#yaml';
 
-import {withPropertyFromObject} from '#composite/data';
-
 import {
+  exitWithoutDependency,
+  exitWithoutUpdateValue,
   exposeConstant,
   exposeDependency,
   exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
   exposeWhetherDependencyAvailable,
+  withAvailabilityFilter,
+  withResultOfAvailabilityCheck,
 } from '#composite/control-flow';
 
 import {
+  fillMissingListItems,
+  withFilteredList,
+  withFlattenedList,
+  withIndexInList,
+  withMappedList,
+  withPropertiesFromObject,
+  withPropertyFromList,
+  withPropertyFromObject,
+} from '#composite/data';
+
+import {
   withRecontextualizedContributionList,
   withRedatedContributionList,
   withResolvedContribs,
+  withResolvedReference,
 } from '#composite/wiki-data';
 
 import {
-  additionalFiles,
-  additionalNameList,
   commentatorArtists,
   constitutibleArtworkList,
   contentString,
@@ -54,7 +78,6 @@ import {
   reverseReferenceList,
   simpleDate,
   simpleString,
-  singleReference,
   soupyFind,
   soupyReverse,
   thing,
@@ -64,282 +87,395 @@ import {
 } from '#composite/wiki-properties';
 
 import {
-  exitWithoutUniqueCoverArt,
   inheritContributionListFromMainRelease,
   inheritFromMainRelease,
-  withAllReleases,
-  withAlwaysReferenceByDirectory,
-  withContainingTrackSection,
-  withCoverArtistContribs,
-  withDate,
-  withDirectorySuffix,
-  withHasUniqueCoverArt,
-  withMainRelease,
-  withOtherReleases,
-  withPropertyFromAlbum,
-  withSuffixDirectoryFromAlbum,
-  withTrackArtDate,
-  withTrackNumber,
 } from '#composite/things/track';
 
 export class Track extends Thing {
   static [Thing.referenceType] = 'track';
+  static [Thing.wikiData] = 'trackData';
+
+  static [Thing.constitutibleProperties] = [
+    // Contributions currently aren't being observed for constitution.
+    // 'artistContribs', // from main release or album
+    // 'contributorContribs', // from main release
+    // 'coverArtistContribs', // from main release
+
+    'trackArtworks', // from inline fields
+  ];
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalFile,
+    AdditionalName,
     Album,
     ArtTag,
     Artwork,
     CommentaryEntry,
     CreditingSourcesEntry,
-    Flash,
     LyricsEntry,
+    ReferencingSourcesEntry,
     TrackSection,
     WikiInfo,
   }) => ({
-    // Update & expose
-
-    name: name('Unnamed Track'),
+    // > Update & expose - Internal relationships
 
-    directory: [
-      withDirectorySuffix(),
+    album: thing(V(Album)),
+    trackSection: thing(V(TrackSection)),
 
-      directory({
-        suffix: '#directorySuffix',
-      }),
-    ],
+    // > Update & expose - Identifying metadata
 
-    suffixDirectoryFromAlbum: [
-      {
-        dependencies: [
-          input.updateValue({validate: isBoolean}),
-        ],
+    name: name(V('Unnamed Track')),
+    nameText: contentString(),
 
-        compute: (continuation, {
-          [input.updateValue()]: value,
-        }) => continuation({
-          ['#flagValue']: value ?? false,
-        }),
-      },
+    directory: directory({
+      suffix: 'directorySuffix',
+    }),
 
-      withSuffixDirectoryFromAlbum({
-        flagValue: '#flagValue',
+    suffixDirectoryFromAlbum: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
       }),
 
-      exposeDependency({
-        dependency: '#suffixDirectoryFromAlbum',
-      })
+      withPropertyFromObject('trackSection', V('suffixTrackDirectories')),
+      exposeDependency('#trackSection.suffixTrackDirectories'),
     ],
 
-    album: thing({
-      class: input.value(Album),
-    }),
-
-    additionalNames: additionalNameList(),
-
-    bandcampTrackIdentifier: simpleString(),
-    bandcampArtworkIdentifier: simpleString(),
-
-    duration: duration(),
-    urls: urls(),
-    dateFirstReleased: simpleDate(),
-
-    color: [
+    // Controls how find.track works - it'll never be matched by
+    // a reference just to the track's name, which means you don't
+    // have to always reference some *other* (much more commonly
+    // referenced) track by directory instead of more naturally by name.
+    alwaysReferenceByDirectory: [
       exposeUpdateValueOrContinue({
-        validate: input.value(isColor),
+        validate: input.value(isBoolean),
       }),
 
-      withContainingTrackSection(),
+      withPropertyFromObject('album', V('alwaysReferenceTracksByDirectory')),
 
-      withPropertyFromObject({
-        object: '#trackSection',
-        property: input.value('color'),
+      // Falsy mode means this exposes true if the album's property is true,
+      // but continues if the property is false (which is also the default).
+      exposeDependencyOrContinue({
+        dependency: '#album.alwaysReferenceTracksByDirectory',
+        mode: input.value('falsy'),
       }),
 
-      exposeDependencyOrContinue({dependency: '#trackSection.color'}),
+      exitWithoutDependency('_mainRelease', V(false)),
+      exitWithoutDependency('mainReleaseTrack', V(false)),
 
-      withPropertyFromAlbum({
-        property: input.value('color'),
-      }),
-
-      exposeDependency({dependency: '#album.color'}),
-    ],
+      withPropertyFromObject('mainReleaseTrack', V('name')),
 
-    alwaysReferenceByDirectory: [
-      withAlwaysReferenceByDirectory(),
-      exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
+      {
+        dependencies: ['name', '#mainReleaseTrack.name'],
+        compute: ({
+          ['name']: name,
+          ['#mainReleaseTrack.name']: mainReleaseName,
+        }) =>
+          getKebabCase(name) ===
+          getKebabCase(mainReleaseName),
+      },
     ],
 
-    // Disables presenting the track as though it has its own unique artwork.
-    // This flag should only be used in select circumstances, i.e. to override
-    // an album's trackCoverArtists. This flag supercedes that property, as well
-    // as the track's own coverArtists.
-    disableUniqueCoverArt: flag(),
-
-    // File extension for track's corresponding media file. This represents the
-    // track's unique cover artwork, if any, and does not inherit the extension
-    // of the album's main artwork. It does inherit trackCoverArtFileExtension,
-    // if present on the album.
-    coverArtFileExtension: [
-      exitWithoutUniqueCoverArt(),
-
-      exposeUpdateValueOrContinue({
-        validate: input.value(isFileExtension),
+    // Album or track. The exposed value is really just what's provided here,
+    // whether or not a matching track is found on a provided album, for
+    // example. When presenting or processing, read `mainReleaseTrack`.
+    mainRelease: [
+      exitWithoutUpdateValue({
+        validate: input.value(
+          validateReference(['album', 'track'])),
       }),
 
-      withPropertyFromAlbum({
-        property: input.value('trackCoverArtFileExtension'),
-      }),
+      {
+        dependencies: ['name'],
+        transform: (ref, continuation, {name: ownName}) =>
+          (ref === 'same name single'
+            ? continuation(ref, {
+                ['#albumOrTrackReference']: null,
+                ['#sameNameSingleReference']: ownName,
+              })
+            : continuation(ref, {
+                ['#albumOrTrackReference']: ref,
+                ['#sameNameSingleReference']: null,
+              })),
+      },
 
-      exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
+      withResolvedReference({
+        ref: '#albumOrTrackReference',
+        find: soupyFind.input('trackMainReleasesOnly'),
+      }).outputs({
+        '#resolvedReference': '#matchingTrack',
+      }),
 
-      exposeConstant({
-        value: input.value('jpg'),
+      withResolvedReference({
+        ref: '#albumOrTrackReference',
+        find: soupyFind.input('album'),
+      }).outputs({
+        '#resolvedReference': '#matchingAlbum',
       }),
-    ],
 
-    coverArtDate: [
-      withTrackArtDate({
-        from: input.updateValue({
-          validate: isDate,
+      withResolvedReference({
+        ref: '#sameNameSingleReference',
+        find: soupyFind.input('albumSinglesOnly'),
+        findOptions: input.value({
+          fuzz: {
+            capitalization: true,
+            kebab: true,
+          },
         }),
+      }).outputs({
+        '#resolvedReference': '#sameNameSingle',
       }),
 
-      exposeDependency({dependency: '#trackArtDate'}),
-    ],
+      exposeDependencyOrContinue('#sameNameSingle'),
 
-    coverArtDimensions: [
-      exitWithoutUniqueCoverArt(),
+      {
+        dependencies: [
+          '#matchingTrack',
+          '#matchingAlbum',
+        ],
 
-      exposeUpdateValueOrContinue(),
+        compute: (continuation, {
+          ['#matchingTrack']: matchingTrack,
+          ['#matchingAlbum']: matchingAlbum,
+        }) =>
+          (matchingTrack && matchingAlbum
+            ? continuation()
+         : matchingTrack ?? matchingAlbum
+            ? matchingTrack ?? matchingAlbum
+            : null),
+      },
 
-      withPropertyFromAlbum({
-        property: input.value('trackDimensions'),
-      }),
+      withPropertyFromObject( '#matchingAlbum', V('tracks')),
 
-      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
+      {
+        dependencies: [
+          '#matchingAlbum.tracks',
+          '#matchingTrack',
+        ],
 
-      dimensions(),
+        compute: ({
+          ['#matchingAlbum.tracks']: matchingAlbumTracks,
+          ['#matchingTrack']: matchingTrack,
+        }) =>
+          (matchingAlbumTracks.includes(matchingTrack)
+            ? matchingTrack
+            : null),
+      },
     ],
 
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
+    bandcampTrackIdentifier: simpleString(),
+    bandcampArtworkIdentifier: simpleString(),
 
-    creditSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
+    additionalNames: thingList(V(AdditionalName)),
 
-    lyrics: [
-      // TODO: Inherited lyrics are literally the same objects, so of course
-      // their .thing properties aren't going to point back to this one, and
-      // certainly couldn't be recontextualized...
-      inheritFromMainRelease(),
+    dateFirstReleased: simpleDate(),
 
-      thingList({
-        class: input.value(LyricsEntry),
+    // > Update & expose - Credits and contributors
+
+    artistText: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isContentString),
       }),
-    ],
 
-    additionalFiles: additionalFiles(),
-    sheetMusicFiles: additionalFiles(),
-    midiProjectFiles: additionalFiles(),
+      withPropertyFromObject('album', V('trackArtistText')),
+      exposeDependency('#album.trackArtistText'),
+    ],
 
-    mainReleaseTrack: singleReference({
-      class: input.value(Track),
-      find: soupyFind.input('track'),
-    }),
+    artistTextInLists: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isContentString),
+      }),
 
-    artistContribs: [
-      inheritContributionListFromMainRelease(),
+      exposeDependencyOrContinue('_artistText'),
 
-      withDate(),
+      withPropertyFromObject('album', V('trackArtistText')),
+      exposeDependency('#album.trackArtistText'),
+    ],
 
+    artistContribs: [
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
+        date: 'date',
         thingProperty: input.thisProperty(),
         artistProperty: input.value('trackArtistContributions'),
-        date: '#date',
       }).outputs({
         '#resolvedContribs': '#artistContribs',
       }),
 
-      exposeDependencyOrContinue({
-        dependency: '#artistContribs',
-        mode: input.value('empty'),
-      }),
+      exposeDependencyOrContinue('#artistContribs', V('empty')),
 
-      withPropertyFromAlbum({
-        property: input.value('artistContribs'),
-      }),
+      // Specifically inherit artist contributions later than artist contribs.
+      // Secondary releases' artists may differ from the main release.
+      inheritContributionListFromMainRelease(),
+
+      withPropertyFromObject('album', V('trackArtistContribs')),
 
       withRecontextualizedContributionList({
-        list: '#album.artistContribs',
+        list: '#album.trackArtistContribs',
         artistProperty: input.value('trackArtistContributions'),
       }),
 
       withRedatedContributionList({
-        list: '#album.artistContribs',
-        date: '#date',
+        list: '#album.trackArtistContribs',
+        date: 'date',
       }),
 
-      exposeDependency({dependency: '#album.artistContribs'}),
+      exposeDependency('#album.trackArtistContribs'),
     ],
 
     contributorContribs: [
       inheritContributionListFromMainRelease(),
 
-      withDate(),
-
       contributionList({
-        date: '#date',
+        date: 'date',
         artistProperty: input.value('trackContributorContributions'),
       }),
     ],
 
-    coverArtistContribs: [
-      withCoverArtistContribs({
-        from: input.updateValue({
-          validate: isContributionList,
-        }),
+    // > Update & expose - General configuration
+
+    countInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
       }),
 
-      exposeDependency({dependency: '#coverArtistContribs'}),
+      withPropertyFromObject('trackSection', V('countTracksInArtistTotals')),
+      exposeDependency('#trackSection.countTracksInArtistTotals'),
     ],
 
-    referencedTracks: [
-      inheritFromMainRelease({
-        notFoundValue: input.value([]),
-      }),
+    disableUniqueCoverArt: flag(V(false)),
+    disableDate: flag(V(false)),
 
-      referenceList({
-        class: input.value(Track),
-        find: soupyFind.input('track'),
+    // > Update & expose - General metadata
+
+    duration: duration(),
+
+    color: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isColor),
       }),
+
+      withPropertyFromObject('trackSection', V('color')),
+      exposeDependencyOrContinue('#trackSection.color'),
+
+      withPropertyFromObject('album', V('color')),
+      exposeDependency('#album.color'),
     ],
 
-    sampledTracks: [
-      inheritFromMainRelease({
-        notFoundValue: input.value([]),
+    needsLyrics: [
+      exposeUpdateValueOrContinue({
+        mode: input.value('falsy'),
+        validate: input.value(isBoolean),
       }),
 
-      referenceList({
-        class: input.value(Track),
-        find: soupyFind.input('track'),
+      exitWithoutDependency('_lyrics', {
+        value: input.value(false),
+        mode: input.value('empty'),
       }),
+
+      withPropertyFromList('_lyrics', V('helpNeeded')),
+
+      {
+        dependencies: ['#lyrics.helpNeeded'],
+        compute: ({
+          ['#lyrics.helpNeeded']: helpNeeded,
+        }) =>
+          helpNeeded.includes(true)
+      },
     ],
 
+    urls: urls(),
+
+    // > Update & expose - Artworks
+
     trackArtworks: [
-      exitWithoutUniqueCoverArt({
+      exitWithoutDependency('hasUniqueCoverArt', {
         value: input.value([]),
+        mode: input.value('falsy'),
       }),
 
       constitutibleArtworkList.fromYAMLFieldSpec
         .call(this, 'Track Artwork'),
     ],
 
+    coverArtistContribs: [
+      exitWithoutDependency('hasUniqueCoverArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
+      }),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        date: 'coverArtDate',
+        thingProperty: input.value('coverArtistContribs'),
+        artistProperty: input.value('trackCoverArtistContributions'),
+      }),
+
+      exposeDependencyOrContinue('#resolvedContribs', V('empty')),
+
+      withPropertyFromObject('album', V('trackCoverArtistContribs')),
+
+      withRecontextualizedContributionList({
+        list: '#album.trackCoverArtistContribs',
+        artistProperty: input.value('trackCoverArtistContributions'),
+      }),
+
+      withRedatedContributionList({
+        list: '#album.trackCoverArtistContribs',
+        date: 'coverArtDate',
+      }),
+
+      exposeDependency('#album.trackCoverArtistContribs'),
+    ],
+
+    coverArtDate: [
+      exitWithoutDependency('hasUniqueCoverArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      withPropertyFromObject('album', V('trackArtDate')),
+      exposeDependencyOrContinue('#album.trackArtDate'),
+
+      exposeDependency('date'),
+    ],
+
+    coverArtFileExtension: [
+      exitWithoutDependency('hasUniqueCoverArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isFileExtension),
+      }),
+
+      withPropertyFromObject('album', V('trackCoverArtFileExtension')),
+      exposeDependencyOrContinue('#album.trackCoverArtFileExtension'),
+
+      exposeConstant(V('jpg')),
+    ],
+
+    coverArtDimensions: [
+      exitWithoutDependency('hasUniqueCoverArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      exposeUpdateValueOrContinue(),
+
+      withPropertyFromObject('album', V('trackDimensions')),
+      exposeDependencyOrContinue('#album.trackDimensions'),
+
+      dimensions(),
+    ],
+
     artTags: [
-      exitWithoutUniqueCoverArt({
+      exitWithoutDependency('hasUniqueCoverArt', {
         value: input.value([]),
+        mode: input.value('falsy'),
       }),
 
       referenceList({
@@ -349,67 +485,341 @@ export class Track extends Thing {
     ],
 
     referencedArtworks: [
-      exitWithoutUniqueCoverArt({
+      exitWithoutDependency('hasUniqueCoverArt', {
         value: input.value([]),
+        mode: input.value('falsy'),
       }),
 
       referencedArtworkList(),
     ],
 
-    // Update only
+    // > Update & expose - Referenced tracks
+
+    previousProductionTracks: [
+      inheritFromMainRelease(),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('trackMainReleasesOnly'),
+      }),
+    ],
+
+    referencedTracks: [
+      inheritFromMainRelease(),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('trackMainReleasesOnly'),
+      }),
+    ],
+
+    sampledTracks: [
+      inheritFromMainRelease(),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('trackMainReleasesOnly'),
+      }),
+    ],
+
+    // > Update & expose - Additional files
+
+    additionalFiles: thingList(V(AdditionalFile)),
+    sheetMusicFiles: thingList(V(AdditionalFile)),
+    midiProjectFiles: thingList(V(AdditionalFile)),
+
+    // > Update & expose - Content entries
+
+    lyrics: [
+      // TODO: Inherited lyrics are literally the same objects, so of course
+      // their .thing properties aren't going to point back to this one, and
+      // certainly couldn't be recontextualized...
+      inheritFromMainRelease(),
+
+      thingList(V(LyricsEntry)),
+    ],
+
+    commentary: thingList(V(CommentaryEntry)),
+    creditingSources: thingList(V(CreditingSourcesEntry)),
+    referencingSources: thingList(V(ReferencingSourcesEntry)),
+
+    // > Update only
 
     find: soupyFind(),
     reverse: soupyReverse(),
 
     // used for referencedArtworkList (mixedFind)
-    artworkData: wikiData({
-      class: input.value(Artwork),
-    }),
-
-    // used for withAlwaysReferenceByDirectory (for some reason)
-    trackData: wikiData({
-      class: input.value(Track),
-    }),
+    artworkData: wikiData(V(Artwork)),
 
     // used for withMatchingContributionPresets (indirectly by Contribution)
-    wikiInfo: thing({
-      class: input.value(WikiInfo),
-    }),
+    wikiInfo: thing(V(WikiInfo)),
+
+    // > Expose only
 
-    // Expose only
+    isTrack: exposeConstant(V(true)),
 
     commentatorArtists: commentatorArtists(),
 
+    directorySuffix: [
+      exitWithoutDependency('suffixDirectoryFromAlbum', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      withPropertyFromObject('trackSection', V('directorySuffix')),
+      exposeDependency('#trackSection.directorySuffix'),
+    ],
+
     date: [
-      withDate(),
-      exposeDependency({dependency: '#date'}),
+      {
+        dependencies: ['disableDate'],
+        compute: (continuation, {disableDate}) =>
+          (disableDate
+            ? null
+            : continuation()),
+      },
+
+      exposeDependencyOrContinue('dateFirstReleased'),
+
+      withPropertyFromObject('album', V('date')),
+      exposeDependency('#album.date'),
     ],
 
     trackNumber: [
-      withTrackNumber(),
-      exposeDependency({dependency: '#trackNumber'}),
+      // Zero is the fallback, not one, but in most albums the first track
+      // (and its intended output by this composition) will be one.
+
+      exitWithoutDependency('trackSection', V(0)),
+      withPropertiesFromObject('trackSection', V(['tracks', 'startCountingFrom'])),
+
+      withIndexInList('#trackSection.tracks', input.myself()),
+      exitWithoutDependency('#index', V(0), V('index')),
+
+      {
+        dependencies: ['#trackSection.startCountingFrom', '#index'],
+        compute: ({
+          ['#trackSection.startCountingFrom']: startCountingFrom,
+          ['#index']: index,
+        }) => startCountingFrom + index,
+      },
     ],
 
+    // Whether or not the track has "unique" cover artwork - a cover which is
+    // specifically associated with this track in particular, rather than with
+    // the track's album as a whole. This is typically used to select between
+    // displaying the track artwork and a fallback, such as the album artwork
+    // or a placeholder. (This property is named hasUniqueCoverArt instead of
+    // the usual hasCoverArt to emphasize that it does not inherit from the
+    // album.)
+    //
+    // hasUniqueCoverArt is based only around the presence of *specified*
+    // cover artist contributions, not whether the references to artists on those
+    // contributions actually resolve to anything. It completely evades interacting
+    // with find/replace.
     hasUniqueCoverArt: [
-      withHasUniqueCoverArt(),
-      exposeDependency({dependency: '#hasUniqueCoverArt'}),
-    ],
+      {
+        dependencies: ['disableUniqueCoverArt'],
+        compute: (continuation, {disableUniqueCoverArt}) =>
+          (disableUniqueCoverArt
+            ? false
+            : continuation()),
+      },
+
+      withResultOfAvailabilityCheck({
+        from: '_coverArtistContribs',
+        mode: input.value('empty'),
+      }),
 
-    isMainRelease: [
-      withMainRelease(),
+      {
+        dependencies: ['#availability'],
+        compute: (continuation, {
+          ['#availability']: availability,
+        }) =>
+          (availability
+            ? true
+            : continuation()),
+      },
+
+      withPropertyFromObject('album', {
+        property: input.value('trackCoverArtistContribs'),
+        internal: input.value(true),
+      }),
+
+      withResultOfAvailabilityCheck({
+        from: '#album.trackCoverArtistContribs',
+        mode: input.value('empty'),
+      }),
+
+      {
+        dependencies: ['#availability'],
+        compute: (continuation, {
+          ['#availability']: availability,
+        }) =>
+          (availability
+            ? true
+            : continuation()),
+      },
+
+      exitWithoutDependency('_trackArtworks', {
+        value: input.value(false),
+        mode: input.value('empty'),
+      }),
+
+      withPropertyFromList('_trackArtworks', {
+        property: input.value('artistContribs'),
+        internal: input.value(true),
+      }),
+
+      // Since we're getting the update value for each artwork's artistContribs,
+      // it may not be set at all, and in that case won't be exposing as [].
+      fillMissingListItems('#trackArtworks.artistContribs', V([])),
+
+      withFlattenedList('#trackArtworks.artistContribs'),
 
       exposeWhetherDependencyAvailable({
-        dependency: '#mainRelease',
-        negate: input.value(true),
+        dependency: '#flattenedList',
+        mode: input.value('empty'),
       }),
     ],
 
-    isSecondaryRelease: [
-      withMainRelease(),
+    isMainRelease:
+      exposeWhetherDependencyAvailable({
+        dependency: 'mainReleaseTrack',
+        negate: input.value(true),
+      }),
 
+    isSecondaryRelease:
       exposeWhetherDependencyAvailable({
-        dependency: '#mainRelease',
+        dependency: 'mainReleaseTrack',
+      }),
+
+    mainReleaseTrack: [
+      exitWithoutDependency('mainRelease'),
+
+      withPropertyFromObject('mainRelease', V('isTrack')),
+
+      {
+        dependencies: ['mainRelease', '#mainRelease.isTrack'],
+        compute: (continuation, {
+          ['mainRelease']: mainRelease,
+          ['#mainRelease.isTrack']: mainReleaseIsTrack,
+        }) =>
+          (mainReleaseIsTrack
+            ? mainRelease
+            : continuation()),
+      },
+
+      {
+        dependencies: ['name', '_directory'],
+        compute: (continuation, {
+          ['name']: ownName,
+          ['_directory']: ownDirectory,
+        }) => {
+          const ownNameKebabed = getKebabCase(ownName);
+
+          return continuation({
+            ['#mapItsNameLikeName']:
+              name => getKebabCase(name) === ownNameKebabed,
+
+            ['#mapItsDirectoryLikeDirectory']:
+              (ownDirectory
+                ? directory => directory === ownDirectory
+                : () => false),
+
+            ['#mapItsNameLikeDirectory']:
+              (ownDirectory
+                ? name => getKebabCase(name) === ownDirectory
+                : () => false),
+
+            ['#mapItsDirectoryLikeName']:
+              directory => directory === ownNameKebabed,
+          });
+        },
+      },
+
+      withPropertyFromObject('mainRelease', V('tracks')),
+
+      withPropertyFromList('#mainRelease.tracks', {
+        property: input.value('mainRelease'),
+        internal: input.value(true),
+      }),
+
+      withAvailabilityFilter({from: '#mainRelease.tracks.mainRelease'}),
+
+      withMappedList({
+        list: '#availabilityFilter',
+        map: input.value(item => !item),
+      }).outputs({
+        '#mappedList': '#availabilityFilter',
       }),
+
+      withFilteredList('#mainRelease.tracks', '#availabilityFilter')
+        .outputs({'#filteredList': '#mainRelease.tracks'}),
+
+      withPropertyFromList('#mainRelease.tracks', V('name')),
+
+      withPropertyFromList('#mainRelease.tracks', {
+        property: input.value('directory'),
+        internal: input.value(true),
+      }),
+
+      withMappedList('#mainRelease.tracks.name', '#mapItsNameLikeName')
+        .outputs({'#mappedList': '#filterItsNameLikeName'}),
+
+      withMappedList('#mainRelease.tracks.directory', '#mapItsDirectoryLikeDirectory')
+        .outputs({'#mappedList': '#filterItsDirectoryLikeDirectory'}),
+
+      withMappedList('#mainRelease.tracks.name', '#mapItsNameLikeDirectory')
+        .outputs({'#mappedList': '#filterItsNameLikeDirectory'}),
+
+      withMappedList('#mainRelease.tracks.directory', '#mapItsDirectoryLikeName')
+        .outputs({'#mappedList': '#filterItsDirectoryLikeName'}),
+
+      withFilteredList('#mainRelease.tracks', '#filterItsNameLikeName')
+        .outputs({'#filteredList': '#matchingItsNameLikeName'}),
+
+      withFilteredList('#mainRelease.tracks', '#filterItsDirectoryLikeDirectory')
+        .outputs({'#filteredList': '#matchingItsDirectoryLikeDirectory'}),
+
+      withFilteredList('#mainRelease.tracks', '#filterItsNameLikeDirectory')
+        .outputs({'#filteredList': '#matchingItsNameLikeDirectory'}),
+
+      withFilteredList('#mainRelease.tracks', '#filterItsDirectoryLikeName')
+        .outputs({'#filteredList': '#matchingItsDirectoryLikeName'}),
+
+      {
+        dependencies: [
+          '#matchingItsNameLikeName',
+          '#matchingItsDirectoryLikeDirectory',
+          '#matchingItsNameLikeDirectory',
+          '#matchingItsDirectoryLikeName',
+        ],
+
+        compute: (continuation, {
+          ['#matchingItsNameLikeName']:           NLN,
+          ['#matchingItsDirectoryLikeDirectory']: DLD,
+          ['#matchingItsNameLikeDirectory']:      NLD,
+          ['#matchingItsDirectoryLikeName']:      DLN,
+        }) => continuation({
+          ['#mainReleaseTrack']:
+            onlyItem(DLD) ??
+            onlyItem(NLN) ??
+            onlyItem(DLN) ??
+            onlyItem(NLD) ??
+            null,
+        }),
+      },
+
+      {
+        dependencies: ['#mainReleaseTrack', input.myself()],
+        compute: ({
+          ['#mainReleaseTrack']: mainReleaseTrack,
+          [input.myself()]: thisTrack,
+        }) =>
+          (mainReleaseTrack === thisTrack
+            ? null
+            : mainReleaseTrack),
+      },
     ],
 
     // Only has any value for main releases, because secondary releases
@@ -419,15 +829,70 @@ export class Track extends Thing {
     }),
 
     allReleases: [
-      withAllReleases(),
-      exposeDependency({dependency: '#allReleases'}),
+      {
+        dependencies: [
+          'mainReleaseTrack',
+          'secondaryReleases',
+          input.myself(),
+        ],
+
+        compute: (continuation, {
+          mainReleaseTrack,
+          secondaryReleases,
+          [input.myself()]: thisTrack,
+        }) =>
+          (mainReleaseTrack
+            ? continuation({
+                ['#mainReleaseTrack']: mainReleaseTrack,
+                ['#secondaryReleaseTracks']: mainReleaseTrack.secondaryReleases,
+              })
+            : continuation({
+                ['#mainReleaseTrack']: thisTrack,
+                ['#secondaryReleaseTracks']: secondaryReleases,
+              })),
+      },
+
+      {
+        dependencies: [
+          '#mainReleaseTrack',
+          '#secondaryReleaseTracks',
+        ],
+
+        compute: ({
+          ['#mainReleaseTrack']: mainReleaseTrack,
+          ['#secondaryReleaseTracks']: secondaryReleaseTracks,
+        }) =>
+          sortByDate([mainReleaseTrack, ...secondaryReleaseTracks]),
+      },
     ],
 
     otherReleases: [
-      withOtherReleases(),
-      exposeDependency({dependency: '#otherReleases'}),
+      {
+        dependencies: [input.myself(), 'allReleases'],
+        compute: ({
+          [input.myself()]: thisTrack,
+          ['allReleases']: allReleases,
+        }) =>
+          allReleases.filter(track => track !== thisTrack),
+      },
     ],
 
+    commentaryFromMainRelease: [
+      exitWithoutDependency('mainReleaseTrack', V([])),
+
+      withPropertyFromObject('mainReleaseTrack', V('commentary')),
+      exposeDependency('#mainReleaseTrack.commentary'),
+    ],
+
+    groups: [
+      withPropertyFromObject('album', V('groups')),
+      exposeDependency('#album.groups'),
+    ],
+
+    followingProductionTracks: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichAreFollowingProductionsOf'),
+    }),
+
     referencedByTracks: reverseReferenceList({
       reverse: soupyReverse.input('tracksWhichReference'),
     }),
@@ -443,14 +908,14 @@ export class Track extends Thing {
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
+      // Identifying metadata
+
       'Track': {property: 'name'},
+      'Track Text': {property: 'nameText'},
       'Directory': {property: 'directory'},
       'Suffix Directory': {property: 'suffixDirectoryFromAlbum'},
-
-      'Additional Names': {
-        property: 'additionalNames',
-        transform: parseAdditionalNames,
-      },
+      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+      'Main Release': {property: 'mainRelease'},
 
       'Bandcamp Track ID': {
         property: 'bandcampTrackIdentifier',
@@ -462,17 +927,86 @@ export class Track extends Thing {
         transform: String,
       },
 
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Date First Released': {
+        property: 'dateFirstReleased',
+        transform: parseDate,
+      },
+
+      // Credits and contributors
+
+      'Artist Text': {property: 'artistText'},
+      'Artist Text In Lists': {property: 'artistTextInLists'},
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
+
+      // General configuration
+
+      'Count In Artist Totals': {property: 'countInArtistTotals'},
+
+      'Has Cover Art': {
+        property: 'disableUniqueCoverArt',
+        transform: value =>
+          (typeof value === 'boolean'
+            ? !value
+            : value),
+      },
+
+      'Has Date': {
+        property: 'disableDate',
+        transform: value =>
+          (typeof value === 'boolean'
+            ? !value
+            : value),
+      },
+
+      // General metadata
+
       'Duration': {
         property: 'duration',
         transform: parseDuration,
       },
 
       'Color': {property: 'color'},
+
+      'Needs Lyrics': {
+        property: 'needsLyrics',
+      },
+
       'URLs': {property: 'urls'},
 
-      'Date First Released': {
-        property: 'dateFirstReleased',
-        transform: parseDate,
+      // Artworks
+
+      'Track Artwork': {
+        property: 'trackArtworks',
+        transform:
+          parseArtwork({
+            thingProperty: 'trackArtworks',
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'trackCoverArtistContributions',
+          }),
+      },
+
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
       },
 
       'Cover Art Date': {
@@ -487,30 +1021,20 @@ export class Track extends Thing {
         transform: parseDimensions,
       },
 
-      'Has Cover Art': {
-        property: 'disableUniqueCoverArt',
-        transform: value =>
-          (typeof value === 'boolean'
-            ? !value
-            : value),
-      },
-
-      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+      'Art Tags': {property: 'artTags'},
 
-      'Lyrics': {
-        property: 'lyrics',
-        transform: parseLyrics,
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
       },
 
-      'Commentary': {
-        property: 'commentary',
-        transform: parseCommentary,
-      },
+      // Referenced tracks
 
-      'Credit Sources': {
-        property: 'creditSources',
-        transform: parseCreditingSources,
-      },
+      'Previous Productions': {property: 'previousProductionTracks'},
+      'Referenced Tracks': {property: 'referencedTracks'},
+      'Sampled Tracks': {property: 'sampledTracks'},
+
+      // Additional files
 
       'Additional Files': {
         property: 'additionalFiles',
@@ -527,53 +1051,41 @@ export class Track extends Thing {
         transform: parseAdditionalFiles,
       },
 
-      'Main Release': {property: 'mainReleaseTrack'},
-      'Referenced Tracks': {property: 'referencedTracks'},
-      'Sampled Tracks': {property: 'sampledTracks'},
+      // Content entries
 
-      'Referenced Artworks': {
-        property: 'referencedArtworks',
-        transform: parseAnnotatedReferences,
-      },
-
-      'Franchises': {ignore: true},
-      'Inherit Franchises': {ignore: true},
-
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
+      'Lyrics': {
+        property: 'lyrics',
+        transform: parseLyrics,
       },
 
-      'Contributors': {
-        property: 'contributorContribs',
-        transform: parseContributors,
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
       },
 
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
       },
 
-      'Track Artwork': {
-        property: 'trackArtworks',
-        transform:
-          parseArtwork({
-            dimensionsFromThingProperty: 'coverArtDimensions',
-            fileExtensionFromThingProperty: 'coverArtFileExtension',
-            dateFromThingProperty: 'coverArtDate',
-            artTagsFromThingProperty: 'artTags',
-            referencedArtworksFromThingProperty: 'referencedArtworks',
-            artistContribsFromThingProperty: 'coverArtistContribs',
-            artistContribsArtistProperty: 'trackCoverArtistContributions',
-          }),
+      'Referencing Sources': {
+        property: 'referencingSources',
+        transform: parseReferencingSources,
       },
 
-      'Art Tags': {property: 'artTags'},
+      // Shenanigans
 
+      'Franchises': {ignore: true},
+      'Inherit Franchises': {ignore: true},
       'Review Points': {ignore: true},
     },
 
     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',
@@ -584,11 +1096,6 @@ export class Track extends Thing {
         'Sampled Tracks',
       ]},
 
-      {message: `Secondary releases inherit artists from the main one`, fields: [
-        'Main Release',
-        'Artists',
-      ]},
-
       {message: `Secondary releases inherit contributors from the main one`, fields: [
         'Main Release',
         'Contributors',
@@ -630,7 +1137,7 @@ export class Track extends Thing {
       bindTo: 'trackData',
 
       include: track =>
-        !CacheableObject.getUpdateValue(track, 'mainReleaseTrack'),
+        !CacheableObject.getUpdateValue(track, 'mainRelease'),
 
       // It's still necessary to check alwaysReferenceByDirectory here, since
       // it may be set manually (with `Always Reference By Directory: true`),
@@ -730,11 +1237,28 @@ export class Track extends Thing {
       referencing: track => track.isSecondaryRelease ? [track] : [],
       referenced: track => [track.mainReleaseTrack],
     },
+
+    tracksWhichAreFollowingProductionsOf: {
+      bindTo: 'trackData',
+
+      referencing: track => track.isMainRelease ? [track] : [],
+      referenced: track => track.previousProductionTracks,
+    },
   };
 
   // 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;
 
@@ -750,12 +1274,36 @@ 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 = [];
 
     parts.push(Thing.prototype[inspect.custom].apply(this));
 
-    if (CacheableObject.getUpdateValue(this, 'mainReleaseTrack')) {
+    if (CacheableObject.getUpdateValue(this, 'mainRelease')) {
       parts.unshift(`${colors.yellow('[secrelease]')} `);
     }
 
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 590598be..26b69ba6 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -1,29 +1,35 @@
 export const WIKI_INFO_FILE = 'wiki-info.yaml';
 
-import {input} from '#composite';
+import {input, V} from '#composite';
 import Thing from '#thing';
-import {parseContributionPresets} from '#yaml';
+import {isBoolean, isContributionPresetList, isLanguageCode, isName}
+  from '#validators';
+import {parseContributionPresets, parseWallpaperParts} from '#yaml';
+
+import {exitWithoutDependency, exposeConstant} from '#composite/control-flow';
 
 import {
-  isBoolean,
-  isColor,
-  isContributionPresetList,
-  isLanguageCode,
-  isName,
-  isURL,
-} from '#validators';
-
-import {exitWithoutDependency} from '#composite/control-flow';
-import {contentString, flag, name, referenceList, soupyFind}
-  from '#composite/wiki-properties';
+  canonicalBase,
+  color,
+  contentString,
+  fileExtension,
+  flag,
+  name,
+  referenceList,
+  simpleString,
+  soupyFind,
+  wallpaperParts,
+} from '#composite/wiki-properties';
 
 export class WikiInfo extends Thing {
   static [Thing.friendlyName] = `Wiki Info`;
+  static [Thing.wikiData] = 'wikiInfo';
+  static [Thing.oneInstancePerWiki] = true;
 
   static [Thing.getPropertyDescriptors] = ({Group}) => ({
     // Update & expose
 
-    name: name('Unnamed Wiki'),
+    name: name(V('Unnamed Wiki')),
 
     // Displayed in nav bar.
     nameShort: {
@@ -36,14 +42,7 @@ export class WikiInfo extends Thing {
       },
     },
 
-    color: {
-      flags: {update: true, expose: true},
-      update: {validate: isColor},
-
-      expose: {
-        transform: color => color ?? '#0088ff',
-      },
-    },
+    color: color(V('#0088ff')),
 
     // One-line description used for <meta rel="description"> tag.
     description: contentString(),
@@ -55,18 +54,12 @@ export class WikiInfo extends Thing {
       update: {validate: isLanguageCode},
     },
 
-    canonicalBase: {
-      flags: {update: true, expose: true},
-      update: {validate: isURL},
-      expose: {
-        transform: (value) =>
-          (value === null
-            ? null
-         : value.endsWith('/')
-            ? value
-            : value + '/'),
-      },
-    },
+    canonicalBase: canonicalBase(),
+    canonicalMediaBase: canonicalBase(),
+
+    wikiWallpaperFileExtension: fileExtension(V('jpg')),
+    wikiWallpaperStyle: simpleString(),
+    wikiWallpaperParts: wallpaperParts(),
 
     divideTrackListsByGroups: referenceList({
       class: input.value(Group),
@@ -79,20 +72,19 @@ export class WikiInfo extends Thing {
     },
 
     // Feature toggles
-    enableFlashesAndGames: flag(false),
-    enableListings: flag(false),
-    enableNews: flag(false),
-    enableArtTagUI: flag(false),
-    enableGroupUI: flag(false),
+    enableFlashesAndGames: flag(V(false)),
+    enableListings: flag(V(false)),
+    enableNews: flag(V(false)),
+    enableArtTagUI: flag(V(false)),
+    enableGroupUI: flag(V(false)),
 
     enableSearch: [
-      exitWithoutDependency({
-        dependency: 'searchDataAvailable',
-        mode: input.value('falsy'),
+      exitWithoutDependency('_searchDataAvailable', {
         value: input.value(false),
+        mode: input.value('falsy'),
       }),
 
-      flag(true),
+      flag(V(true)),
     ],
 
     // Update only
@@ -106,24 +98,45 @@ export class WikiInfo extends Thing {
         default: false,
       },
     },
+
+    // Expose only
+
+    isWikiInfo: exposeConstant(V(true)),
   });
 
   static [Thing.yamlDocumentSpec] = {
     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'},
+      'Canonical Media Base': {property: 'canonicalMediaBase'},
+
+      '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,
@@ -140,13 +153,5 @@ export class WikiInfo extends Thing {
 
     documentMode: oneDocumentTotal,
     documentThing: WikiInfo,
-
-    save(wikiInfo) {
-      if (!wikiInfo) {
-        return;
-      }
-
-      return {wikiInfo};
-    },
   });
 }
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 79602faa..fbb4e5d6 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';
@@ -43,6 +44,42 @@ function inspect(value, opts = {}) {
   return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
 
+function makeEmptyWikiData() {
+  const wikiData = {};
+
+  for (const thingConstructor of Object.values(thingConstructors)) {
+    if (thingConstructor[Thing.wikiData]) {
+      if (thingConstructor[Thing.oneInstancePerWiki]) {
+        wikiData[thingConstructor[Thing.wikiData]] = null;
+      } else {
+        wikiData[thingConstructor[Thing.wikiData]] = [];
+      }
+    }
+  }
+
+  return wikiData;
+}
+
+function pushWikiData(a, b) {
+  for (const key of Object.keys(b)) {
+    if (!Object.hasOwn(a, key)) {
+      throw new Error(`${key} not present`);
+    }
+
+    if (Array.isArray(a[key])) {
+      if (Array.isArray(b[key])) {
+        a[key].push(...b[key]);
+      } else {
+        throw new Error(`${key} is an array, expected array of items to push`);
+      }
+    } else if (a[key] === null) {
+      a[key] = b[key];
+    } else if (b[key] !== null) {
+      throw new Error(`${key} already has a value: ${inspect(a[key])}`);
+    }
+  }
+}
+
 // General function for inputting a single document (usually loaded from YAML)
 // and outputting an instance of a provided Thing subclass.
 //
@@ -86,7 +123,7 @@ function makeProcessDocument(thingConstructor, {
   //   ]
   //
   // ...means A can't coexist with B or C, B can't coexist with A or C, and
-  // C can't coexist iwth A, B, or D - but it's okay for D to coexist with
+  // C can't coexist with A, B, or D - but it's okay for D to coexist with
   // A or B.
   //
   invalidFieldCombinations = [],
@@ -160,6 +197,16 @@ function makeProcessDocument(thingConstructor, {
 
     const thing = Reflect.construct(thingConstructor, []);
 
+    const wikiData = makeEmptyWikiData();
+    const flat = [thing];
+    if (thingConstructor[Thing.wikiData]) {
+      if (thingConstructor[Thing.oneInstancePerWiki]) {
+        wikiData[thingConstructor[Thing.wikiData]] = thing;
+      } else {
+        wikiData[thingConstructor[Thing.wikiData]] = [thing];
+      }
+    }
+
     const documentEntries = Object.entries(document)
       .filter(([field]) => !ignoredFields.includes(field));
 
@@ -181,9 +228,22 @@ function makeProcessDocument(thingConstructor, {
 
     const fieldCombinationErrors = [];
 
-    for (const {message, fields} of invalidFieldCombinations) {
+    for (const {message, fields: fieldsSpec} of invalidFieldCombinations) {
       const fieldsPresent =
-        presentFields.filter(field => fields.includes(field));
+        fieldsSpec.flatMap(fieldSpec => {
+          if (Array.isArray(fieldSpec)) {
+            const [field, match] = fieldSpec;
+            if (!presentFields.includes(field)) return [];
+            if (typeof match === 'function') {
+              return match(document[field]) ? [field] : [];
+            } else {
+              return document[field] === match ? [field] : [];
+            }
+          }
+
+          const field = fieldSpec;
+          return presentFields.includes(field) ? [field] : [];
+        });
 
       if (fieldsPresent.length >= 2) {
         const filteredDocument =
@@ -193,7 +253,10 @@ function makeProcessDocument(thingConstructor, {
             {preserveOriginalOrder: true});
 
         fieldCombinationErrors.push(
-          new FieldCombinationError(filteredDocument, message));
+          new FieldCombinationError(
+            filteredDocument,
+            fieldsSpec,
+            message));
 
         for (const field of Object.keys(filteredDocument)) {
           skippedFields.add(field);
@@ -249,7 +312,9 @@ function makeProcessDocument(thingConstructor, {
 
       // This variable would like to certify itself as "not into capitalism".
       let propertyValue =
-        (fieldSpecs[field].transform
+        (documentValue === null
+          ? null
+       : fieldSpecs[field].transform
           ? fieldSpecs[field].transform(documentValue, transformUtilities)
           : documentValue);
 
@@ -293,26 +358,29 @@ function makeProcessDocument(thingConstructor, {
     const followSubdocSetup = setup => {
       let error = null;
 
-      let subthing;
+      let result;
       try {
-        const result = bouncer(setup.data, setup.documentType);
-        subthing = result.thing;
-        result.aggregate.close();
+        let aggregate;
+        ({result, aggregate} = bouncer(setup.data, setup.documentType));
+        aggregate.close();
       } catch (caughtError) {
         error = caughtError;
       }
 
-      if (subthing) {
+      if (result.thing) {
         if (setup.bindInto) {
-          subthing[setup.bindInto] = thing;
+          result.thing[setup.bindInto] = thing;
         }
 
         if (setup.provide) {
-          Object.assign(subthing, setup.provide);
+          Object.assign(result.thing, setup.provide);
         }
       }
 
-      return {error, subthing};
+      pushWikiData(wikiData, result.wikiData);
+      flat.push(...result.flat);
+
+      return {error, subthing: result.thing};
     };
 
     for (const [field, layout] of Object.entries(subdocLayouts)) {
@@ -395,7 +463,14 @@ function makeProcessDocument(thingConstructor, {
             {preserveOriginalOrder: true})));
     }
 
-    return {thing, aggregate};
+    return {
+      aggregate,
+      result: {
+        thing,
+        flat,
+        wikiData,
+      },
+    };
   });
 }
 
@@ -415,19 +490,36 @@ export class FieldCombinationAggregateError extends AggregateError {
 }
 
 export class FieldCombinationError extends Error {
-  constructor(fields, message) {
-    const fieldNames = Object.keys(fields);
+  constructor(filteredDocument, fieldsSpec, message) {
+    const fieldNames = Object.keys(filteredDocument);
 
     const fieldNamesText =
       fieldNames
-        .map(field => colors.red(field))
+        .map(field => {
+          if (fieldsSpec.includes(field)) {
+            return colors.red(field);
+          }
+
+          const match =
+            fieldsSpec
+              .find(fieldSpec =>
+                Array.isArray(fieldSpec) &&
+                fieldSpec[0] === field)
+              .at(1);
+
+          if (typeof match === 'function') {
+            return colors.red(`${field}: ${filteredDocument[field]}`);
+          } else {
+            return colors.red(`${field}: ${match}`);
+          }
+        })
         .join(', ');
 
     const mainMessage = `Don't combine ${fieldNamesText}`;
 
     const causeMessage =
       (typeof message === 'function'
-        ? message(fields)
+        ? message(filteredDocument)
      : typeof message === 'string'
         ? message
         : null);
@@ -439,7 +531,7 @@ export class FieldCombinationError extends Error {
           : null),
     });
 
-    this.fields = fields;
+    this.fields = fieldNames;
   }
 }
 
@@ -610,49 +702,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'});
   });
 }
 
@@ -792,6 +874,7 @@ export function parseAnnotatedReferences(entries, {
 
 export function parseArtwork({
   single = false,
+  thingProperty = null,
   dimensionsFromThingProperty = null,
   fileExtensionFromThingProperty = null,
   dateFromThingProperty = null,
@@ -801,6 +884,7 @@ export function parseArtwork({
   referencedArtworksFromThingProperty = null,
 }) {
   const provide = {
+    thingProperty,
     dimensionsFromThingProperty,
     fileExtensionFromThingProperty,
     dateFromThingProperty,
@@ -825,37 +909,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)
@@ -874,22 +1003,63 @@ 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});
+}
+
+export function parseArtistAliases(value, {subdoc, Artist}) {
+  return parseArrayEntries(value, item => {
+    const config = {
+      bindInto: 'aliasedArtist',
+      provide: {isAlias: true},
+    };
+
+    if (typeof item === 'string') {
+      return subdoc(Artist, {'Artist': item}, config);
+    } else if (typeof item === 'object' && !Array.isArray(item)) {
+      if (item['Name']) {
+        const clone = {...item};
+        clone['Artist'] = item['Name'];
+        delete clone['Name'];
+        return subdoc(Artist, clone, config);
+      } else {
+        return subdoc(Artist, item, config);
+      }
+    } else {
+      return item;
+    }
+  });
 }
 
 // documentModes: Symbols indicating sets of behavior for loading and processing
@@ -921,6 +1091,12 @@ export const documentModes = {
   // array of processed documents (wiki objects).
   allInOne: Symbol('Document mode: allInOne'),
 
+  // allTogether: One or more documens, spread across any number of files.
+  // Expects files array (or function) and processDocument function.
+  // Calls save with an array of processed documents (wiki objects) - this is
+  // a flat array, *not* an array of the documents processed from *each* file.
+  allTogether: Symbol('Document mode: allTogether'),
+
   // oneDocumentTotal: Just a single document, represented in one file.
   // Expects file string (or function) and processDocument function. Calls
   // save with the single processed wiki document (data object).
@@ -967,7 +1143,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`);
   }
 
@@ -1031,6 +1207,7 @@ export async function getFilesFromDataStep(dataStep, {dataPath}) {
       }
     }
 
+    case documentModes.allTogether:
     case documentModes.headerAndEntries:
     case documentModes.onePerFile: {
       if (!dataStep.files) {
@@ -1186,27 +1363,37 @@ export function processThingsFromDataStep(documents, dataStep) {
   const {documentMode} = dataStep;
 
   switch (documentMode) {
-    case documentModes.allInOne: {
-      const result = [];
+    case documentModes.allInOne:
+    case documentModes.allTogether: {
+      const things = [];
+      const flat = [];
+      const wikiData = makeEmptyWikiData();
       const aggregate = openAggregate({message: `Errors processing documents`});
 
       documents.forEach(
         decorateErrorWithIndex((document, index) => {
-          const {thing, aggregate: subAggregate} =
+          const {result, aggregate: subAggregate} =
             processDocument(document, dataStep.documentThing);
 
-          thing[Thing.yamlSourceDocument] = document;
-          thing[Thing.yamlSourceDocumentPlacement] =
+          result.thing[Thing.yamlSourceDocument] = document;
+          result.thing[Thing.yamlSourceDocumentPlacement] =
             [documentModes.allInOne, index];
 
-          result.push(thing);
+          things.push(result.thing);
+          flat.push(...result.flat);
+          pushWikiData(wikiData, result.wikiData);
+
           aggregate.call(subAggregate.close);
         }));
 
       return {
         aggregate,
-        result,
-        things: result,
+        result: {
+          network: things,
+          flat: things,
+          file: things,
+          wikiData,
+        },
       };
     }
 
@@ -1214,17 +1401,21 @@ export function processThingsFromDataStep(documents, dataStep) {
       if (documents.length > 1)
         throw new Error(`Only expected one document to be present, got ${documents.length}`);
 
-      const {thing, aggregate} =
+      const {result, aggregate} =
         processDocument(documents[0], dataStep.documentThing);
 
-      thing[Thing.yamlSourceDocument] = documents[0];
-      thing[Thing.yamlSourceDocumentPlacement] =
+      result.thing[Thing.yamlSourceDocument] = documents[0];
+      result.thing[Thing.yamlSourceDocumentPlacement] =
         [documentModes.oneDocumentTotal];
 
       return {
         aggregate,
-        result: thing,
-        things: [thing],
+        result: {
+          network: result.thing,
+          flat: result.flat,
+          file: [result.thing],
+          wikiData: result.wikiData,
+        },
       };
     }
 
@@ -1236,14 +1427,17 @@ export function processThingsFromDataStep(documents, dataStep) {
         throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
 
       const aggregate = openAggregate({message: `Errors processing documents`});
+      const wikiData = makeEmptyWikiData();
 
-      const {thing: headerThing, aggregate: headerAggregate} =
+      const {result: headerResult, aggregate: headerAggregate} =
         processDocument(headerDocument, dataStep.headerDocumentThing);
 
-      headerThing[Thing.yamlSourceDocument] = headerDocument;
-      headerThing[Thing.yamlSourceDocumentPlacement] =
+      headerResult.thing[Thing.yamlSourceDocument] = headerDocument;
+      headerResult.thing[Thing.yamlSourceDocumentPlacement] =
         [documentModes.headerAndEntries, 'header'];
 
+      pushWikiData(wikiData, headerResult.wikiData);
+
       try {
         headerAggregate.close();
       } catch (caughtError) {
@@ -1251,17 +1445,18 @@ export function processThingsFromDataStep(documents, dataStep) {
         aggregate.push(caughtError);
       }
 
-      const entryThings = [];
+      const entryResults = [];
 
       for (const [index, entryDocument] of entryDocuments.entries()) {
-        const {thing: entryThing, aggregate: entryAggregate} =
+        const {result: entryResult, aggregate: entryAggregate} =
           processDocument(entryDocument, dataStep.entryDocumentThing);
 
-        entryThing[Thing.yamlSourceDocument] = entryDocument;
-        entryThing[Thing.yamlSourceDocumentPlacement] =
+        entryResult.thing[Thing.yamlSourceDocument] = entryDocument;
+        entryResult.thing[Thing.yamlSourceDocumentPlacement] =
           [documentModes.headerAndEntries, 'entry', index];
 
-        entryThings.push(entryThing);
+        entryResults.push(entryResult);
+        pushWikiData(wikiData, entryResult.wikiData);
 
         try {
           entryAggregate.close();
@@ -1274,10 +1469,16 @@ export function processThingsFromDataStep(documents, dataStep) {
       return {
         aggregate,
         result: {
-          header: headerThing,
-          entries: entryThings,
+          network: {
+            header: headerResult.thing,
+            entries: entryResults.map(result => result.thing),
+          },
+
+          flat: headerResult.flat.concat(entryResults.flatMap(result => result.flat)),
+          file: [headerResult.thing, ...entryResults.map(result => result.thing)],
+
+          wikiData,
         },
-        things: [headerThing, ...entryThings],
       };
     }
 
@@ -1288,17 +1489,21 @@ export function processThingsFromDataStep(documents, dataStep) {
       if (empty(documents) || !documents[0])
         throw new Error(`Expected a document, this file is empty`);
 
-      const {thing, aggregate} =
+      const {result, aggregate} =
         processDocument(documents[0], dataStep.documentThing);
 
-      thing[Thing.yamlSourceDocument] = documents[0];
-      thing[Thing.yamlSourceDocumentPlacement] =
+      result.thing[Thing.yamlSourceDocument] = documents[0];
+      result.thing[Thing.yamlSourceDocumentPlacement] =
         [documentModes.onePerFile];
 
       return {
         aggregate,
-        result: thing,
-        things: [thing],
+        result: {
+          network: result.thing,
+          flat: result.flat,
+          file: [result.thing],
+          wikiData: result.wikiData,
+        },
       };
     }
 
@@ -1399,10 +1604,10 @@ export async function processThingsFromDataSteps(documentLists, fileLists, dataS
           file: files,
           documents: documentLists,
         }).map(({file, documents}) => {
-            const {result, aggregate, things} =
+            const {result, aggregate} =
               processThingsFromDataStep(documents, dataStep);
 
-            for (const thing of things) {
+            for (const thing of result.file) {
               thing[Thing.yamlSourceFilename] =
                 path.relative(dataPath, file)
                   .split(path.sep)
@@ -1429,41 +1634,35 @@ export async function processThingsFromDataSteps(documentLists, fileLists, dataS
           translucent: true,
         }).contain(await fileListPromise));
 
-  const thingLists =
+  const results =
     aggregate
       .receive(await Promise.all(dataStepPromises));
 
-  return {aggregate, result: thingLists};
+  return {aggregate, result: results};
 }
 
-// Flattens a list of *lists* of things for a given data step (each list
-// corresponding to one YAML file) into results to be saved on the final
-// wikiData object, routing thing lists into the step's save() function.
-export function saveThingsFromDataStep(thingLists, dataStep) {
+// Runs a data step's connect() function, if present, with representations
+// of the results from the YAML files, called "networks" - one network and
+// one call to .connect() per YAML file - in order to form data connections
+// (direct links) between related objects within a file.
+export function connectThingsFromDataStep(results, dataStep) {
   const {documentMode} = dataStep;
 
   switch (documentMode) {
-    case documentModes.allInOne: {
-      const things =
-        (empty(thingLists)
-          ? []
-          : thingLists[0]);
-
-      return dataStep.save(things);
+    case documentModes.oneDocumentTotal:
+    case documentModes.onePerFile: {
+      // These results are never connected.
+      return;
     }
 
-    case documentModes.oneDocumentTotal: {
-      const thing =
-        (empty(thingLists)
-          ? {}
-          : thingLists[0]);
+    case documentModes.allInOne:
+    case documentModes.allTogether:
+    case documentModes.headerAndEntries: {
+      for (const result of results) {
+        dataStep.connect?.(result.network);
+      }
 
-      return dataStep.save(thing);
-    }
-
-    case documentModes.headerAndEntries:
-    case documentModes.onePerFile: {
-      return dataStep.save(thingLists);
+      break;
     }
 
     default:
@@ -1471,60 +1670,70 @@ export function saveThingsFromDataStep(thingLists, dataStep) {
   }
 }
 
-// Flattens a list of *lists* of things for each data step (each list
-// corresponding to one YAML file) into the final wikiData object,
-// routing thing lists into each step's save() function.
-export function saveThingsFromDataSteps(thingLists, dataSteps) {
+export function connectThingsFromDataSteps(processThingResultLists, dataSteps) {
   const aggregate =
     openAggregate({
-      message: `Errors finalizing things from data files`,
+      message: `Errors connecting things from data files`,
       translucent: true,
     });
 
-  const wikiData = {};
-
   stitchArrays({
     dataStep: dataSteps,
-    thingLists: thingLists,
-  }).map(({dataStep, thingLists}) => {
+    processThingResults: processThingResultLists,
+  }).forEach(({dataStep, processThingResults}) => {
       try {
-        return saveThingsFromDataStep(thingLists, dataStep);
+        connectThingsFromDataStep(processThingResults, dataStep);
       } catch (caughtError) {
         const error = new Error(
-          `Error finalizing things for data step: ${colors.bright(dataStep.title)}`,
+          `Error connecting things for data step: ${colors.bright(dataStep.title)}`,
           {cause: caughtError});
 
         error[Symbol.for('hsmusic.aggregate.translucent')] = true;
 
         aggregate.push(error);
-
-        return null;
       }
-    })
-    .filter(Boolean)
-    .forEach(saveResult => {
-      for (const [saveKey, saveValue] of Object.entries(saveResult)) {
-        if (Object.hasOwn(wikiData, saveKey)) {
-          if (Array.isArray(wikiData[saveKey])) {
-            if (Array.isArray(saveValue)) {
-              wikiData[saveKey].push(...saveValue);
-            } else {
-              throw new Error(`${saveKey} already present, expected array of items to push`);
-            }
-          } else {
-            if (Array.isArray(saveValue)) {
-              throw new Error(`${saveKey} already present and not an array, refusing to overwrite`);
-            } else {
-              throw new Error(`${saveKey} already present, refusing to overwrite`);
-            }
-          }
-        } else {
-          wikiData[saveKey] = saveValue;
+    });
+
+  return {result: null, aggregate};
+}
+
+export function makeWikiDataFromDataSteps(processThingResultLists, _dataSteps) {
+  const wikiData = makeEmptyWikiData();
+
+  for (const result of processThingResultLists.flat(2)) {
+    pushWikiData(wikiData, result.wikiData);
+  }
+
+  const scanForConstituted =
+    processThingResultLists.flat(2).flatMap(result => result.flat);
+
+  const exists = new Set(scanForConstituted);
+
+  while (scanForConstituted.length) {
+    const scanningThing = scanForConstituted.pop();
+
+    for (const key of scanningThing.constructor[Thing.constitutibleProperties] ?? []) {
+      const maybeConstitutedThings =
+        (Array.isArray(scanningThing[key])
+          ? scanningThing[key]
+       : scanningThing[key]
+          ? [scanningThing[key]]
+          : []);
+
+      for (const thing of maybeConstitutedThings) {
+        if (exists.has(thing)) continue;
+        exists.add(thing);
+
+        if (thing.constructor[Thing.wikiData]) {
+          pushWikiData(wikiData, {[thing.constructor[Thing.wikiData]]: [thing]});
         }
+
+        scanForConstituted.push(thing);
       }
-    });
+    }
+  }
 
-  return {aggregate, result: wikiData};
+  return wikiData;
 }
 
 export async function loadAndProcessDataDocuments(dataSteps, {dataPath}) {
@@ -1537,13 +1746,15 @@ export async function loadAndProcessDataDocuments(dataSteps, {dataPath}) {
     aggregate.receive(
       await loadYAMLDocumentsFromDataSteps(dataSteps, {dataPath}));
 
-  const thingLists =
+  const processThingResultLists =
     aggregate.receive(
       await processThingsFromDataSteps(documentLists, fileLists, dataSteps, {dataPath}));
 
+  aggregate.receive(
+    connectThingsFromDataSteps(processThingResultLists, dataSteps));
+
   const wikiData =
-    aggregate.receive(
-      saveThingsFromDataSteps(thingLists, dataSteps));
+    makeWikiDataFromDataSteps(processThingResultLists, dataSteps);
 
   return {aggregate, result: wikiData};
 }
@@ -1587,9 +1798,12 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
 
     ['lyricsData', [/* find */]],
 
+    ['referencingSourceData', [/* find */]],
+
+    ['seriesData', [/* find */]],
+
     ['trackData', [
       'artworkData',
-      'trackData',
       'wikiInfo',
     ]],