« 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/things
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/things')
-rw-r--r--src/data/things/AdditionalFile.js54
-rw-r--r--src/data/things/AdditionalName.js31
-rw-r--r--src/data/things/ArtTag.js195
-rw-r--r--src/data/things/Artist.js353
-rw-r--r--src/data/things/Artwork.js431
-rw-r--r--src/data/things/Language.js (renamed from src/data/things/language.js)279
-rw-r--r--src/data/things/MusicVideo.js145
-rw-r--r--src/data/things/NewsEntry.js (renamed from src/data/things/news-entry.js)27
-rw-r--r--src/data/things/StaticPage.js (renamed from src/data/things/static-page.js)39
-rw-r--r--src/data/things/Track.js1347
-rw-r--r--src/data/things/WikiInfo.js (renamed from src/data/things/wiki-info.js)116
-rw-r--r--src/data/things/album.js851
-rw-r--r--src/data/things/album/Album.js851
-rw-r--r--src/data/things/album/TrackSection.js267
-rw-r--r--src/data/things/album/index.js2
-rw-r--r--src/data/things/art-tag.js104
-rw-r--r--src/data/things/artist.js269
-rw-r--r--src/data/things/content/CommentaryEntry.js20
-rw-r--r--src/data/things/content/ContentEntry.js246
-rw-r--r--src/data/things/content/CreditingSourcesEntry.js16
-rw-r--r--src/data/things/content/LyricsEntry.js43
-rw-r--r--src/data/things/content/ReferencingSourcesEntry.js16
-rw-r--r--src/data/things/content/index.js9
-rw-r--r--src/data/things/contrib/AlbumArtistContribution.js12
-rw-r--r--src/data/things/contrib/AlbumAssetArtworkArtistContribution.js12
-rw-r--r--src/data/things/contrib/AlbumBannerArtistContribution.js12
-rw-r--r--src/data/things/contrib/AlbumWallpaperArtistContribution.js12
-rw-r--r--src/data/things/contrib/ArtworkArtistContribution.js20
-rw-r--r--src/data/things/contrib/Contribution.js351
-rw-r--r--src/data/things/contrib/MusicalArtistContribution.js20
-rw-r--r--src/data/things/contrib/TrackArtistContribution.js12
-rw-r--r--src/data/things/contrib/index.js11
-rw-r--r--src/data/things/contribution.js302
-rw-r--r--src/data/things/flash.js412
-rw-r--r--src/data/things/flash/Flash.js246
-rw-r--r--src/data/things/flash/FlashAct.js74
-rw-r--r--src/data/things/flash/FlashSide.js56
-rw-r--r--src/data/things/flash/index.js3
-rw-r--r--src/data/things/group/Group.js (renamed from src/data/things/group.js)158
-rw-r--r--src/data/things/group/GroupCategory.js58
-rw-r--r--src/data/things/group/Series.js79
-rw-r--r--src/data/things/group/index.js3
-rw-r--r--src/data/things/homepage-layout.js210
-rw-r--r--src/data/things/homepage-layout/HomepageLayout.js39
-rw-r--r--src/data/things/homepage-layout/HomepageLayoutActionsRow.js31
-rw-r--r--src/data/things/homepage-layout/HomepageLayoutAlbumCarouselRow.js31
-rw-r--r--src/data/things/homepage-layout/HomepageLayoutAlbumGridRow.js68
-rw-r--r--src/data/things/homepage-layout/HomepageLayoutRow.js60
-rw-r--r--src/data/things/homepage-layout/HomepageLayoutSection.js30
-rw-r--r--src/data/things/homepage-layout/index.js6
-rw-r--r--src/data/things/index.js227
-rw-r--r--src/data/things/init.js208
-rw-r--r--src/data/things/sorting-rule/DocumentSortingRule.js242
-rw-r--r--src/data/things/sorting-rule/SortingRule.js70
-rw-r--r--src/data/things/sorting-rule/ThingSortingRule.js83
-rw-r--r--src/data/things/sorting-rule/index.js3
-rw-r--r--src/data/things/track.js725
57 files changed, 6204 insertions, 3393 deletions
diff --git a/src/data/things/AdditionalFile.js b/src/data/things/AdditionalFile.js
new file mode 100644
index 00000000..b15f62e0
--- /dev/null
+++ b/src/data/things/AdditionalFile.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/AdditionalName.js b/src/data/things/AdditionalName.js
new file mode 100644
index 00000000..99f3ee46
--- /dev/null
+++ b/src/data/things/AdditionalName.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/ArtTag.js b/src/data/things/ArtTag.js
new file mode 100644
index 00000000..9d35f54d
--- /dev/null
+++ b/src/data/things/ArtTag.js
@@ -0,0 +1,195 @@
+import {input, V} from '#composite';
+import Thing from '#thing';
+import {unique} from '#sugar';
+import {isName} from '#validators';
+import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  annotatedReferenceList,
+  color,
+  contentString,
+  directory,
+  flag,
+  referenceList,
+  reverseReferenceList,
+  name,
+  soupyFind,
+  soupyReverse,
+  thingList,
+  urls,
+} from '#composite/wiki-properties';
+
+export class ArtTag extends Thing {
+  static [Thing.referenceType] = 'tag';
+  static [Thing.friendlyName] = `Art Tag`;
+  static [Thing.wikiData] = 'artTagData';
+
+  static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({
+    // Update & expose
+
+    name: name(V('Unnamed Art Tag')),
+    directory: directory(),
+    color: color(),
+    isContentWarning: flag(V(false)),
+    extraReadingURLs: urls(),
+
+    nameShort: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isName),
+      }),
+
+      {
+        dependencies: ['name'],
+        compute: ({name}) =>
+          name.replace(/ \([^)]*?\)$/, ''),
+      },
+    ],
+
+    additionalNames: thingList(V(AdditionalName)),
+
+    description: contentString(),
+
+    directDescendantArtTags: referenceList({
+      class: input.value(ArtTag),
+      find: soupyFind.input('artTag'),
+    }),
+
+    relatedArtTags: annotatedReferenceList({
+      class: input.value(ArtTag),
+      find: soupyFind.input('artTag'),
+
+      reference: input.value('artTag'),
+      thing: input.value('artTag'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // Expose only
+
+    isArtTag: exposeConstant(V(true)),
+
+    descriptionShort: [
+      exitWithoutDependency('description', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      {
+        dependencies: ['description'],
+        compute: ({description}) =>
+          description.split('<hr class="split">')[0],
+      },
+    ],
+
+    directlyFeaturedInArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichFeature'),
+    }),
+
+    indirectlyFeaturedInArtworks: [
+      {
+        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: [
+      {
+        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: [
+      {
+        dependencies: ['directAncestorArtTags'],
+        compute: ({directAncestorArtTags}) =>
+          new Map(
+            directAncestorArtTags
+              .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])),
+      },
+    ],
+  });
+
+  static [Thing.findSpecs] = {
+    artTag: {
+      referenceTypes: ['tag'],
+      bindTo: 'artTagData',
+
+      getMatchableNames: artTag =>
+        (artTag.isContentWarning
+          ? [`cw: ${artTag.name}`]
+          : [artTag.name]),
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    artTagsWhichDirectlyAncestor: {
+      bindTo: 'artTagData',
+
+      referencing: artTag => [artTag],
+      referenced: artTag => artTag.directDescendantArtTags,
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Tag': {property: 'name'},
+      'Short Name': {property: 'nameShort'},
+      'Directory': {property: 'directory'},
+      'Description': {property: 'description'},
+      'Extra Reading URLs': {property: 'extraReadingURLs'},
+
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Color': {property: 'color'},
+      'Is CW': {property: 'isContentWarning'},
+
+      'Direct Descendant Tags': {property: 'directDescendantArtTags'},
+
+      'Related Tags': {
+        property: 'relatedArtTags',
+        transform: entries =>
+          parseAnnotatedReferences(entries, {
+            referenceField: 'Tag',
+            referenceProperty: 'artTag',
+          }),
+      },
+    },
+  };
+}
diff --git a/src/data/things/Artist.js b/src/data/things/Artist.js
new file mode 100644
index 00000000..f518e31e
--- /dev/null
+++ b/src/data/things/Artist.js
@@ -0,0 +1,353 @@
+import {inspect} from 'node:util';
+
+import CacheableObject from '#cacheable-object';
+import {colors} from '#cli';
+import {input, V} from '#composite';
+import Thing from '#thing';
+import {parseArtistAliases, parseArtwork} from '#yaml';
+
+import {
+  sortAlbumsTracksChronologically,
+  sortArtworksChronologically,
+  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,
+  contentString,
+  directory,
+  fileExtension,
+  flag,
+  name,
+  reverseReferenceList,
+  soupyFind,
+  soupyReverse,
+  thing,
+  thingList,
+  urls,
+} from '#composite/wiki-properties';
+
+export class Artist extends Thing {
+  static [Thing.referenceType] = 'artist';
+  static [Thing.wikiData] = 'artistData';
+
+  static [Thing.constitutibleProperties] = [
+    'avatarArtwork', // from inline fields
+  ];
+
+  static [Thing.getPropertyDescriptors] = ({Contribution}) => ({
+    // Update & expose
+
+    name: name(V('Unnamed Artist')),
+    directory: directory(),
+    urls: urls(),
+
+    contextNotes: contentString(),
+
+    hasAvatar: flag(V(false)),
+    avatarFileExtension: fileExtension(V('jpg')),
+
+    avatarArtwork: [
+      exitWithoutDependency('hasAvatar', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Avatar Artwork'),
+    ],
+
+    isAlias: flag(V(false)),
+    artistAliases: thingList(V(Artist)),
+    aliasedArtist: thing(V(Artist)),
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // Expose only
+
+    isArtist: exposeConstant(V(true)),
+
+    mockSimpleContribution: {
+      flags: {expose: true},
+      expose: {
+        dependencies: ['directory', '_find'],
+        compute: ({directory, _find: find}) =>
+          Object.assign(new Contribution, {
+            artist: 'artist:' + directory,
+
+            // These nulls have no effect, they're only included
+            // here for clarity.
+            date: null,
+            thing: null,
+            annotation: null,
+            artistProperty: null,
+            thingProperty: null,
+
+            find,
+          }),
+      },
+    },
+
+    trackArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('trackArtistContributionsBy'),
+    }),
+
+    trackContributorContributions: reverseReferenceList({
+      reverse: soupyReverse.input('trackContributorContributionsBy'),
+    }),
+
+    trackCoverArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('trackCoverArtistContributionsBy'),
+    }),
+
+    tracksAsCommentator: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWithCommentaryBy'),
+    }),
+
+    albumArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumArtistContributionsBy'),
+    }),
+
+    albumTrackArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumTrackArtistContributionsBy'),
+    }),
+
+    albumCoverArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumCoverArtistContributionsBy'),
+    }),
+
+    albumWallpaperArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'),
+    }),
+
+    albumBannerArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumBannerArtistContributionsBy'),
+    }),
+
+    albumsAsCommentator: reverseReferenceList({
+      reverse: soupyReverse.input('albumsWithCommentaryBy'),
+    }),
+
+    flashContributorContributions: reverseReferenceList({
+      reverse: soupyReverse.input('flashContributorContributionsBy'),
+    }),
+
+    flashesAsCommentator: reverseReferenceList({
+      reverse: soupyReverse.input('flashesWithCommentaryBy'),
+    }),
+
+    closelyLinkedGroups: reverseReferenceList({
+      reverse: soupyReverse.input('groupsCloselyLinkedTo'),
+    }),
+
+    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),
+      },
+    ],
+
+    musicVideoArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('musicVideoArtistContributionsBy'),
+    }),
+
+    musicVideoContributorContributions: reverseReferenceList({
+      reverse: soupyReverse.input('musicVideoContributorContributionsBy'),
+    }),
+
+    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] = ({
+    serialize: S,
+  }) => ({
+    name: S.id,
+    directory: S.id,
+    urls: S.id,
+    contextNotes: S.id,
+
+    hasAvatar: S.id,
+    avatarFileExtension: S.id,
+
+    tracksAsCommentator: S.toRefs,
+    albumsAsCommentator: S.toRefs,
+  });
+
+  static [Thing.findSpecs] = {
+    artist: {
+      referenceTypes: ['artist', 'artist-gallery'],
+      bindTo: 'artistData',
+
+      include: artist => !artist.isAlias,
+    },
+
+    artistAlias: {
+      referenceTypes: ['artist', 'artist-gallery'],
+      bindTo: 'artistData',
+
+      include: artist => artist.isAlias,
+
+      getMatchableDirectories(artist) {
+        const originalArtist = artist.aliasedArtist;
+
+        // Aliases never match by the same directory as the original.
+        if (artist.directory === originalArtist.directory) {
+          return [];
+        }
+
+        // Aliases never match by the same directory as some *previous* alias
+        // 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 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
+        // spooky because it doesn't handle two differently named aliases, on
+        // different artists, who have names that are similar *apart* from a
+        // character that's shortened. But that's also so fundamentally scary
+        // that we can't support it properly with existing code, anyway - we
+        // would need to be able to specifically set a directory *on an alias,*
+        // which currently can't be done in YAML data files.
+        if (artist.directory === '') {
+          return [];
+        }
+
+        return [artist.directory];
+      },
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Artist': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'URLs': {property: 'urls'},
+      'Context Notes': {property: 'contextNotes'},
+
+      // note: doesn't really work as an independent field yet
+      'Avatar Artwork': {
+        property: 'avatarArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'avatarArtwork',
+            fileExtensionFromThingProperty: 'avatarFileExtension',
+          }),
+      },
+
+      'Has Avatar': {property: 'hasAvatar'},
+      'Avatar File Extension': {property: 'avatarFileExtension'},
+
+      'Aliases': {
+        property: 'artistAliases',
+        transform: parseArtistAliases,
+      },
+
+      'Dead URLs': {ignore: true},
+
+      'Review Points': {ignore: true},
+    },
+  };
+
+  [inspect.custom]() {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (CacheableObject.getUpdateValue(this, 'isAlias')) {
+      parts.unshift(`${colors.yellow('[alias]')} `);
+
+      let aliasedArtist;
+      try {
+        aliasedArtist = this.aliasedArtist.name;
+      } catch {
+        aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist');
+      }
+
+      parts.push(` ${colors.yellow(`[of ${aliasedArtist}]`)}`);
+    }
+
+    return parts.join('');
+  }
+
+  getOwnArtworkPath(artwork) {
+    return [
+      'media.artistAvatar',
+      this.directory,
+      artwork.fileExtension,
+    ];
+  }
+}
diff --git a/src/data/things/Artwork.js b/src/data/things/Artwork.js
new file mode 100644
index 00000000..7beb3567
--- /dev/null
+++ b/src/data/things/Artwork.js
@@ -0,0 +1,431 @@
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {input, V} from '#composite';
+import find from '#find';
+import Thing from '#thing';
+
+import {
+  isContentString,
+  isContributionList,
+  isDate,
+  isDimensions,
+  isFileExtension,
+  optional,
+  validateArrayItems,
+  validateProperties,
+  validateReference,
+  validateReferenceList,
+} from '#validators';
+
+import {
+  parseAnnotatedReferences,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+} from '#yaml';
+
+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,
+  withResolvedReferenceList,
+} from '#composite/wiki-data';
+
+import {
+  contentString,
+  directory,
+  flag,
+  reverseReferenceList,
+  simpleString,
+  soupyFind,
+  soupyReverse,
+  thing,
+  wikiData,
+} from '#composite/wiki-properties';
+
+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,
+    ArtworkArtistContribution,
+  }) => ({
+    // Update & expose
+
+    unqualifiedDirectory: directory({
+      name: input.value(null),
+    }),
+
+    thing: thing(),
+    thingProperty: simpleString(),
+
+    label: simpleString(),
+    source: contentString(),
+    originDetails: contentString(),
+    showFilename: simpleString(),
+
+    dateFromThingProperty: simpleString(),
+
+    date: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      constituteFrom('thing', 'dateFromThingProperty'),
+    ],
+
+    fileExtensionFromThingProperty: simpleString(),
+
+    fileExtension: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isFileExtension),
+      }),
+
+      constituteFrom('thing', 'fileExtensionFromThingProperty', {
+        else: input.value('jpg'),
+      }),
+    ],
+
+    dimensionsFromThingProperty: simpleString(),
+
+    dimensions: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDimensions),
+      }),
+
+      constituteFrom('thing', 'dimensionsFromThingProperty'),
+    ],
+
+    attachAbove: flag(V(false)),
+
+    artistContribsFromThingProperty: simpleString(),
+    artistContribsArtistProperty: simpleString(),
+
+    artistContribs: [
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+
+        // XXX: All artwork artist contributions, as resolved from update value
+        // (*not* those constituted from thing), are generic artwork contribs.
+        // The class should be specified by whatever the artwork is placed on!!
+        class: input.value(ArtworkArtistContribution),
+
+        date: 'date',
+        thingProperty: input.thisProperty(),
+        artistProperty: 'artistContribsArtistProperty',
+      }),
+
+      exposeDependencyOrContinue('#resolvedContribs', V('empty')),
+
+      withPropertyFromObject('attachedArtwork', V('artistContribs')),
+
+      withRecontextualizedContributionList('#attachedArtwork.artistContribs'),
+      exposeDependencyOrContinue('#attachedArtwork.artistContribs'),
+
+      exitWithoutDependency('artistContribsFromThingProperty', V([])),
+
+      withPropertyFromObject('thing', 'artistContribsFromThingProperty')
+        .outputs({'#value': '#artistContribsFromThing'}),
+
+      withRecontextualizedContributionList('#artistContribsFromThing'),
+      exposeDependency('#artistContribsFromThing'),
+    ],
+
+    style: simpleString(),
+
+    artTagsFromThingProperty: simpleString(),
+
+    artTags: [
+      withResolvedReferenceList({
+        list: input.updateValue({
+          validate:
+            validateReferenceList(ArtTag[Thing.referenceType]),
+        }),
+        find: soupyFind.input('artTag'),
+      }),
+
+      exposeDependencyOrContinue('#resolvedReferenceList', V('empty')),
+
+      constituteOrContinue('attachedArtwork', V('artTags'), V('empty')),
+
+      constituteFrom('thing', 'artTagsFromThingProperty', V([])),
+    ],
+
+    referencedArtworksFromThingProperty: simpleString(),
+
+    referencedArtworks: [
+      {
+        compute: (continuation) => continuation({
+          ['#find']:
+            find.mixed({
+              track: find.trackPrimaryArtwork,
+              album: find.albumPrimaryArtwork,
+            }),
+        }),
+      },
+
+      withResolvedAnnotatedReferenceList({
+        list: input.updateValue({
+          validate:
+            // TODO: It's annoying to hardcode this when it's really the
+            // same behavior as through annotatedReferenceList and through
+            // referenceListUpdateDescription, the latter of which isn't
+            // available outside of #composite/wiki-data internals.
+            validateArrayItems(
+              validateProperties({
+                reference: validateReference(['album', 'track']),
+                annotation: optional(isContentString),
+              })),
+        }),
+
+        data: '_artworkData',
+        find: '#find',
+
+        thing: input.value('artwork'),
+      }),
+
+      exposeDependencyOrContinue('#resolvedAnnotatedReferenceList', V('empty')),
+
+      constituteFrom('thing', 'referencedArtworksFromThingProperty', {
+        else: input.value([]),
+      }),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for referencedArtworks (mixedFind)
+    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] = {
+    fields: {
+      'Directory': {property: 'unqualifiedDirectory'},
+      'File Extension': {property: 'fileExtension'},
+
+      'Dimensions': {
+        property: 'dimensions',
+        transform: parseDimensions,
+      },
+
+      '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': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    artworksWhichReference: {
+      bindTo: 'artworkData',
+
+      referencing: referencingArtwork =>
+        referencingArtwork.referencedArtworks
+          .map(({artwork: referencedArtwork, ...referenceDetails}) => ({
+            referencingArtwork,
+            referencedArtwork,
+            referenceDetails,
+          })),
+
+      referenced: ({referencedArtwork}) => [referencedArtwork],
+
+      tidy: ({referencingArtwork, referenceDetails}) => ({
+        artwork: referencingArtwork,
+        ...referenceDetails,
+      }),
+
+      date: ({artwork}) => artwork.date,
+    },
+
+    artworksWhichAttach: {
+      bindTo: 'artworkData',
+
+      referencing: referencingArtwork =>
+        (referencingArtwork.attachAbove
+          ? [referencingArtwork]
+          : []),
+
+      referenced: referencingArtwork =>
+        [referencingArtwork.attachedArtwork],
+    },
+
+    artworksWhichFeature: {
+      bindTo: 'artworkData',
+
+      referencing: artwork => [artwork],
+      referenced: artwork => artwork.artTags,
+    },
+  };
+
+  get path() {
+    if (!this.thing) return null;
+    if (!this.thing.getOwnArtworkPath) return null;
+
+    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 = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (this.thing) {
+      if (depth >= 0) {
+        const newOptions = {
+          ...options,
+          depth:
+            (options.depth === null
+              ? null
+              : options.depth - 1),
+        };
+
+        parts.push(` for ${inspect(this.thing, newOptions)}`);
+      } else {
+        parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`);
+      }
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/language.js b/src/data/things/Language.js
index 800c4471..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) {
@@ -896,13 +974,14 @@ const countHelper = (stringKey, optionName = stringKey) =>
 Object.assign(Language.prototype, {
   countAdditionalFiles: countHelper('additionalFiles', 'files'),
   countAlbums: countHelper('albums'),
+  countArtTags: countHelper('artTags', 'tags'),
   countArtworks: countHelper('artworks'),
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
-  countCoverArts: countHelper('coverArts'),
   countDays: countHelper('days'),
   countFlashes: countHelper('flashes'),
   countMonths: countHelper('months'),
+  countTimesFeatured: countHelper('timesFeatured'),
   countTimesReferenced: countHelper('timesReferenced'),
   countTimesUsed: countHelper('timesUsed'),
   countTracks: countHelper('tracks'),
diff --git a/src/data/things/MusicVideo.js b/src/data/things/MusicVideo.js
new file mode 100644
index 00000000..5016b9c3
--- /dev/null
+++ b/src/data/things/MusicVideo.js
@@ -0,0 +1,145 @@
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {input, V} from '#composite';
+import Thing from '#thing';
+import {is, isDate, isStringNonEmpty, isURL} from '#validators';
+import {parseContributors, parseDate} from '#yaml';
+
+import {constituteFrom} from '#composite/wiki-data';
+
+import {
+  exposeConstant,
+  exposeDependency,
+  exposeUpdateValueOrContinue,
+  withResultOfAvailabilityCheck,
+} from '#composite/control-flow';
+
+import {
+  contributionList,
+  dimensions,
+  directory,
+  fileExtension,
+  soupyFind,
+  soupyReverse,
+  thing,
+} from '#composite/wiki-properties';
+
+export class MusicVideo extends Thing {
+  static [Thing.referenceType] = 'music-video';
+  static [Thing.wikiData] = 'musicVideoData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: thing(),
+
+    label: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+      expose: {transform: value => value ?? 'Music video'},
+    },
+
+    labelStyle: {
+      flags: {update: true, expose: true},
+      update: {
+        validate:
+          is('label', 'title'),
+      },
+    },
+
+    unqualifiedDirectory: directory({name: 'label'}),
+
+    date: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      constituteFrom('thing', V('date')),
+    ],
+
+    url: {
+      flags: {update: true, expose: true},
+      update: {validate: isURL},
+    },
+
+    coverArtFileExtension: fileExtension(V('jpg')),
+    coverArtDimensions: dimensions(),
+
+    artistContribs: contributionList({
+      artistProperty: input.value('musicVideoArtistContributions'),
+    }),
+
+    contributorContribs: contributionList({
+      artistProperty: input.value('musicVideoContributorContributions'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+
+    // Expose only
+
+    isMusicVideo: exposeConstant(V(true)),
+
+    dateIsSpecified: [
+      withResultOfAvailabilityCheck('_date'),
+      exposeDependency('#availability'),
+    ],
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Label': {property: 'label'},
+      'Label Style': {property: 'labelStyle'},
+      'Directory': {property: 'unqualifiedDirectory'},
+      'Date': {property: 'date', transform: parseDate},
+      'URL': {property: 'url'},
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Cover Art Dimensions': {property: 'coverArtDimensions'},
+
+      'Artists': {property: 'artistContribs', transform: parseContributors},
+      'Contributors': {property: 'contributorContribs', transform: parseContributors},
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    musicVideoArtistContributionsBy:
+      soupyReverse.contributionsBy('musicVideoData', 'artistContribs'),
+
+    musicVideoContributorContributionsBy:
+      soupyReverse.contributionsBy('musicVideoData', 'contributorContribs'),
+  };
+
+  get path() {
+    if (!this.thing) return null;
+    if (!this.thing.getOwnMusicVideoCoverPath) return null;
+
+    return this.thing.getOwnMusicVideoCoverPath(this);
+  }
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (this.thing) {
+      if (depth >= 0) {
+        const newOptions = {
+          ...options,
+          depth:
+            (options.depth === null
+              ? null
+              : options.depth - 1),
+        };
+
+        parts.push(` for ${inspect(this.thing, newOptions)}`);
+      } else {
+        parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`);
+      }
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/news-entry.js b/src/data/things/NewsEntry.js
index 43d1638e..7cbbfc4b 100644
--- a/src/data/things/news-entry.js
+++ b/src/data/things/NewsEntry.js
@@ -1,20 +1,20 @@
-export const NEWS_DATA_FILE = 'news.yaml';
-
-import {sortChronologically} from '#sort';
+import {V} from '#composite';
 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 +22,8 @@ export class NewsEntry extends Thing {
 
     // Expose only
 
+    isNewsEntry: exposeConstant(V(true)),
+
     contentShort: {
       flags: {expose: true},
 
@@ -53,21 +55,4 @@ export class NewsEntry extends Thing {
       'Content': {property: 'content'},
     },
   };
-
-  static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {allInOne},
-    thingConstructors: {NewsEntry},
-  }) => ({
-    title: `Process news data file`,
-    file: NEWS_DATA_FILE,
-
-    documentMode: allInOne,
-    documentThing: NewsEntry,
-
-    save: (results) => ({newsData: results}),
-
-    sort({newsData}) {
-      sortChronologically(newsData, {latestFirst: true});
-    },
-  });
 }
