« 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/album.js959
-rw-r--r--src/data/things/art-tag.js192
-rw-r--r--src/data/things/artist.js306
-rw-r--r--src/data/things/artwork.js399
-rw-r--r--src/data/things/contribution.js302
-rw-r--r--src/data/things/flash.js452
-rw-r--r--src/data/things/group.js242
-rw-r--r--src/data/things/homepage-layout.js338
-rw-r--r--src/data/things/index.js227
-rw-r--r--src/data/things/language.js913
-rw-r--r--src/data/things/news-entry.js73
-rw-r--r--src/data/things/sorting-rule.js386
-rw-r--r--src/data/things/static-page.js85
-rw-r--r--src/data/things/track.js753
-rw-r--r--src/data/things/wiki-info.js152
15 files changed, 5779 insertions, 0 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js
new file mode 100644
index 00000000..4c85ddfa
--- /dev/null
+++ b/src/data/things/album.js
@@ -0,0 +1,959 @@
+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, isNumber} from '#validators';
+
+import {
+  parseAdditionalFiles,
+  parseAdditionalNames,
+  parseAnnotatedReferences,
+  parseArtwork,
+  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,
+  constitutibleArtwork,
+  constitutibleArtworkList,
+  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 {withHasCoverArt, withTracks} from '#composite/things/album';
+import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
+  from '#composite/things/track-section';
+
+export class Album extends Thing {
+  static [Thing.referenceType] = 'album';
+
+  static [Thing.getPropertyDescriptors] = ({
+    ArtTag,
+    Artwork,
+    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: [
+      withCoverArtDate({
+        from: input.updateValue({
+          validate: isDate,
+        }),
+      }),
+
+      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',
+        value: input.value([]),
+      }),
+
+      wallpaperParts(),
+    ],
+
+    bannerStyle: [
+      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
+      simpleString(),
+    ],
+
+    coverArtDimensions: [
+      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      dimensions(),
+    ],
+
+    trackDimensions: dimensions(),
+
+    bannerDimensions: [
+      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
+      dimensions(),
+    ],
+
+    wallpaperArtwork: [
+      exitWithoutDependency({
+        dependency: 'wallpaperArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Wallpaper Artwork'),
+    ],
+
+    bannerArtwork: [
+      exitWithoutDependency({
+        dependency: 'bannerArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
+    ],
+
+    coverArtworks: [
+      withHasCoverArt(),
+
+      exitWithoutDependency({
+        dependency: '#hasCoverArt',
+        mode: input.value('falsy'),
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+    ],
+
+    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(),
+
+      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(),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumWallpaperArtistContributions'),
+      }),
+    ],
+
+    bannerArtistContribs: [
+      withCoverArtDate(),
+
+      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([]),
+      }),
+
+      referencedArtworkList(),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for referencedArtworkList (mixedFind)
+    artworkData: wikiData({
+      class: input.value(Artwork),
+    }),
+
+    // used for withMatchingContributionPresets (indirectly by Contribution)
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
+    }),
+
+    // Expose only
+
+    commentatorArtists: commentatorArtists(),
+
+    hasCoverArt: [
+      withHasCoverArt(),
+      exposeDependency({dependency: '#hasCoverArt'}),
+    ],
+
+    hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
+    hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
+
+    tracks: [
+      withTracks(),
+      exposeDependency({dependency: '#tracks'}),
+    ],
+  });
+
+  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]),
+    },
+
+    albumPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'album',
+        'album-referencing-artworks',
+        'album-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork, {Artwork, Album}) =>
+        artwork instanceof Artwork &&
+        artwork.thing instanceof Album &&
+        artwork === artwork.thing.coverArtworks[0],
+
+      getMatchableNames: ({thing: album}) =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+
+      getMatchableDirectories: ({thing: album}) =>
+        [album.directory],
+    },
+  };
+
+  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.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: {
+      '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 Artwork': {
+        property: 'coverArtworks',
+        transform:
+          parseArtwork({
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'albumCoverArtistContributions',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+          }),
+      },
+
+      'Banner Artwork': {
+        property: 'bannerArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            dimensionsFromThingProperty: 'bannerDimensions',
+            fileExtensionFromThingProperty: 'bannerFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'bannerArtistContribs',
+            artistContribsArtistProperty: 'albumBannerArtistContributions',
+          }),
+      },
+
+      'Wallpaper Artwork': {
+        property: 'wallpaperArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            dimensionsFromThingProperty: null,
+            fileExtensionFromThingProperty: 'wallpaperFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'wallpaperArtistContribs',
+            artistContribsArtistProperty: 'albumWallpaperArtistContributions',
+          }),
+      },
+
+      '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 = [];
+      const artworkData = [];
+
+      for (const {header: album, entries} of results) {
+        const trackSections = [];
+
+        let currentTrackSection = new TrackSection();
+        let currentTrackSectionTracks = [];
+
+        Object.assign(currentTrackSection, {
+          name: `Default Track Section`,
+          isDefaultTrackSection: true,
+        });
+
+        const albumRef = Thing.getReference(album);
+
+        const closeCurrentTrackSection = () => {
+          if (
+            currentTrackSection.isDefaultTrackSection &&
+            empty(currentTrackSectionTracks)
+          ) {
+            return;
+          }
+
+          currentTrackSection.tracks =
+            currentTrackSectionTracks;
+
+          trackSections.push(currentTrackSection);
+          trackSectionData.push(currentTrackSection);
+        };
+
+        for (const entry of entries) {
+          if (entry instanceof TrackSection) {
+            closeCurrentTrackSection();
+            currentTrackSection = entry;
+            currentTrackSectionTracks = [];
+            continue;
+          }
+
+          currentTrackSectionTracks.push(entry);
+          trackData.push(entry);
+
+          // Set the track's album before accessing its list of artworks.
+          // The existence of its artwork objects may depend on access to
+          // its album's 'Default Track Cover Artists'.
+          entry.album = album;
+
+          artworkData.push(...entry.trackArtworks);
+        }
+
+        closeCurrentTrackSection();
+
+        albumData.push(album);
+
+        artworkData.push(...album.coverArtworks);
+
+        if (album.bannerArtwork) {
+          artworkData.push(album.bannerArtwork);
+        }
+
+        if (album.wallpaperArtwork) {
+          artworkData.push(album.wallpaperArtwork);
+        }
+
+        album.trackSections = trackSections;
+      }
+
+      return {
+        albumData,
+        trackSectionData,
+        trackData,
+        artworkData,
+      };
+    },
+
+    sort({albumData, trackData}) {
+      sortChronologically(albumData);
+      sortAlbumsTracksChronologically(trackData);
+    },
+  });
+
+  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,
+    ];
+  }
+}
+
+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'}),
+    ],
+
+    startCountingFrom: [
+      withStartCountingFrom({
+        from: input.updateValue({validate: isNumber}),
+      }),
+
+      exposeDependency({dependency: '#startCountingFrom'}),
+    ],
+
+    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,
+      },
+    ],
+
+    continueCountingFrom: [
+      withContinueCountingFrom(),
+
+      exposeDependency({dependency: '#continueCountingFrom'}),
+    ],
+  });
+
+  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'},
+      'Start Counting From': {property: 'startCountingFrom'},
+
+      '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.tracks.at(0).trackNumber;
+      } catch {}
+
+      let last = null;
+      try {
+        last = this.tracks.at(-1).trackNumber;
+      } 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 && last !== null
+            ? `: ${first}-${last}`
+            : '');
+
+        parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
+      }
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
new file mode 100644
index 00000000..57e156ee
--- /dev/null
+++ b/src/data/things/art-tag.js
@@ -0,0 +1,192 @@
+export const ART_TAG_DATA_FILE = 'tags.yaml';
+
+import {input} from '#composite';
+import find from '#find';
+import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort';
+import Thing from '#thing';
+import {unique} from '#sugar';
+import {isName} from '#validators';
+import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml';
+
+import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+
+import {
+  additionalNameList,
+  annotatedReferenceList,
+  color,
+  contentString,
+  directory,
+  flag,
+  referenceList,
+  reverseReferenceList,
+  name,
+  soupyFind,
+  soupyReverse,
+  urls,
+  wikiData,
+} from '#composite/wiki-properties';
+
+import {withAllDescendantArtTags, withAncestorArtTagBaobabTree}
+  from '#composite/things/art-tag';
+
+export class ArtTag extends Thing {
+  static [Thing.referenceType] = 'tag';
+  static [Thing.friendlyName] = `Art Tag`;
+
+  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+    // Update & expose
+
+    name: name('Unnamed Art Tag'),
+    directory: directory(),
+    color: color(),
+    isContentWarning: flag(false),
+    extraReadingURLs: urls(),
+
+    nameShort: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isName),
+      }),
+
+      {
+        dependencies: ['name'],
+        compute: ({name}) =>
+          name.replace(/ \([^)]*?\)$/, ''),
+      },
+    ],
+
+    additionalNames: additionalNameList(),
+
+    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
+
+    descriptionShort: [
+      exitWithoutDependency({
+        dependency: 'description',
+        mode: input.value('falsy'),
+      }),
+
+      {
+        dependencies: ['description'],
+        compute: ({description}) =>
+          description.split('<hr class="split">')[0],
+      },
+    ],
+
+    directlyFeaturedInArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichFeature'),
+    }),
+
+    indirectlyFeaturedInArtworks: [
+      withAllDescendantArtTags(),
+
+      {
+        dependencies: ['#allDescendantArtTags'],
+        compute: ({'#allDescendantArtTags': allDescendantArtTags}) =>
+          unique(
+            allDescendantArtTags
+              .flatMap(artTag => artTag.directlyFeaturedInArtworks)),
+      },
+    ],
+
+    allDescendantArtTags: [
+      withAllDescendantArtTags(),
+      exposeDependency({dependency: '#allDescendantArtTags'}),
+    ],
+
+    directAncestorArtTags: reverseReferenceList({
+      reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'),
+    }),
+
+    ancestorArtTagBaobabTree: [
+      withAncestorArtTagBaobabTree(),
+      exposeDependency({dependency: '#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',
+          }),
+      },
+    },
+  };
+
+  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
new file mode 100644
index 00000000..87e1c563
--- /dev/null
+++ b/src/data/things/artist.js
@@ -0,0 +1,306 @@
+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 {parseArtwork} from '#yaml';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import {
+  constitutibleArtwork,
+  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'),
+
+    avatarArtwork: [
+      exitWithoutDependency({
+        dependency: 'hasAvatar',
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Avatar Artwork'),
+    ],
+
+    aliasNames: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isName)},
+      expose: {transform: (names) => names ?? []},
+    },
+
+    isAlias: flag(),
+
+    aliasedArtist: singleReference({
+      class: input.value(Artist),
+      find: soupyFind.input('artist'),
+    }),
+
+    // 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'},
+
+      // note: doesn't really work as an independent field yet
+      'Avatar Artwork': {
+        property: 'avatarArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            fileExtensionFromThingProperty: 'avatarFileExtension',
+          }),
+      },
+
+      '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];
+
+      const artworkData =
+        artistData
+          .filter(artist => artist.hasAvatar)
+          .map(artist => artist.avatarArtwork);
+
+      return {artistData, artworkData};
+    },
+
+    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('');
+  }
+
+  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..2a97fd6d
--- /dev/null
+++ b/src/data/things/artwork.js
@@ -0,0 +1,399 @@
+import {inspect} from 'node:util';
+
+import {input} 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 {withPropertyFromObject} from '#composite/data';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  withRecontextualizedContributionList,
+  withResolvedAnnotatedReferenceList,
+  withResolvedContribs,
+  withResolvedReferenceList,
+} from '#composite/wiki-data';
+
+import {
+  contentString,
+  directory,
+  reverseReferenceList,
+  simpleString,
+  soupyFind,
+  soupyReverse,
+  thing,
+  wikiData,
+} from '#composite/wiki-properties';
+
+import {withDate} from '#composite/things/artwork';
+
+export class Artwork extends Thing {
+  static [Thing.referenceType] = 'artwork';
+
+  static [Thing.getPropertyDescriptors] = ({
+    ArtTag,
+    Contribution,
+  }) => ({
+    // Update & expose
+
+    unqualifiedDirectory: directory({
+      name: input.value(null),
+    }),
+
+    thing: thing(),
+
+    label: simpleString(),
+    source: contentString(),
+
+    dateFromThingProperty: simpleString(),
+
+    date: [
+      withDate({
+        from: input.updateValue({validate: isDate}),
+      }),
+
+      exposeDependency({dependency: '#date'}),
+    ],
+
+    fileExtensionFromThingProperty: simpleString(),
+
+    fileExtension: [
+      {
+        compute: (continuation) => continuation({
+          ['#default']: 'jpg',
+        }),
+      },
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isFileExtension),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'thing',
+        value: '#default',
+      }),
+
+      exitWithoutDependency({
+        dependency: 'fileExtensionFromThingProperty',
+        value: '#default',
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'fileExtensionFromThingProperty',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#value',
+      }),
+
+      exposeDependency({
+        dependency: '#default',
+      }),
+    ],
+
+    dimensionsFromThingProperty: simpleString(),
+
+    dimensions: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDimensions),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'artistContribsFromThingProperty',
+        value: input.value(null),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'dimensionsFromThingProperty',
+      }).outputs({
+        ['#value']: '#dimensionsFromThing',
+      }),
+
+      exitWithoutDependency({
+        dependency: 'dimensionsFromThingProperty',
+        value: input.value(null),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#dimensionsFromThing',
+      }),
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
+
+    artistContribsFromThingProperty: simpleString(),
+    artistContribsArtistProperty: simpleString(),
+
+    artistContribs: [
+      withDate(),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        date: '#date',
+        artistProperty: 'artistContribsArtistProperty',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#resolvedContribs',
+        mode: input.value('empty'),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'artistContribsFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'artistContribsFromThingProperty',
+      }).outputs({
+        ['#value']: '#artistContribs',
+      }),
+
+      withRecontextualizedContributionList({
+        list: '#artistContribs',
+      }),
+
+      exposeDependency({
+        dependency: '#artistContribs',
+      }),
+    ],
+
+    artTagsFromThingProperty: simpleString(),
+
+    artTags: [
+      withResolvedReferenceList({
+        list: input.updateValue({
+          validate:
+            validateReferenceList(ArtTag[Thing.referenceType]),
+        }),
+
+        find: soupyFind.input('artTag'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#resolvedReferenceList',
+        mode: input.value('empty'),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'artTagsFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'artTagsFromThingProperty',
+      }).outputs({
+        ['#value']: '#artTags',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#artTags',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+
+    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({
+        dependency: '#resolvedAnnotatedReferenceList',
+        mode: input.value('empty'),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'referencedArtworksFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'referencedArtworksFromThingProperty',
+      }).outputs({
+        ['#value']: '#referencedArtworks',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#referencedArtworks',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for referencedArtworks (mixedFind)
+    artworkData: wikiData({
+      class: input.value(Artwork),
+    }),
+
+    // Expose only
+
+    referencedByArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichReference'),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Directory': {property: 'unqualifiedDirectory'},
+      'File Extension': {property: 'fileExtension'},
+
+      'Dimensions': {
+        property: 'dimensions',
+        transform: parseDimensions,
+      },
+
+      'Label': {property: 'label'},
+      'Source': {property: 'source'},
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      '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,
+    },
+
+    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);
+  }
+
+  [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/contribution.js b/src/data/things/contribution.js
new file mode 100644
index 00000000..c92fafb4
--- /dev/null
+++ b/src/data/things/contribution.js
@@ -0,0 +1,302 @@
+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
new file mode 100644
index 00000000..ace18af9
--- /dev/null
+++ b/src/data/things/flash.js
@@ -0,0 +1,452 @@
+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 {
+  parseArtwork,
+  parseAdditionalNames,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+} from '#yaml';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import {
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  additionalNameList,
+  color,
+  commentary,
+  commentatorArtists,
+  constitutibleArtwork,
+  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(),
+
+    coverArtwork:
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+
+    contributorContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('flashContributorContributions'),
+    }),
+
+    featuredTracks: referenceList({
+      class: input.value(Track),
+      find: soupyFind.input('track'),
+    }),
+
+    urls: urls(),
+
+    additionalNames: additionalNameList(),
+
+    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,
+      },
+
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Cover Artwork': {
+        property: 'coverArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            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'},
+      'Credit Sources': {property: 'creditSources'},
+
+      'Review Points': {ignore: true},
+    },
+  };
+
+  getOwnArtworkPath(artwork) {
+    return [
+      'media.flashArt',
+      this.directory,
+      artwork.fileExtension,
+    ];
+  }
+}
+
+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);
+
+      const artworkData = flashData.map(flash => flash.coverArtwork);
+
+      return {flashData, flashActData, flashSideData, artworkData};
+    },
+
+    sort({flashData}) {
+      sortFlashesChronologically(flashData);
+    },
+  });
+}
diff --git a/src/data/things/group.js b/src/data/things/group.js
new file mode 100644
index 00000000..b40d15b4
--- /dev/null
+++ b/src/data/things/group.js
@@ -0,0 +1,242 @@
+export const GROUP_DATA_FILE = 'groups.yaml';
+
+import {input} from '#composite';
+import Thing from '#thing';
+import {parseAnnotatedReferences, parseSerieses} from '#yaml';
+
+import {
+  annotatedReferenceList,
+  color,
+  contentString,
+  directory,
+  name,
+  referenceList,
+  seriesList,
+  soupyFind,
+  urls,
+  wikiData,
+} from '#composite/wiki-properties';
+
+export class Group extends Thing {
+  static [Thing.referenceType] = 'group';
+
+  static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({
+    // Update & expose
+
+    name: name('Unnamed Group'),
+    directory: directory(),
+
+    description: contentString(),
+
+    urls: urls(),
+
+    closelyLinkedArtists: annotatedReferenceList({
+      class: input.value(Artist),
+      find: soupyFind.input('artist'),
+
+      reference: input.value('artist'),
+      thing: input.value('artist'),
+    }),
+
+    featuredAlbums: referenceList({
+      class: input.value(Album),
+      find: soupyFind.input('album'),
+    }),
+
+    serieses: seriesList({
+      group: input.myself(),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyFind(),
+
+    // Expose only
+
+    descriptionShort: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['description'],
+        compute: ({description}) =>
+          (description
+            ? description.split('<hr class="split">')[0]
+            : null),
+      },
+    },
+
+    albums: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['this', 'reverse'],
+        compute: ({this: group, reverse}) =>
+          reverse.albumsWhoseGroupsInclude(group),
+      },
+    },
+
+    color: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['this', 'reverse'],
+        compute: ({this: group, reverse}) =>
+          reverse.groupCategoriesWhichInclude(group, {unique: true})
+            ?.color,
+      },
+    },
+
+    category: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['this', 'reverse'],
+        compute: ({this: group, reverse}) =>
+          reverse.groupCategoriesWhichInclude(group, {unique: true}) ??
+          null,
+      },
+    },
+  });
+
+  static [Thing.findSpecs] = {
+    group: {
+      referenceTypes: ['group', 'group-gallery'],
+      bindTo: 'groupData',
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    groupsCloselyLinkedTo: {
+      bindTo: 'groupData',
+
+      referencing: group =>
+        group.closelyLinkedArtists
+          .map(({artist, ...referenceDetails}) => ({
+            group,
+            artist,
+            referenceDetails,
+          })),
+
+      referenced: ({artist}) => [artist],
+
+      tidy: ({group, referenceDetails}) =>
+        ({group, ...referenceDetails}),
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Group': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'Description': {property: 'description'},
+      'URLs': {property: 'urls'},
+
+      'Closely Linked Artists': {
+        property: 'closelyLinkedArtists',
+        transform: value =>
+          parseAnnotatedReferences(value, {
+            referenceField: 'Artist',
+            referenceProperty: 'artist',
+          }),
+      },
+
+      'Featured Albums': {property: 'featuredAlbums'},
+
+      'Series': {
+        property: 'serieses',
+        transform: parseSerieses,
+      },
+
+      '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/homepage-layout.js b/src/data/things/homepage-layout.js
new file mode 100644
index 00000000..82bad2d3
--- /dev/null
+++ b/src/data/things/homepage-layout.js
@@ -0,0 +1,338 @@
+export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml';
+
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {input} from '#composite';
+import Thing from '#thing';
+import {empty} from '#sugar';
+
+import {
+  anyOf,
+  is,
+  isCountingNumber,
+  isString,
+  isStringNonEmpty,
+  validateArrayItems,
+  validateReference,
+} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withResolvedReference} from '#composite/wiki-data';
+
+import {
+  color,
+  contentString,
+  name,
+  referenceList,
+  soupyFind,
+  thing,
+  thingList,
+} from '#composite/wiki-properties';
+
+export class HomepageLayout extends Thing {
+  static [Thing.friendlyName] = `Homepage Layout`;
+
+  static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({
+    // Update & expose
+
+    sidebarContent: contentString(),
+
+    navbarLinks: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isStringNonEmpty)},
+      expose: {transform: value => value ?? []},
+    },
+
+    sections: thingList({
+      class: input.value(HomepageLayoutSection),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Homepage': {ignore: true},
+
+      'Sidebar Content': {property: 'sidebarContent'},
+      'Navbar Links': {property: 'navbarLinks'},
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {allInOne},
+    thingConstructors: {
+      HomepageLayout,
+      HomepageLayoutSection,
+      HomepageLayoutAlbumsRow,
+    },
+  }) => ({
+    title: `Process homepage layout file`,
+    file: HOMEPAGE_LAYOUT_DATA_FILE,
+
+    documentMode: allInOne,
+    documentThing: document => {
+      if (document['Homepage']) {
+        return HomepageLayout;
+      }
+
+      if (document['Section']) {
+        return HomepageLayoutSection;
+      }
+
+      if (document['Row']) {
+        switch (document['Row']) {
+          case 'actions':
+            return HomepageLayoutActionsRow;
+          case 'album carousel':
+            return HomepageLayoutAlbumCarouselRow;
+          case 'album grid':
+            return HomepageLayoutAlbumGridRow;
+          default:
+            throw new TypeError(`Unrecognized row type ${document['Row']}`);
+        }
+      }
+
+      return null;
+    },
+
+    save(results) {
+      if (!empty(results) && !(results[0] instanceof HomepageLayout)) {
+        throw new Error(`Expected 'Homepage' document at top of homepage layout file`);
+      }
+
+      const homepageLayout = results[0];
+      const sections = [];
+
+      let currentSection = null;
+      let currentSectionRows = [];
+
+      const closeCurrentSection = () => {
+        if (currentSection) {
+          for (const row of currentSectionRows) {
+            row.section = currentSection;
+          }
+
+          currentSection.rows = currentSectionRows;
+          sections.push(currentSection);
+
+          currentSection = null;
+          currentSectionRows = [];
+        }
+      };
+
+      for (const entry of results.slice(1)) {
+        if (entry instanceof HomepageLayout) {
+          throw new Error(`Expected only one 'Homepage' document in total`);
+        } else if (entry instanceof HomepageLayoutSection) {
+          closeCurrentSection();
+          currentSection = entry;
+        } else if (entry instanceof HomepageLayoutRow) {
+          if (currentSection) {
+            currentSectionRows.push(entry);
+          } else {
+            throw new Error(`Expected a 'Section' document to add following rows into`);
+          }
+        }
+      }
+
+      closeCurrentSection();
+
+      homepageLayout.sections = sections;
+
+      return {homepageLayout};
+    },
+  });
+}
+
+export class HomepageLayoutSection extends Thing {
+  static [Thing.friendlyName] = `Homepage Section`;
+
+  static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({
+    // Update & expose
+
+    name: name(`Unnamed Homepage Section`),
+
+    color: color(),
+
+    rows: thingList({
+      class: input.value(HomepageLayoutRow),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Section': {property: 'name'},
+      'Color': {property: 'color'},
+    },
+  };
+}
+
+export class HomepageLayoutRow extends Thing {
+  static [Thing.friendlyName] = `Homepage Row`;
+
+  static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({
+    // Update & expose
+
+    section: thing({
+      class: input.value(HomepageLayoutSection),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+
+    // Expose only
+
+    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('');
+  }
+}
+
+export class HomepageLayoutActionsRow extends HomepageLayoutRow {
+  static [Thing.friendlyName] = `Homepage Actions Row`;
+
+  static [Thing.getPropertyDescriptors] = (opts) => ({
+    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
+
+    // Update & expose
+
+    actionLinks: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isString)},
+    },
+
+    // Expose only
+
+    type: {
+      flags: {expose: true},
+      expose: {compute: () => 'actions'},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+    fields: {
+      'Actions': {property: 'actionLinks'},
+    },
+  });
+}
+
+export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
+  static [Thing.friendlyName] = `Homepage Album Carousel Row`;
+
+  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
+    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
+
+    // Update & expose
+
+    albums: referenceList({
+      class: input.value(Album),
+      find: soupyFind.input('album'),
+    }),
+
+    // Expose only
+
+    type: {
+      flags: {expose: true},
+      expose: {compute: () => 'album carousel'},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+    fields: {
+      'Albums': {property: 'albums'},
+    },
+  });
+}
+
+export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow {
+  static [Thing.friendlyName] = `Homepage Album Grid Row`;
+
+  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
+    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
+
+    // Update & expose
+
+    sourceGroup: [
+      {
+        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},
+    },
+
+    // Expose only
+
+    type: {
+      flags: {expose: true},
+      expose: {compute: () => 'album grid'},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+    fields: {
+      'Group': {property: 'sourceGroup'},
+      'Count': {property: 'countAlbumsFromGroup'},
+      'Albums': {property: 'sourceAlbums'},
+    },
+  });
+}
diff --git a/src/data/things/index.js b/src/data/things/index.js
new file mode 100644
index 00000000..96cec88e
--- /dev/null
+++ b/src/data/things/index.js
@@ -0,0 +1,227 @@
+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 {withEntries} from '#sugar';
+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 artworkClasses from './artwork.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 sortingRuleClasses from './sorting-rule.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,
+  'artwork.js': artworkClasses,
+  'contribution.js': contributionClasses,
+  'flash.js': flashClasses,
+  'group.js': groupClasses,
+  'homepage-layout.js': homepageLayoutClasses,
+  'language.js': languageClasses,
+  'news-entry.js': newsEntryClasses,
+  'sorting-rule.js': sortingRuleClasses,
+  '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() {
+  let allClassesUnsorted = Object.create(null);
+
+  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;
+      allClassesUnsorted[name] = constructor;
+    }
+  }
+
+  // Sort subclasses after their superclasses.
+  Object.assign(allClasses,
+    withEntries(allClassesUnsorted, entries =>
+      entries.sort(({[1]: A}, {[1]: B}) =>
+        (A.prototype instanceof B
+          ? +1
+       : B.prototype instanceof A
+          ? -1
+          :  0))));
+}
+
+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();
+    },
+
+    showFailedClasses(failedClasses) {
+      logError`Failed to finalize cacheable object prototypes for classes: ${failedClasses.join(', ')}`;
+    },
+  });
+}
+
+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;
diff --git a/src/data/things/language.js b/src/data/things/language.js
new file mode 100644
index 00000000..a3f861bd
--- /dev/null
+++ b/src/data/things/language.js
@@ -0,0 +1,913 @@
+import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
+
+import {withAggregate} from '#aggregate';
+import CacheableObject from '#cacheable-object';
+import {logWarn} from '#cli';
+import * as html from '#html';
+import {empty} from '#sugar';
+import {isLanguageCode} from '#validators';
+import Thing from '#thing';
+
+import {
+  getExternalLinkStringOfStyleFromDescriptors,
+  getExternalLinkStringsFromDescriptors,
+  isExternalLinkContext,
+  isExternalLinkSpec,
+  isExternalLinkStyle,
+} from '#external-links';
+
+import {externalFunction, flag, name} from '#composite/wiki-properties';
+
+export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g;
+
+export class Language extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    // General language code. This is used to identify the language distinctly
+    // from other languages (similar to how "Directory" operates in many data
+    // objects).
+    code: {
+      flags: {update: true, expose: true},
+      update: {validate: isLanguageCode},
+    },
+
+    // Human-readable name. This should be the language's own native name, not
+    // localized to any other language.
+    name: name(`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
+    // may be overridden to provide Intl constructors an alternative value.
+    intlCode: {
+      flags: {update: true, expose: true},
+      update: {validate: isLanguageCode},
+      expose: {
+        dependencies: ['code'],
+        transform: (intlCode, {code}) => intlCode ?? code,
+      },
+    },
+
+    // Flag which represents whether or not to hide a language from general
+    // access. If a language is hidden, its portion of the website will still
+    // be built (with all strings localized to the language), but it won't be
+    // included in controls for switching languages or the <link rel=alternate>
+    // tags used for search engine optimization. This flag is intended for use
+    // 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),
+
+    // 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'},
+
+      expose: {
+        dependencies: ['inheritedStrings', 'code'],
+        transform(strings, {inheritedStrings, code}) {
+          if (!strings && !inheritedStrings) return null;
+          if (!inheritedStrings) return strings;
+
+          const validStrings = {
+            ...inheritedStrings,
+            ...strings,
+          };
+
+          const optionsFromTemplate = template =>
+            Array.from(template.matchAll(languageOptionRegex))
+              .map(({groups}) => groups.name);
+
+          for (const [key, providedTemplate] of Object.entries(strings)) {
+            const inheritedTemplate = inheritedStrings[key];
+            if (!inheritedTemplate) continue;
+
+            const providedOptions = optionsFromTemplate(providedTemplate);
+            const inheritedOptions = optionsFromTemplate(inheritedTemplate);
+
+            const missingOptionNames =
+              inheritedOptions.filter(name => !providedOptions.includes(name));
+
+            const misplacedOptionNames =
+              providedOptions.filter(name => !inheritedOptions.includes(name));
+
+            if (!empty(missingOptionNames) || !empty(misplacedOptionNames)) {
+              logWarn`Not using ${code ?? '(no code)'} string ${key}:`;
+              if (!empty(missingOptionNames))
+                logWarn`- Missing options: ${missingOptionNames.join(', ')}`;
+              if (!empty(misplacedOptionNames))
+                logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`;
+              validStrings[key] = inheritedStrings[key];
+            }
+          }
+
+          return validStrings;
+        },
+      },
+    },
+
+    // May be provided to specify "default" strings, generally (but not
+    // necessarily) inherited from another Language object.
+    inheritedStrings: {
+      flags: {update: true, expose: true},
+      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`),
+      },
+    },
+
+    intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}),
+    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'}),
+
+    validKeys: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['strings', 'inheritedStrings'],
+        compute: ({strings, inheritedStrings}) =>
+          Array.from(
+            new Set([
+              ...Object.keys(inheritedStrings ?? {}),
+              ...Object.keys(strings ?? {}),
+            ])
+          ),
+      },
+    },
+
+    // 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)])
+          );
+        },
+      },
+    },
+  });
+
+  static #intlHelper (constructor, opts) {
+    return {
+      flags: {expose: true},
+      expose: {
+        dependencies: ['code', 'intlCode'],
+        compute: ({code, intlCode}) => {
+          const constructCode = intlCode ?? code;
+          if (!constructCode) return null;
+          return Reflect.construct(constructor, [constructCode, opts]);
+        },
+      },
+    };
+  }
+
+  $(...args) {
+    return this.formatString(...args);
+  }
+
+  assertIntlAvailable(property) {
+    if (!this[property]) {
+      throw new Error(`Intl API ${property} unavailable`);
+    }
+  }
+
+  getUnitForm(value) {
+    this.assertIntlAvailable('intl_pluralCardinal');
+    return this.intl_pluralCardinal.select(value);
+  }
+
+  formatString(...args) {
+    const hasOptions =
+      typeof args.at(-1) === 'object' &&
+      args.at(-1) !== null;
+
+    const key =
+      this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args);
+
+    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, '_$&')
+        .toUpperCase();
+
+    // These will be filled up as we iterate over the template, slotting in
+    // each option (if it's present).
+    const missingOptionNames = new Set();
+
+    // These will also be filled. It's a bit different of an error, indicating
+    // a provided option was *expected,* but its value was null, undefined, or
+    // blank HTML content.
+    const valuelessOptionNames = new Set();
+
+    // These *might* be missing, and if they are, that's OK!! Instead of adding
+    // to the valueless set above, we'll just mark to return a blank for the
+    // whole string.
+    const expectedValuelessOptionNames =
+      new Set(
+        (options[this.onlyIfOptions] ?? [])
+          .map(constantCasify));
+
+    let seenExpectedValuelessOption = false;
+
+    const isValueless =
+      value =>
+        value === null ||
+        value === undefined ||
+        html.isBlank(value);
+
+    // And this will have entries deleted as they're encountered in the
+    // template. Leftover entries are misplaced.
+    const optionsMap =
+      new Map(
+        Object.entries(options).map(([name, value]) => [
+          constantCasify(name),
+          value,
+        ]));
+
+    const output = this.#iterateOverTemplate({
+      template: this.strings[key],
+
+      match: languageOptionRegex,
+
+      insert: ({name: optionName}, canceledForming) => {
+        if (!optionsMap.has(optionName)) {
+          missingOptionNames.add(optionName);
+
+          // We don't need to continue forming the output if we've hit a
+          // missing option name, since the end result of this formatString
+          // call will be a thrown error, and formed output won't be needed.
+          // Return undefined to mark canceledForming for the following
+          // iterations (and exit early out of this iteration).
+          return undefined;
+        }
+
+        // Even if we're not actually forming the output anymore, we'll still
+        // have to access this option's value to check if it is invalid.
+        const optionValue = optionsMap.get(optionName);
+
+        // We always have to delete expected options off the provided option
+        // map, since the leftovers are what will be used to tell which are
+        // misplaced - information you want even (or doubly so) if we've
+        // already stopped forming the output thanks to missing options.
+        optionsMap.delete(optionName);
+
+        // Just like if an option is missing, a valueless option cancels
+        // forming the rest of the output.
+        if (isValueless(optionValue)) {
+          // It's also an error, *except* if this option is one of the ones
+          // that we're indicated to *expect* might be valueless! In that case,
+          // we still need to stop forming the string (and mark a separate flag
+          // so that we return a blank), but it's not an error.
+          if (expectedValuelessOptionNames.has(optionName)) {
+            seenExpectedValuelessOption = true;
+          } else {
+            valuelessOptionNames.add(optionName);
+          }
+
+          return undefined;
+        }
+
+        if (canceledForming) {
+          return undefined;
+        }
+
+        return optionValue;
+      },
+    });
+
+    const misplacedOptionNames =
+      Array.from(optionsMap.keys());
+
+    withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => {
+      const names = set => Array.from(set).join(', ');
+
+      if (!empty(missingOptionNames)) {
+        push(new Error(
+          `Missing options: ${names(missingOptionNames)}`));
+      }
+
+      if (!empty(valuelessOptionNames)) {
+        push(new Error(
+          `Valueless options: ${names(valuelessOptionNames)}`));
+      }
+
+      if (!empty(misplacedOptionNames)) {
+        push(new Error(
+          `Unexpected options: ${names(misplacedOptionNames)}`));
+      }
+    });
+
+    // If an option was valueless as marked to expect, then that indicates
+    // the whole string should be treated as blank content.
+    if (seenExpectedValuelessOption) {
+      return html.blank();
+    }
+
+    return output;
+  }
+
+  #iterateOverTemplate({
+    template,
+    match: regexp,
+    insert: insertFn,
+  }) {
+    const outputParts = [];
+
+    let canceledForming = false;
+
+    let lastIndex = 0;
+    let partInProgress = '';
+
+    for (const match of template.matchAll(regexp)) {
+      const insertion =
+        insertFn(match.groups, canceledForming);
+
+      if (insertion === undefined) {
+        canceledForming = true;
+      }
+
+      // Don't proceed with forming logic if the insertion function has
+      // indicated that's not needed anymore - but continue iterating over
+      // the rest of the template's matches, so other iteration logic (with
+      // side effects) gets to process everything.
+      if (canceledForming) {
+        continue;
+      }
+
+      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);
+        partInProgress = '';
+      }
+
+      lastIndex = match.index + match[0].length;
+    }
+
+    if (canceledForming) {
+      return undefined;
+    }
+
+    // Tack onto the final partInProgress, which may still have a value by this
+    // point, if the final inserted value was a string. (Otherwise, it'll just
+    // be equal to the remaining template text.)
+    if (lastIndex < template.length) {
+      partInProgress += template.slice(lastIndex);
+    }
+
+    if (partInProgress) {
+      outputParts.push(partInProgress);
+    }
+
+    return this.#wrapSanitized(outputParts);
+  }
+
+  // Processes a value so that it's suitable to be inserted into a template.
+  // For strings, this escapes HTML special characters, displaying them as-are
+  // instead of representing HTML markup. For numbers and booleans, this turns
+  // them into string values, so they never accidentally get caught as falsy
+  // by #html stringification. Everything else - most importantly including
+  // 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);
+
+      case 'number':
+      case 'boolean':
+        return value.toString();
+
+      default:
+        return value;
+    }
+  }
+
+  // Wraps the output of a formatting function in a no-name-nor-attributes
+  // HTML tag, which will indicate to other calls to formatString that this
+  // content is a string *that may contain HTML* and doesn't need to
+  // sanitized any further. It'll still .toString() to just the string
+  // contents, if needed.
+  #wrapSanitized(content) {
+    return html.tags(content, {
+      [html.blessAttributes]: true,
+      [html.joinChildren]: '',
+      [html.noEdgeWhitespace]: true,
+    });
+  }
+
+  // Similar to the above internal methods, but this one is public.
+  // It should be used when embedding content that may not have previously
+  // been sanitized directly into an HTML tag or template's contents.
+  // The templating engine usually handles this on its own, as does passing
+  // a value (sanitized or not) directly for inserting into formatting
+  // functions, but if you used a custom slot validation function (for example,
+  // {validate: v => v.isHTML} instead of {type: 'string'} / {type: 'html'})
+  // and are embedding the contents of the slot as a direct child of another
+  // tag, you should manually sanitize those contents with this function.
+  sanitize(value) {
+    if (typeof value === 'string') {
+      return this.#wrapSanitized(this.#sanitizeValueForInsertion(value));
+    } else {
+      return value;
+    }
+  }
+
+  formatDate(date) {
+    // Null or undefined date is blank content.
+    if (date === null || date === undefined) {
+      return html.blank();
+    }
+
+    this.assertIntlAvailable('intl_date');
+    return this.intl_date.format(date);
+  }
+
+  formatDateRange(startDate, endDate) {
+    // formatDateRange 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) {
+      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`);
+      }
+    }
+
+    this.assertIntlAvailable('intl_date');
+    return this.intl_date.formatRange(startDate, endDate);
+  }
+
+  formatDateDuration({
+    years: numYears = 0,
+    months: numMonths = 0,
+    days: numDays = 0,
+    approximate = false,
+  }) {
+    // Give up if any of years, months, or days is null or undefined.
+    // These default to zero, so something's gone pretty badly wrong to
+    // pass in all or partial missing values.
+    if (
+      numYears === undefined || numYears === null ||
+      numMonths === undefined || numMonths === null ||
+      numDays === undefined || numDays === null
+    ) {
+      throw new Error(`Expected values or default zero for years, months, and days`);
+    }
+
+    let basis;
+
+    const years = this.countYears(numYears, {unit: true});
+    const months = this.countMonths(numMonths, {unit: true});
+    const days = this.countDays(numDays, {unit: true});
+
+    if (numYears && numMonths && numDays)
+      basis = this.formatString('count.dateDuration.yearsMonthsDays', {years, months, days});
+    else if (numYears && numMonths)
+      basis = this.formatString('count.dateDuration.yearsMonths', {years, months});
+    else if (numYears && numDays)
+      basis = this.formatString('count.dateDuration.yearsDays', {years, days});
+    else if (numYears)
+      basis = this.formatString('count.dateDuration.years', {years});
+    else if (numMonths && numDays)
+      basis = this.formatString('count.dateDuration.monthsDays', {months, days});
+    else if (numMonths)
+      basis = this.formatString('count.dateDuration.months', {months});
+    else if (numDays)
+      basis = this.formatString('count.dateDuration.days', {days});
+    else
+      return this.formatString('count.dateDuration.zero');
+
+    if (approximate) {
+      return this.formatString('count.dateDuration.approximate', {
+        duration: basis,
+      });
+    } else {
+      return basis;
+    }
+  }
+
+  formatRelativeDate(currentDate, referenceDate, {
+    considerRoundingDays = false,
+    approximate = true,
+    absolute = true,
+  } = {}) {
+    // Give up if current and/or reference date is null or undefined.
+    if (
+      currentDate === undefined || currentDate === null ||
+      referenceDate === undefined || referenceDate === null
+    ) {
+      throw new Error(`Expected values for currentDate and referenceDate`);
+    }
+
+    const currentInstant = toTemporalInstant.apply(currentDate);
+    const referenceInstant = toTemporalInstant.apply(referenceDate);
+
+    const comparison =
+      Temporal.Instant.compare(currentInstant, referenceInstant);
+
+    if (comparison === 0) {
+      return this.formatString('count.dateDuration.same');
+    }
+
+    const currentTDZ = currentInstant.toZonedDateTimeISO('Etc/UTC');
+    const referenceTDZ = referenceInstant.toZonedDateTimeISO('Etc/UTC');
+
+    const earlierTDZ = (comparison === -1 ? currentTDZ : referenceTDZ);
+    const laterTDZ = (comparison === 1 ? currentTDZ : referenceTDZ);
+
+    const {years, months, days} =
+      laterTDZ.since(earlierTDZ, {
+        largestUnit: 'year',
+        smallestUnit:
+          (considerRoundingDays
+            ? (laterTDZ.since(earlierTDZ, {
+                largestUnit: 'year',
+                smallestUnit: 'day',
+              }).years
+                ? 'month'
+                : 'day')
+            : 'day'),
+        roundingMode: 'halfCeil',
+      });
+
+    const duration =
+      this.formatDateDuration({
+        years, months, days,
+        approximate: false,
+      });
+
+    const relative =
+      this.formatString(
+        'count.dateDuration',
+        (approximate && (years || months || days)
+          ? (comparison === -1
+              ? 'approximateEarlier'
+              : 'approximateLater')
+          : (comparison === -1
+              ? 'earlier'
+              : 'later')),
+        {duration});
+
+    if (absolute) {
+      return this.formatString('count.dateDuration.relativeAbsolute', {
+        relative,
+        absolute: this.formatDate(currentDate),
+      });
+    } else {
+      return relative;
+    }
+  }
+
+  formatDuration(secTotal, {approximate = false, unit = false} = {}) {
+    // Null or undefined duration is blank content.
+    if (secTotal === null || secTotal === undefined) {
+      return html.blank();
+    }
+
+    // Zero duration is a "missing" string.
+    if (secTotal === 0) {
+      return this.formatString('count.duration.missing');
+    }
+
+    const hour = Math.floor(secTotal / 3600);
+    const min = Math.floor((secTotal - hour * 3600) / 60);
+    const sec = Math.floor(secTotal - hour * 3600 - min * 60);
+
+    const pad = (val) => val.toString().padStart(2, '0');
+
+    const stringSubkey = unit ? '.withUnit' : '';
+
+    const duration =
+      hour > 0
+        ? this.formatString('count.duration.hours' + stringSubkey, {
+            hours: hour,
+            minutes: pad(min),
+            seconds: pad(sec),
+          })
+        : this.formatString('count.duration.minutes' + stringSubkey, {
+            minutes: min,
+            seconds: pad(sec),
+          });
+
+    return approximate
+      ? this.formatString('count.duration.approximate', {duration})
+      : duration;
+  }
+
+  formatExternalLink(url, {
+    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();
+    }
+
+    isExternalLinkContext(context);
+
+    if (style === 'all') {
+      return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, {
+        language: this,
+        context,
+      });
+    }
+
+    isExternalLinkStyle(style);
+
+    const result =
+      getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+        language: this,
+        context,
+      });
+
+    // It's possible for there to not actually be any string available for the
+    // given URL, style, and context, and we want this to be detectable via
+    // html.blank().
+    return result ?? html.blank();
+  }
+
+  formatIndex(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
+    this.assertIntlAvailable('intl_pluralOrdinal');
+    return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
+  }
+
+  formatNumber(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
+    this.assertIntlAvailable('intl_number');
+    return this.intl_number.format(value);
+  }
+
+  formatWordCount(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
+    const num = this.formatNumber(
+      value > 1000 ? Math.floor(value / 100) / 10 : value
+    );
+
+    const words =
+      value > 1000
+        ? this.formatString('count.words.thousand', {words: num})
+        : this.formatString('count.words', {words: num});
+
+    return this.formatString('count.words.withUnit.' + this.getUnitForm(value), {words});
+  }
+
+  #formatListHelper(array, processFn) {
+    // Empty lists, null, and undefined are blank content.
+    if (empty(array) || array === null || array === undefined) {
+      return html.blank();
+    }
+
+    // Operate on "insertion markers" instead of the actual contents of the
+    // array, because the process function (likely an Intl operation) is taken
+    // to only operate on strings. We'll insert the contents of the array back
+    // at these points afterwards.
+
+    const insertionMarkers =
+      Array.from(
+        {length: array.length},
+        (_item, index) => `<::insertion_${index}>`);
+
+    // Basically the same insertion logic as in formatString. Like there, we
+    // can't assume that insertion markers were kept in the same order as they
+    // were provided, so we'll refer to the marked index. But we don't need to
+    // worry about some of the indices *not* corresponding to a provided source
+    // item, like we do in formatString, so that cuts out a lot of the
+    // validation logic.
+
+    return this.#iterateOverTemplate({
+      template: processFn(insertionMarkers),
+
+      match: /<::insertion_(?<index>[0-9]+)>/g,
+
+      insert: ({index: markerIndex}) => {
+        return array[markerIndex];
+      },
+    });
+  }
+
+  // Conjunction list: A, B, and C
+  formatConjunctionList(array) {
+    this.assertIntlAvailable('intl_listConjunction');
+    return this.#formatListHelper(
+      array,
+      array => this.intl_listConjunction.format(array));
+  }
+
+  // Disjunction lists: A, B, or C
+  formatDisjunctionList(array) {
+    this.assertIntlAvailable('intl_listDisjunction');
+    return this.#formatListHelper(
+      array,
+      array => this.intl_listDisjunction.format(array));
+  }
+
+  // Unit lists: A, B, C
+  formatUnitList(array) {
+    this.assertIntlAvailable('intl_listUnit');
+    return this.#formatListHelper(
+      array,
+      array => this.intl_listUnit.format(array));
+  }
+
+  // Lists without separator: A B C
+  formatListWithoutSeparator(array) {
+    return this.#formatListHelper(
+      array,
+      array => array.join(' '));
+  }
+
+  // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
+  formatFileSize(bytes) {
+    // Null or undefined bytes is blank content.
+    if (bytes === null || bytes === undefined) {
+      return html.blank();
+    }
+
+    // Zero bytes is blank content.
+    if (bytes === 0) {
+      return html.blank();
+    }
+
+    bytes = parseInt(bytes);
+
+    // Non-number bytes is blank content! Wow.
+    if (isNaN(bytes)) {
+      return html.blank();
+    }
+
+    const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10;
+
+    if (bytes >= 10 ** 12) {
+      return this.formatString('count.fileSize.terabytes', {
+        terabytes: round(12),
+      });
+    } else if (bytes >= 10 ** 9) {
+      return this.formatString('count.fileSize.gigabytes', {
+        gigabytes: round(9),
+      });
+    } else if (bytes >= 10 ** 6) {
+      return this.formatString('count.fileSize.megabytes', {
+        megabytes: round(6),
+      });
+    } else if (bytes >= 10 ** 3) {
+      return this.formatString('count.fileSize.kilobytes', {
+        kilobytes: round(3),
+      });
+    } else {
+      return this.formatString('count.fileSize.bytes', {bytes});
+    }
+  }
+
+  // Utility function to quickly provide a useful string key
+  // (generally a prefix) to stuff nested beneath it.
+  encapsulate(...args) {
+    const fn =
+      (typeof args.at(-1) === 'function'
+        ? args.at(-1)
+        : null);
+
+    const parts =
+      (fn
+        ? args.slice(0, -1)
+        : args);
+
+    const capsule =
+      this.#joinKeyParts(parts);
+
+    if (fn) {
+      return fn(capsule);
+    } else {
+      return capsule;
+    }
+  }
+
+  #joinKeyParts(parts) {
+    return parts.filter(Boolean).join('.');
+  }
+}
+
+const countHelper = (stringKey, optionName = stringKey) =>
+  function(value, {
+    unit = false,
+    blankIfZero = false,
+  } = {}) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
+    // Zero is blank content, if that option is set.
+    if (value === 0 && blankIfZero) {
+      return html.blank();
+    }
+
+    return this.formatString(
+      unit
+        ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
+        : `count.${stringKey}`,
+      {[optionName]: this.formatNumber(value)});
+  };
+
+// TODO: These are hard-coded. Is there a better way?
+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'),
+  countWeeks: countHelper('weeks'),
+  countYears: countHelper('years'),
+});
diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js
new file mode 100644
index 00000000..43d1638e
--- /dev/null
+++ b/src/data/things/news-entry.js
@@ -0,0 +1,73 @@
+export const NEWS_DATA_FILE = 'news.yaml';
+
+import {sortChronologically} from '#sort';
+import Thing from '#thing';
+import {parseDate} from '#yaml';
+
+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.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    name: name('Unnamed News Entry'),
+    directory: directory(),
+    date: simpleDate(),
+
+    content: contentString(),
+
+    // Expose only
+
+    contentShort: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['content'],
+
+        compute: ({content}) => content.split('<hr class="split">')[0],
+      },
+    },
+  });
+
+  static [Thing.findSpecs] = {
+    newsEntry: {
+      referenceTypes: ['news-entry'],
+      bindTo: 'newsData',
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+      'Directory': {property: 'directory'},
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
+
+      '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/sorting-rule.js b/src/data/things/sorting-rule.js
new file mode 100644
index 00000000..b169a541
--- /dev/null
+++ b/src/data/things/sorting-rule.js
@@ -0,0 +1,386 @@
+export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml';
+
+import {readFile, writeFile} from 'node:fs/promises';
+import * as path from 'node:path';
+
+import {input} from '#composite';
+import {chunkByProperties, compareArrays, unique} from '#sugar';
+import Thing from '#thing';
+import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators';
+
+import {
+  compareCaseLessSensitive,
+  sortByDate,
+  sortByDirectory,
+  sortByName,
+} from '#sort';
+
+import {
+  documentModes,
+  flattenThingLayoutToDocumentOrder,
+  getThingLayoutForFilename,
+  reorderDocumentsInYAMLSourceText,
+} from '#yaml';
+
+import {flag} from '#composite/wiki-properties';
+
+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;
+}
+
+export class SortingRule extends Thing {
+  static [Thing.friendlyName] = `Sorting Rule`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    active: flag(true),
+
+    message: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Message': {property: 'message'},
+      'Active': {property: 'active'},
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {allInOne},
+    thingConstructors: {DocumentSortingRule},
+  }) => ({
+    title: `Process sorting rules file`,
+    file: SORTING_RULE_DATA_FILE,
+
+    documentMode: allInOne,
+    documentThing: document =>
+      (document['Sort Documents']
+        ? DocumentSortingRule
+        : null),
+
+    save: (results) => ({sortingRules: results}),
+  });
+
+  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});
+    }
+  }
+}
+
+export class ThingSortingRule extends SortingRule {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    properties: {
+      flags: {update: true, expose: true},
+      update: {
+        validate: strictArrayOf(isStringNonEmpty),
+      },
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(SortingRule, {
+    fields: {
+      'By Properties': {property: 'properties'},
+    },
+  });
+
+  sort(sortable) {
+    if (this.properties) {
+      for (const property of this.properties.slice().reverse()) {
+        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;
+  }
+}
+
+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},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ThingSortingRule, {
+    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
+        .slice()
+        .sort((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/static-page.js b/src/data/things/static-page.js
new file mode 100644
index 00000000..52a09c31
--- /dev/null
+++ b/src/data/things/static-page.js
@@ -0,0 +1,85 @@
+export const DATA_STATIC_PAGE_DIRECTORY = 'static-page';
+
+import * as path from 'node:path';
+
+import {traverse} from '#node-utils';
+import {sortAlphabetically} from '#sort';
+import Thing from '#thing';
+import {isName} from '#validators';
+
+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.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    name: name('Unnamed Static Page'),
+
+    nameShort: {
+      flags: {update: true, expose: true},
+      update: {validate: isName},
+
+      expose: {
+        dependencies: ['name'],
+        transform: (value, {name}) => value ?? name,
+      },
+    },
+
+    directory: directory(),
+
+    stylesheet: simpleString(),
+    script: simpleString(),
+    content: contentString(),
+
+    absoluteLinks: flag(),
+  });
+
+  static [Thing.findSpecs] = {
+    staticPage: {
+      referenceTypes: ['static'],
+      bindTo: 'staticPageData',
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+      'Short Name': {property: 'nameShort'},
+      'Directory': {property: 'directory'},
+
+      'Absolute Links': {property: 'absoluteLinks'},
+
+      'Style': {property: 'stylesheet'},
+      'Script': {property: 'script'},
+      'Content': {property: 'content'},
+
+      '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..bcf84aa8
--- /dev/null
+++ b/src/data/things/track.js
@@ -0,0 +1,753 @@
+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,
+  parseArtwork,
+  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,
+  constitutibleArtworkList,
+  contentString,
+  contributionList,
+  dimensions,
+  directory,
+  duration,
+  flag,
+  lyrics,
+  name,
+  referenceList,
+  referencedArtworkList,
+  reverseReferenceList,
+  simpleDate,
+  simpleString,
+  singleReference,
+  soupyFind,
+  soupyReverse,
+  thing,
+  urls,
+  wikiData,
+} from '#composite/wiki-properties';
+
+import {
+  exitWithoutUniqueCoverArt,
+  inheritContributionListFromMainRelease,
+  inheritFromMainRelease,
+  withAllReleases,
+  withAlwaysReferenceByDirectory,
+  withContainingTrackSection,
+  withCoverArtistContribs,
+  withDate,
+  withDirectorySuffix,
+  withHasUniqueCoverArt,
+  withMainRelease,
+  withOtherReleases,
+  withPropertyFromAlbum,
+  withSuffixDirectoryFromAlbum,
+  withTrackArtDate,
+  withTrackNumber,
+} from '#composite/things/track';
+
+export class Track extends Thing {
+  static [Thing.referenceType] = 'track';
+
+  static [Thing.getPropertyDescriptors] = ({
+    Album,
+    ArtTag,
+    Artwork,
+    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',
+      })
+    ],
+
+    album: thing({
+      class: input.value(Album),
+    }),
+
+    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(),
+
+      exposeUpdateValueOrContinue(),
+
+      withPropertyFromAlbum({
+        property: input.value('trackDimensions'),
+      }),
+
+      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
+
+      dimensions(),
+    ],
+
+    commentary: commentary(),
+    creditSources: commentary(),
+
+    lyrics: [
+      inheritFromMainRelease(),
+      lyrics(),
+    ],
+
+    additionalFiles: additionalFiles(),
+    sheetMusicFiles: additionalFiles(),
+    midiProjectFiles: additionalFiles(),
+
+    mainReleaseTrack: singleReference({
+      class: input.value(Track),
+      find: soupyFind.input('track'),
+    }),
+
+    artistContribs: [
+      inheritContributionListFromMainRelease(),
+
+      withDate(),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
+        date: '#date',
+      }).outputs({
+        '#resolvedContribs': '#artistContribs',
+      }),
+
+      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: [
+      inheritContributionListFromMainRelease(),
+
+      withDate(),
+
+      contributionList({
+        date: '#date',
+        artistProperty: input.value('trackContributorContributions'),
+      }),
+    ],
+
+    coverArtistContribs: [
+      withCoverArtistContribs({
+        from: input.updateValue({
+          validate: isContributionList,
+        }),
+      }),
+
+      exposeDependency({dependency: '#coverArtistContribs'}),
+    ],
+
+    referencedTracks: [
+      inheritFromMainRelease({
+        notFoundValue: input.value([]),
+      }),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('track'),
+      }),
+    ],
+
+    sampledTracks: [
+      inheritFromMainRelease({
+        notFoundValue: input.value([]),
+      }),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('track'),
+      }),
+    ],
+
+    trackArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Track Artwork'),
+    ],
+
+    artTags: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
+
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
+    ],
+
+    referencedArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
+
+      referencedArtworkList(),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for referencedArtworkList (mixedFind)
+    artworkData: wikiData({
+      class: input.value(Artwork),
+    }),
+
+    // used for withAlwaysReferenceByDirectory (for some reason)
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
+
+    // used for withMatchingContributionPresets (indirectly by Contribution)
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
+    }),
+
+    // Expose only
+
+    commentatorArtists: commentatorArtists(),
+
+    date: [
+      withDate(),
+      exposeDependency({dependency: '#date'}),
+    ],
+
+    trackNumber: [
+      withTrackNumber(),
+      exposeDependency({dependency: '#trackNumber'}),
+    ],
+
+    hasUniqueCoverArt: [
+      withHasUniqueCoverArt(),
+      exposeDependency({dependency: '#hasUniqueCoverArt'}),
+    ],
+
+    isMainRelease: [
+      withMainRelease(),
+
+      exposeWhetherDependencyAvailable({
+        dependency: '#mainRelease',
+        negate: input.value(true),
+      }),
+    ],
+
+    isSecondaryRelease: [
+      withMainRelease(),
+
+      exposeWhetherDependencyAvailable({
+        dependency: '#mainRelease',
+      }),
+    ],
+
+    // Only has any value for main releases, because secondary releases
+    // are never secondary to *another* secondary release.
+    secondaryReleases: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichAreSecondaryReleasesOf'),
+    }),
+
+    allReleases: [
+      withAllReleases(),
+      exposeDependency({dependency: '#allReleases'}),
+    ],
+
+    otherReleases: [
+      withOtherReleases(),
+      exposeDependency({dependency: '#otherReleases'}),
+    ],
+
+    referencedByTracks: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichReference'),
+    }),
+
+    sampledByTracks: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichSample'),
+    }),
+
+    featuredInFlashes: reverseReferenceList({
+      reverse: soupyReverse.input('flashesWhichFeature'),
+    }),
+  });
+
+  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,
+      },
+
+      'Main Release': {property: 'mainReleaseTrack'},
+      'Referenced Tracks': {property: 'referencedTracks'},
+      'Sampled Tracks': {property: 'sampledTracks'},
+
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+
+      'Franchises': {ignore: true},
+      'Inherit Franchises': {ignore: true},
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
+
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Track Artwork': {
+        property: 'trackArtworks',
+        transform:
+          parseArtwork({
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'trackCoverArtistContributions',
+          }),
+      },
+
+      'Art Tags': {property: 'artTags'},
+
+      'Review Points': {ignore: true},
+    },
+
+    invalidFieldCombinations: [
+      {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: `Secondary releases inherit artists from the main one`, fields: [
+        'Main Release',
+        'Artists',
+      ]},
+
+      {message: `Secondary releases inherit contributors from the main one`, fields: [
+        'Main Release',
+        'Contributors',
+      ]},
+
+      {message: `Secondary releases inherit lyrics from the main one`, fields: [
+        'Main Release',
+        '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]),
+    },
+
+    trackMainReleasesOnly: {
+      referenceTypes: ['track'],
+      bindTo: 'trackData',
+
+      include: track =>
+        !CacheableObject.getUpdateValue(track, 'mainReleaseTrack'),
+
+      // 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]),
+    },
+
+    trackPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'track',
+        'track-referencing-artworks',
+        'track-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork, {Artwork, Track}) =>
+        artwork instanceof Artwork &&
+        artwork.thing instanceof Track &&
+        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],
+    },
+  };
+
+  // Track YAML loading is handled in album.js.
+  static [Thing.getYamlLoadingSpec] = null;
+
+  getOwnArtworkPath(artwork) {
+    if (!this.album) return null;
+
+    return [
+      'media.trackCover',
+      this.album.directory,
+
+      (artwork.unqualifiedDirectory
+        ? this.directory + '-' + artwork.unqualifiedDirectory
+        : this.directory),
+
+      artwork.fileExtension,
+    ];
+  }
+
+  [inspect.custom](depth) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (CacheableObject.getUpdateValue(this, 'mainReleaseTrack')) {
+      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/wiki-info.js
new file mode 100644
index 00000000..590598be
--- /dev/null
+++ b/src/data/things/wiki-info.js
@@ -0,0 +1,152 @@
+export const WIKI_INFO_FILE = 'wiki-info.yaml';
+
+import {input} from '#composite';
+import Thing from '#thing';
+import {parseContributionPresets} from '#yaml';
+
+import {
+  isBoolean,
+  isColor,
+  isContributionPresetList,
+  isLanguageCode,
+  isName,
+  isURL,
+} from '#validators';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {contentString, flag, name, referenceList, soupyFind}
+  from '#composite/wiki-properties';
+
+export class WikiInfo extends Thing {
+  static [Thing.friendlyName] = `Wiki Info`;
+
+  static [Thing.getPropertyDescriptors] = ({Group}) => ({
+    // Update & expose
+
+    name: name('Unnamed Wiki'),
+
+    // Displayed in nav bar.
+    nameShort: {
+      flags: {update: true, expose: true},
+      update: {validate: isName},
+
+      expose: {
+        dependencies: ['name'],
+        transform: (value, {name}) => value ?? name,
+      },
+    },
+
+    color: {
+      flags: {update: true, expose: true},
+      update: {validate: isColor},
+
+      expose: {
+        transform: color => color ?? '#0088ff',
+      },
+    },
+
+    // One-line description used for <meta rel="description"> tag.
+    description: contentString(),
+
+    footerContent: contentString(),
+
+    defaultLanguage: {
+      flags: {update: true, expose: true},
+      update: {validate: isLanguageCode},
+    },
+
+    canonicalBase: {
+      flags: {update: true, expose: true},
+      update: {validate: isURL},
+      expose: {
+        transform: (value) =>
+          (value === null
+            ? null
+         : value.endsWith('/')
+            ? value
+            : value + '/'),
+      },
+    },
+
+    divideTrackListsByGroups: referenceList({
+      class: input.value(Group),
+      find: soupyFind.input('group'),
+    }),
+
+    contributionPresets: {
+      flags: {update: true, expose: true},
+      update: {validate: isContributionPresetList},
+    },
+
+    // Feature toggles
+    enableFlashesAndGames: flag(false),
+    enableListings: flag(false),
+    enableNews: flag(false),
+    enableArtTagUI: flag(false),
+    enableGroupUI: flag(false),
+
+    enableSearch: [
+      exitWithoutDependency({
+        dependency: 'searchDataAvailable',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      flag(true),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+
+    searchDataAvailable: {
+      flags: {update: true},
+      update: {
+        validate: isBoolean,
+        default: false,
+      },
+    },
+  });
+
+  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'},
+      'Enable Flashes & Games': {property: 'enableFlashesAndGames'},
+      'Enable Listings': {property: 'enableListings'},
+      'Enable News': {property: 'enableNews'},
+      'Enable Art Tag UI': {property: 'enableArtTagUI'},
+      'Enable Group UI': {property: 'enableGroupUI'},
+
+      'Contribution Presets': {
+        property: 'contributionPresets',
+        transform: parseContributionPresets,
+      },
+    },
+  };
+
+  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};
+    },
+  });
+}