diff --git a/src/data/things/static-page.js b/src/data/things/StaticPage.js
index 52a09c31..5ddddb9d 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/StaticPage.js
@@ -1,23 +1,20 @@
-export const DATA_STATIC_PAGE_DIRECTORY = 'static-page';
-
-import * as path from 'node:path';
-
-import {traverse} from '#node-utils';
-import {sortAlphabetically} from '#sort';
+import {V} from '#composite';
 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 +32,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] = {
@@ -60,26 +61,4 @@ export class StaticPage extends Thing {
       'Review Points': {ignore: true},
     },
   };
-
-  static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {onePerFile},
-    thingConstructors: {StaticPage},
-  }) => ({
-    title: `Process static page files`,
-
-    files: dataPath =>
-      traverse(path.join(dataPath, DATA_STATIC_PAGE_DIRECTORY), {
-        filterFile: name => path.extname(name) === '.yaml',
-        prefixPath: DATA_STATIC_PAGE_DIRECTORY,
-      }),
-
-    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
new file mode 100644
index 00000000..36e073b6
--- /dev/null
+++ b/src/data/things/Track.js
@@ -0,0 +1,1347 @@
+import {inspect} from 'node:util';
+
+import CacheableObject from '#cacheable-object';
+import {colors} from '#cli';
+import {input, V} from '#composite';
+import find, {keyRefRegex} from '#find';
+import {onlyItem} from '#sugar';
+import {sortByDate} from '#sort';
+import Thing from '#thing';
+import {compareKebabCase} from '#wiki-data';
+
+import {
+  isBoolean,
+  isColor,
+  isContentString,
+  isContributionList,
+  isDate,
+  isFileExtension,
+  validateReference,
+} from '#validators';
+
+import {
+  parseAdditionalFiles,
+  parseAdditionalNames,
+  parseAnnotatedReferences,
+  parseArtwork,
+  parseCommentary,
+  parseContributors,
+  parseCreditingSources,
+  parseReferencingSources,
+  parseDate,
+  parseDimensions,
+  parseDuration,
+  parseLyrics,
+  parseMusicVideos,
+} from '#yaml';
+
+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 {
+  commentatorArtists,
+  constitutibleArtworkList,
+  contentString,
+  dimensions,
+  directory,
+  duration,
+  flag,
+  name,
+  referenceList,
+  referencedArtworkList,
+  reverseReferenceList,
+  simpleDate,
+  simpleString,
+  soupyFind,
+  soupyReverse,
+  thing,
+  thingList,
+  urls,
+  wikiData,
+} from '#composite/wiki-properties';
+
+import {
+  inheritContributionListFromMainRelease,
+  inheritFromMainRelease,
+} 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,
+    LyricsEntry,
+    MusicVideo,
+    ReferencingSourcesEntry,
+    TrackArtistContribution,
+    TrackSection,
+    WikiInfo,
+  }) => ({
+    // > Update & expose - Internal relationships
+
+    album: thing(V(Album)),
+    trackSection: thing(V(TrackSection)),
+
+    // > Update & expose - Identifying metadata
+
+    name: name(V('Unnamed Track')),
+    nameText: contentString(),
+
+    directory: directory({
+      suffix: 'directorySuffix',
+    }),
+
+    suffixDirectoryFromAlbum: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject('trackSection', V('suffixTrackDirectories')),
+      exposeDependency('#trackSection.suffixTrackDirectories'),
+    ],
+
+    // 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(isBoolean),
+      }),
+
+      withPropertyFromObject('album', V('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'),
+      }),
+
+      exitWithoutDependency('_mainRelease', V(false)),
+      exitWithoutDependency('mainReleaseTrack', V(false)),
+
+      withPropertyFromObject('mainReleaseTrack', V('name')),
+
+      {
+        dependencies: ['name', '#mainReleaseTrack.name'],
+        compute: ({
+          ['name']: name,
+          ['#mainReleaseTrack.name']: mainReleaseName,
+        }) =>
+          compareKebabCase(name, mainReleaseName),
+      },
+    ],
+
+    // 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'])),
+      }),
+
+      {
+        dependencies: ['name'],
+        transform: (ref, continuation, {name: ownName}) =>
+          (ref === 'same name single'
+            ? continuation(ref, {
+                ['#albumOrTrackReference']: null,
+                ['#sameNameSingleReference']: ownName,
+              })
+            : continuation(ref, {
+                ['#albumOrTrackReference']: ref,
+                ['#sameNameSingleReference']: null,
+              })),
+      },
+
+      withResolvedReference({
+        ref: '#albumOrTrackReference',
+        find: soupyFind.input('trackMainReleasesOnly'),
+      }).outputs({
+        '#resolvedReference': '#matchingTrack',
+      }),
+
+      withResolvedReference({
+        ref: '#albumOrTrackReference',
+        find: soupyFind.input('album'),
+      }).outputs({
+        '#resolvedReference': '#matchingAlbum',
+      }),
+
+      withResolvedReference({
+        ref: '#sameNameSingleReference',
+        find: soupyFind.input('albumSinglesOnly'),
+        findOptions: input.value({
+          fuzz: {
+            capitalization: true,
+            kebab: true,
+          },
+        }),
+      }).outputs({
+        '#resolvedReference': '#sameNameSingle',
+      }),
+
+      exposeDependencyOrContinue('#sameNameSingle'),
+      exposeDependencyOrContinue('#matchingAlbum'),
+      exposeDependency('#matchingTrack'),
+    ],
+
+    bandcampTrackIdentifier: simpleString(),
+    bandcampArtworkIdentifier: simpleString(),
+
+    additionalNames: thingList(V(AdditionalName)),
+
+    dateFirstReleased: simpleDate(),
+
+    // > Update & expose - Credits and contributors
+
+    artistText: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isContentString),
+      }),
+
+      withPropertyFromObject('album', V('trackArtistText')),
+      exposeDependency('#album.trackArtistText'),
+    ],
+
+    artistTextInLists: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isContentString),
+      }),
+
+      exposeDependencyOrContinue('_artistText'),
+
+      withPropertyFromObject('album', V('trackArtistText')),
+      exposeDependency('#album.trackArtistText'),
+    ],
+
+    artistContribs: [
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        class: input.value(TrackArtistContribution),
+        date: 'date',
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
+      }).outputs({
+        '#resolvedContribs': '#artistContribs',
+      }),
+
+      exposeDependencyOrContinue('#artistContribs', V('empty')),
+
+      // 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.trackArtistContribs',
+        artistProperty: input.value('trackArtistContributions'),
+      }),
+
+      withRedatedContributionList({
+        list: '#album.trackArtistContribs',
+        date: 'date',
+      }),
+
+      exposeDependency('#album.trackArtistContribs'),
+    ],
+
+    contributorContribs: [
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        date: 'date',
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
+      }).outputs({
+        '#resolvedContribs': '#contributorContribs',
+      }),
+
+      exposeDependencyOrContinue('#contributorContribs', V('empty')),
+
+      inheritContributionListFromMainRelease(),
+
+      exposeConstant(V([])),
+    ],
+
+    // > Update & expose - General configuration
+
+    countInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject('trackSection', V('countTracksInArtistTotals')),
+      exposeDependency('#trackSection.countTracksInArtistTotals'),
+    ],
+
+    disableUniqueCoverArt: flag(V(false)),
+    disableDate: flag(V(false)),
+
+    // > 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'),
+    ],
+
+    needsLyrics: [
+      exposeUpdateValueOrContinue({
+        mode: input.value('falsy'),
+        validate: input.value(isBoolean),
+      }),
+
+      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: [
+      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: [
+      exitWithoutDependency('hasUniqueCoverArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
+      }),
+
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
+    ],
+
+    referencedArtworks: [
+      exitWithoutDependency('hasUniqueCoverArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
+      }),
+
+      referencedArtworkList(),
+    ],
+
+    // > Update & expose - Referenced tracks
+
+    referencedTracks: [
+      inheritFromMainRelease(),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('trackReference'),
+      }),
+    ],
+
+    sampledTracks: [
+      inheritFromMainRelease(),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('trackReference'),
+      }),
+    ],
+
+    // > Update & expose - Music videos
+
+    musicVideos: [
+      exposeUpdateValueOrContinue(),
+
+      // TODO: Same situation as lyrics. Inherited music videos don't set
+      // the proper .thing property back to this track... but then, it needs
+      // to keep a reference to its original .thing to get its proper path,
+      // so maybe this is okay...
+      inheritFromMainRelease(),
+
+      thingList(V(MusicVideo)),
+    ],
+
+    // > Update & expose - Additional files
+
+    additionalFiles: thingList(V(AdditionalFile)),
+    sheetMusicFiles: thingList(V(AdditionalFile)),
+    midiProjectFiles: thingList(V(AdditionalFile)),
+
+    // > Update & expose - Content entries
+
+    lyrics: [
+      exposeUpdateValueOrContinue(),
+
+      // 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(V(Artwork)),
+
+    // used for withMatchingContributionPresets (indirectly by Contribution)
+    wikiInfo: thing(V(WikiInfo)),
+
+    // > 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: [
+      {
+        dependencies: ['disableDate'],
+        compute: (continuation, {disableDate}) =>
+          (disableDate
+            ? null
+            : continuation()),
+      },
+
+      exposeDependencyOrContinue('dateFirstReleased'),
+
+      withPropertyFromObject('album', V('date')),
+      exposeDependency('#album.date'),
+    ],
+
+    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: [
+      {
+        dependencies: ['disableUniqueCoverArt'],
+        compute: (continuation, {disableUniqueCoverArt}) =>
+          (disableUniqueCoverArt
+            ? false
+            : continuation()),
+      },
+
+      withResultOfAvailabilityCheck({
+        from: '_coverArtistContribs',
+        mode: input.value('empty'),
+      }),
+
+      {
+        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: '#flattenedList',
+        mode: input.value('empty'),
+      }),
+    ],
+
+    isMainRelease:
+      exposeWhetherDependencyAvailable({
+        dependency: 'mainReleaseTrack',
+        negate: input.value(true),
+      }),
+
+    isSecondaryRelease:
+      exposeWhetherDependencyAvailable({
+        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,
+        }) => continuation({
+          ['#mapItsNameLikeName']:
+            itsName => compareKebabCase(itsName, ownName),
+
+          ['#mapItsDirectoryLikeDirectory']:
+            (ownDirectory
+              ? itsDirectory => itsDirectory === ownDirectory
+              : () => false),
+
+          ['#mapItsNameLikeDirectory']:
+            (ownDirectory
+              ? itsName => compareKebabCase(itsName, ownDirectory)
+              : () => false),
+
+          ['#mapItsDirectoryLikeName']:
+            itsDirectory => compareKebabCase(itsDirectory, ownName),
+        }),
+      },
+
+      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
+    // are never secondary to *another* secondary release.
+    secondaryReleases: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichAreSecondaryReleasesOf'),
+    }),
+
+    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: [
+      {
+        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'),
+    ],
+
+    referencedByTracks: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichReference'),
+    }),
+
+    sampledByTracks: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichSample'),
+    }),
+
+    featuredInFlashes: reverseReferenceList({
+      reverse: soupyReverse.input('flashesWhichFeature'),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      // Identifying metadata
+
+      'Track': {property: 'name'},
+      'Track Text': {property: 'nameText'},
+      'Directory': {property: 'directory'},
+      'Suffix Directory': {property: 'suffixDirectoryFromAlbum'},
+      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+      'Main Release': {property: 'mainRelease'},
+
+      'Bandcamp Track ID': {
+        property: 'bandcampTrackIdentifier',
+        transform: String,
+      },
+
+      'Bandcamp Artwork ID': {
+        property: 'bandcampArtworkIdentifier',
+        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'},
+
+      // 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': {
+        property: 'coverArtDate',
+        transform: parseDate,
+      },
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
+      'Art Tags': {property: 'artTags'},
+
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+
+      // Referenced tracks
+
+      'Referenced Tracks': {property: 'referencedTracks'},
+      'Sampled Tracks': {property: 'sampledTracks'},
+
+      // Music videos
+
+      'Music Videos': {
+        property: 'musicVideos',
+        transform: parseMusicVideos,
+      },
+
+      // Additional files
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'Sheet Music Files': {
+        property: 'sheetMusicFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'MIDI Project Files': {
+        property: 'midiProjectFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      // Content entries
+
+      'Lyrics': {
+        property: 'lyrics',
+        transform: parseLyrics,
+      },
+
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
+      },
+
+      'Referencing Sources': {
+        property: 'referencingSources',
+        transform: parseReferencingSources,
+      },
+
+      // 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',
+      ]},
+
+      {message: `Secondary releases inherit samples from the main one`, fields: [
+        'Main Release',
+        'Sampled Tracks',
+      ]},
+
+      {
+        message: ({'Has Cover Art': hasCoverArt}) =>
+          (hasCoverArt
+            ? `"Has Cover Art: true" is inferred from cover artist credits`
+            : `Tracks without cover art must not have cover artist credits`),
+
+        fields: [
+          'Has Cover Art',
+          'Cover Artists',
+        ],
+      },
+    ],
+  };
+
+  static [Thing.findSpecs] = {
+    track: {
+      referenceTypes: ['track'],
+
+      bindTo: 'trackData',
+
+      getMatchableNames: track =>
+        (track.alwaysReferenceByDirectory
+          ? []
+          : [track.name]),
+    },
+
+    trackMainReleasesOnly: {
+      referenceTypes: ['track'],
+      bindTo: 'trackData',
+
+      include: track =>
+        !CacheableObject.getUpdateValue(track, 'mainRelease'),
+
+      // It's still necessary to check alwaysReferenceByDirectory here, since
+      // it may be set manually (with `Always Reference By Directory: true`),
+      // and these shouldn't be matched by name (as per usual).
+      // See the definition for that property for more information.
+      getMatchableNames: track =>
+        (track.alwaysReferenceByDirectory
+          ? []
+          : [track.name]),
+    },
+
+    trackReference: {
+      referenceTypes: ['track'],
+      bindTo: 'trackData',
+
+      byob(fullRef, data, opts) {
+        const {from} = opts;
+
+        const acontextual = () =>
+          find.trackMainReleasesOnly(fullRef, data, opts);
+
+        const regexMatch = fullRef.match(keyRefRegex);
+        if (!regexMatch || regexMatch?.keyPart) {
+          // It's a reference by directory or it's malformed.
+          // Either way, we can't handle it here!
+          return acontextual();
+        }
+
+        if (!from?.isTrack) {
+          throw new Error(
+            `Expected to find starting from a track, got: ` +
+            inspect(from, {compact: true}));
+        }
+
+        const referencingTrack = from;
+        const referencedName = fullRef;
+
+        for (const track of referencingTrack.album.tracks) {
+          // Totally ignore alwaysReferenceByDirectory here.
+          // void track.alwaysReferenceByDirectory;
+
+          if (track.name === referencedName) {
+            if (track.isSecondaryRelease) {
+              return track.mainReleaseTrack;
+            } else {
+              return track;
+            }
+          }
+        }
+
+        return acontextual();
+      },
+    },
+
+    trackWithArtwork: {
+      referenceTypes: [
+        'track',
+        'track-referencing-artworks',
+        'track-referenced-artworks',
+      ],
+
+      bindTo: 'trackData',
+
+      include: track =>
+        track.hasUniqueCoverArt,
+
+      getMatchableNames: track =>
+        (track.alwaysReferenceByDirectory
+          ? []
+          : [track.name]),
+    },
+
+    trackPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'track',
+        'track-referencing-artworks',
+        'track-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork) =>
+        artwork.isArtwork &&
+        artwork.thing.isTrack &&
+        artwork === artwork.thing.trackArtworks[0],
+
+      getMatchableNames: ({thing: track}) =>
+        (track.alwaysReferenceByDirectory
+          ? []
+          : [track.name]),
+
+      getMatchableDirectories: ({thing: track}) =>
+        [track.directory],
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    tracksWhichReference: {
+      bindTo: 'trackData',
+
+      referencing: track => track.isMainRelease ? [track] : [],
+      referenced: track => track.referencedTracks,
+    },
+
+    tracksWhichSample: {
+      bindTo: 'trackData',
+
+      referencing: track => track.isMainRelease ? [track] : [],
+      referenced: track => track.sampledTracks,
+    },
+
+    tracksWhoseArtworksFeature: {
+      bindTo: 'trackData',
+
+      referencing: track => [track],
+      referenced: track => track.artTags,
+    },
+
+    trackArtistContributionsBy:
+      soupyReverse.contributionsBy('trackData', 'artistContribs'),
+
+    trackContributorContributionsBy:
+      soupyReverse.contributionsBy('trackData', 'contributorContribs'),
+
+    trackCoverArtistContributionsBy:
+      soupyReverse.artworkContributionsBy('trackData', 'trackArtworks'),
+
+    tracksWithCommentaryBy: {
+      bindTo: 'trackData',
+
+      referencing: track => [track],
+      referenced: track => track.commentatorArtists,
+    },
+
+    tracksWhichAreSecondaryReleasesOf: {
+      bindTo: 'trackData',
+
+      referencing: track => track.isSecondaryRelease ? [track] : [],
+      referenced: track => [track.mainReleaseTrack],
+    },
+  };
+
+  getOwnAdditionalFilePath(_file, filename) {
+    if (!this.album) return null;
+
+    return [
+      'media.albumAdditionalFile',
+      this.album.directory,
+      filename,
+    ];
+  }
+
+  getOwnArtworkPath(artwork) {
+    if (!this.album) return null;
+
+    return [
+      'media.trackCover',
+      this.album.directory,
+
+      (artwork.unqualifiedDirectory
+        ? this.directory + '-' + artwork.unqualifiedDirectory
+        : this.directory),
+
+      artwork.fileExtension,
+    ];
+  }
+
+  getOwnMusicVideoCoverPath(musicVideo) {
+    if (!this.album) return null;
+    if (!musicVideo.unqualifiedDirectory) return null;
+
+    return [
+      'media.trackCover',
+      this.album.directory,
+      this.directory + '-' + musicVideo.unqualifiedDirectory,
+      musicVideo.coverArtFileExtension,
+    ];
+  }
+
+  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, 'mainRelease')) {
+      parts.unshift(`${colors.yellow('[secrelease]')} `);
+    }
+
+    let album;
+
+    if (depth >= 0) {
+      album = this.album;
+    }
+
+    if (album) {
+      const albumName = album.name;
+      const albumIndex = album.tracks.indexOf(this);
+      const trackNum =
+        (albumIndex === -1
+          ? 'indeterminate position'
+          : `#${albumIndex + 1}`);
+      parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/wiki-info.js b/src/data/things/WikiInfo.js
index 590598be..ffb18cd8 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/WikiInfo.js
@@ -1,29 +1,39 @@
-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 {parseContributionPresets, parseWallpaperParts} from '#yaml';
 
 import {
   isBoolean,
-  isColor,
   isContributionPresetList,
   isLanguageCode,
   isName,
-  isURL,
+  isNumber,
 } from '#validators';
 
-import {exitWithoutDependency} from '#composite/control-flow';
-import {contentString, flag, name, referenceList, soupyFind}
-  from '#composite/wiki-properties';
+import {exitWithoutDependency, exposeConstant} from '#composite/control-flow';
+
+import {
+  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 +46,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,19 +58,18 @@ export class WikiInfo extends Thing {
       update: {validate: isLanguageCode},
     },
 
-    canonicalBase: {
+    canonicalBase: canonicalBase(),
+    canonicalMediaBase: canonicalBase(),
+
+    wikiWallpaperBrightness: {
       flags: {update: true, expose: true},
-      update: {validate: isURL},
-      expose: {
-        transform: (value) =>
-          (value === null
-            ? null
-         : value.endsWith('/')
-            ? value
-            : value + '/'),
-      },
+      update: {validate: isNumber},
     },
 
+    wikiWallpaperFileExtension: fileExtension(V('jpg')),
+    wikiWallpaperStyle: simpleString(),
+    wikiWallpaperParts: wallpaperParts(),
+
     divideTrackListsByGroups: referenceList({
       class: input.value(Group),
       find: soupyFind.input('group'),
@@ -79,20 +81,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 +107,46 @@ 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 Brightness': {property: 'wikiWallpaperBrightness'},
+      '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,
@@ -131,22 +154,5 @@ export class WikiInfo extends Thing {
     },
   };
 
-  static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {oneDocumentTotal},
-    thingConstructors: {WikiInfo},
-  }) => ({
-    title: `Process wiki info file`,
-    file: WIKI_INFO_FILE,
 
-    documentMode: oneDocumentTotal,
-    documentThing: WikiInfo,
-
-    save(wikiInfo) {
-      if (!wikiInfo) {
-        return;
-      }
-
-      return {wikiInfo};
-    },
-  });
 }
diff --git a/src/data/things/album.js b/src/data/things/album.js
deleted file mode 100644
index 6bf683c5..00000000
--- a/src/data/things/album.js
+++ /dev/null
@@ -1,851 +0,0 @@
-export const DATA_ALBUM_DIRECTORY = 'album';
-
-import * as path from 'node:path';
-import {inspect} from 'node:util';
-
-import {colors} from '#cli';
-import {input} from '#composite';
-import {traverse} from '#node-utils';
-import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
-import {accumulateSum, empty} from '#sugar';
-import Thing from '#thing';
-import {isColor, isDate, isDirectory} from '#validators';
-
-import {
-  parseAdditionalFiles,
-  parseAdditionalNames,
-  parseAnnotatedReferences,
-  parseContributors,
-  parseDate,
-  parseDimensions,
-  parseWallpaperParts,
-} from '#yaml';
-
-import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-import {exitWithoutContribs, withDirectory, withCoverArtDate}
-  from '#composite/wiki-data';
-
-import {
-  additionalFiles,
-  additionalNameList,
-  commentary,
-  color,
-  commentatorArtists,
-  contentString,
-  contribsPresent,
-  contributionList,
-  dimensions,
-  directory,
-  fileExtension,
-  flag,
-  name,
-  referencedArtworkList,
-  referenceList,
-  reverseReferenceList,
-  simpleDate,
-  simpleString,
-  soupyFind,
-  soupyReverse,
-  thing,
-  thingList,
-  urls,
-  wallpaperParts,
-  wikiData,
-} from '#composite/wiki-properties';
-
-import {withTracks} from '#composite/things/album';
-import {withAlbum} from '#composite/things/track-section';
-
-export class Album extends Thing {
-  static [Thing.referenceType] = 'album';
-
-  static [Thing.getPropertyDescriptors] = ({
-    ArtTag,
-    Group,
-    Track,
-    TrackSection,
-    WikiInfo,
-  }) => ({
-    // Update & expose
-
-    name: name('Unnamed Album'),
-    directory: directory(),
-
-    directorySuffix: [
-      exposeUpdateValueOrContinue({
-        validate: input.value(isDirectory),
-      }),
-
-      withDirectory(),
-
-      exposeDependency({
-        dependency: '#directory',
-      }),
-    ],
-
-    alwaysReferenceByDirectory: flag(false),
-    alwaysReferenceTracksByDirectory: flag(false),
-    suffixTrackDirectories: flag(false),
-
-    color: color(),
-    urls: urls(),
-
-    additionalNames: additionalNameList(),
-
-    bandcampAlbumIdentifier: simpleString(),
-    bandcampArtworkIdentifier: simpleString(),
-
-    date: simpleDate(),
-    trackArtDate: simpleDate(),
-    dateAddedToWiki: simpleDate(),
-
-    coverArtDate: [
-      // ~~TODO: Why does this fall back, but Track.coverArtDate doesn't?~~
-      // TODO: OK so it's because tracks don't *store* dates just like that.
-      // Really instead of fallback being a flag, it should be a date value,
-      // if this option is worth existing at all.
-      withCoverArtDate({
-        from: input.updateValue({
-          validate: isDate,
-        }),
-
-        fallback: input.value(true),
-      }),
-
-      exposeDependency({dependency: '#coverArtDate'}),
-    ],
-
-    coverArtFileExtension: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      fileExtension('jpg'),
-    ],
-
-    trackCoverArtFileExtension: fileExtension('jpg'),
-
-    wallpaperFileExtension: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      fileExtension('jpg'),
-    ],
-
-    bannerFileExtension: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      fileExtension('jpg'),
-    ],
-
-    wallpaperStyle: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      simpleString(),
-    ],
-
-    wallpaperParts: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      wallpaperParts(),
-    ],
-
-    bannerStyle: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      simpleString(),
-    ],
-
-    coverArtDimensions: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      dimensions(),
-    ],
-
-    trackDimensions: dimensions(),
-
-    bannerDimensions: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      dimensions(),
-    ],
-
-    hasTrackNumbers: flag(true),
-    isListedOnHomepage: flag(true),
-    isListedInGalleries: flag(true),
-
-    commentary: commentary(),
-    creditSources: commentary(),
-    additionalFiles: additionalFiles(),
-
-    trackSections: thingList({
-      class: input.value(TrackSection),
-    }),
-
-    artistContribs: contributionList({
-      date: 'date',
-      artistProperty: input.value('albumArtistContributions'),
-    }),
-
-    coverArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
-      }),
-
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumCoverArtistContributions'),
-      }),
-    ],
-
-    trackCoverArtistContribs: contributionList({
-      // May be null, indicating cover art was added for tracks on the date
-      // each track specifies, or else the track's own release date.
-      date: 'trackArtDate',
-
-      // This is the "correct" value, but it gets overwritten - with the same
-      // value - regardless.
-      artistProperty: input.value('trackCoverArtistContributions'),
-    }),
-
-    wallpaperArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
-      }),
-
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumWallpaperArtistContributions'),
-      }),
-    ],
-
-    bannerArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
-      }),
-
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumBannerArtistContributions'),
-      }),
-    ],
-
-    groups: referenceList({
-      class: input.value(Group),
-      find: soupyFind.input('group'),
-    }),
-
-    artTags: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
-
-      referenceList({
-        class: input.value(ArtTag),
-        find: soupyFind.input('artTag'),
-      }),
-    ],
-
-    referencedArtworks: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
-
-      {
-        dependencies: ['coverArtDate', 'date'],
-        compute: (continuation, {
-          coverArtDate,
-          date,
-        }) => continuation({
-          ['#date']:
-            coverArtDate ?? date,
-        }),
-      },
-
-      referencedArtworkList({
-        date: '#date',
-      }),
-    ],
-
-    // Update only
-
-    find: soupyFind(),
-    reverse: soupyReverse(),
-
-    // used for referencedArtworkList (mixedFind)
-    albumData: wikiData({
-      class: input.value(Album),
-    }),
-
-    // used for referencedArtworkList (mixedFind)
-    trackData: wikiData({
-      class: input.value(Track),
-    }),
-
-    // used for withMatchingContributionPresets (indirectly by Contribution)
-    wikiInfo: thing({
-      class: input.value(WikiInfo),
-    }),
-
-    // Expose only
-
-    commentatorArtists: commentatorArtists(),
-
-    hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}),
-    hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
-    hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
-
-    tracks: [
-      withTracks(),
-      exposeDependency({dependency: '#tracks'}),
-    ],
-
-    referencedByArtworks: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
-
-      reverseReferenceList({
-        reverse: soupyReverse.input('artworksWhichReference'),
-      }),
-    ],
-  });
-
-  static [Thing.getSerializeDescriptors] = ({
-    serialize: S,
-  }) => ({
-    name: S.id,
-    color: S.id,
-    directory: S.id,
-    urls: S.id,
-
-    date: S.id,
-    coverArtDate: S.id,
-    trackArtDate: S.id,
-    dateAddedToWiki: S.id,
-
-    artistContribs: S.toContribRefs,
-    coverArtistContribs: S.toContribRefs,
-    trackCoverArtistContribs: S.toContribRefs,
-    wallpaperArtistContribs: S.toContribRefs,
-    bannerArtistContribs: S.toContribRefs,
-
-    coverArtFileExtension: S.id,
-    trackCoverArtFileExtension: S.id,
-    wallpaperStyle: S.id,
-    wallpaperFileExtension: S.id,
-    bannerStyle: S.id,
-    bannerFileExtension: S.id,
-    bannerDimensions: S.id,
-
-    hasTrackArt: S.id,
-    isListedOnHomepage: S.id,
-
-    commentary: S.toCommentaryRefs,
-
-    additionalFiles: S.id,
-
-    tracks: S.toRefs,
-    groups: S.toRefs,
-    artTags: S.toRefs,
-    commentatorArtists: S.toRefs,
-  });
-
-  static [Thing.findSpecs] = {
-    album: {
-      referenceTypes: [
-        'album',
-        'album-commentary',
-        'album-gallery',
-      ],
-
-      bindTo: 'albumData',
-
-      getMatchableNames: album =>
-        (album.alwaysReferenceByDirectory 
-          ? [] 
-          : [album.name]),
-    },
-
-    albumWithArtwork: {
-      referenceTypes: [
-        'album',
-        'album-referencing-artworks',
-        'album-referenced-artworks',
-      ],
-
-      bindTo: 'albumData',
-
-      include: album =>
-        album.hasCoverArt,
-
-      getMatchableNames: album =>
-        (album.alwaysReferenceByDirectory 
-          ? [] 
-          : [album.name]),
-    },
-  };
-
-  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',
-
-      referencing: album => [album],
-      referenced: album => album.artTags,
-    },
-
-    albumsWhoseGroupsInclude: {
-      bindTo: 'albumData',
-
-      referencing: album => [album],
-      referenced: album => album.groups,
-    },
-
-    albumArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'artistContribs'),
-
-    albumCoverArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'coverArtistContribs'),
-
-    albumWallpaperArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'wallpaperArtistContribs'),
-
-    albumBannerArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'bannerArtistContribs'),
-
-    albumsWithCommentaryBy: {
-      bindTo: 'albumData',
-
-      referencing: album => [album],
-      referenced: album => album.commentatorArtists,
-    },
-  };
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      '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,
-      },
-
-      'Bandcamp Album ID': {
-        property: 'bandcampAlbumIdentifier',
-        transform: String,
-      },
-
-      'Bandcamp Artwork ID': {
-        property: 'bandcampArtworkIdentifier',
-        transform: String,
-      },
-
-      'Date': {
-        property: 'date',
-        transform: parseDate,
-      },
-
-      'Color': {property: 'color'},
-      'URLs': {property: 'urls'},
-
-      'Has Track Numbers': {property: 'hasTrackNumbers'},
-      'Listed on Homepage': {property: 'isListedOnHomepage'},
-      'Listed in Galleries': {property: 'isListedInGalleries'},
-
-      'Cover Art Date': {
-        property: 'coverArtDate',
-        transform: parseDate,
-      },
-
-      'Default Track Cover Art Date': {
-        property: 'trackArtDate',
-        transform: parseDate,
-      },
-
-      'Date Added': {
-        property: 'dateAddedToWiki',
-        transform: parseDate,
-      },
-
-      'Cover Art File Extension': {property: 'coverArtFileExtension'},
-      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
-
-      'Cover Art Dimensions': {
-        property: 'coverArtDimensions',
-        transform: parseDimensions,
-      },
-
-      'Default Track Dimensions': {
-        property: 'trackDimensions',
-        transform: parseDimensions,
-      },
-
-      'Wallpaper Artists': {
-        property: 'wallpaperArtistContribs',
-        transform: parseContributors,
-      },
-
-      'Wallpaper Style': {property: 'wallpaperStyle'},
-      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
-
-      'Wallpaper Parts': {
-        property: 'wallpaperParts',
-        transform: parseWallpaperParts,
-      },
-
-      'Banner Artists': {
-        property: 'bannerArtistContribs',
-        transform: parseContributors,
-      },
-
-      'Banner Style': {property: 'bannerStyle'},
-      'Banner File Extension': {property: 'bannerFileExtension'},
-
-      'Banner Dimensions': {
-        property: 'bannerDimensions',
-        transform: parseDimensions,
-      },
-
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
-
-      'Additional Files': {
-        property: 'additionalFiles',
-        transform: parseAdditionalFiles,
-      },
-
-      'Referenced Artworks': {
-        property: 'referencedArtworks',
-        transform: parseAnnotatedReferences,
-      },
-
-      'Franchises': {ignore: true},
-
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
-      },
-
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
-      },
-
-      'Default Track Cover Artists': {
-        property: 'trackCoverArtistContribs',
-        transform: parseContributors,
-      },
-
-      'Groups': {property: 'groups'},
-      'Art Tags': {property: 'artTags'},
-
-      'Review Points': {ignore: true},
-    },
-
-    invalidFieldCombinations: [
-      {message: `Specify one wallpaper style or multiple wallpaper parts, not both`, fields: [
-        'Wallpaper Parts',
-        'Wallpaper Style',
-      ]},
-
-      {message: `Wallpaper file extensions are specified on asset, per part`, fields: [
-        'Wallpaper Parts',
-        'Wallpaper File Extension',
-      ]},
-    ],
-  };
-
-  static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {headerAndEntries},
-    thingConstructors: {Album, Track},
-  }) => ({
-    title: `Process album files`,
-
-    files: dataPath =>
-      traverse(path.join(dataPath, DATA_ALBUM_DIRECTORY), {
-        filterFile: name => path.extname(name) === '.yaml',
-        prefixPath: DATA_ALBUM_DIRECTORY,
-      }),
-
-    documentMode: headerAndEntries,
-    headerDocumentThing: Album,
-    entryDocumentThing: document =>
-      ('Section' in document
-        ? TrackSection
-        : Track),
-
-    save(results) {
-      const albumData = [];
-      const trackSectionData = [];
-      const trackData = [];
-
-      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);
-
-          entry.dataSourceAlbum = albumRef;
-        }
-
-        closeCurrentTrackSection();
-
-        albumData.push(album);
-
-        album.trackSections = trackSections;
-      }
-
-      return {albumData, trackSectionData, trackData};
-    },
-
-    sort({albumData, trackData}) {
-      sortChronologically(albumData);
-      sortAlbumsTracksChronologically(trackData);
-    },
-  });
-}
-
-export class TrackSection extends Thing {
-  static [Thing.friendlyName] = `Track Section`;
-  static [Thing.referenceType] = `track-section`;
-
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
-    // Update & expose
-
-    name: name('Unnamed Track Section'),
-
-    unqualifiedDirectory: directory(),
-
-    color: [
-      exposeUpdateValueOrContinue({
-        validate: input.value(isColor),
-      }),
-
-      withAlbum(),
-
-      withPropertyFromObject({
-        object: '#album',
-        property: input.value('color'),
-      }),
-
-      exposeDependency({dependency: '#album.color'}),
-    ],
-
-    dateOriginallyReleased: simpleDate(),
-
-    isDefaultTrackSection: flag(false),
-
-    description: contentString(),
-
-    album: [
-      withAlbum(),
-      exposeDependency({dependency: '#album'}),
-    ],
-
-    tracks: thingList({
-      class: input.value(Track),
-    }),
-
-    // Update only
-
-    reverse: soupyReverse(),
-
-    // Expose only
-
-    directory: [
-      withAlbum(),
-
-      exitWithoutDependency({
-        dependency: '#album',
-      }),
-
-      withPropertyFromObject({
-        object: '#album',
-        property: input.value('directory'),
-      }),
-
-      withDirectory({
-        directory: 'unqualifiedDirectory',
-      }).outputs({
-        '#directory': '#unqualifiedDirectory',
-      }),
-
-      {
-        dependencies: ['#album.directory', '#unqualifiedDirectory'],
-        compute: ({
-          ['#album.directory']: albumDirectory,
-          ['#unqualifiedDirectory']: unqualifiedDirectory,
-        }) =>
-          albumDirectory + '/' + unqualifiedDirectory,
-      },
-    ],
-
-    startIndex: [
-      withAlbum(),
-
-      withPropertyFromObject({
-        object: '#album',
-        property: input.value('trackSections'),
-      }),
-
-      {
-        dependencies: ['#album.trackSections', input.myself()],
-        compute: (continuation, {
-          ['#album.trackSections']: trackSections,
-          [input.myself()]: myself,
-        }) => continuation({
-          ['#index']:
-            trackSections.indexOf(myself),
-        }),
-      },
-
-      exitWithoutDependency({
-        dependency: '#index',
-        mode: input.value('index'),
-        value: input.value(0),
-      }),
-
-      {
-        dependencies: ['#album.trackSections', '#index'],
-        compute: ({
-          ['#album.trackSections']: trackSections,
-          ['#index']: index,
-        }) =>
-          accumulateSum(
-            trackSections
-              .slice(0, index)
-              .map(section => section.tracks.length)),
-      },
-    ],
-  });
-
-  static [Thing.findSpecs] = {
-    trackSection: {
-      referenceTypes: ['track-section'],
-      bindTo: 'trackSectionData',
-    },
-
-    unqualifiedTrackSection: {
-      referenceTypes: ['unqualified-track-section'],
-
-      getMatchableDirectories: trackSection =>
-        [trackSection.unqualifiedDirectory],
-    },
-  };
-
-  static [Thing.reverseSpecs] = {
-    trackSectionsWhichInclude: {
-      bindTo: 'trackSectionData',
-
-      referencing: trackSection => [trackSection],
-      referenced: trackSection => trackSection.tracks,
-    },
-  };
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      'Section': {property: 'name'},
-      'Color': {property: 'color'},
-
-      'Date Originally Released': {
-        property: 'dateOriginallyReleased',
-        transform: parseDate,
-      },
-
-      'Description': {property: 'description'},
-    },
-  };
-
-  [inspect.custom](depth) {
-    const parts = [];
-
-    parts.push(Thing.prototype[inspect.custom].apply(this));
-
-    if (depth >= 0) {
-      let album = null;
-      try {
-        album = this.album;
-      } catch {}
-
-      let first = null;
-      try {
-        first = this.startIndex;
-      } catch {}
-
-      let length = null;
-      try {
-        length = this.tracks.length;
-      } catch {}
-
-      if (album) {
-        const albumName = album.name;
-        const albumIndex = album.trackSections.indexOf(this);
-
-        const num =
-          (albumIndex === -1
-            ? 'indeterminate position'
-            : `#${albumIndex + 1}`);
-
-        const range =
-          (albumIndex >= 0 && first !== null && length !== null
-            ? `: ${first + 1}-${first + length + 1}`
-            : '');
-
-        parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
-      }
-    }
-
-    return parts.join('');
-  }
-}
diff --git a/src/data/things/album/Album.js b/src/data/things/album/Album.js
new file mode 100644
index 00000000..48d52223
--- /dev/null
+++ b/src/data/things/album/Album.js
@@ -0,0 +1,851 @@
+import {input, V} from '#composite';
+import {empty} from '#sugar';
+import Thing from '#thing';
+import {is, isContributionList, isDate, isDirectory, isNumber}
+  from '#validators';
+
+import {
+  parseAdditionalFiles,
+  parseAdditionalNames,
+  parseAnnotatedReferences,
+  parseArtwork,
+  parseCommentary,
+  parseContributors,
+  parseCreditingSources,
+  parseDate,
+  parseDimensions,
+  parseWallpaperParts,
+} from '#yaml';
+
+import {withFlattenedList, withPropertyFromList} from '#composite/data';
+import {withRecontextualizedContributionList, withResolvedContribs}
+  from '#composite/wiki-data';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  color,
+  commentatorArtists,
+  constitutibleArtwork,
+  constitutibleArtworkList,
+  contentString,
+  contributionList,
+  dimensions,
+  directory,
+  fileExtension,
+  flag,
+  hasArtwork,
+  name,
+  referencedArtworkList,
+  referenceList,
+  simpleDate,
+  simpleString,
+  soupyFind,
+  soupyReverse,
+  thing,
+  thingList,
+  urls,
+  wallpaperParts,
+  wikiData,
+} from '#composite/wiki-properties';
+
+export class Album extends Thing {
+  static [Thing.referenceType] = 'album';
+  static [Thing.wikiData] = 'albumData';
+
+  static [Thing.constitutibleProperties] = [
+    'coverArtworks',
+    'wallpaperArtwork',
+    'bannerArtwork',
+  ];
+
+  static [Thing.getPropertyDescriptors] = ({
+    AdditionalFile,
+    AdditionalName,
+    AlbumArtistContribution,
+    AlbumBannerArtistContribution,
+    AlbumWallpaperArtistContribution,
+    ArtTag,
+    Artwork,
+    CommentaryEntry,
+    CreditingSourcesEntry,
+    Group,
+    TrackArtistContribution,
+    TrackSection,
+    WikiInfo,
+  }) => ({
+    // > Update & expose - Internal relationships
+
+    trackSections: thingList(V(TrackSection)),
+
+    // > Update & expose - Identifying metadata
+
+    name: name(V('Unnamed Album')),
+    directory: directory(),
+
+    directorySuffix: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDirectory),
+      }),
+
+      exposeDependency('directory'),
+    ],
+
+    alwaysReferenceByDirectory: flag(V(false)),
+    alwaysReferenceTracksByDirectory: flag(V(false)),
+    suffixTrackDirectories: flag(V(false)),
+
+    style: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(is(...[
+          'album',
+          'single',
+        ])),
+      }),
+
+      exposeConstant(V('album')),
+    ],
+
+    bandcampAlbumIdentifier: simpleString(),
+    bandcampArtworkIdentifier: simpleString(),
+
+    additionalNames: thingList(V(AdditionalName)),
+
+    date: simpleDate(),
+    dateAddedToWiki: simpleDate(),
+
+    // > Update & expose - Credits and contributors
+
+    artistContribs: contributionList({
+      class: input.value(AlbumArtistContribution),
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    trackArtistText: contentString(),
+
+    trackArtistContribs: [
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        class: input.value(TrackArtistContribution),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('albumTrackArtistContributions'),
+      }).outputs({
+        '#resolvedContribs': '#trackArtistContribs',
+      }),
+
+      exposeDependencyOrContinue('#trackArtistContribs', V('empty')),
+
+      withRecontextualizedContributionList('artistContribs', {
+        reclass: input.value(TrackArtistContribution),
+        artistProperty: input.value('albumTrackArtistContributions'),
+      }),
+
+      exposeDependency('#artistContribs'),
+    ],
+
+    // > Update & expose - General configuration
+
+    countTracksInArtistTotals: flag(V(true)),
+
+    showAlbumInTracksWithoutArtists: flag(V(false)),
+
+    hasTrackNumbers: flag(V(true)),
+    isListedOnHomepage: flag(V(true)),
+    isListedInGalleries: flag(V(true)),
+
+    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'),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+    ],
+
+    coverArtistContribs: contributionList({
+      date: 'coverArtDate',
+      artistProperty: input.value('albumCoverArtistContributions'),
+    }),
+
+    coverArtDate: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      exposeDependency('date'),
+    ],
+
+    coverArtFileExtension: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      fileExtension(V('jpg')),
+    ],
+
+    coverArtDimensions: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      dimensions(),
+    ],
+
+    artTags: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
+      }),
+
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
+    ],
+
+    referencedArtworks: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
+      }),
+
+      referencedArtworkList(),
+    ],
+
+    trackCoverArtistContribs: contributionList({
+      // May be null, indicating cover art was added for tracks on the date
+      // each track specifies, or else the track's own release date.
+      date: 'trackArtDate',
+
+      // This is the "correct" value, but it gets overwritten - with the same
+      // value - regardless.
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
+
+    trackArtDate: simpleDate(),
+
+    trackCoverArtFileExtension: fileExtension(V('jpg')),
+
+    trackDimensions: dimensions(),
+
+    wallpaperBrightness: {
+      flags: {update: true, expose: true},
+      update: {validate: isNumber},
+    },
+
+    wallpaperArtwork: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Wallpaper Artwork'),
+    ],
+
+    wallpaperArtistContribs: contributionList({
+      class: input.value(AlbumWallpaperArtistContribution),
+      date: 'coverArtDate',
+      artistProperty: input.value('albumWallpaperArtistContributions'),
+    }),
+
+    wallpaperFileExtension: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      fileExtension(V('jpg')),
+    ],
+
+    wallpaperStyle: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      simpleString(),
+    ],
+
+    wallpaperParts: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
+      }),
+
+      wallpaperParts(),
+    ],
+
+    bannerArtwork: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
+    ],
+
+    bannerArtistContribs: contributionList({
+      class: input.value(AlbumBannerArtistContribution),
+      date: 'coverArtDate',
+      artistProperty: input.value('albumBannerArtistContributions'),
+    }),
+
+    bannerFileExtension: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      fileExtension(V('jpg')),
+    ],
+
+    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(V(Artwork)),
+
+    // used for withMatchingContributionPresets (indirectly by Contribution)
+    wikiInfo: thing(V(WikiInfo)),
+
+    // > Expose only
+
+    isAlbum: exposeConstant(V(true)),
+
+    commentatorArtists: commentatorArtists(),
+
+    hasCoverArt: hasArtwork({
+      contribs: '_coverArtistContribs',
+      artworks: '_coverArtworks',
+    }),
+
+    hasWallpaperArt: hasArtwork({
+      contribs: '_wallpaperArtistContribs',
+      artwork: '_wallpaperArtwork',
+    }),
+
+    hasBannerArt: hasArtwork({
+      contribs: '_bannerArtistContribs',
+      artwork: '_bannerArtwork',
+    }),
+
+    tracks: [
+      exitWithoutDependency('trackSections', V([])),
+
+      withPropertyFromList('trackSections', V('tracks')),
+      withFlattenedList('#trackSections.tracks'),
+      exposeDependency('#flattenedList'),
+    ],
+  });
+
+  static [Thing.getSerializeDescriptors] = ({
+    serialize: S,
+  }) => ({
+    name: S.id,
+    color: S.id,
+    directory: S.id,
+    urls: S.id,
+
+    date: S.id,
+    coverArtDate: S.id,
+    trackArtDate: S.id,
+    dateAddedToWiki: S.id,
+
+    artistContribs: S.toContribRefs,
+    coverArtistContribs: S.toContribRefs,
+    trackCoverArtistContribs: S.toContribRefs,
+    wallpaperArtistContribs: S.toContribRefs,
+    bannerArtistContribs: S.toContribRefs,
+
+    coverArtFileExtension: S.id,
+    trackCoverArtFileExtension: S.id,
+    wallpaperStyle: S.id,
+    wallpaperFileExtension: S.id,
+    bannerStyle: S.id,
+    bannerFileExtension: S.id,
+    bannerDimensions: S.id,
+
+    hasTrackArt: S.id,
+    isListedOnHomepage: S.id,
+
+    commentary: S.toCommentaryRefs,
+
+    additionalFiles: S.id,
+
+    tracks: S.toRefs,
+    groups: S.toRefs,
+    artTags: S.toRefs,
+    commentatorArtists: S.toRefs,
+  });
+
+  static [Thing.findSpecs] = {
+    album: {
+      referenceTypes: [
+        'album',
+        'album-commentary',
+        'album-gallery',
+      ],
+
+      bindTo: 'albumData',
+
+      getMatchableNames: album =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+    },
+
+    albumSinglesOnly: {
+      referencing: ['album'],
+
+      bindTo: 'albumData',
+
+      incldue: album =>
+        album.style === 'single',
+
+      getMatchableNames: album =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+    },
+
+    albumWithArtwork: {
+      referenceTypes: [
+        'album',
+        'album-referencing-artworks',
+        'album-referenced-artworks',
+      ],
+
+      bindTo: 'albumData',
+
+      include: album =>
+        album.hasCoverArt,
+
+      getMatchableNames: album =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+    },
+
+    albumPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'album',
+        'album-referencing-artworks',
+        'album-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork) =>
+        artwork.isArtwork &&
+        artwork.thing.isAlbum &&
+        artwork === artwork.thing.coverArtworks[0],
+
+      getMatchableNames: ({thing: album}) =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+
+      getMatchableDirectories: ({thing: album}) =>
+        [album.directory],
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    albumsWhoseArtworksFeature: {
+      bindTo: 'albumData',
+
+      referencing: album => [album],
+      referenced: album => album.artTags,
+    },
+
+    albumsWhoseGroupsInclude: {
+      bindTo: 'albumData',
+
+      referencing: album => [album],
+      referenced: album => album.groups,
+    },
+
+    albumArtistContributionsBy:
+      soupyReverse.contributionsBy('albumData', 'artistContribs'),
+
+    albumTrackArtistContributionsBy:
+      soupyReverse.contributionsBy('albumData', 'trackArtistContribs'),
+
+    albumCoverArtistContributionsBy:
+      soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'),
+
+    albumWallpaperArtistContributionsBy:
+      soupyReverse.artworkContributionsBy('albumData', 'wallpaperArtwork', {single: true}),
+
+    albumBannerArtistContributionsBy:
+      soupyReverse.artworkContributionsBy('albumData', 'bannerArtwork', {single: true}),
+
+    albumsWithCommentaryBy: {
+      bindTo: 'albumData',
+
+      referencing: album => [album],
+      referenced: album => album.commentatorArtists,
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      // 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'},
+      'Style': {property: 'style'},
+
+      'Bandcamp Album ID': {
+        property: 'bandcampAlbumIdentifier',
+        transform: String,
+      },
+
+      'Bandcamp Artwork ID': {
+        property: 'bandcampArtworkIdentifier',
+        transform: String,
+      },
+
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
+
+      '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',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'albumCoverArtistContributions',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+          }),
+      },
+
+      'Banner Artwork': {
+        property: 'bannerArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'bannerArtwork',
+            dimensionsFromThingProperty: 'bannerDimensions',
+            fileExtensionFromThingProperty: 'bannerFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'bannerArtistContribs',
+            artistContribsArtistProperty: 'albumBannerArtistContributions',
+          }),
+      },
+
+      'Wallpaper Brightness': {property: 'wallpaperBrightness'},
+
+      'Wallpaper Artwork': {
+        property: 'wallpaperArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'wallpaperArtwork',
+            dimensionsFromThingProperty: null,
+            fileExtensionFromThingProperty: 'wallpaperFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'wallpaperArtistContribs',
+            artistContribsArtistProperty: 'albumWallpaperArtistContributions',
+          }),
+      },
+
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Cover Art Date': {
+        property: 'coverArtDate',
+        transform: parseDate,
+      },
+
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
+      'Default Track Cover Artists': {
+        property: 'trackCoverArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Default Track Cover Art Date': {
+        property: 'trackArtDate',
+        transform: parseDate,
+      },
+
+      'Default Track Dimensions': {
+        property: 'trackDimensions',
+        transform: parseDimensions,
+      },
+
+      'Wallpaper Artists': {
+        property: 'wallpaperArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Wallpaper Style': {property: 'wallpaperStyle'},
+
+      'Wallpaper Parts': {
+        property: 'wallpaperParts',
+        transform: parseWallpaperParts,
+      },
+
+      'Banner Artists': {
+        property: 'bannerArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Banner Dimensions': {
+        property: 'bannerDimensions',
+        transform: parseDimensions,
+      },
+
+      'Banner Style': {property: 'bannerStyle'},
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
+      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
+      'Banner File Extension': {property: 'bannerFileExtension'},
+
+      'Art Tags': {property: 'artTags'},
+
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+
+      // Groups
+
+      'Groups': {property: 'groups'},
+
+      // Content entries
+
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
+      },
+
+      // Additional files
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      // 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',
+      ]},
+
+      {message: `Wallpaper file extensions are specified on asset, per part`, fields: [
+        'Wallpaper Parts',
+        'Wallpaper File Extension',
+      ]},
+    ],
+  };
+
+  getOwnAdditionalFilePath(_file, filename) {
+    return [
+      'media.albumAdditionalFile',
+      this.directory,
+      filename,
+    ];
+  }
+
+  getOwnArtworkPath(artwork) {
+    if (artwork === this.bannerArtwork) {
+      return [
+        'media.albumBanner',
+        this.directory,
+        artwork.fileExtension,
+      ];
+    }
+
+    if (artwork === this.wallpaperArtwork) {
+      if (!empty(this.wallpaperParts)) {
+        return null;
+      }
+
+      return [
+        'media.albumWallpaper',
+        this.directory,
+        artwork.fileExtension,
+      ];
+    }
+
+    // TODO: using trackCover here is obviously, badly wrong
+    // but we ought to refactor banners and wallpapers similarly
+    // (i.e. depend on those intrinsic artwork paths rather than
+    // accessing media.{albumBanner,albumWallpaper} from content
+    // or other code directly)
+    return [
+      'media.trackCover',
+      this.directory,
+
+      (artwork.unqualifiedDirectory
+        ? 'cover-' + artwork.unqualifiedDirectory
+        : 'cover'),
+
+      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;
+  }
+}
diff --git a/src/data/things/album/TrackSection.js b/src/data/things/album/TrackSection.js
new file mode 100644
index 00000000..4bc43a3c
--- /dev/null
+++ b/src/data/things/album/TrackSection.js
@@ -0,0 +1,267 @@
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {input, V} from '#composite';
+import Thing from '#thing';
+import {isBoolean, isColor, isDirectory, isNumber} from '#validators';
+import {parseDate} from '#yaml';
+
+import {withLengthOfList, withNearbyItemFromList, withPropertyFromObject}
+  from '#composite/data';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  contentString,
+  directory,
+  flag,
+  name,
+  simpleDate,
+  soupyReverse,
+  thing,
+  thingList,
+} from '#composite/wiki-properties';
+
+export class TrackSection extends Thing {
+  static [Thing.friendlyName] = `Track Section`;
+  static [Thing.referenceType] = `track-section`;
+  static [Thing.wikiData] = 'trackSectionData';
+
+  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+    // Update & expose
+
+    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),
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('color'),
+      }),
+
+      exposeDependency({dependency: '#album.color'}),
+    ],
+
+    hasTrackNumbers: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject('album', V('hasTrackNumbers')),
+      exposeDependency('#album.hasTrackNumbers'),
+    ],
+
+    startCountingFrom: [
+      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',
+      }),
+
+      exitWithoutDependency('#previousTrackSection', V(1)),
+
+      withPropertyFromObject('#previousTrackSection', V('continueCountingFrom')),
+      exposeDependency('#previousTrackSection.continueCountingFrom'),
+    ],
+
+    dateOriginallyReleased: simpleDate(),
+
+    countTracksInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('countTracksInArtistTotals'),
+      }),
+
+      exposeDependency({dependency: '#album.countTracksInArtistTotals'}),
+    ],
+
+    isDefaultTrackSection: flag(V(false)),
+
+    description: contentString(),
+
+    tracks: thingList(V(Track)),
+
+    // Update only
+
+    reverse: soupyReverse(),
+
+    // Expose only
+
+    isTrackSection: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
+    directory: [
+      exitWithoutDependency({
+        dependency: 'album',
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('directory'),
+      }),
+
+      {
+        dependencies: ['#album.directory', 'unqualifiedDirectory'],
+        compute: ({
+          ['#album.directory']: albumDirectory,
+          ['unqualifiedDirectory']: unqualifiedDirectory,
+        }) =>
+          albumDirectory + '/' + unqualifiedDirectory,
+      },
+    ],
+
+    continueCountingFrom: [
+      withPropertyFromObject('album', V('hasTrackNumbers')),
+      exitWithoutDependency('#album.hasTrackNumbers', V(null), V('falsy')),
+
+      {
+        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,
+      },
+    ],
+  });
+
+  static [Thing.findSpecs] = {
+    trackSection: {
+      referenceTypes: ['track-section'],
+      bindTo: 'trackSectionData',
+    },
+
+    unqualifiedTrackSection: {
+      referenceTypes: ['unqualified-track-section'],
+
+      getMatchableDirectories: trackSection =>
+        [trackSection.unqualifiedDirectory],
+    },
+  };
+
+  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': {
+        property: 'dateOriginallyReleased',
+        transform: parseDate,
+      },
+
+      'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'},
+
+      'Description': {property: 'description'},
+    },
+  };
+
+  [inspect.custom](depth) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (depth >= 0) showAlbum: {
+      let album = null;
+      try {
+        album = this.album;
+      } catch {
+        break showAlbum;
+      }
+
+      let first = null;
+      try {
+        first = this.tracks.at(0).trackNumber;
+      } catch {}
+
+      let last = null;
+      try {
+        last = this.tracks.at(-1).trackNumber;
+      } catch {}
+
+      const albumName = album.name;
+      const albumIndex = album.trackSections.indexOf(this);
+
+      const num =
+        (albumIndex === -1
+          ? 'indeterminate position'
+          : `#${albumIndex + 1}`);
+
+      const range =
+        (albumIndex >= 0 && first !== null && last !== null
+          ? `: ${first}-${last}`
+          : '');
+
+      parts.push(` (${colors.yellow(num + range)} in ${colors.green(`"${albumName}"`)})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/album/index.js b/src/data/things/album/index.js
new file mode 100644
index 00000000..67bf47ab
--- /dev/null
+++ b/src/data/things/album/index.js
@@ -0,0 +1,2 @@
+export * from './Album.js';
+export * from './TrackSection.js';
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
deleted file mode 100644
index 9842c887..00000000
--- a/src/data/things/art-tag.js
+++ /dev/null
@@ -1,104 +0,0 @@
-export const ART_TAG_DATA_FILE = 'tags.yaml';
-
-import {input} from '#composite';
-import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort';
-import Thing from '#thing';
-import {isName} from '#validators';
-
-import {exposeUpdateValueOrContinue} from '#composite/control-flow';
-
-import {
-  color,
-  directory,
-  flag,
-  name,
-  soupyReverse,
-  wikiData,
-} from '#composite/wiki-properties';
-
-export class ArtTag extends Thing {
-  static [Thing.referenceType] = 'tag';
-  static [Thing.friendlyName] = `Art Tag`;
-
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
-    // Update & expose
-
-    name: name('Unnamed Art Tag'),
-    directory: directory(),
-    color: color(),
-    isContentWarning: flag(false),
-
-    nameShort: [
-      exposeUpdateValueOrContinue({
-        validate: input.value(isName),
-      }),
-
-      {
-        dependencies: ['name'],
-        compute: ({name}) =>
-          name.replace(/ \([^)]*?\)$/, ''),
-      },
-    ],
-
-    // Update only
-
-    reverse: soupyReverse(),
-
-    // Expose only
-
-    taggedInThings: {
-      flags: {expose: true},
-
-      expose: {
-        dependencies: ['this', 'reverse'],
-        compute: ({this: artTag, reverse}) =>
-          sortAlbumsTracksChronologically(
-            [
-              ...reverse.albumsWhoseArtworksFeature(artTag),
-              ...reverse.tracksWhoseArtworksFeature(artTag),
-            ],
-            {getDate: thing => thing.coverArtDate ?? thing.date}),
-      },
-    },
-  });
-
-  static [Thing.findSpecs] = {
-    artTag: {
-      referenceTypes: ['tag'],
-      bindTo: 'artTagData',
-
-      getMatchableNames: tag =>
-        (tag.isContentWarning
-          ? [`cw: ${tag.name}`]
-          : [tag.name]),
-    },
-  };
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      'Tag': {property: 'name'},
-      'Short Name': {property: 'nameShort'},
-      'Directory': {property: 'directory'},
-
-      'Color': {property: 'color'},
-      'Is CW': {property: 'isContentWarning'},
-    },
-  };
-
-  static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {allInOne},
-    thingConstructors: {ArtTag},
-  }) => ({
-    title: `Process art tags file`,
-    file: ART_TAG_DATA_FILE,
-
-    documentMode: allInOne,
-    documentThing: ArtTag,
-
-    save: (results) => ({artTagData: results}),
-
-    sort({artTagData}) {
-      sortAlphabetically(artTagData);
-    },
-  });
-}
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
deleted file mode 100644
index 7ed99a8e..00000000
--- a/src/data/things/artist.js
+++ /dev/null
@@ -1,269 +0,0 @@
-export const ARTIST_DATA_FILE = 'artists.yaml';
-
-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 Thing from '#thing';
-import {isName, validateArrayItems} from '#validators';
-import {getKebabCase} from '#wiki-data';
-
-import {
-  contentString,
-  directory,
-  fileExtension,
-  flag,
-  name,
-  reverseReferenceList,
-  singleReference,
-  soupyFind,
-  soupyReverse,
-  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.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({
-    // Update & expose
-
-    name: name('Unnamed Artist'),
-    directory: directory(),
-    urls: urls(),
-
-    contextNotes: contentString(),
-
-    hasAvatar: flag(false),
-    avatarFileExtension: fileExtension('jpg'),
-
-    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'),
-    }),
-
-    // Update only
-
-    find: soupyFind(),
-    reverse: soupyReverse(),
-
-    // Expose only
-
-    trackArtistContributions: reverseReferenceList({
-      reverse: soupyReverse.input('trackArtistContributionsBy'),
-    }),
-
-    trackContributorContributions: reverseReferenceList({
-      reverse: soupyReverse.input('trackContributorContributionsBy'),
-    }),
-
-    trackCoverArtistContributions: reverseReferenceList({
-      reverse: soupyReverse.input('trackCoverArtistContributionsBy'),
-    }),
-
-    tracksAsCommentator: reverseReferenceList({
-      reverse: soupyReverse.input('tracksWithCommentaryBy'),
-    }),
-
-    albumArtistContributions: reverseReferenceList({
-      reverse: soupyReverse.input('albumArtistContributionsBy'),
-    }),
-
-    albumCoverArtistContributions: reverseReferenceList({
-      reverse: soupyReverse.input('albumCoverArtistContributionsBy'),
-    }),
-
-    albumWallpaperArtistContributions: reverseReferenceList({
-      reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'),
-    }),
-
-    albumBannerArtistContributions: reverseReferenceList({
-      reverse: soupyReverse.input('albumBannerArtistContributionsBy'),
-    }),
-
-    albumsAsCommentator: reverseReferenceList({
-      reverse: soupyReverse.input('albumsWithCommentaryBy'),
-    }),
-
-    flashContributorContributions: reverseReferenceList({
-      reverse: soupyReverse.input('flashContributorContributionsBy'),
-    }),
-
-    flashesAsCommentator: reverseReferenceList({
-      reverse: soupyReverse.input('flashesWithCommentaryBy'),
-    }),
-
-    closelyLinkedGroups: reverseReferenceList({
-      reverse: soupyReverse.input('groupsCloselyLinkedTo'),
-    }),
-
-    totalDuration: artistTotalDuration(),
-  });
-
-  static [Thing.getSerializeDescriptors] = ({
-    serialize: S,
-  }) => ({
-    name: S.id,
-    directory: S.id,
-    urls: S.id,
-    contextNotes: S.id,
-
-    hasAvatar: S.id,
-    avatarFileExtension: S.id,
-
-    aliasNames: S.id,
-
-    tracksAsCommentator: S.toRefs,
-    albumsAsCommentator: S.toRefs,
-  });
-
-  static [Thing.findSpecs] = {
-    artist: {
-      referenceTypes: ['artist', 'artist-gallery'],
-      bindTo: 'artistData',
-
-      include: artist => !artist.isAlias,
-    },
-
-    artistAlias: {
-      referenceTypes: ['artist', 'artist-gallery'],
-      bindTo: 'artistData',
-
-      include: artist => artist.isAlias,
-
-      getMatchableDirectories(artist) {
-        const originalArtist = artist.aliasedArtist;
-
-        // Aliases never match by the same directory as the original.
-        if (artist.directory === originalArtist.directory) {
-          return [];
-        }
-
-        // Aliases never match by the same directory as some *previous* alias
-        // 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 [];
-          }
-        }
-
-        // And, aliases never return just a blank string. This part is pretty
-        // spooky because it doesn't handle two differently named aliases, on
-        // different artists, who have names that are similar *apart* from a
-        // character that's shortened. But that's also so fundamentally scary
-        // that we can't support it properly with existing code, anyway - we
-        // would need to be able to specifically set a directory *on an alias,*
-        // which currently can't be done in YAML data files.
-        if (artist.directory === '') {
-          return [];
-        }
-
-        return [artist.directory];
-      },
-    },
-  };
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      'Artist': {property: 'name'},
-      'Directory': {property: 'directory'},
-      'URLs': {property: 'urls'},
-      'Context Notes': {property: 'contextNotes'},
-
-      'Has Avatar': {property: 'hasAvatar'},
-      'Avatar File Extension': {property: 'avatarFileExtension'},
-
-      'Aliases': {property: 'aliasNames'},
-
-      'Dead URLs': {ignore: true},
-
-      'Review Points': {ignore: true},
-    },
-  };
-
-  static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {allInOne},
-    thingConstructors: {Artist},
-  }) => ({
-    title: `Process artists file`,
-    file: ARTIST_DATA_FILE,
-
-    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];
-
-      return {artistData};
-    },
-
-    sort({artistData}) {
-      sortAlphabetically(artistData);
-    },
-  });
-
-  [inspect.custom]() {
-    const parts = [];
-
-    parts.push(Thing.prototype[inspect.custom].apply(this));
-
-    if (CacheableObject.getUpdateValue(this, 'isAlias')) {
-      parts.unshift(`${colors.yellow('[alias]')} `);
-
-      let aliasedArtist;
-      try {
-        aliasedArtist = this.aliasedArtist.name;
-      } catch (_error) {
-        aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist');
-      }
-
-      parts.push(` ${colors.yellow(`[of ${aliasedArtist}]`)}`);
-    }
-
-    return parts.join('');
-  }
-}
diff --git a/src/data/things/content/CommentaryEntry.js b/src/data/things/content/CommentaryEntry.js
new file mode 100644
index 00000000..32d69213
--- /dev/null
+++ b/src/data/things/content/CommentaryEntry.js
@@ -0,0 +1,20 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {hasAnnotationPart} from '#composite/things/content';
+
+import {ContentEntry} from './ContentEntry.js';
+
+export class CommentaryEntry extends ContentEntry {
+  static [Thing.wikiData] = 'commentaryData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isCommentaryEntry: exposeConstant(V(true)),
+
+    isWikiEditorCommentary: hasAnnotationPart(V('wiki editor')),
+  });
+}
diff --git a/src/data/things/content/ContentEntry.js b/src/data/things/content/ContentEntry.js
new file mode 100644
index 00000000..7dc81345
--- /dev/null
+++ b/src/data/things/content/ContentEntry.js
@@ -0,0 +1,246 @@
+import {input, V} from '#composite';
+import {transposeArrays} from '#sugar';
+import Thing from '#thing';
+import {is, isDate, validateReferenceList} from '#validators';
+import {parseDate} from '#yaml';
+
+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 {
+  withAnnotationPartNodeLists,
+  withExpressedOrImplicitArtistReferences,
+  withWebArchiveDate,
+} from '#composite/things/content';
+
+export class ContentEntry extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: thing(),
+
+    artists: [
+      withExpressedOrImplicitArtistReferences({
+        from: input.updateValue({
+          validate: validateReferenceList('artist'),
+        }),
+      }),
+
+      exitWithoutDependency('#artistReferences', V([])),
+
+      withResolvedReferenceList({
+        list: '#artistReferences',
+        find: soupyFind.input('artist'),
+      }),
+
+      exposeDependency('#resolvedReferenceList'),
+    ],
+
+    artistText: contentString(),
+
+    annotation: contentString(),
+
+    dateKind: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate: is(...[
+          'sometime',
+          'throughout',
+          'around',
+        ]),
+      },
+    },
+
+    accessKind: [
+      exitWithoutDependency('_accessDate'),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(
+          is(...[
+            'captured',
+            'accessed',
+          ])),
+      }),
+
+      withWebArchiveDate(),
+
+      withResultOfAvailabilityCheck({from: '#webArchiveDate'}),
+
+      {
+        dependencies: ['#availability'],
+        compute: (continuation, {['#availability']: availability}) =>
+          (availability
+            ? continuation.exit('captured')
+            : continuation()),
+      },
+
+      exposeConstant(V('accessed')),
+    ],
+
+    date: simpleDate(),
+    secondDate: simpleDate(),
+
+    accessDate: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      withWebArchiveDate(),
+
+      exposeDependencyOrContinue({
+        dependency: '#webArchiveDate',
+      }),
+
+      exposeConstant(V(null)),
+    ],
+
+    body: contentString(),
+
+    // 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] = {
+    fields: {
+      'Artists': {property: 'artists'},
+      'Artist Text': {property: 'artistText'},
+
+      'Annotation': {property: 'annotation'},
+
+      'Date Kind': {property: 'dateKind'},
+      'Access Kind': {property: 'accessKind'},
+
+      'Date': {property: 'date', transform: parseDate},
+      'Second Date': {property: 'secondDate', transform: parseDate},
+      'Access Date': {property: 'accessDate', transform: parseDate},
+
+      'Body': {property: 'body'},
+    },
+  };
+}
diff --git a/src/data/things/content/CreditingSourcesEntry.js b/src/data/things/content/CreditingSourcesEntry.js
new file mode 100644
index 00000000..7331ae8c
--- /dev/null
+++ b/src/data/things/content/CreditingSourcesEntry.js
@@ -0,0 +1,16 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {ContentEntry} from './ContentEntry.js';
+
+export class CreditingSourcesEntry extends ContentEntry {
+  static [Thing.wikiData] = 'creditingSourceData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isCreditingSourcesEntry: exposeConstant(V(true)),
+  });
+}
diff --git a/src/data/things/content/LyricsEntry.js b/src/data/things/content/LyricsEntry.js
new file mode 100644
index 00000000..88e4464d
--- /dev/null
+++ b/src/data/things/content/LyricsEntry.js
@@ -0,0 +1,43 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exitWithoutDependency, exposeConstant} from '#composite/control-flow';
+import {contentString} from '#composite/wiki-properties';
+
+import {hasAnnotationPart} from '#composite/things/content';
+
+import {ContentEntry} from './ContentEntry.js';
+
+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'},
+    },
+  });
+}
diff --git a/src/data/things/content/ReferencingSourcesEntry.js b/src/data/things/content/ReferencingSourcesEntry.js
new file mode 100644
index 00000000..76fafbc2
--- /dev/null
+++ b/src/data/things/content/ReferencingSourcesEntry.js
@@ -0,0 +1,16 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {ContentEntry} from './ContentEntry.js';
+
+export class ReferencingSourcesEntry extends ContentEntry {
+  static [Thing.wikiData] = 'referencingSourceData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isReferencingSourceEntry: exposeConstant(V(true)),
+  });
+}
diff --git a/src/data/things/content/index.js b/src/data/things/content/index.js
new file mode 100644
index 00000000..aaa7f304
--- /dev/null
+++ b/src/data/things/content/index.js
@@ -0,0 +1,9 @@
+// Yet Another Index.js File Descending From A Folder Named Content
+
+export * from './ContentEntry.js';
+
+export * from './CommentaryEntry.js';
+export * from './LyricsEntry.js';
+
+export * from './CreditingSourcesEntry.js';
+export * from './ReferencingSourcesEntry.js';
diff --git a/src/data/things/contrib/AlbumArtistContribution.js b/src/data/things/contrib/AlbumArtistContribution.js
new file mode 100644
index 00000000..7b6bc9da
--- /dev/null
+++ b/src/data/things/contrib/AlbumArtistContribution.js
@@ -0,0 +1,12 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {MusicalArtistContribution} from './MusicalArtistContribution.js';
+
+export class AlbumArtistContribution extends MusicalArtistContribution {
+  static [Thing.getPropertyDescriptors] = () => ({
+    isAlbumArtistContribution: exposeConstant(V(true)),
+  });
+}
diff --git a/src/data/things/contrib/AlbumAssetArtworkArtistContribution.js b/src/data/things/contrib/AlbumAssetArtworkArtistContribution.js
new file mode 100644
index 00000000..fbc3f719
--- /dev/null
+++ b/src/data/things/contrib/AlbumAssetArtworkArtistContribution.js
@@ -0,0 +1,12 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {ArtworkArtistContribution} from './ArtworkArtistContribution.js';
+
+export class AlbumAssetArtworkArtistContribution extends ArtworkArtistContribution {
+  static [Thing.getPropertyDescriptors] = () => ({
+    isAlbumAssetArtworkArtistContribution: exposeConstant(V(true)),
+  });
+}
diff --git a/src/data/things/contrib/AlbumBannerArtistContribution.js b/src/data/things/contrib/AlbumBannerArtistContribution.js
new file mode 100644
index 00000000..16f1c9bb
--- /dev/null
+++ b/src/data/things/contrib/AlbumBannerArtistContribution.js
@@ -0,0 +1,12 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {AlbumAssetArtworkArtistContribution} from './AlbumAssetArtworkArtistContribution.js';
+
+export class AlbumBannerArtistContribution extends AlbumAssetArtworkArtistContribution {
+  static [Thing.getPropertyDescriptors] = () => ({
+    isAlbumBannerArtistContribution: exposeConstant(V(true)),
+  });
+}
diff --git a/src/data/things/contrib/AlbumWallpaperArtistContribution.js b/src/data/things/contrib/AlbumWallpaperArtistContribution.js
new file mode 100644
index 00000000..acd29cf8
--- /dev/null
+++ b/src/data/things/contrib/AlbumWallpaperArtistContribution.js
@@ -0,0 +1,12 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {AlbumAssetArtworkArtistContribution} from './AlbumAssetArtworkArtistContribution.js';
+
+export class AlbumWallpaperArtistContribution extends AlbumAssetArtworkArtistContribution {
+  static [Thing.getPropertyDescriptors] = () => ({
+    isAlbumWallpaperArtistContribution: exposeConstant(V(true)),
+  });
+}
diff --git a/src/data/things/contrib/ArtworkArtistContribution.js b/src/data/things/contrib/ArtworkArtistContribution.js
new file mode 100644
index 00000000..a47f2391
--- /dev/null
+++ b/src/data/things/contrib/ArtworkArtistContribution.js
@@ -0,0 +1,20 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {hasAnnotationFront} from '#composite/things/contribution';
+
+import {Contribution} from './Contribution.js';
+
+export class ArtworkArtistContribution extends Contribution {
+  static [Thing.getPropertyDescriptors] = () => ({
+    isArtworkArtistContribution: exposeConstant(V(true)),
+
+    recognizedAnnotationFronts:
+      exposeConstant(V(['edits for wiki'])),
+
+    isEditsForWikiCredit:
+      hasAnnotationFront(V('edits for wiki')),
+  });
+}
diff --git a/src/data/things/contrib/Contribution.js b/src/data/things/contrib/Contribution.js
new file mode 100644
index 00000000..4352b58a
--- /dev/null
+++ b/src/data/things/contrib/Contribution.js
@@ -0,0 +1,351 @@
+import {inspect} from 'node:util';
+
+import CacheableObject from '#cacheable-object';
+import {colors} from '#cli';
+import {input, V} from '#composite';
+import {empty} from '#sugar';
+import Thing from '#thing';
+import {isBoolean, isStringNonEmpty, isThing} from '#validators';
+
+import {simpleDate, singleReference, simpleString, soupyFind}
+  from '#composite/wiki-properties';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  withFilteredList,
+  withNearbyItemFromList,
+  withPropertyFromList,
+  withPropertyFromObject,
+} from '#composite/data';
+
+import {
+  inheritFromContributionPresets,
+  withContainingReverseContributionList,
+  withContributionContext,
+} from '#composite/things/contribution';
+
+export class Contribution extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: {
+      flags: {update: true, expose: true},
+      update: {validate: isThing},
+    },
+
+    thingProperty: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    artistProperty: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    date: simpleDate(),
+
+    artist: singleReference({
+      find: soupyFind.input('artist'),
+    }),
+
+    artistText: simpleString(),
+
+    annotation: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    countInContributionTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      inheritFromContributionPresets(),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInContributionTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInContributionTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant(V(true)),
+    ],
+
+    countInDurationTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      inheritFromContributionPresets(),
+
+      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
+
+    find: soupyFind(),
+
+    // Expose only
+
+    isContribution: exposeConstant(V(true)),
+
+    recognizedAnnotationFronts: exposeConstant(V([])),
+
+    annotationFront: [
+      exitWithoutDependency('annotation'),
+
+      {
+        dependencies: ['recognizedAnnotationFronts', 'annotation'],
+        compute: ({recognizedAnnotationFronts, annotation}) =>
+          recognizedAnnotationFronts
+            .find(front =>
+              annotation.startsWith(front) && (
+                annotation === front ||
+                annotation.at(front.length) === ':' ||
+                annotation.at(front.length) === ','
+              )) ?? null,
+      },
+    ],
+
+    annotationBack: [
+      exitWithoutDependency('annotation'),
+
+      exitWithoutDependency({
+        dependency: 'annotationFront',
+        value: 'annotation',
+      }),
+
+      {
+        dependencies: ['annotation', 'annotationFront'],
+        compute: ({annotation, annotationFront}) =>
+          annotation.slice(annotationFront.length + 1).trim()
+            || null,
+      },
+    ],
+
+    annotationParts: [
+      exitWithoutDependency('annotationBack', V([])),
+
+      {
+        dependencies: ['annotationBack'],
+        compute: ({annotationBack}) =>
+          annotationBack
+            .split(',')
+            .map(part => part.trim()),
+      },
+    ],
+
+    context: [
+      withContributionContext(),
+
+      {
+        dependencies: [
+          '#contributionTarget',
+          '#contributionProperty',
+        ],
+
+        compute: ({
+          ['#contributionTarget']: target,
+          ['#contributionProperty']: property,
+        }) => ({
+          target,
+          property,
+        }),
+      },
+    ],
+
+    matchingPresets: [
+      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.
+    // Note that this list contains not only other contributions by the same
+    // artist, but also this very contribution. It doesn't mix contributions
+    // exposed on different properties.
+    associatedContributions: [
+      exitWithoutDependency('thing', V([])),
+      exitWithoutDependency('thingProperty', V([])),
+
+      withPropertyFromObject('thing', 'thingProperty')
+        .outputs({'#value': '#contributions'}),
+
+      withPropertyFromList('#contributions', V('annotation')),
+
+      {
+        dependencies: ['#contributions.annotation', 'annotation'],
+        compute: (continuation, {
+          ['#contributions.annotation']: contributionAnnotations,
+          ['annotation']: annotation,
+        }) => continuation({
+          ['#likeContributionsFilter']:
+            contributionAnnotations.map(mappingAnnotation =>
+              (annotation?.startsWith(`edits for wiki`)
+                ? mappingAnnotation?.startsWith(`edits for wiki`)
+                : !mappingAnnotation?.startsWith(`edits for wiki`))),
+        }),
+      },
+
+      withFilteredList('#contributions', '#likeContributionsFilter')
+        .outputs({'#filteredList': '#contributions'}),
+
+      exposeDependency('#contributions'),
+    ],
+
+    previousBySameArtist: [
+      withContainingReverseContributionList()
+        .outputs({'#containingReverseContributionList': '#list'}),
+
+      exitWithoutDependency('#list'),
+
+      withNearbyItemFromList('#list', input.myself(), V(-1)),
+      exposeDependency('#nearbyItem'),
+    ],
+
+    nextBySameArtist: [
+      withContainingReverseContributionList()
+        .outputs({'#containingReverseContributionList': '#list'}),
+
+      exitWithoutDependency('#list'),
+
+      withNearbyItemFromList('#list', input.myself(), V(+1)),
+      exposeDependency('#nearbyItem'),
+    ],
+
+    groups: [
+      withPropertyFromObject('thing', V('groups')),
+      exposeDependencyOrContinue('#thing.groups'),
+
+      exposeConstant(V([])),
+    ],
+  });
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+    const accentParts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (this.annotation) {
+      accentParts.push(colors.green(`"${this.annotation}"`));
+    }
+
+    if (this.date) {
+      accentParts.push(colors.yellow(this.date.toLocaleDateString()));
+    }
+
+    let artistRef;
+    if (depth >= 0) {
+      let artist;
+      try {
+        artist = this.artist;
+      } catch {
+        // Computing artist might crash for any reason - don't distract from
+        // other errors as a result of inspecting this contribution.
+      }
+
+      if (artist) {
+        artistRef =
+          colors.blue(Thing.getReference(artist));
+      }
+    } else {
+      artistRef =
+        colors.green(CacheableObject.getUpdateValue(this, 'artist'));
+    }
+
+    if (artistRef) {
+      accentParts.push(`by ${artistRef}`);
+    }
+
+    if (this.thing) {
+      if (depth >= 0) {
+        const newOptions = {
+          ...options,
+          depth:
+            (options.depth === null
+              ? null
+              : options.depth - 1),
+        };
+
+        accentParts.push(`to ${inspect(this.thing, newOptions)}`);
+      } else {
+        accentParts.push(`to ${colors.blue(Thing.getReference(this.thing))}`);
+      }
+    }
+
+    if (!empty(accentParts)) {
+      parts.push(` (${accentParts.join(', ')})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/contrib/MusicalArtistContribution.js b/src/data/things/contrib/MusicalArtistContribution.js
new file mode 100644
index 00000000..df26850b
--- /dev/null
+++ b/src/data/things/contrib/MusicalArtistContribution.js
@@ -0,0 +1,20 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {hasAnnotationFront} from '#composite/things/contribution';
+
+import {Contribution} from './Contribution.js';
+
+export class MusicalArtistContribution extends Contribution {
+  static [Thing.getPropertyDescriptors] = () => ({
+    isMusicalArtistContribution: exposeConstant(V(true)),
+
+    recognizedAnnotationFronts:
+      exposeConstant(V(['featuring'])),
+
+    isFeaturingCredit:
+      hasAnnotationFront(V('featuring')),
+  });
+}
diff --git a/src/data/things/contrib/TrackArtistContribution.js b/src/data/things/contrib/TrackArtistContribution.js
new file mode 100644
index 00000000..ecbe9b34
--- /dev/null
+++ b/src/data/things/contrib/TrackArtistContribution.js
@@ -0,0 +1,12 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {MusicalArtistContribution} from './MusicalArtistContribution.js';
+
+export class TrackArtistContribution extends MusicalArtistContribution {
+  static [Thing.getPropertyDescriptors] = () => ({
+    isTrackArtistContribution: exposeConstant(V(true)),
+  });
+}
diff --git a/src/data/things/contrib/index.js b/src/data/things/contrib/index.js
new file mode 100644
index 00000000..187ddb2c
--- /dev/null
+++ b/src/data/things/contrib/index.js
@@ -0,0 +1,11 @@
+export * from './Contribution.js';
+
+export * from './MusicalArtistContribution.js';
+export * from './AlbumArtistContribution.js';
+export * from './TrackArtistContribution.js';
+
+export * from './ArtworkArtistContribution.js';
+
+export * from './AlbumAssetArtworkArtistContribution.js';
+export * from './AlbumBannerArtistContribution.js';
+export * from './AlbumWallpaperArtistContribution.js';
diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js
deleted file mode 100644
index c92fafb4..00000000
--- a/src/data/things/contribution.js
+++ /dev/null
@@ -1,302 +0,0 @@
-import {inspect} from 'node:util';
-
-import CacheableObject from '#cacheable-object';
-import {colors} from '#cli';
-import {input} from '#composite';
-import {empty} from '#sugar';
-import Thing from '#thing';
-import {isStringNonEmpty, isThing, validateReference} from '#validators';
-
-import {exitWithoutDependency, exposeDependency} from '#composite/control-flow';
-import {flag, simpleDate, soupyFind} from '#composite/wiki-properties';
-
-import {
-  withFilteredList,
-  withNearbyItemFromList,
-  withPropertyFromList,
-  withPropertyFromObject,
-} from '#composite/data';
-
-import {
-  inheritFromContributionPresets,
-  thingPropertyMatches,
-  thingReferenceTypeMatches,
-  withContainingReverseContributionList,
-  withContributionArtist,
-  withContributionContext,
-  withMatchingContributionPresets,
-} from '#composite/things/contribution';
-
-export class Contribution extends Thing {
-  static [Thing.getPropertyDescriptors] = () => ({
-    // Update & expose
-
-    thing: {
-      flags: {update: true, expose: true},
-      update: {validate: isThing},
-    },
-
-    thingProperty: {
-      flags: {update: true, expose: true},
-      update: {validate: isStringNonEmpty},
-    },
-
-    artistProperty: {
-      flags: {update: true, expose: true},
-      update: {validate: isStringNonEmpty},
-    },
-
-    date: simpleDate(),
-
-    artist: [
-      withContributionArtist({
-        ref: input.updateValue({
-          validate: validateReference('artist'),
-        }),
-      }),
-
-      exposeDependency({
-        dependency: '#artist',
-      }),
-    ],
-
-    annotation: {
-      flags: {update: true, expose: true},
-      update: {validate: isStringNonEmpty},
-    },
-
-    countInContributionTotals: [
-      inheritFromContributionPresets({
-        property: input.thisProperty(),
-      }),
-
-      flag(true),
-    ],
-
-    countInDurationTotals: [
-      inheritFromContributionPresets({
-        property: input.thisProperty(),
-      }),
-
-      flag(true),
-    ],
-
-    // Update only
-
-    find: soupyFind(),
-
-    // Expose only
-
-    context: [
-      withContributionContext(),
-
-      {
-        dependencies: [
-          '#contributionTarget',
-          '#contributionProperty',
-        ],
-
-        compute: ({
-          ['#contributionTarget']: target,
-          ['#contributionProperty']: property,
-        }) => ({
-          target,
-          property,
-        }),
-      },
-    ],
-
-    matchingPresets: [
-      withMatchingContributionPresets(),
-
-      exposeDependency({
-        dependency: '#matchingContributionPresets',
-      }),
-    ],
-
-    // All the contributions from the list which includes this contribution.
-    // Note that this list contains not only other contributions by the same
-    // artist, but also this very contribution. It doesn't mix contributions
-    // exposed on different properties.
-    associatedContributions: [
-      exitWithoutDependency({
-        dependency: 'thing',
-        value: input.value([]),
-      }),
-
-      exitWithoutDependency({
-        dependency: 'thingProperty',
-        value: input.value([]),
-      }),
-
-      withPropertyFromObject({
-        object: 'thing',
-        property: 'thingProperty',
-      }).outputs({
-        '#value': '#contributions',
-      }),
-
-      withPropertyFromList({
-        list: '#contributions',
-        property: input.value('annotation'),
-      }),
-
-      {
-        dependencies: ['#contributions.annotation', 'annotation'],
-        compute: (continuation, {
-          ['#contributions.annotation']: contributionAnnotations,
-          ['annotation']: annotation,
-        }) => continuation({
-          ['#likeContributionsFilter']:
-            contributionAnnotations.map(mappingAnnotation =>
-              (annotation?.startsWith(`edits for wiki`)
-                ? mappingAnnotation?.startsWith(`edits for wiki`)
-                : !mappingAnnotation?.startsWith(`edits for wiki`))),
-        }),
-      },
-
-      withFilteredList({
-        list: '#contributions',
-        filter: '#likeContributionsFilter',
-      }).outputs({
-        '#filteredList': '#contributions',
-      }),
-
-      exposeDependency({
-        dependency: '#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',
-      }),
-
-      withNearbyItemFromList({
-        list: '#list',
-        item: input.myself(),
-        offset: input.value(-1),
-      }),
-
-      exposeDependency({
-        dependency: '#nearbyItem',
-      }),
-    ],
-
-    nextBySameArtist: [
-      withContainingReverseContributionList().outputs({
-        '#containingReverseContributionList': '#list',
-      }),
-
-      exitWithoutDependency({
-        dependency: '#list',
-      }),
-
-      withNearbyItemFromList({
-        list: '#list',
-        item: input.myself(),
-        offset: input.value(+1),
-      }),
-
-      exposeDependency({
-        dependency: '#nearbyItem',
-      }),
-    ],
-  });
-
-  [inspect.custom](depth, options, inspect) {
-    const parts = [];
-    const accentParts = [];
-
-    parts.push(Thing.prototype[inspect.custom].apply(this));
-
-    if (this.annotation) {
-      accentParts.push(colors.green(`"${this.annotation}"`));
-    }
-
-    if (this.date) {
-      accentParts.push(colors.yellow(this.date.toLocaleDateString()));
-    }
-
-    let artistRef;
-    if (depth >= 0) {
-      let artist;
-      try {
-        artist = this.artist;
-      } catch (_error) {
-        // Computing artist might crash for any reason - don't distract from
-        // other errors as a result of inspecting this contribution.
-      }
-
-      if (artist) {
-        artistRef =
-          colors.blue(Thing.getReference(artist));
-      }
-    } else {
-      artistRef =
-        colors.green(CacheableObject.getUpdateValue(this, 'artist'));
-    }
-
-    if (artistRef) {
-      accentParts.push(`by ${artistRef}`);
-    }
-
-    if (this.thing) {
-      if (depth >= 0) {
-        const newOptions = {
-          ...options,
-          depth:
-            (options.depth === null
-              ? null
-              : options.depth - 1),
-        };
-
-        accentParts.push(`to ${inspect(this.thing, newOptions)}`);
-      } else {
-        accentParts.push(`to ${colors.blue(Thing.getReference(this.thing))}`);
-      }
-    }
-
-    if (!empty(accentParts)) {
-      parts.push(` (${accentParts.join(', ')})`);
-    }
-
-    return parts.join('');
-  }
-}
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
deleted file mode 100644
index b143b560..00000000
--- a/src/data/things/flash.js
+++ /dev/null
@@ -1,412 +0,0 @@
-export const FLASH_DATA_FILE = 'flashes.yaml';
-
-import {input} from '#composite';
-import {empty} from '#sugar';
-import {sortFlashesChronologically} from '#sort';
-import Thing from '#thing';
-import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
-  from '#validators';
-import {parseContributors, parseDate, parseDimensions} from '#yaml';
-
-import {withPropertyFromObject} from '#composite/data';
-
-import {
-  exposeConstant,
-  exposeDependency,
-  exposeDependencyOrContinue,
-  exposeUpdateValueOrContinue,
-} from '#composite/control-flow';
-
-import {
-  color,
-  commentary,
-  commentatorArtists,
-  contentString,
-  contributionList,
-  dimensions,
-  directory,
-  fileExtension,
-  name,
-  referenceList,
-  simpleDate,
-  soupyFind,
-  soupyReverse,
-  thing,
-  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.getPropertyDescriptors] = ({
-    Track,
-    FlashAct,
-    WikiInfo,
-  }) => ({
-    // Update & expose
-
-    name: name('Unnamed Flash'),
-
-    directory: {
-      flags: {update: true, expose: true},
-      update: {validate: isDirectory},
-
-      // Flashes expose directory differently from other Things! Their
-      // default directory is dependent on the page number (or ID), not
-      // the name.
-      expose: {
-        dependencies: ['page'],
-        transform(directory, {page}) {
-          if (directory === null && page === null) return null;
-          else if (directory === null) return page;
-          else return directory;
-        },
-      },
-    },
-
-    page: {
-      flags: {update: true, expose: true},
-      update: {validate: anyOf(isString, isNumber)},
-
-      expose: {
-        transform: (value) => (value === null ? null : value.toString()),
-      },
-    },
-
-    color: [
-      exposeUpdateValueOrContinue({
-        validate: input.value(isColor),
-      }),
-
-      withFlashAct(),
-
-      withPropertyFromObject({
-        object: '#flashAct',
-        property: input.value('color'),
-      }),
-
-      exposeDependency({dependency: '#flashAct.color'}),
-    ],
-
-    date: simpleDate(),
-
-    coverArtFileExtension: fileExtension('jpg'),
-
-    coverArtDimensions: dimensions(),
-
-    contributorContribs: contributionList({
-      date: 'date',
-      artistProperty: input.value('flashContributorContributions'),
-    }),
-
-    featuredTracks: referenceList({
-      class: input.value(Track),
-      find: soupyFind.input('track'),
-    }),
-
-    urls: urls(),
-
-    commentary: commentary(),
-    creditSources: commentary(),
-
-    // Update only
-
-    find: soupyFind(),
-    reverse: soupyReverse(),
-
-    // used for withMatchingContributionPresets (indirectly by Contribution)
-    wikiInfo: thing({
-      class: input.value(WikiInfo),
-    }),
-
-    // Expose only
-
-    commentatorArtists: commentatorArtists(),
-
-    act: [
-      withFlashAct(),
-      exposeDependency({dependency: '#flashAct'}),
-    ],
-
-    side: [
-      withFlashAct(),
-
-      withPropertyFromObject({
-        object: '#flashAct',
-        property: input.value('side'),
-      }),
-
-      exposeDependency({dependency: '#flashAct.side'}),
-    ],
-  });
-
-  static [Thing.getSerializeDescriptors] = ({
-    serialize: S,
-  }) => ({
-    name: S.id,
-    page: S.id,
-    directory: S.id,
-    date: S.id,
-    contributors: S.toContribRefs,
-    tracks: S.toRefs,
-    urls: S.id,
-    color: S.id,
-  });
-
-  static [Thing.findSpecs] = {
-    flash: {
-      referenceTypes: ['flash'],
-      bindTo: 'flashData',
-    },
-  };
-
-  static [Thing.reverseSpecs] = {
-    flashesWhichFeature: {
-      bindTo: 'flashData',
-
-      referencing: flash => [flash],
-      referenced: flash => flash.featuredTracks,
-    },
-
-    flashContributorContributionsBy:
-      soupyReverse.contributionsBy('flashData', 'contributorContribs'),
-
-    flashesWithCommentaryBy: {
-      bindTo: 'flashData',
-
-      referencing: flash => [flash],
-      referenced: flash => flash.commentatorArtists,
-    },
-  };
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      'Flash': {property: 'name'},
-      'Directory': {property: 'directory'},
-      'Page': {property: 'page'},
-      'Color': {property: 'color'},
-      'URLs': {property: 'urls'},
-
-      'Date': {
-        property: 'date',
-        transform: parseDate,
-      },
-
-      'Cover Art File Extension': {property: 'coverArtFileExtension'},
-
-      'Cover Art Dimensions': {
-        property: 'coverArtDimensions',
-        transform: parseDimensions,
-      },
-
-      'Featured Tracks': {property: 'featuredTracks'},
-
-      'Contributors': {
-        property: 'contributorContribs',
-        transform: parseContributors,
-      },
-
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
-
-      'Review Points': {ignore: true},
-    },
-  };
-}
-
-export class FlashAct extends Thing {
-  static [Thing.referenceType] = 'flash-act';
-  static [Thing.friendlyName] = `Flash Act`;
-
-  static [Thing.getPropertyDescriptors] = () => ({
-    // Update & expose
-
-    name: name('Unnamed Flash Act'),
-    directory: directory(),
-    color: color(),
-
-    listTerminology: [
-      exposeUpdateValueOrContinue({
-        validate: input.value(isContentString),
-      }),
-
-      withFlashSide(),
-
-      withPropertyFromObject({
-        object: '#flashSide',
-        property: input.value('listTerminology'),
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#flashSide.listTerminology',
-      }),
-
-      exposeConstant({
-        value: input.value(null),
-      }),
-    ],
-
-    flashes: referenceList({
-      class: input.value(Flash),
-      find: soupyFind.input('flash'),
-    }),
-
-    // Update only
-
-    find: soupyFind(),
-    reverse: soupyReverse(),
-
-    // Expose only
-
-    side: [
-      withFlashSide(),
-      exposeDependency({dependency: '#flashSide'}),
-    ],
-  });
-
-  static [Thing.findSpecs] = {
-    flashAct: {
-      referenceTypes: ['flash-act'],
-      bindTo: 'flashActData',
-    },
-  };
-
-  static [Thing.reverseSpecs] = {
-    flashActsWhoseFlashesInclude: {
-      bindTo: 'flashActData',
-
-      referencing: flashAct => [flashAct],
-      referenced: flashAct => flashAct.flashes,
-    },
-  };
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      'Act': {property: 'name'},
-      'Directory': {property: 'directory'},
-
-      'Color': {property: 'color'},
-      'List Terminology': {property: 'listTerminology'},
-
-      'Review Points': {ignore: true},
-    },
-  };
-}
-
-export class FlashSide extends Thing {
-  static [Thing.referenceType] = 'flash-side';
-  static [Thing.friendlyName] = `Flash Side`;
-
-  static [Thing.getPropertyDescriptors] = () => ({
-    // Update & expose
-
-    name: name('Unnamed Flash Side'),
-    directory: directory(),
-    color: color(),
-    listTerminology: contentString(),
-
-    acts: referenceList({
-      class: input.value(FlashAct),
-      find: soupyFind.input('flashAct'),
-    }),
-
-    // Update only
-
-    find: soupyFind(),
-  });
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      'Side': {property: 'name'},
-      'Directory': {property: 'directory'},
-      'Color': {property: 'color'},
-      'List Terminology': {property: 'listTerminology'},
-    },
-  };
-
-  static [Thing.findSpecs] = {
-    flashSide: {
-      referenceTypes: ['flash-side'],
-      bindTo: 'flashSideData',
-    },
-  };
-
-  static [Thing.reverseSpecs] = {
-    flashSidesWhoseActsInclude: {
-      bindTo: 'flashSideData',
-
-      referencing: flashSide => [flashSide],
-      referenced: flashSide => flashSide.acts,
-    },
-  };
-
-  static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {allInOne},
-    thingConstructors: {Flash, FlashAct},
-  }) => ({
-    title: `Process flashes file`,
-    file: FLASH_DATA_FILE,
-
-    documentMode: allInOne,
-    documentThing: document =>
-      ('Side' in document
-        ? FlashSide
-     : 'Act' in document
-        ? FlashAct
-        : Flash),
-
-    save(results) {
-      // JavaScript likes you.
-
-      if (!empty(results) && !(results[0] instanceof FlashSide)) {
-        throw new Error(`Expected a side at top of flash data file`);
-      }
-
-      let index = 0;
-      let thing;
-      for (; thing = results[index]; index++) {
-        const flashSide = thing;
-        const flashActRefs = [];
-
-        if (results[index + 1] instanceof Flash) {
-          throw new Error(`Expected an act to immediately follow a side`);
-        }
-
-        for (
-          index++;
-          (thing = results[index]) && thing instanceof FlashAct;
-          index++
-        ) {
-          const flashAct = thing;
-          const flashRefs = [];
-          for (
-            index++;
-            (thing = results[index]) && thing instanceof Flash;
-            index++
-          ) {
-            flashRefs.push(Thing.getReference(thing));
-          }
-          index--;
-          flashAct.flashes = flashRefs;
-          flashActRefs.push(Thing.getReference(flashAct));
-        }
-        index--;
-        flashSide.acts = flashActRefs;
-      }
-
-      const flashData = results.filter(x => x instanceof Flash);
-      const flashActData = results.filter(x => x instanceof FlashAct);
-      const flashSideData = results.filter(x => x instanceof FlashSide);
-
-      return {flashData, flashActData, flashSideData};
-    },
-
-    sort({flashData}) {
-      sortFlashesChronologically(flashData);
-    },
-  });
-}
diff --git a/src/data/things/flash/Flash.js b/src/data/things/flash/Flash.js
new file mode 100644
index 00000000..1f290b3f
--- /dev/null
+++ b/src/data/things/flash/Flash.js
@@ -0,0 +1,246 @@
+import {input, V} from '#composite';
+import Thing from '#thing';
+import {anyOf, isColor, isDirectory, isNumber, isString}
+  from '#validators';
+
+import {
+  parseArtwork,
+  parseAdditionalNames,
+  parseCommentary,
+  parseContributors,
+  parseCreditingSources,
+  parseDate,
+  parseDimensions,
+} from '#yaml';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import {
+  exposeConstant,
+  exposeDependency,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  commentatorArtists,
+  constitutibleArtwork,
+  contributionList,
+  dimensions,
+  fileExtension,
+  name,
+  referenceList,
+  simpleDate,
+  soupyFind,
+  soupyReverse,
+  thing,
+  thingList,
+  urls,
+} from '#composite/wiki-properties';
+
+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,
+    FlashAct,
+    Track,
+    WikiInfo,
+  }) => ({
+    // Update & expose
+
+    act: thing(V(FlashAct)),
+
+    name: name(V('Unnamed Flash')),
+
+    directory: {
+      flags: {update: true, expose: true},
+      update: {validate: isDirectory},
+
+      // Flashes expose directory differently from other Things! Their
+      // default directory is dependent on the page number (or ID), not
+      // the name.
+      expose: {
+        dependencies: ['page'],
+        transform(directory, {page}) {
+          if (directory === null && page === null) return null;
+          else if (directory === null) return page;
+          else return directory;
+        },
+      },
+    },
+
+    page: {
+      flags: {update: true, expose: true},
+      update: {validate: anyOf(isString, isNumber)},
+
+      expose: {
+        transform: (value) => (value === null ? null : value.toString()),
+      },
+    },
+
+    color: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isColor),
+      }),
+
+      withPropertyFromObject('act', V('color')),
+      exposeDependency('#act.color'),
+    ],
+
+    date: simpleDate(),
+
+    coverArtFileExtension: fileExtension(V('jpg')),
+
+    coverArtDimensions: dimensions(),
+
+    coverArtwork:
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+
+    contributorContribs: contributionList({
+      artistProperty: input.value('flashContributorContributions'),
+    }),
+
+    featuredTracks: referenceList({
+      class: input.value(Track),
+      find: soupyFind.input('track'),
+    }),
+
+    urls: urls(),
+
+    additionalNames: thingList(V(AdditionalName)),
+
+    commentary: thingList(V(CommentaryEntry)),
+    creditingSources: thingList(V(CreditingSourcesEntry)),
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for withMatchingContributionPresets (indirectly by Contribution)
+    wikiInfo: thing(V(WikiInfo)),
+
+    // Expose only
+
+    isFlash: exposeConstant(V(true)),
+
+    commentatorArtists: commentatorArtists(),
+
+    side: [
+      withPropertyFromObject('act', V('side')),
+      exposeDependency('#act.side'),
+    ],
+  });
+
+  static [Thing.getSerializeDescriptors] = ({
+    serialize: S,
+  }) => ({
+    name: S.id,
+    page: S.id,
+    directory: S.id,
+    date: S.id,
+    contributors: S.toContribRefs,
+    tracks: S.toRefs,
+    urls: S.id,
+    color: S.id,
+  });
+
+  static [Thing.findSpecs] = {
+    flash: {
+      referenceTypes: ['flash'],
+      bindTo: 'flashData',
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    flashesWhichFeature: {
+      bindTo: 'flashData',
+
+      referencing: flash => [flash],
+      referenced: flash => flash.featuredTracks,
+    },
+
+    flashContributorContributionsBy:
+      soupyReverse.contributionsBy('flashData', 'contributorContribs'),
+
+    flashesWithCommentaryBy: {
+      bindTo: 'flashData',
+
+      referencing: flash => [flash],
+      referenced: flash => flash.commentatorArtists,
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Flash': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'Page': {property: 'page'},
+      'Color': {property: 'color'},
+      'URLs': {property: 'urls'},
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
+
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Cover Artwork': {
+        property: 'coverArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'coverArtwork',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dimensionsFromThingProperty: 'coverArtDimensions',
+          }),
+      },
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
+      'Featured Tracks': {property: 'featuredTracks'},
+
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
+
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
+      },
+
+      'Review Points': {ignore: true},
+    },
+  };
+
+  getOwnArtworkPath(artwork) {
+    return [
+      'media.flashArt',
+      this.directory,
+      artwork.fileExtension,
+    ];
+  }
+}
diff --git a/src/data/things/flash/FlashAct.js b/src/data/things/flash/FlashAct.js
new file mode 100644
index 00000000..66d4ee1b
--- /dev/null
+++ b/src/data/things/flash/FlashAct.js
@@ -0,0 +1,74 @@
+
+import {input, V} from '#composite';
+import Thing from '#thing';
+import {isContentString} from '#validators';
+
+import {withPropertyFromObject} from '#composite/data';
+import {exposeConstant, exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {color, directory, name, soupyFind, soupyReverse, thing, thingList}
+  from '#composite/wiki-properties';
+
+export class FlashAct extends Thing {
+  static [Thing.referenceType] = 'flash-act';
+  static [Thing.friendlyName] = `Flash Act`;
+  static [Thing.wikiData] = 'flashActData';
+
+  static [Thing.getPropertyDescriptors] = ({Flash, FlashSide}) => ({
+    // Update & expose
+
+    side: thing(V(FlashSide)),
+
+    name: name(V('Unnamed Flash Act')),
+    directory: directory(),
+    color: color(),
+
+    listTerminology: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isContentString),
+      }),
+
+      withPropertyFromObject('side', V('listTerminology')),
+      exposeDependency('#side.listTerminology'),
+    ],
+
+    flashes: thingList(V(Flash)),
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // Expose only
+
+    isFlashAct: exposeConstant(V(true)),
+  });
+
+  static [Thing.findSpecs] = {
+    flashAct: {
+      referenceTypes: ['flash-act'],
+      bindTo: 'flashActData',
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    flashActsWhoseFlashesInclude: {
+      bindTo: 'flashActData',
+
+      referencing: flashAct => [flashAct],
+      referenced: flashAct => flashAct.flashes,
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Act': {property: 'name'},
+      'Directory': {property: 'directory'},
+
+      'Color': {property: 'color'},
+      'List Terminology': {property: 'listTerminology'},
+
+      'Review Points': {ignore: true},
+    },
+  };
+}
diff --git a/src/data/things/flash/FlashSide.js b/src/data/things/flash/FlashSide.js
new file mode 100644
index 00000000..5e2ea3de
--- /dev/null
+++ b/src/data/things/flash/FlashSide.js
@@ -0,0 +1,56 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+import {color, contentString, directory, name, soupyFind, thingList}
+  from '#composite/wiki-properties';
+
+export class FlashSide extends Thing {
+  static [Thing.referenceType] = 'flash-side';
+  static [Thing.friendlyName] = `Flash Side`;
+  static [Thing.wikiData] = 'flashSideData';
+
+  static [Thing.getPropertyDescriptors] = ({FlashAct}) => ({
+    // Update & expose
+
+    name: name(V('Unnamed Flash Side')),
+    directory: directory(),
+    color: color(),
+    listTerminology: contentString(),
+
+    acts: thingList(V(FlashAct)),
+
+    // Update only
+
+    find: soupyFind(),
+
+    // Expose only
+
+    isFlashSide: exposeConstant(V(true)),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Side': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'Color': {property: 'color'},
+      'List Terminology': {property: 'listTerminology'},
+    },
+  };
+
+  static [Thing.findSpecs] = {
+    flashSide: {
+      referenceTypes: ['flash-side'],
+      bindTo: 'flashSideData',
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    flashSidesWhoseActsInclude: {
+      bindTo: 'flashSideData',
+
+      referencing: flashSide => [flashSide],
+      referenced: flashSide => flashSide.acts,
+    },
+  };
+}
diff --git a/src/data/things/flash/index.js b/src/data/things/flash/index.js
new file mode 100644
index 00000000..19b8cc34
--- /dev/null
+++ b/src/data/things/flash/index.js
@@ -0,0 +1,3 @@
+export * from './Flash.js';
+export * from './FlashAct.js';
+export * from './FlashSide.js';
diff --git a/src/data/things/group.js b/src/data/things/group/Group.js
index ed3c59bb..6f698682 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group/Group.js
@@ -1,31 +1,59 @@
-export const GROUP_DATA_FILE = 'groups.yaml';
-
-import {input} from '#composite';
+import {input, V} from '#composite';
 import Thing from '#thing';
+import {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,
+  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(),
@@ -34,8 +62,6 @@ export class Group extends Thing {
       class: input.value(Artist),
       find: soupyFind.input('artist'),
 
-      date: input.value(null),
-
       reference: input.value('artist'),
       thing: input.value('artist'),
     }),
@@ -45,17 +71,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},
 
@@ -72,8 +98,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),
       },
     },
@@ -82,8 +108,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,
       },
@@ -93,8 +119,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,
       },
@@ -131,6 +157,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'},
 
@@ -153,92 +183,4 @@ export class Group extends Thing {
       'Review Points': {ignore: true},
     },
   };
-
-  static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {allInOne},
-    thingConstructors: {Group, GroupCategory},
-  }) => ({
-    title: `Process groups file`,
-    file: GROUP_DATA_FILE,
-
-    documentMode: allInOne,
-    documentThing: document =>
-      ('Category' in document
-        ? GroupCategory
-        : Group),
-
-    save(results) {
-      let groupCategory;
-      let groupRefs = [];
-
-      if (results[0] && !(results[0] instanceof GroupCategory)) {
-        throw new Error(`Expected a category at top of group data file`);
-      }
-
-      for (const thing of results) {
-        if (thing instanceof GroupCategory) {
-          if (groupCategory) {
-            Object.assign(groupCategory, {groups: groupRefs});
-          }
-
-          groupCategory = thing;
-          groupRefs = [];
-        } else {
-          groupRefs.push(Thing.getReference(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
-    // file as-is.
-    sort: null,
-  });
-}
-
-export class GroupCategory extends Thing {
-  static [Thing.referenceType] = 'group-category';
-  static [Thing.friendlyName] = `Group Category`;
-
-  static [Thing.getPropertyDescriptors] = ({Group}) => ({
-    // Update & expose
-
-    name: name('Unnamed Group Category'),
-    directory: directory(),
-
-    color: color(),
-
-    groups: referenceList({
-      class: input.value(Group),
-      find: soupyFind.input('group'),
-    }),
-
-    // Update only
-
-    find: soupyFind(),
-  });
-
-  static [Thing.reverseSpecs] = {
-    groupCategoriesWhichInclude: {
-      bindTo: 'groupCategoryData',
-
-      referencing: groupCategory => [groupCategory],
-      referenced: groupCategory => groupCategory.groups,
-    },
-  };
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      'Category': {property: 'name'},
-      'Color': {property: 'color'},
-    },
-  };
 }
diff --git a/src/data/things/group/GroupCategory.js b/src/data/things/group/GroupCategory.js
new file mode 100644
index 00000000..daf31868
--- /dev/null
+++ b/src/data/things/group/GroupCategory.js
@@ -0,0 +1,58 @@
+import {input, V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {color, directory, flag, name, referenceList, soupyFind}
+  from '#composite/wiki-properties';
+
+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(V('Unnamed Group Category')),
+    directory: directory(),
+
+    excludeGroupsFromGalleryTabs: flag(V(false)),
+
+    color: color(),
+
+    groups: referenceList({
+      class: input.value(Group),
+      find: soupyFind.input('group'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+
+    // Expose only
+
+    isGroupCategory: exposeConstant(V(true)),
+  });
+
+  static [Thing.reverseSpecs] = {
+    groupCategoriesWhichInclude: {
+      bindTo: 'groupCategoryData',
+
+      referencing: groupCategory => [groupCategory],
+      referenced: groupCategory => groupCategory.groups,
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Category': {property: 'name'},
+
+      'Color': {property: 'color'},
+
+      'Exclude Groups From Gallery Tabs': {
+        property: 'excludeGroupsFromGalleryTabs',
+      },
+    },
+  };
+}
diff --git a/src/data/things/group/Series.js b/src/data/things/group/Series.js
new file mode 100644
index 00000000..940fe575
--- /dev/null
+++ b/src/data/things/group/Series.js
@@ -0,0 +1,79 @@
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {input, V} from '#composite';
+import Thing from '#thing';
+import {is} from '#validators';
+
+import {contentString, name, referenceList, soupyFind, thing}
+  from '#composite/wiki-properties';
+
+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/group/index.js b/src/data/things/group/index.js
new file mode 100644
index 00000000..1723f136
--- /dev/null
+++ b/src/data/things/group/index.js
@@ -0,0 +1,3 @@
+export * from './Group.js';
+export * from './GroupCategory.js';
+export * from './Series.js';
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
deleted file mode 100644
index 47d92471..00000000
--- a/src/data/things/homepage-layout.js
+++ /dev/null
@@ -1,210 +0,0 @@
-export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml';
-
-import {input} from '#composite';
-import Thing from '#thing';
-
-import {
-  anyOf,
-  is,
-  isCountingNumber,
-  isString,
-  isStringNonEmpty,
-  validateArrayItems,
-  validateInstanceOf,
-  validateReference,
-} from '#validators';
-
-import {exposeDependency} from '#composite/control-flow';
-import {withResolvedReference} from '#composite/wiki-data';
-import {color, contentString, name, referenceList, soupyFind}
-  from '#composite/wiki-properties';
-
-export class HomepageLayout extends Thing {
-  static [Thing.friendlyName] = `Homepage Layout`;
-
-  static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({
-    // Update & expose
-
-    sidebarContent: contentString(),
-
-    navbarLinks: {
-      flags: {update: true, expose: true},
-      update: {validate: validateArrayItems(isStringNonEmpty)},
-    },
-
-    rows: {
-      flags: {update: true, expose: true},
-
-      update: {
-        validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)),
-      },
-    },
-  });
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      'Homepage': {ignore: true},
-
-      'Sidebar Content': {property: 'sidebarContent'},
-      'Navbar Links': {property: 'navbarLinks'},
-    },
-  };
-}
-
-export class HomepageLayoutRow extends Thing {
-  static [Thing.friendlyName] = `Homepage Row`;
-
-  static [Thing.getPropertyDescriptors] = () => ({
-    // Update & expose
-
-    name: name('Unnamed Homepage Row'),
-
-    type: {
-      flags: {update: true, expose: true},
-
-      update: {
-        validate() {
-          throw new Error(`'type' property validator must be overridden`);
-        },
-      },
-    },
-
-    color: color(),
-
-    // Update only
-
-    find: soupyFind(),
-  });
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      'Row': {property: 'name'},
-      'Color': {property: 'color'},
-      'Type': {property: 'type'},
-    },
-  };
-}
-
-export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
-  static [Thing.friendlyName] = `Homepage Albums Row`;
-
-  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
-    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
-
-    // Update & expose
-
-    type: {
-      flags: {update: true, expose: true},
-      update: {
-        validate(value) {
-          if (value !== 'albums') {
-            throw new TypeError(`Expected 'albums'`);
-          }
-
-          return true;
-        },
-      },
-    },
-
-    displayStyle: {
-      flags: {update: true, expose: true},
-
-      update: {
-        validate: is('grid', 'carousel'),
-      },
-
-      expose: {
-        transform: (displayStyle) =>
-          displayStyle ?? 'grid',
-      },
-    },
-
-    sourceGroup: [
-      {
-        flags: {expose: true, update: true, compose: true},
-
-        update: {
-          validate:
-            anyOf(
-              is('new-releases', 'new-additions'),
-              validateReference(Group[Thing.referenceType])),
-        },
-
-        expose: {
-          transform: (value, continuation) =>
-            (value === 'new-releases' || value === 'new-additions'
-              ? value
-              : continuation(value)),
-        },
-      },
-
-      withResolvedReference({
-        ref: input.updateValue(),
-        find: soupyFind.input('group'),
-      }),
-
-      exposeDependency({dependency: '#resolvedReference'}),
-    ],
-
-    sourceAlbums: referenceList({
-      class: input.value(Album),
-      find: soupyFind.input('album'),
-    }),
-
-    countAlbumsFromGroup: {
-      flags: {update: true, expose: true},
-      update: {validate: isCountingNumber},
-    },
-
-    actionLinks: {
-      flags: {update: true, expose: true},
-      update: {validate: validateArrayItems(isString)},
-    },
-  });
-
-  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
-    fields: {
-      'Display Style': {property: 'displayStyle'},
-      'Group': {property: 'sourceGroup'},
-      'Count': {property: 'countAlbumsFromGroup'},
-      'Albums': {property: 'sourceAlbums'},
-      'Actions': {property: 'actionLinks'},
-    },
-  });
-
-  static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {headerAndEntries}, // Kludge, see below
-    thingConstructors: {
-      HomepageLayout,
-      HomepageLayoutAlbumsRow,
-    },
-  }) => ({
-    title: `Process homepage layout file`,
-
-    // Kludge: This benefits from the same headerAndEntries style messaging as
-    // albums and tracks (for example), but that document mode is designed to
-    // support multiple files, and only one is actually getting processed here.
-    files: [HOMEPAGE_LAYOUT_DATA_FILE],
-
-    documentMode: headerAndEntries,
-    headerDocumentThing: HomepageLayout,
-    entryDocumentThing: document => {
-      switch (document['Type']) {
-        case 'albums':
-          return HomepageLayoutAlbumsRow;
-        default:
-          throw new TypeError(`No processDocument function for row type ${document['Type']}!`);
-      }
-    },
-
-    save(results) {
-      if (!results[0]) {
-        return;
-      }
-
-      const {header: homepageLayout, entries: rows} = results[0];
-      Object.assign(homepageLayout, {rows});
-      return {homepageLayout};
-    },
-  });
-}
diff --git a/src/data/things/homepage-layout/HomepageLayout.js b/src/data/things/homepage-layout/HomepageLayout.js
new file mode 100644
index 00000000..1c432b53
--- /dev/null
+++ b/src/data/things/homepage-layout/HomepageLayout.js
@@ -0,0 +1,39 @@
+import {V} from '#composite';
+import Thing from '#thing';
+import {isStringNonEmpty, validateArrayItems} from '#validators';
+
+import {exposeConstant} from '#composite/control-flow';
+import {contentString, thingList} from '#composite/wiki-properties';
+
+export class HomepageLayout extends Thing {
+  static [Thing.friendlyName] = `Homepage Layout`;
+  static [Thing.wikiData] = 'homepageLayout';
+  static [Thing.oneInstancePerWiki] = true;
+
+  static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({
+    // Update & expose
+
+    sidebarContent: contentString(),
+
+    navbarLinks: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isStringNonEmpty)},
+      expose: {transform: value => value ?? []},
+    },
+
+    sections: thingList(V(HomepageLayoutSection)),
+
+    // Expose only
+
+    isHomepageLayout: exposeConstant(V(true)),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Homepage': {ignore: true},
+
+      'Sidebar Content': {property: 'sidebarContent'},
+      'Navbar Links': {property: 'navbarLinks'},
+    },
+  };
+}
diff --git a/src/data/things/homepage-layout/HomepageLayoutActionsRow.js b/src/data/things/homepage-layout/HomepageLayoutActionsRow.js
new file mode 100644
index 00000000..b6d19793
--- /dev/null
+++ b/src/data/things/homepage-layout/HomepageLayoutActionsRow.js
@@ -0,0 +1,31 @@
+import {V} from '#composite';
+import Thing from '#thing';
+import {validateArrayItems, isString} from '#validators';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {HomepageLayoutRow} from './HomepageLayoutRow.js';
+
+export class HomepageLayoutActionsRow extends HomepageLayoutRow {
+  static [Thing.friendlyName] = `Homepage Actions Row`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    actionLinks: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isString)},
+    },
+
+    // Expose only
+
+    isHomepageLayoutActionsRow: exposeConstant(V(true)),
+    type: exposeConstant(V('actions')),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Actions': {property: 'actionLinks'},
+    },
+  };
+}
diff --git a/src/data/things/homepage-layout/HomepageLayoutAlbumCarouselRow.js b/src/data/things/homepage-layout/HomepageLayoutAlbumCarouselRow.js
new file mode 100644
index 00000000..41cfd0af
--- /dev/null
+++ b/src/data/things/homepage-layout/HomepageLayoutAlbumCarouselRow.js
@@ -0,0 +1,31 @@
+import {input, V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+import {referenceList, soupyFind} from '#composite/wiki-properties';
+
+import {HomepageLayoutRow} from './HomepageLayoutRow.js';
+
+export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
+  static [Thing.friendlyName] = `Homepage Album Carousel Row`;
+
+  static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({
+    // Update & expose
+
+    albums: referenceList({
+      class: input.value(Album),
+      find: soupyFind.input('album'),
+    }),
+
+    // Expose only
+
+    isHomepageLayoutAlbumCarouselRow: exposeConstant(V(true)),
+    type: exposeConstant(V('album carousel')),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Albums': {property: 'albums'},
+    },
+  };
+}
diff --git a/src/data/things/homepage-layout/HomepageLayoutAlbumGridRow.js b/src/data/things/homepage-layout/HomepageLayoutAlbumGridRow.js
new file mode 100644
index 00000000..fafeb1ed
--- /dev/null
+++ b/src/data/things/homepage-layout/HomepageLayoutAlbumGridRow.js
@@ -0,0 +1,68 @@
+import {input, V} from '#composite';
+import Thing from '#thing';
+
+import {anyOf, is, isCountingNumber, validateReference} from '#validators';
+
+import {exposeConstant, exposeDependency} from '#composite/control-flow';
+import {withResolvedReference} from '#composite/wiki-data';
+import {referenceList, soupyFind} from '#composite/wiki-properties';
+
+import {HomepageLayoutRow} from './HomepageLayoutRow.js';
+
+export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow {
+  static [Thing.friendlyName] = `Homepage Album Grid Row`;
+
+  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
+    // Update & expose
+
+    sourceGroup: [
+      {
+        flags: {expose: true, update: true, compose: true},
+
+        update: {
+          validate:
+            anyOf(
+              is('new-releases', 'new-additions'),
+              validateReference(Group[Thing.referenceType])),
+        },
+
+        expose: {
+          transform: (value, continuation) =>
+            (value === 'new-releases' || value === 'new-additions'
+              ? value
+              : continuation(value)),
+        },
+      },
+
+      withResolvedReference({
+        ref: input.updateValue(),
+        find: soupyFind.input('group'),
+      }),
+
+      exposeDependency('#resolvedReference'),
+    ],
+
+    sourceAlbums: referenceList({
+      class: input.value(Album),
+      find: soupyFind.input('album'),
+    }),
+
+    countAlbumsFromGroup: {
+      flags: {update: true, expose: true},
+      update: {validate: isCountingNumber},
+    },
+
+    // Expose only
+
+    isHomepageLayoutAlbumGridRow: exposeConstant(V(true)),
+    type: exposeConstant(V('album grid')),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Group': {property: 'sourceGroup'},
+      'Count': {property: 'countAlbumsFromGroup'},
+      'Albums': {property: 'sourceAlbums'},
+    },
+  };
+}
diff --git a/src/data/things/homepage-layout/HomepageLayoutRow.js b/src/data/things/homepage-layout/HomepageLayoutRow.js
new file mode 100644
index 00000000..5b0899e9
--- /dev/null
+++ b/src/data/things/homepage-layout/HomepageLayoutRow.js
@@ -0,0 +1,60 @@
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+import {soupyFind, thing} from '#composite/wiki-properties';
+
+export class HomepageLayoutRow extends Thing {
+  static [Thing.friendlyName] = `Homepage Row`;
+
+  static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({
+    // Update & expose
+
+    section: thing(V(HomepageLayoutSection)),
+
+    // Update only
+
+    find: soupyFind(),
+
+    // Expose only
+
+    isHomepageLayoutRow: exposeConstant(V(true)),
+
+    type: {
+      flags: {expose: true},
+
+      expose: {
+        compute() {
+          throw new Error(`'type' property validator must be overridden`);
+        },
+      },
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Row': {ignore: true},
+    },
+  };
+
+  [inspect.custom](depth) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (depth >= 0 && this.section) {
+      const sectionName = this.section.name;
+      const index = this.section.rows.indexOf(this);
+      const rowNum =
+        (index === -1
+          ? 'indeterminate position'
+          : `#${index + 1}`);
+      parts.push(` (${colors.yellow(rowNum)} in ${colors.green(sectionName)})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/homepage-layout/HomepageLayoutSection.js b/src/data/things/homepage-layout/HomepageLayoutSection.js
new file mode 100644
index 00000000..1593ba6e
--- /dev/null
+++ b/src/data/things/homepage-layout/HomepageLayoutSection.js
@@ -0,0 +1,30 @@
+import {V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+import {color, name, thingList} from '#composite/wiki-properties';
+
+export class HomepageLayoutSection extends Thing {
+  static [Thing.friendlyName] = `Homepage Section`;
+
+  static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({
+    // Update & expose
+
+    name: name(V(`Unnamed Homepage Section`)),
+
+    color: color(),
+
+    rows: thingList(V(HomepageLayoutRow)),
+
+    // Expose only
+
+    isHomepageLayoutSection: exposeConstant(V(true)),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Section': {property: 'name'},
+      'Color': {property: 'color'},
+    },
+  };
+}
diff --git a/src/data/things/homepage-layout/index.js b/src/data/things/homepage-layout/index.js
new file mode 100644
index 00000000..d003e39a
--- /dev/null
+++ b/src/data/things/homepage-layout/index.js
@@ -0,0 +1,6 @@
+export * from './HomepageLayout.js';
+export * from './HomepageLayoutSection.js';
+export * from './HomepageLayoutRow.js';
+export * from './HomepageLayoutActionsRow.js';
+export * from './HomepageLayoutAlbumCarouselRow.js';
+export * from './HomepageLayoutAlbumGridRow.js';
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 9f033c23..3773864b 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -1,206 +1,21 @@
-import * as path from 'node:path';
-import {fileURLToPath} from 'node:url';
-
-import {openAggregate, showAggregate} from '#aggregate';
-import CacheableObject from '#cacheable-object';
-import {logError} from '#cli';
-import {compositeFrom} from '#composite';
-import * as serialize from '#serialize';
-import Thing from '#thing';
-
-import * as albumClasses from './album.js';
-import * as artTagClasses from './art-tag.js';
-import * as artistClasses from './artist.js';
-import * as contributionClasses from './contribution.js';
-import * as flashClasses from './flash.js';
-import * as groupClasses from './group.js';
-import * as homepageLayoutClasses from './homepage-layout.js';
-import * as languageClasses from './language.js';
-import * as newsEntryClasses from './news-entry.js';
-import * as staticPageClasses from './static-page.js';
-import * as trackClasses from './track.js';
-import * as wikiInfoClasses from './wiki-info.js';
-
-const allClassLists = {
-  'album.js': albumClasses,
-  'art-tag.js': artTagClasses,
-  'artist.js': artistClasses,
-  'contribution.js': contributionClasses,
-  'flash.js': flashClasses,
-  'group.js': groupClasses,
-  'homepage-layout.js': homepageLayoutClasses,
-  'language.js': languageClasses,
-  'news-entry.js': newsEntryClasses,
-  'static-page.js': staticPageClasses,
-  'track.js': trackClasses,
-  'wiki-info.js': wikiInfoClasses,
-};
-
-let allClasses = Object.create(null);
-
-// src/data/things/index.js -> src/
-const __dirname = path.dirname(
-  path.resolve(
-    fileURLToPath(import.meta.url),
-    '../..'));
-
-function niceShowAggregate(error, ...opts) {
-  showAggregate(error, {
-    pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
-    ...opts,
-  });
-}
-
-function errorDuplicateClassNames() {
-  const locationDict = Object.create(null);
-
-  for (const [location, classes] of Object.entries(allClassLists)) {
-    for (const className of Object.keys(classes)) {
-      if (className in locationDict) {
-        locationDict[className].push(location);
-      } else {
-        locationDict[className] = [location];
-      }
-    }
-  }
-
-  let success = true;
-
-  for (const [className, locations] of Object.entries(locationDict)) {
-    if (locations.length === 1) {
-      continue;
-    }
-
-    logError`Thing class name ${`"${className}"`} is defined more than once: ${locations.join(', ')}`;
-    success = false;
-  }
-
-  return success;
-}
-
-function flattenClassLists() {
-  for (const classes of Object.values(allClassLists)) {
-    for (const [name, constructor] of Object.entries(classes)) {
-      if (typeof constructor !== 'function') continue;
-      if (!(constructor.prototype instanceof Thing)) continue;
-      allClasses[name] = constructor;
-    }
-  }
-}
-
-function descriptorAggregateHelper({
-  showFailedClasses,
-  message,
-  op,
-}) {
-  const failureSymbol = Symbol();
-  const aggregate = openAggregate({
-    message,
-    returnOnFail: failureSymbol,
-  });
-
-  const failedClasses = [];
-
-  for (const [name, constructor] of Object.entries(allClasses)) {
-    const result = aggregate.call(op, constructor);
-
-    if (result === failureSymbol) {
-      failedClasses.push(name);
-    }
-  }
-
-  try {
-    aggregate.close();
-    return true;
-  } catch (error) {
-    niceShowAggregate(error);
-    showFailedClasses(failedClasses);
-    return false;
-  }
-}
-
-function evaluatePropertyDescriptors() {
-  const opts = {...allClasses};
-
-  return descriptorAggregateHelper({
-    message: `Errors evaluating Thing class property descriptors`,
-
-    op(constructor) {
-      if (!constructor[Thing.getPropertyDescriptors]) {
-        throw new Error(`Missing [Thing.getPropertyDescriptors] function`);
-      }
-
-      const results = constructor[Thing.getPropertyDescriptors](opts);
-
-      for (const [key, value] of Object.entries(results)) {
-        if (Array.isArray(value)) {
-          results[key] = compositeFrom({
-            annotation: `${constructor.name}.${key}`,
-            compose: false,
-            steps: value,
-          });
-        } else if (value.toResolvedComposition) {
-          results[key] = compositeFrom(value.toResolvedComposition());
-        }
-      }
-
-      constructor[CacheableObject.propertyDescriptors] = {
-        ...constructor[CacheableObject.propertyDescriptors] ?? {},
-        ...results,
-      };
-    },
-
-    showFailedClasses(failedClasses) {
-      logError`Failed to evaluate property descriptors for classes: ${failedClasses.join(', ')}`;
-    },
-  });
-}
-
-function evaluateSerializeDescriptors() {
-  const opts = {...allClasses, serialize};
-
-  return descriptorAggregateHelper({
-    message: `Errors evaluating Thing class serialize descriptors`,
-
-    op(constructor) {
-      if (!constructor[Thing.getSerializeDescriptors]) {
-        return;
-      }
-
-      constructor[serialize.serializeDescriptors] =
-        constructor[Thing.getSerializeDescriptors](opts);
-    },
-
-    showFailedClasses(failedClasses) {
-      logError`Failed to evaluate serialize descriptors for classes: ${failedClasses.join(', ')}`;
-    },
-  });
-}
-
-function finalizeCacheableObjectPrototypes() {
-  return descriptorAggregateHelper({
-    message: `Errors finalizing Thing class prototypes`,
-
-    op(constructor) {
-      constructor.finalizeCacheableObjectPrototype();
-    },
-  });
-}
-
-if (!errorDuplicateClassNames())
-  process.exit(1);
-
-flattenClassLists();
-
-if (!evaluatePropertyDescriptors())
-  process.exit(1);
-
-if (!evaluateSerializeDescriptors())
-  process.exit(1);
-
-if (!finalizeCacheableObjectPrototypes())
-  process.exit(1);
-
-Object.assign(allClasses, {Thing});
-
-export default allClasses;
+// Not actually the entry point for #things - that's init.js in this folder.
+
+export * from './album/index.js';
+export * from './content/index.js';
+export * from './contrib/index.js';
+export * from './flash/index.js';
+export * from './group/index.js';
+export * from './homepage-layout/index.js';
+export * from './sorting-rule/index.js';
+
+export * from './AdditionalFile.js';
+export * from './AdditionalName.js';
+export * from './ArtTag.js';
+export * from './Artist.js';
+export * from './Artwork.js';
+export * from './Language.js';
+export * from './MusicVideo.js';
+export * from './NewsEntry.js';
+export * from './StaticPage.js';
+export * from './Track.js';
+export * from './WikiInfo.js';
diff --git a/src/data/things/init.js b/src/data/things/init.js
new file mode 100644
index 00000000..e705f626
--- /dev/null
+++ b/src/data/things/init.js
@@ -0,0 +1,208 @@
+// This is the actual entry point for #things.
+
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+import {openAggregate, showAggregate} from '#aggregate';
+import CacheableObject from '#cacheable-object';
+import {logError} from '#cli';
+import {compositeFrom} from '#composite';
+import * as serialize from '#serialize';
+import {empty} from '#sugar';
+import Thing from '#thing';
+
+import * as indexExports from './index.js';
+
+const thingConstructors = Object.create(null);
+
+// src/data/things/index.js -> src/
+const __dirname = path.dirname(
+  path.resolve(
+    fileURLToPath(import.meta.url),
+    '../..'));
+
+function niceShowAggregate(error, ...opts) {
+  showAggregate(error, {
+    pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
+    showClasses: false,
+    ...opts,
+  });
+}
+
+function sortThingConstructors() {
+  let remaining = [];
+  for (const constructor of Object.values(indexExports)) {
+    if (typeof constructor !== 'function') continue;
+    if (!(constructor.prototype instanceof Thing)) continue;
+    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;
+    }
+  }
+
+  for (const constructor of sorted) {
+    thingConstructors[constructor.name] = constructor;
+  }
+}
+
+function descriptorAggregateHelper({
+  showFailedClasses,
+  message,
+  op,
+}) {
+  const failureSymbol = Symbol();
+  const aggregate = openAggregate({
+    message,
+    returnOnFail: failureSymbol,
+  });
+
+  const failedClasses = [];
+
+  for (const [name, constructor] of Object.entries(thingConstructors)) {
+    const result = aggregate.call(op, constructor);
+
+    if (result === failureSymbol) {
+      failedClasses.push(name);
+    }
+  }
+
+  try {
+    aggregate.close();
+    return true;
+  } catch (error) {
+    niceShowAggregate(error);
+    showFailedClasses(failedClasses);
+
+    /*
+    if (error.errors) {
+      for (const sub of error.errors) {
+        console.error(sub);
+      }
+    }
+    */
+
+    return false;
+  }
+}
+
+function evaluatePropertyDescriptors() {
+  const opts = {...thingConstructors};
+
+  return descriptorAggregateHelper({
+    message: `Errors evaluating Thing class property descriptors`,
+
+    op(constructor) {
+      if (!constructor[Thing.getPropertyDescriptors]) {
+        throw new Error(`Missing [Thing.getPropertyDescriptors] function`);
+      }
+
+      const results = constructor[Thing.getPropertyDescriptors](opts);
+
+      for (const [key, value] of Object.entries(results)) {
+        if (Array.isArray(value)) {
+          results[key] = compositeFrom({
+            annotation: `${constructor.name}.${key}`,
+            compose: false,
+            steps: value,
+          });
+        } else if (value.toResolvedComposition) {
+          results[key] = compositeFrom(value.toResolvedComposition());
+        }
+      }
+
+      constructor[CacheableObject.propertyDescriptors] =
+        Object.create(constructor[CacheableObject.propertyDescriptors] ?? null);
+
+      Object.assign(constructor[CacheableObject.propertyDescriptors], results);
+    },
+
+    showFailedClasses(failedClasses) {
+      logError`Failed to evaluate property descriptors for classes: ${failedClasses.join(', ')}`;
+    },
+  });
+}
+
+function evaluateSerializeDescriptors() {
+  const opts = {...thingConstructors, serialize};
+
+  return descriptorAggregateHelper({
+    message: `Errors evaluating Thing class serialize descriptors`,
+
+    op(constructor) {
+      if (!constructor[Thing.getSerializeDescriptors]) {
+        return;
+      }
+
+      constructor[serialize.serializeDescriptors] =
+        constructor[Thing.getSerializeDescriptors](opts);
+    },
+
+    showFailedClasses(failedClasses) {
+      logError`Failed to evaluate serialize descriptors for classes: ${failedClasses.join(', ')}`;
+    },
+  });
+}
+
+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`,
+
+    op(constructor) {
+      constructor.finalizeCacheableObjectPrototype();
+    },
+
+    showFailedClasses(failedClasses) {
+      logError`Failed to finalize cacheable object prototypes for classes: ${failedClasses.join(', ')}`;
+    },
+  });
+}
+
+sortThingConstructors();
+
+if (!evaluatePropertyDescriptors()) process.exit(1);
+if (!evaluateSerializeDescriptors()) process.exit(1);
+if (!finalizeYamlDocumentSpecs()) process.exit(1);
+if (!finalizeCacheableObjectPrototypes()) process.exit(1);
+
+Object.assign(thingConstructors, {Thing});
+
+export default thingConstructors;
diff --git a/src/data/things/sorting-rule/DocumentSortingRule.js b/src/data/things/sorting-rule/DocumentSortingRule.js
new file mode 100644
index 00000000..0f67d8f5
--- /dev/null
+++ b/src/data/things/sorting-rule/DocumentSortingRule.js
@@ -0,0 +1,242 @@
+import {readFile, writeFile} from 'node:fs/promises';
+import * as path from 'node:path';
+
+import {V} from '#composite';
+import {chunkByProperties, compareArrays} from '#sugar';
+import Thing from '#thing';
+import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators';
+
+import {
+  documentModes,
+  flattenThingLayoutToDocumentOrder,
+  getThingLayoutForFilename,
+  reorderDocumentsInYAMLSourceText,
+} from '#yaml';
+
+import {exposeConstant} from '#composite/control-flow';
+
+function isSelectFollowingEntry(value) {
+  isObject(value);
+
+  const {length} = Object.keys(value);
+  if (length !== 1) {
+    throw new Error(`Expected object with 1 key, got ${length}`);
+  }
+
+  return true;
+}
+
+import {ThingSortingRule} from './ThingSortingRule.js';
+
+export class DocumentSortingRule extends ThingSortingRule {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    // TODO: glob :plead:
+    filename: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    message: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+
+      expose: {
+        dependencies: ['filename'],
+        transform: (value, {filename}) =>
+          value ??
+          `Sort ${filename}`,
+      },
+    },
+
+    selectDocumentsFollowing: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate:
+          anyOf(
+            isSelectFollowingEntry,
+            strictArrayOf(isSelectFollowingEntry)),
+      },
+
+      compute: {
+        transform: value =>
+          (Array.isArray(value)
+            ? value
+            : [value]),
+      },
+    },
+
+    selectDocumentsUnder: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    // Expose only
+
+    isDocumentSortingRule: exposeConstant(V(true)),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Sort Documents': {property: 'filename'},
+      'Select Documents Following': {property: 'selectDocumentsFollowing'},
+      'Select Documents Under': {property: 'selectDocumentsUnder'},
+    },
+
+    invalidFieldCombinations: [
+      {message: `Specify only one of these`, fields: [
+        'Select Documents Following',
+        'Select Documents Under',
+      ]},
+    ],
+  };
+
+  static async apply(rule, {wikiData, dataPath, dry}) {
+    const oldLayout = getThingLayoutForFilename(rule.filename, wikiData);
+    if (!oldLayout) return null;
+
+    const newLayout = rule.#processLayout(oldLayout);
+
+    const oldOrder = flattenThingLayoutToDocumentOrder(oldLayout);
+    const newOrder = flattenThingLayoutToDocumentOrder(newLayout);
+    const changed = compareArrays(oldOrder, newOrder);
+
+    if (dry) return {changed};
+
+    const realPath =
+      path.join(
+        dataPath,
+        rule.filename.split(path.posix.sep).join(path.sep));
+
+    const oldSourceText = await readFile(realPath, 'utf8');
+    const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder);
+
+    await writeFile(realPath, newSourceText);
+
+    return {changed};
+  }
+
+  static async* applyAll(rules, {wikiData, dataPath, dry}) {
+    rules = rules
+      .toSorted((a, b) => a.filename.localeCompare(b.filename, 'en'));
+
+    for (const {chunk, filename} of chunkByProperties(rules, ['filename'])) {
+      const initialLayout = getThingLayoutForFilename(filename, wikiData);
+      if (!initialLayout) continue;
+
+      let currLayout = initialLayout;
+      let prevLayout = initialLayout;
+      let anyChanged = false;
+
+      for (const rule of chunk) {
+        currLayout = rule.#processLayout(currLayout);
+
+        const prevOrder = flattenThingLayoutToDocumentOrder(prevLayout);
+        const currOrder = flattenThingLayoutToDocumentOrder(currLayout);
+
+        if (compareArrays(currOrder, prevOrder)) {
+          yield {rule, changed: false};
+        } else {
+          anyChanged = true;
+          yield {rule, changed: true};
+        }
+
+        prevLayout = currLayout;
+      }
+
+      if (!anyChanged) continue;
+      if (dry) continue;
+
+      const newLayout = currLayout;
+      const newOrder = flattenThingLayoutToDocumentOrder(newLayout);
+
+      const realPath =
+        path.join(
+          dataPath,
+          filename.split(path.posix.sep).join(path.sep));
+
+      const oldSourceText = await readFile(realPath, 'utf8');
+      const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder);
+
+      await writeFile(realPath, newSourceText);
+    }
+  }
+
+  #processLayout(layout) {
+    const fresh = {...layout};
+
+    let sortable = null;
+    switch (fresh.documentMode) {
+      case documentModes.headerAndEntries:
+        sortable = fresh.entryThings =
+          fresh.entryThings.slice();
+        break;
+
+      case documentModes.allInOne:
+        sortable = fresh.things =
+          fresh.things.slice();
+        break;
+
+      default:
+        throw new Error(`Invalid document type for sorting`);
+    }
+
+    if (this.selectDocumentsFollowing) {
+      for (const entry of this.selectDocumentsFollowing) {
+        const [field, value] = Object.entries(entry)[0];
+
+        const after =
+          sortable.findIndex(thing =>
+            thing[Thing.yamlSourceDocument][field] === value);
+
+        const different =
+          after +
+          sortable
+            .slice(after)
+            .findIndex(thing =>
+              Object.hasOwn(thing[Thing.yamlSourceDocument], field) &&
+              thing[Thing.yamlSourceDocument][field] !== value);
+
+        const before =
+          (different === -1
+            ? sortable.length
+            : different);
+
+        const subsortable =
+          sortable.slice(after + 1, before);
+
+        this.sort(subsortable);
+
+        sortable.splice(after + 1, before - after - 1, ...subsortable);
+      }
+    } else if (this.selectDocumentsUnder) {
+      const field = this.selectDocumentsUnder;
+
+      const indices =
+        Array.from(sortable.entries())
+          .filter(([_index, thing]) =>
+            Object.hasOwn(thing[Thing.yamlSourceDocument], field))
+          .map(([index, _thing]) => index);
+
+      for (const [indicesIndex, after] of indices.entries()) {
+        const before =
+          (indicesIndex === indices.length - 1
+            ? sortable.length
+            : indices[indicesIndex + 1]);
+
+        const subsortable =
+          sortable.slice(after + 1, before);
+
+        this.sort(subsortable);
+
+        sortable.splice(after + 1, before - after - 1, ...subsortable);
+      }
+    } else {
+      this.sort(sortable);
+    }
+
+    return fresh;
+  }
+}
diff --git a/src/data/things/sorting-rule/SortingRule.js b/src/data/things/sorting-rule/SortingRule.js
new file mode 100644
index 00000000..5d4bba99
--- /dev/null
+++ b/src/data/things/sorting-rule/SortingRule.js
@@ -0,0 +1,70 @@
+import {V} from '#composite';
+import {unique} from '#sugar';
+import Thing from '#thing';
+import {isStringNonEmpty} from '#validators';
+
+import {exposeConstant} from '#composite/control-flow';
+import {flag} from '#composite/wiki-properties';
+
+export class SortingRule extends Thing {
+  static [Thing.friendlyName] = `Sorting Rule`;
+  static [Thing.wikiData] = 'sortingRules';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    active: flag(V(true)),
+
+    message: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    // Expose only
+
+    isSortingRule: exposeConstant(V(true)),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Message': {property: 'message'},
+      'Active': {property: 'active'},
+    },
+  };
+
+  check(opts) {
+    return this.constructor.check(this, opts);
+  }
+
+  apply(opts) {
+    return this.constructor.apply(this, opts);
+  }
+
+  static check(rule, opts) {
+    const result = this.apply(rule, {...opts, dry: true});
+    if (!result) return true;
+    if (!result.changed) return true;
+    return false;
+  }
+
+  static async apply(_rule, _opts) {
+    throw new Error(`Not implemented`);
+  }
+
+  static async* applyAll(_rules, _opts) {
+    throw new Error(`Not implemented`);
+  }
+
+  static async* go({dataPath, wikiData, dry}) {
+    const rules = wikiData.sortingRules;
+    const constructors = unique(rules.map(rule => rule.constructor));
+
+    for (const constructor of constructors) {
+      yield* constructor.applyAll(
+        rules
+          .filter(rule => rule.active)
+          .filter(rule => rule.constructor === constructor),
+        {dataPath, wikiData, dry});
+    }
+  }
+}
diff --git a/src/data/things/sorting-rule/ThingSortingRule.js b/src/data/things/sorting-rule/ThingSortingRule.js
new file mode 100644
index 00000000..b5cc76dc
--- /dev/null
+++ b/src/data/things/sorting-rule/ThingSortingRule.js
@@ -0,0 +1,83 @@
+import {V} from '#composite';
+import Thing from '#thing';
+import {isStringNonEmpty, strictArrayOf} from '#validators';
+
+import {
+  compareCaseLessSensitive,
+  sortByDate,
+  sortByDirectory,
+  sortByName,
+} from '#sort';
+
+import {exposeConstant} from '#composite/control-flow';
+
+import {SortingRule} from './SortingRule.js';
+
+export class ThingSortingRule extends SortingRule {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    properties: {
+      flags: {update: true, expose: true},
+      update: {
+        validate: strictArrayOf(isStringNonEmpty),
+      },
+    },
+
+    // Expose only
+
+    isThingSortingRule: exposeConstant(V(true)),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'By Properties': {property: 'properties'},
+    },
+  };
+
+  sort(sortable) {
+    if (this.properties) {
+      for (const property of this.properties.toReversed()) {
+        const get = thing => thing[property];
+        const lc = property.toLowerCase();
+
+        if (lc.endsWith('date')) {
+          sortByDate(sortable, {getDate: get});
+          continue;
+        }
+
+        if (lc.endsWith('directory')) {
+          sortByDirectory(sortable, {getDirectory: get});
+          continue;
+        }
+
+        if (lc.endsWith('name')) {
+          sortByName(sortable, {getName: get});
+          continue;
+        }
+
+        const values = sortable.map(get);
+
+        if (values.every(v => typeof v === 'string')) {
+          sortable.sort((a, b) =>
+            compareCaseLessSensitive(get(a), get(b)));
+          continue;
+        }
+
+        if (values.every(v => typeof v === 'number')) {
+          sortable.sort((a, b) => get(a) - get(b));
+          continue;
+        }
+
+        sortable.sort((a, b) =>
+          (get(a).toString() < get(b).toString()
+            ? -1
+         : get(a).toString() > get(b).toString()
+            ? +1
+            :  0));
+      }
+    }
+
+    return sortable;
+  }
+}
diff --git a/src/data/things/sorting-rule/index.js b/src/data/things/sorting-rule/index.js
new file mode 100644
index 00000000..7b83bd44
--- /dev/null
+++ b/src/data/things/sorting-rule/index.js
@@ -0,0 +1,3 @@
+export * from './SortingRule.js';
+export * from './ThingSortingRule.js';
+export * from './DocumentSortingRule.js';
diff --git a/src/data/things/track.js b/src/data/things/track.js
deleted file mode 100644
index af2206f0..00000000
--- a/src/data/things/track.js
+++ /dev/null
@@ -1,725 +0,0 @@
-import {inspect} from 'node:util';
-
-import CacheableObject from '#cacheable-object';
-import {colors} from '#cli';
-import {input} from '#composite';
-import Thing from '#thing';
-import {isBoolean, isColor, isContributionList, isDate, isFileExtension}
-  from '#validators';
-
-import {
-  parseAdditionalFiles,
-  parseAdditionalNames,
-  parseAnnotatedReferences,
-  parseContributors,
-  parseDate,
-  parseDimensions,
-  parseDuration,
-} from '#yaml';
-
-import {withPropertyFromObject} from '#composite/data';
-
-import {
-  exposeConstant,
-  exposeDependency,
-  exposeDependencyOrContinue,
-  exposeUpdateValueOrContinue,
-  exposeWhetherDependencyAvailable,
-} from '#composite/control-flow';
-
-import {
-  withRecontextualizedContributionList,
-  withRedatedContributionList,
-  withResolvedContribs,
-} from '#composite/wiki-data';
-
-import {
-  additionalFiles,
-  additionalNameList,
-  commentary,
-  commentatorArtists,
-  contentString,
-  contributionList,
-  dimensions,
-  directory,
-  duration,
-  flag,
-  name,
-  referenceList,
-  referencedArtworkList,
-  reverseReferenceList,
-  simpleDate,
-  simpleString,
-  singleReference,
-  soupyFind,
-  soupyReverse,
-  thing,
-  urls,
-  wikiData,
-} from '#composite/wiki-properties';
-
-import {
-  exitWithoutUniqueCoverArt,
-  inheritContributionListFromOriginalRelease,
-  inheritFromOriginalRelease,
-  withAlbum,
-  withAlwaysReferenceByDirectory,
-  withContainingTrackSection,
-  withDate,
-  withDirectorySuffix,
-  withHasUniqueCoverArt,
-  withOriginalRelease,
-  withOtherReleases,
-  withPropertyFromAlbum,
-  withSuffixDirectoryFromAlbum,
-  withTrackArtDate,
-} from '#composite/things/track';
-
-export class Track extends Thing {
-  static [Thing.referenceType] = 'track';
-
-  static [Thing.getPropertyDescriptors] = ({
-    Album,
-    ArtTag,
-    Flash,
-    TrackSection,
-    WikiInfo,
-  }) => ({
-    // Update & expose
-
-    name: name('Unnamed Track'),
-
-    directory: [
-      withDirectorySuffix(),
-
-      directory({
-        suffix: '#directorySuffix',
-      }),
-    ],
-
-    suffixDirectoryFromAlbum: [
-      {
-        dependencies: [
-          input.updateValue({validate: isBoolean}),
-        ],
-
-        compute: (continuation, {
-          [input.updateValue()]: value,
-        }) => continuation({
-          ['#flagValue']: value ?? false,
-        }),
-      },
-
-      withSuffixDirectoryFromAlbum({
-        flagValue: '#flagValue',
-      }),
-
-      exposeDependency({
-        dependency: '#suffixDirectoryFromAlbum',
-      })
-    ],
-
-    additionalNames: additionalNameList(),
-
-    bandcampTrackIdentifier: simpleString(),
-    bandcampArtworkIdentifier: simpleString(),
-
-    duration: duration(),
-    urls: urls(),
-    dateFirstReleased: simpleDate(),
-
-    color: [
-      exposeUpdateValueOrContinue({
-        validate: input.value(isColor),
-      }),
-
-      withContainingTrackSection(),
-
-      withPropertyFromObject({
-        object: '#trackSection',
-        property: input.value('color'),
-      }),
-
-      exposeDependencyOrContinue({dependency: '#trackSection.color'}),
-
-      withPropertyFromAlbum({
-        property: input.value('color'),
-      }),
-
-      exposeDependency({dependency: '#album.color'}),
-    ],
-
-    alwaysReferenceByDirectory: [
-      withAlwaysReferenceByDirectory(),
-      exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
-    ],
-
-    // 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),
-      }),
-
-      withPropertyFromAlbum({
-        property: input.value('trackCoverArtFileExtension'),
-      }),
-
-      exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
-
-      exposeConstant({
-        value: input.value('jpg'),
-      }),
-    ],
-
-    coverArtDate: [
-      withTrackArtDate({
-        from: input.updateValue({
-          validate: isDate,
-        }),
-      }),
-
-      exposeDependency({dependency: '#trackArtDate'}),
-    ],
-
-    coverArtDimensions: [
-      exitWithoutUniqueCoverArt(),
-
-      withPropertyFromAlbum({
-        property: input.value('trackDimensions'),
-      }),
-
-      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
-
-      dimensions(),
-    ],
-
-    commentary: commentary(),
-    creditSources: commentary(),
-
-    lyrics: [
-      inheritFromOriginalRelease(),
-      contentString(),
-    ],
-
-    additionalFiles: additionalFiles(),
-    sheetMusicFiles: additionalFiles(),
-    midiProjectFiles: additionalFiles(),
-
-    originalReleaseTrack: singleReference({
-      class: input.value(Track),
-      find: soupyFind.input('track'),
-    }),
-
-    // Internal use only - for directly identifying an album inside a track's
-    // util.inspect display, if it isn't indirectly available (by way of being
-    // included in an album's track list).
-    dataSourceAlbum: singleReference({
-      class: input.value(Album),
-      find: soupyFind.input('album'),
-    }),
-
-    artistContribs: [
-      inheritContributionListFromOriginalRelease(),
-
-      withDate(),
-
-      withResolvedContribs({
-        from: input.updateValue({validate: isContributionList}),
-        thingProperty: input.thisProperty(),
-        artistProperty: input.value('trackArtistContributions'),
-        date: '#date',
-      }).outputs({
-        '#resolvedContribs': '#artistContribs',
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#artistContribs',
-        mode: input.value('empty'),
-      }),
-
-      withPropertyFromAlbum({
-        property: input.value('artistContribs'),
-      }),
-
-      withRecontextualizedContributionList({
-        list: '#album.artistContribs',
-        artistProperty: input.value('trackArtistContributions'),
-      }),
-
-      withRedatedContributionList({
-        list: '#album.artistContribs',
-        date: '#date',
-      }),
-
-      exposeDependency({dependency: '#album.artistContribs'}),
-    ],
-
-    contributorContribs: [
-      inheritContributionListFromOriginalRelease(),
-
-      withDate(),
-
-      contributionList({
-        date: '#date',
-        artistProperty: input.value('trackContributorContributions'),
-      }),
-    ],
-
-    // Cover artists aren't inherited from the original release, since it
-    // typically varies by release and isn't defined by the musical qualities
-    // of the track.
-    coverArtistContribs: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
-
-      withTrackArtDate({
-        fallback: input.value(true),
-      }),
-
-      withResolvedContribs({
-        from: input.updateValue({validate: isContributionList}),
-        thingProperty: input.thisProperty(),
-        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',
-      }),
-
-      exposeDependency({dependency: '#album.trackCoverArtistContribs'}),
-    ],
-
-    referencedTracks: [
-      inheritFromOriginalRelease({
-        notFoundValue: input.value([]),
-      }),
-
-      referenceList({
-        class: input.value(Track),
-        find: soupyFind.input('track'),
-      }),
-    ],
-
-    sampledTracks: [
-      inheritFromOriginalRelease({
-        notFoundValue: input.value([]),
-      }),
-
-      referenceList({
-        class: input.value(Track),
-        find: soupyFind.input('track'),
-      }),
-    ],
-
-    artTags: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
-
-      referenceList({
-        class: input.value(ArtTag),
-        find: soupyFind.input('artTag'),
-      }),
-    ],
-
-    referencedArtworks: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
-
-      withTrackArtDate({
-        fallback: input.value(true),
-      }),
-
-      referencedArtworkList({
-        date: '#trackArtDate',
-      }),
-    ],
-
-    // Update only
-
-    find: soupyFind(),
-    reverse: soupyReverse(),
-
-    // used for referencedArtworkList (mixedFind)
-    albumData: wikiData({
-      class: input.value(Album),
-    }),
-
-    // used for referencedArtworkList (mixedFind)
-    trackData: wikiData({
-      class: input.value(Track),
-    }),
-
-    // used for withMatchingContributionPresets (indirectly by Contribution)
-    wikiInfo: thing({
-      class: input.value(WikiInfo),
-    }),
-
-    // Expose only
-
-    commentatorArtists: commentatorArtists(),
-
-    album: [
-      withAlbum(),
-      exposeDependency({dependency: '#album'}),
-    ],
-
-    date: [
-      withDate(),
-      exposeDependency({dependency: '#date'}),
-    ],
-
-    hasUniqueCoverArt: [
-      withHasUniqueCoverArt(),
-      exposeDependency({dependency: '#hasUniqueCoverArt'}),
-    ],
-
-    isOriginalRelease: [
-      withOriginalRelease(),
-
-      exposeWhetherDependencyAvailable({
-        dependency: '#originalRelease',
-        negate: input.value(true),
-      }),
-    ],
-
-    isRerelease: [
-      withOriginalRelease(),
-
-      exposeWhetherDependencyAvailable({
-        dependency: '#originalRelease',
-      }),
-    ],
-
-    otherReleases: [
-      withOtherReleases(),
-      exposeDependency({dependency: '#otherReleases'}),
-    ],
-
-    referencedByTracks: reverseReferenceList({
-      reverse: soupyReverse.input('tracksWhichReference'),
-    }),
-
-    sampledByTracks: reverseReferenceList({
-      reverse: soupyReverse.input('tracksWhichSample'),
-    }),
-
-    featuredInFlashes: reverseReferenceList({
-      reverse: soupyReverse.input('flashesWhichFeature'),
-    }),
-
-    referencedByArtworks: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
-
-      reverseReferenceList({
-        reverse: soupyReverse.input('artworksWhichReference'),
-      }),
-    ],
-  });
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      'Track': {property: 'name'},
-      'Directory': {property: 'directory'},
-      'Suffix Directory': {property: 'suffixDirectoryFromAlbum'},
-
-      'Additional Names': {
-        property: 'additionalNames',
-        transform: parseAdditionalNames,
-      },
-
-      'Bandcamp Track ID': {
-        property: 'bandcampTrackIdentifier',
-        transform: String,
-      },
-
-      'Bandcamp Artwork ID': {
-        property: 'bandcampArtworkIdentifier',
-        transform: String,
-      },
-
-      'Duration': {
-        property: 'duration',
-        transform: parseDuration,
-      },
-
-      'Color': {property: 'color'},
-      'URLs': {property: 'urls'},
-
-      'Date First Released': {
-        property: 'dateFirstReleased',
-        transform: parseDate,
-      },
-
-      'Cover Art Date': {
-        property: 'coverArtDate',
-        transform: parseDate,
-      },
-
-      'Cover Art File Extension': {property: 'coverArtFileExtension'},
-
-      'Cover Art Dimensions': {
-        property: 'coverArtDimensions',
-        transform: parseDimensions,
-      },
-
-      'Has Cover Art': {
-        property: 'disableUniqueCoverArt',
-        transform: value =>
-          (typeof value === 'boolean'
-            ? !value
-            : value),
-      },
-
-      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
-
-      'Lyrics': {property: 'lyrics'},
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
-
-      'Additional Files': {
-        property: 'additionalFiles',
-        transform: parseAdditionalFiles,
-      },
-
-      'Sheet Music Files': {
-        property: 'sheetMusicFiles',
-        transform: parseAdditionalFiles,
-      },
-
-      'MIDI Project Files': {
-        property: 'midiProjectFiles',
-        transform: parseAdditionalFiles,
-      },
-
-      'Originally Released As': {property: 'originalReleaseTrack'},
-      'Referenced Tracks': {property: 'referencedTracks'},
-      'Sampled Tracks': {property: 'sampledTracks'},
-
-      'Referenced Artworks': {
-        property: 'referencedArtworks',
-        transform: parseAnnotatedReferences,
-      },
-
-      'Franchises': {ignore: true},
-      'Inherit Franchises': {ignore: true},
-
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
-      },
-
-      'Contributors': {
-        property: 'contributorContribs',
-        transform: parseContributors,
-      },
-
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
-      },
-
-      'Art Tags': {property: 'artTags'},
-
-      'Review Points': {ignore: true},
-    },
-
-    invalidFieldCombinations: [
-      {message: `Rereleases inherit references from the original`, fields: [
-        'Originally Released As',
-        'Referenced Tracks',
-      ]},
-
-      {message: `Rereleases inherit samples from the original`, fields: [
-        'Originally Released As',
-        'Sampled Tracks',
-      ]},
-
-      {message: `Rereleases inherit artists from the original`, fields: [
-        'Originally Released As',
-        'Artists',
-      ]},
-
-      {message: `Rereleases inherit contributors from the original`, fields: [
-        'Originally Released As',
-        'Contributors',
-      ]},
-
-      {message: `Rereleases inherit lyrics from the original`, fields: [
-        'Originally Released As',
-        'Lyrics',
-      ]},
-
-      {
-        message: ({'Has Cover Art': hasCoverArt}) =>
-          (hasCoverArt
-            ? `"Has Cover Art: true" is inferred from cover artist credits`
-            : `Tracks without cover art must not have cover artist credits`),
-
-        fields: [
-          'Has Cover Art',
-          'Cover Artists',
-        ],
-      },
-    ],
-  };
-
-  static [Thing.findSpecs] = {
-    track: {
-      referenceTypes: ['track'],
-
-      bindTo: 'trackData',
-
-      getMatchableNames: track =>
-        (track.alwaysReferenceByDirectory
-          ? []
-          : [track.name]),
-    },
-
-    trackOriginalReleasesOnly: {
-      referenceTypes: ['track'],
-      bindTo: 'trackData',
-
-      include: track =>
-        !CacheableObject.getUpdateValue(track, 'originalReleaseTrack'),
-
-      // It's still necessary to check alwaysReferenceByDirectory here, since
-      // it may be set manually (with `Always Reference By Directory: true`),
-      // and these shouldn't be matched by name (as per usual).
-      // See the definition for that property for more information.
-      getMatchableNames: track =>
-        (track.alwaysReferenceByDirectory
-          ? []
-          : [track.name]),
-    },
-
-    trackWithArtwork: {
-      referenceTypes: [
-        'track',
-        'track-referencing-artworks',
-        'track-referenced-artworks',
-      ],
-
-      bindTo: 'trackData',
-
-      include: track =>
-        track.hasUniqueCoverArt,
-
-      getMatchableNames: track =>
-        (track.alwaysReferenceByDirectory
-          ? []
-          : [track.name]),
-    },
-  };
-
-  static [Thing.reverseSpecs] = {
-    tracksWhichReference: {
-      bindTo: 'trackData',
-
-      referencing: track => track.isOriginalRelease ? [track] : [],
-      referenced: track => track.referencedTracks,
-    },
-
-    tracksWhichSample: {
-      bindTo: 'trackData',
-
-      referencing: track => track.isOriginalRelease ? [track] : [],
-      referenced: track => track.sampledTracks,
-    },
-
-    tracksWhoseArtworksFeature: {
-      bindTo: 'trackData',
-
-      referencing: track => [track],
-      referenced: track => track.artTags,
-    },
-
-    trackArtistContributionsBy:
-      soupyReverse.contributionsBy('trackData', 'artistContribs'),
-
-    trackContributorContributionsBy:
-      soupyReverse.contributionsBy('trackData', 'contributorContribs'),
-
-    trackCoverArtistContributionsBy:
-      soupyReverse.contributionsBy('trackData', 'coverArtistContribs'),
-
-    tracksWithCommentaryBy: {
-      bindTo: 'trackData',
-
-      referencing: track => [track],
-      referenced: track => track.commentatorArtists,
-    },
-  };
-
-  // Track YAML loading is handled in album.js.
-  static [Thing.getYamlLoadingSpec] = null;
-
-  [inspect.custom](depth) {
-    const parts = [];
-
-    parts.push(Thing.prototype[inspect.custom].apply(this));
-
-    if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) {
-      parts.unshift(`${colors.yellow('[rerelease]')} `);
-    }
-
-    let album;
-
-    if (depth >= 0) {
-      try {
-        album = this.album;
-      } catch (_error) {
-        // Computing album might crash for any reason, which we don't want to
-        // distract from another error we might be trying to work out at the
-        // moment (for which debugging might involve inspecting this track!).
-      }
-
-      album ??= this.dataSourceAlbum;
-    }
-
-    if (album) {
-      const albumName = album.name;
-      const albumIndex = album.tracks.indexOf(this);
-      const trackNum =
-        (albumIndex === -1
-          ? 'indeterminate position'
-          : `#${albumIndex + 1}`);
-      parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`);
-    }
-
-    return parts.join('');
-  }
-}