« 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.js243
-rw-r--r--src/data/things/art-tag.js42
-rw-r--r--src/data/things/artist.js163
-rw-r--r--src/data/things/cacheable-object.js350
-rw-r--r--src/data/things/flash.js150
-rw-r--r--src/data/things/group.js94
-rw-r--r--src/data/things/homepage-layout.js114
-rw-r--r--src/data/things/index.js173
-rw-r--r--src/data/things/language.js321
-rw-r--r--src/data/things/news-entry.js27
-rw-r--r--src/data/things/static-page.js30
-rw-r--r--src/data/things/thing.js385
-rw-r--r--src/data/things/track.js332
-rw-r--r--src/data/things/validators.js367
-rw-r--r--src/data/things/wiki-info.js68
15 files changed, 2859 insertions, 0 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js
new file mode 100644
index 00000000..4890aaaa
--- /dev/null
+++ b/src/data/things/album.js
@@ -0,0 +1,243 @@
+import Thing from './thing.js';
+
+import find from '../../util/find.js';
+
+export class Album extends Thing {
+  static [Thing.referenceType] = 'album';
+
+  static [Thing.getPropertyDescriptors] = ({
+    ArtTag,
+    Artist,
+    Group,
+    Track,
+    TrackGroup,
+
+    validators: {
+      isDate,
+      isDimensions,
+      validateArrayItems,
+      validateInstanceOf,
+    },
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Album'),
+    color: Thing.common.color(),
+    directory: Thing.common.directory(),
+    urls: Thing.common.urls(),
+
+    date: Thing.common.simpleDate(),
+    trackArtDate: Thing.common.simpleDate(),
+    dateAddedToWiki: Thing.common.simpleDate(),
+
+    coverArtDate: {
+      flags: {update: true, expose: true},
+
+      update: {validate: isDate},
+
+      expose: {
+        dependencies: ['date'],
+        transform: (coverArtDate, {date}) => coverArtDate ?? date ?? null,
+      },
+    },
+
+    artistContribsByRef: Thing.common.contribsByRef(),
+    coverArtistContribsByRef: Thing.common.contribsByRef(),
+    trackCoverArtistContribsByRef: Thing.common.contribsByRef(),
+    wallpaperArtistContribsByRef: Thing.common.contribsByRef(),
+    bannerArtistContribsByRef: Thing.common.contribsByRef(),
+
+    groupsByRef: Thing.common.referenceList(Group),
+    artTagsByRef: Thing.common.referenceList(ArtTag),
+
+    trackGroups: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate: validateArrayItems(validateInstanceOf(TrackGroup)),
+      },
+    },
+
+    coverArtFileExtension: Thing.common.fileExtension('jpg'),
+    trackCoverArtFileExtension: Thing.common.fileExtension('jpg'),
+
+    wallpaperStyle: Thing.common.simpleString(),
+    wallpaperFileExtension: Thing.common.fileExtension('jpg'),
+
+    bannerStyle: Thing.common.simpleString(),
+    bannerFileExtension: Thing.common.fileExtension('jpg'),
+    bannerDimensions: {
+      flags: {update: true, expose: true},
+      update: {validate: isDimensions},
+    },
+
+    hasCoverArt: Thing.common.flag(true),
+    hasTrackArt: Thing.common.flag(true),
+    hasTrackNumbers: Thing.common.flag(true),
+    isMajorRelease: Thing.common.flag(false),
+    isListedOnHomepage: Thing.common.flag(true),
+
+    commentary: Thing.common.commentary(),
+    additionalFiles: Thing.common.additionalFiles(),
+
+    // Update only
+
+    artistData: Thing.common.wikiData(Artist),
+    artTagData: Thing.common.wikiData(ArtTag),
+    groupData: Thing.common.wikiData(Group),
+    trackData: Thing.common.wikiData(Track),
+
+    // Expose only
+
+    artistContribs: Thing.common.dynamicContribs('artistContribsByRef'),
+    coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'),
+    trackCoverArtistContribs: Thing.common.dynamicContribs(
+      'trackCoverArtistContribsByRef'
+    ),
+    wallpaperArtistContribs: Thing.common.dynamicContribs(
+      'wallpaperArtistContribsByRef'
+    ),
+    bannerArtistContribs: Thing.common.dynamicContribs(
+      'bannerArtistContribsByRef'
+    ),
+
+    commentatorArtists: Thing.common.commentatorArtists(),
+
+    tracks: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['trackGroups', 'trackData'],
+        compute: ({trackGroups, trackData}) =>
+          trackGroups && trackData
+            ? trackGroups
+                .flatMap((group) => group.tracksByRef ?? [])
+                .map((ref) => find.track(ref, trackData, {mode: 'quiet'}))
+                .filter(Boolean)
+            : [],
+      },
+    },
+
+    groups: Thing.common.dynamicThingsFromReferenceList(
+      'groupsByRef',
+      'groupData',
+      find.group
+    ),
+
+    artTags: Thing.common.dynamicThingsFromReferenceList(
+      'artTagsByRef',
+      'artTagData',
+      find.artTag
+    ),
+  });
+
+  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,
+    isMajorRelease: S.id,
+    isListedOnHomepage: S.id,
+
+    commentary: S.id,
+    additionalFiles: S.id,
+
+    tracks: S.toRefs,
+    groups: S.toRefs,
+    artTags: S.toRefs,
+    commentatorArtists: S.toRefs,
+  });
+}
+
+export class TrackGroup extends Thing {
+  static [Thing.getPropertyDescriptors] = ({
+    isColor,
+    Track,
+
+    validators: {
+      validateInstanceOf,
+    },
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Track Group'),
+
+    color: {
+      flags: {update: true, expose: true},
+
+      update: {validate: isColor},
+
+      expose: {
+        dependencies: ['album'],
+
+        transform(color, {album}) {
+          return color ?? album?.color ?? null;
+        },
+      },
+    },
+
+    dateOriginallyReleased: Thing.common.simpleDate(),
+
+    tracksByRef: Thing.common.referenceList(Track),
+
+    isDefaultTrackGroup: Thing.common.flag(false),
+
+    // Update only
+
+    album: {
+      flags: {update: true},
+      update: {validate: validateInstanceOf(Album)},
+    },
+
+    trackData: Thing.common.wikiData(Track),
+
+    // Expose only
+
+    tracks: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['tracksByRef', 'trackData'],
+        compute: ({tracksByRef, trackData}) =>
+          tracksByRef && trackData
+            ? tracksByRef.map((ref) => find.track(ref, trackData)).filter(Boolean)
+            : [],
+      },
+    },
+
+    startIndex: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['album'],
+        compute: ({album, [TrackGroup.instance]: trackGroup}) =>
+          album.trackGroups
+            .slice(0, album.trackGroups.indexOf(trackGroup))
+            .reduce((acc, tg) => acc + tg.tracks.length, 0),
+      },
+    },
+  })
+}
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
new file mode 100644
index 00000000..0f888a20
--- /dev/null
+++ b/src/data/things/art-tag.js
@@ -0,0 +1,42 @@
+import Thing from './thing.js';
+
+import {
+  sortAlbumsTracksChronologically,
+} from '../../util/wiki-data.js';
+
+export class ArtTag extends Thing {
+  static [Thing.referenceType] = 'tag';
+
+  static [Thing.getPropertyDescriptors] = ({
+    Album,
+    Track,
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Art Tag'),
+    directory: Thing.common.directory(),
+    color: Thing.common.color(),
+    isContentWarning: Thing.common.flag(false),
+
+    // Update only
+
+    albumData: Thing.common.wikiData(Album),
+    trackData: Thing.common.wikiData(Track),
+
+    // Expose only
+
+    // Previously known as: (tag).things
+    taggedInThings: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['albumData', 'trackData'],
+        compute: ({albumData, trackData, [ArtTag.instance]: artTag}) =>
+          sortAlbumsTracksChronologically(
+            [...albumData, ...trackData]
+              .filter(({artTags}) => artTags.includes(artTag)),
+            {getDate: o => o.coverArtDate}),
+      },
+    },
+  });
+}
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
new file mode 100644
index 00000000..303f33f3
--- /dev/null
+++ b/src/data/things/artist.js
@@ -0,0 +1,163 @@
+import Thing from './thing.js';
+
+import find from '../../util/find.js';
+
+export class Artist extends Thing {
+  static [Thing.referenceType] = 'artist';
+
+  static [Thing.getPropertyDescriptors] = ({
+    Album,
+    Flash,
+    Track,
+
+    validators: {
+      isName,
+      validateArrayItems,
+    },
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Artist'),
+    directory: Thing.common.directory(),
+    urls: Thing.common.urls(),
+    contextNotes: Thing.common.simpleString(),
+
+    hasAvatar: Thing.common.flag(false),
+    avatarFileExtension: Thing.common.fileExtension('jpg'),
+
+    aliasNames: {
+      flags: {update: true, expose: true},
+      update: {
+        validate: validateArrayItems(isName),
+      },
+    },
+
+    isAlias: Thing.common.flag(),
+    aliasedArtistRef: Thing.common.singleReference(Artist),
+
+    // Update only
+
+    albumData: Thing.common.wikiData(Album),
+    artistData: Thing.common.wikiData(Artist),
+    flashData: Thing.common.wikiData(Flash),
+    trackData: Thing.common.wikiData(Track),
+
+    // Expose only
+
+    aliasedArtist: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['artistData', 'aliasedArtistRef'],
+        compute: ({artistData, aliasedArtistRef}) =>
+          aliasedArtistRef && artistData
+            ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'})
+            : null,
+      },
+    },
+
+    tracksAsArtist:
+      Artist.filterByContrib('trackData', 'artistContribs'),
+    tracksAsContributor:
+      Artist.filterByContrib('trackData', 'contributorContribs'),
+    tracksAsCoverArtist:
+      Artist.filterByContrib('trackData', 'coverArtistContribs'),
+
+    tracksAsAny: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['trackData'],
+
+        compute: ({trackData, [Artist.instance]: artist}) =>
+          trackData?.filter((track) =>
+            [
+              ...track.artistContribs,
+              ...track.contributorContribs,
+              ...track.coverArtistContribs,
+            ].some(({who}) => who === artist)) ?? [],
+      },
+    },
+
+    tracksAsCommentator: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['trackData'],
+
+        compute: ({trackData, [Artist.instance]: artist}) =>
+          trackData?.filter(({commentatorArtists}) =>
+            commentatorArtists.includes(artist)) ?? [],
+      },
+    },
+
+    albumsAsAlbumArtist:
+      Artist.filterByContrib('albumData', 'artistContribs'),
+    albumsAsCoverArtist:
+      Artist.filterByContrib('albumData', 'coverArtistContribs'),
+    albumsAsWallpaperArtist:
+      Artist.filterByContrib('albumData', 'wallpaperArtistContribs'),
+    albumsAsBannerArtist:
+      Artist.filterByContrib('albumData', 'bannerArtistContribs'),
+
+    albumsAsCommentator: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['albumData'],
+
+        compute: ({albumData, [Artist.instance]: artist}) =>
+          albumData?.filter(({commentatorArtists}) =>
+            commentatorArtists.includes(artist)) ?? [],
+      },
+    },
+
+    flashesAsContributor: Artist.filterByContrib(
+      'flashData',
+      'contributorContribs'
+    ),
+  });
+
+  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,
+
+    tracksAsArtist: S.toRefs,
+    tracksAsContributor: S.toRefs,
+    tracksAsCoverArtist: S.toRefs,
+    tracksAsCommentator: S.toRefs,
+
+    albumsAsAlbumArtist: S.toRefs,
+    albumsAsCoverArtist: S.toRefs,
+    albumsAsWallpaperArtist: S.toRefs,
+    albumsAsBannerArtist: S.toRefs,
+    albumsAsCommentator: S.toRefs,
+
+    flashesAsContributor: S.toRefs,
+  });
+
+  static filterByContrib = (thingDataProperty, contribsProperty) => ({
+    flags: {expose: true},
+
+    expose: {
+      dependencies: [thingDataProperty],
+
+      compute: ({
+        [thingDataProperty]: thingData,
+        [Artist.instance]: artist
+      }) =>
+        thingData?.filter(thing =>
+          thing[contribsProperty]
+            .some(contrib => contrib.who === artist)) ?? [],
+    },
+  });
+}
diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js
new file mode 100644
index 00000000..6a210cc1
--- /dev/null
+++ b/src/data/things/cacheable-object.js
@@ -0,0 +1,350 @@
+// Generally extendable class for caching properties and handling dependencies,
+// with a few key properties:
+//
+// 1) The behavior of every property is defined by its descriptor, which is a
+//    static value stored on the subclass (all instances share the same property
+//    descriptors).
+//
+//  1a) Additional properties may not be added past the time of object
+//      construction, and attempts to do so (including externally setting a
+//      property name which has no corresponding descriptor) will throw a
+//      TypeError. (This is done via an Object.seal(this) call after a newly
+//      created instance defines its own properties according to the descriptor
+//      on its constructor class.)
+//
+// 2) Properties may have two flags set: update and expose. Properties which
+//    update are provided values from the external. Properties which expose
+//    provide values to the external, generally dependent on other update
+//    properties (within the same object).
+//
+//  2a) Properties may be flagged as both updating and exposing. This is so
+//      that the same name may be used for both "output" and "input".
+//
+// 3) Exposed properties have values which are computations dependent on other
+//    properties, as described by a `compute` function on the descriptor.
+//    Depended-upon properties are explicitly listed on the descriptor next to
+//    this function, and are only provided as arguments to the function once
+//    listed.
+//
+//  3a) An exposed property may depend only upon updating properties, not other
+//      exposed properties (within the same object). This is to force the
+//      general complexity of a single object to be fairly simple: inputs
+//      directly determine outputs, with the only in-between step being the
+//      `compute` function, no multiple-layer dependencies. Note that this is
+//      only true within a given object - externally, values provided to one
+//      object's `update` may be (and regularly are) the exposed values of
+//      another object.
+//
+//  3b) If a property both updates and exposes, it is automatically regarded as
+//      a dependancy. (That is, its exposed value will depend on the value it is
+//      updated with.) Rather than a required `compute` function, these have an
+//      optional `transform` function, which takes the update value as its first
+//      argument and then the usual key-value dependencies as its second. If no
+//      `transform` function is provided, the expose value is the same as the
+//      update value.
+//
+// 4) Exposed properties are cached; that is, if no depended-upon properties are
+//    updated, the value of an exposed property is not recomputed.
+//
+//  4a) The cache for an exposed property is invalidated as soon as any of its
+//      dependencies are updated, but the cache itself is lazy: the exposed
+//      value will not be recomputed until it is again accessed. (Likewise, an
+//      exposed value won't be computed for the first time until it is first
+//      accessed.)
+//
+// 5) Updating a property may optionally apply validation checks before passing,
+//    declared by a `validate` function on the `update` block. This function
+//    should either throw an error (e.g. TypeError) or return false if the value
+//    is invalid.
+//
+// 6) Objects do not expect all updating properties to be provided at once.
+//    Incomplete objects are deliberately supported and enabled.
+//
+//  6a) The default value for every updating property is null; undefined is not
+//      accepted as a property value under any circumstances (it always errors).
+//      However, this default may be overridden by specifying a `default` value
+//      on a property's `update` block. (This value will be checked against
+//      the property's validate function.) Note that a property may always be
+//      updated to null, even if the default is non-null. (Null always bypasses
+//      the validate check.)
+//
+//  6b) It's required by the external consumer of an object to determine whether
+//      or not the object is ready for use (within the larger program). This is
+//      convenienced by the static CacheableObject.listAccessibleProperties()
+//      function, which provides a mapping of exposed property names to whether
+//      or not their dependencies are yet met.
+
+import {color, ENABLE_COLOR} from '../../util/cli.js';
+
+import {inspect as nodeInspect} from 'util';
+
+function inspect(value) {
+  return nodeInspect(value, {colors: ENABLE_COLOR});
+}
+
+export default class CacheableObject {
+  static instance = Symbol('CacheableObject `this` instance');
+
+  #propertyUpdateValues = Object.create(null);
+  #propertyUpdateCacheInvalidators = Object.create(null);
+
+  /*
+    // Note the constructor doesn't take an initial data source. Due to a quirk
+    // of JavaScript, private members can't be accessed before the superclass's
+    // constructor is finished processing - so if we call the overridden
+    // update() function from inside this constructor, it will error when
+    // writing to private members. Pretty bad!
+    //
+    // That means initial data must be provided by following up with update()
+    // after constructing the new instance of the Thing (sub)class.
+    */
+
+  constructor() {
+    this.#defineProperties();
+    this.#initializeUpdatingPropertyValues();
+
+    if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
+      return new Proxy(this, {
+        get: (obj, key) => {
+          if (!Object.hasOwn(obj, key)) {
+            if (key !== 'constructor') {
+              CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`);
+            }
+          }
+          return obj[key];
+        },
+      });
+    }
+  }
+
+  #initializeUpdatingPropertyValues() {
+    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
+      const {flags, update} = descriptor;
+
+      if (!flags.update) {
+        continue;
+      }
+
+      if (update?.default) {
+        this[property] = update?.default;
+      } else {
+        this[property] = null;
+      }
+    }
+  }
+
+  #defineProperties() {
+    if (!this.constructor.propertyDescriptors) {
+      throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`);
+    }
+
+    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
+      const {flags} = descriptor;
+
+      const definition = {
+        configurable: false,
+        enumerable: true,
+      };
+
+      if (flags.update) {
+        definition.set = this.#getUpdateObjectDefinitionSetterFunction(property);
+      }
+
+      if (flags.expose) {
+        definition.get = this.#getExposeObjectDefinitionGetterFunction(property);
+      }
+
+      Object.defineProperty(this, property, definition);
+    }
+
+    Object.seal(this);
+  }
+
+  #getUpdateObjectDefinitionSetterFunction(property) {
+    const {update} = this.#getPropertyDescriptor(property);
+    const validate = update?.validate;
+
+    return (newValue) => {
+      const oldValue = this.#propertyUpdateValues[property];
+
+      if (newValue === undefined) {
+        throw new TypeError(`Properties cannot be set to undefined`);
+      }
+
+      if (newValue === oldValue) {
+        return;
+      }
+
+      if (newValue !== null && validate) {
+        try {
+          const result = validate(newValue);
+          if (result === undefined) {
+            throw new TypeError(`Validate function returned undefined`);
+          } else if (result !== true) {
+            throw new TypeError(`Validation failed for value ${newValue}`);
+          }
+        } catch (error) {
+          error.message = [
+            `Property ${color.green(property)}`,
+            `(${inspect(this[property])} -> ${inspect(newValue)}):`,
+            error.message
+          ].join(' ');
+          throw error;
+        }
+      }
+
+      this.#propertyUpdateValues[property] = newValue;
+      this.#invalidateCachesDependentUpon(property);
+    };
+  }
+
+  #getPropertyDescriptor(property) {
+    return this.constructor.propertyDescriptors[property];
+  }
+
+  #invalidateCachesDependentUpon(property) {
+    const invalidators = this.#propertyUpdateCacheInvalidators[property];
+    if (!invalidators) {
+      return;
+    }
+
+    for (const invalidate of invalidators) {
+      invalidate();
+    }
+  }
+
+  #getExposeObjectDefinitionGetterFunction(property) {
+    const {flags} = this.#getPropertyDescriptor(property);
+    const compute = this.#getExposeComputeFunction(property);
+
+    if (compute) {
+      let cachedValue;
+      const checkCacheValid = this.#getExposeCheckCacheValidFunction(property);
+      return () => {
+        if (checkCacheValid()) {
+          return cachedValue;
+        } else {
+          return (cachedValue = compute());
+        }
+      };
+    } else if (!flags.update && !compute) {
+      throw new Error(`Exposed property ${property} does not update and is missing compute function`);
+    } else {
+      return () => this.#propertyUpdateValues[property];
+    }
+  }
+
+  #getExposeComputeFunction(property) {
+    const {flags, expose} = this.#getPropertyDescriptor(property);
+
+    const compute = expose?.compute;
+    const transform = expose?.transform;
+
+    if (flags.update && !transform) {
+      return null;
+    } else if (flags.update && compute) {
+      throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
+    } else if (!flags.update && !compute) {
+      throw new Error(`Exposed property ${property} does not update and is missing compute function`);
+    }
+
+    let getAllDependencies;
+
+    const dependencyKeys = expose.dependencies;
+    if (dependencyKeys?.length > 0) {
+      const reflectionEntry = [this.constructor.instance, this];
+      const dependencyGetters = dependencyKeys
+        .map(key => () => [key, this.#propertyUpdateValues[key]]);
+
+      getAllDependencies = () =>
+        Object.fromEntries(dependencyGetters
+          .map(f => f())
+          .concat([reflectionEntry]));
+    } else {
+      const allDependencies = {[this.constructor.instance]: this};
+      Object.freeze(allDependencies);
+      getAllDependencies = () => allDependencies;
+    }
+
+    if (flags.update) {
+      return () => transform(this.#propertyUpdateValues[property], getAllDependencies());
+    } else {
+      return () => compute(getAllDependencies());
+    }
+  }
+
+  #getExposeCheckCacheValidFunction(property) {
+    const {flags, expose} = this.#getPropertyDescriptor(property);
+
+    let valid = false;
+
+    const invalidate = () => {
+      valid = false;
+    };
+
+    const dependencyKeys = new Set(expose?.dependencies);
+
+    if (flags.update) {
+      dependencyKeys.add(property);
+    }
+
+    for (const key of dependencyKeys) {
+      if (this.#propertyUpdateCacheInvalidators[key]) {
+        this.#propertyUpdateCacheInvalidators[key].push(invalidate);
+      } else {
+        this.#propertyUpdateCacheInvalidators[key] = [invalidate];
+      }
+    }
+
+    return () => {
+      if (!valid) {
+        valid = true;
+        return false;
+      } else {
+        return true;
+      }
+    };
+  }
+
+  static cacheAllExposedProperties(obj) {
+    if (!(obj instanceof CacheableObject)) {
+      console.warn('Not a CacheableObject:', obj);
+      return;
+    }
+
+    const {propertyDescriptors} = obj.constructor;
+
+    if (!propertyDescriptors) {
+      console.warn('Missing property descriptors:', obj);
+      return;
+    }
+
+    for (const [property, descriptor] of Object.entries(propertyDescriptors)) {
+      const {flags} = descriptor;
+
+      if (!flags.expose) {
+        continue;
+      }
+
+      obj[property];
+    }
+  }
+
+  static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false;
+  static _invalidAccesses = new Set();
+
+  static showInvalidAccesses() {
+    if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
+      return;
+    }
+
+    if (!this._invalidAccesses.size) {
+      return;
+    }
+
+    console.log(`${this._invalidAccesses.size} unique invalid accesses:`);
+    for (const line of this._invalidAccesses) {
+      console.log(` - ${line}`);
+    }
+  }
+}
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
new file mode 100644
index 00000000..1383fa83
--- /dev/null
+++ b/src/data/things/flash.js
@@ -0,0 +1,150 @@
+import Thing from './thing.js';
+
+import find from '../../util/find.js';
+
+export class Flash extends Thing {
+  static [Thing.referenceType] = 'flash';
+
+  static [Thing.getPropertyDescriptors] = ({
+    Artist,
+    Track,
+    FlashAct,
+
+    validators: {
+      isDirectory,
+      isNumber,
+      isString,
+      oneOf,
+    },
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.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: oneOf(isString, isNumber)},
+
+      expose: {
+        transform: (value) => (value === null ? null : value.toString()),
+      },
+    },
+
+    date: Thing.common.simpleDate(),
+
+    coverArtFileExtension: Thing.common.fileExtension('jpg'),
+
+    contributorContribsByRef: Thing.common.contribsByRef(),
+
+    featuredTracksByRef: Thing.common.referenceList(Track),
+
+    urls: Thing.common.urls(),
+
+    // Update only
+
+    artistData: Thing.common.wikiData(Artist),
+    trackData: Thing.common.wikiData(Track),
+    flashActData: Thing.common.wikiData(FlashAct),
+
+    // Expose only
+
+    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
+
+    featuredTracks: Thing.common.dynamicThingsFromReferenceList(
+      'featuredTracksByRef',
+      'trackData',
+      find.track
+    ),
+
+    act: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['flashActData'],
+
+        compute: ({flashActData, [Flash.instance]: flash}) =>
+          flashActData.find((act) => act.flashes.includes(flash)) ?? null,
+      },
+    },
+
+    color: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['flashActData'],
+
+        compute: ({flashActData, [Flash.instance]: flash}) =>
+          flashActData.find((act) => act.flashes.includes(flash))?.color ?? null,
+      },
+    },
+  });
+
+  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,
+  });
+}
+
+export class FlashAct extends Thing {
+  static [Thing.getPropertyDescriptors] = ({
+    validators: {
+      isColor,
+    },
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Flash Act'),
+    color: Thing.common.color(),
+    anchor: Thing.common.simpleString(),
+    jump: Thing.common.simpleString(),
+
+    jumpColor: {
+      flags: {update: true, expose: true},
+      update: {validate: isColor},
+      expose: {
+        dependencies: ['color'],
+        transform: (jumpColor, {color}) =>
+          jumpColor ?? color,
+      }
+    },
+
+    flashesByRef: Thing.common.referenceList(Flash),
+
+    // Update only
+
+    flashData: Thing.common.wikiData(Flash),
+
+    // Expose only
+
+    flashes: Thing.common.dynamicThingsFromReferenceList(
+      'flashesByRef',
+      'flashData',
+      find.flash
+    ),
+  })
+}
diff --git a/src/data/things/group.js b/src/data/things/group.js
new file mode 100644
index 00000000..26fe9a55
--- /dev/null
+++ b/src/data/things/group.js
@@ -0,0 +1,94 @@
+import Thing from './thing.js';
+
+import find from '../../util/find.js';
+
+export class Group extends Thing {
+  static [Thing.referenceType] = 'group';
+
+  static [Thing.getPropertyDescriptors] = ({
+    Album,
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Group'),
+    directory: Thing.common.directory(),
+
+    description: Thing.common.simpleString(),
+
+    urls: Thing.common.urls(),
+
+    // Update only
+
+    albumData: Thing.common.wikiData(Album),
+    groupCategoryData: Thing.common.wikiData(GroupCategory),
+
+    // Expose only
+
+    descriptionShort: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['description'],
+        compute: ({description}) => description.split('<hr class="split">')[0],
+      },
+    },
+
+    albums: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['albumData'],
+        compute: ({albumData, [Group.instance]: group}) =>
+          albumData?.filter((album) => album.groups.includes(group)) ?? [],
+      },
+    },
+
+    color: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['groupCategoryData'],
+
+        compute: ({groupCategoryData, [Group.instance]: group}) =>
+          groupCategoryData.find((category) => category.groups.includes(group))
+            ?.color,
+      },
+    },
+
+    category: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['groupCategoryData'],
+        compute: ({groupCategoryData, [Group.instance]: group}) =>
+          groupCategoryData.find((category) => category.groups.includes(group)) ??
+          null,
+      },
+    },
+  });
+}
+
+export class GroupCategory extends Thing {
+  static [Thing.getPropertyDescriptors] = ({
+    Group,
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Group Category'),
+    color: Thing.common.color(),
+
+    groupsByRef: Thing.common.referenceList(Group),
+
+    // Update only
+
+    groupData: Thing.common.wikiData(Group),
+
+    // Expose only
+
+    groups: Thing.common.dynamicThingsFromReferenceList(
+      'groupsByRef',
+      'groupData',
+      find.group
+    ),
+  });
+}
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
new file mode 100644
index 00000000..5948ff46
--- /dev/null
+++ b/src/data/things/homepage-layout.js
@@ -0,0 +1,114 @@
+import Thing from './thing.js';
+
+import find from '../../util/find.js';
+
+export class HomepageLayout extends Thing {
+  static [Thing.getPropertyDescriptors] = ({
+    HomepageLayoutRow,
+
+    validators: {
+      validateArrayItems,
+      validateInstanceOf,
+    },
+  }) => ({
+    // Update & expose
+
+    sidebarContent: Thing.common.simpleString(),
+
+    rows: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)),
+      },
+    },
+  })
+}
+
+export class HomepageLayoutRow extends Thing {
+  static [Thing.getPropertyDescriptors] = ({
+    Album,
+    Group,
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Homepage Row'),
+
+    type: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate() {
+          throw new Error(`'type' property validator must be overridden`);
+        },
+      },
+    },
+
+    color: Thing.common.color(),
+
+    // Update only
+
+    // These aren't necessarily used by every HomepageLayoutRow subclass, but
+    // for convenience of providing this data, every row accepts all wiki data
+    // arrays depended upon by any subclass's behavior.
+    albumData: Thing.common.wikiData(Album),
+    groupData: Thing.common.wikiData(Group),
+  });
+}
+
+export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
+  static [Thing.getPropertyDescriptors] = (opts, {
+    Album,
+    Group,
+
+    validators: {
+      isCountingNumber,
+      isString,
+      validateArrayItems,
+    },
+  } = opts) => ({
+    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
+
+    // Update & expose
+
+    type: {
+      flags: {update: true, expose: true},
+      update: {
+        validate(value) {
+          if (value !== 'albums') {
+            throw new TypeError(`Expected 'albums'`);
+          }
+
+          return true;
+        },
+      },
+    },
+
+    sourceGroupByRef: Thing.common.singleReference(Group),
+    sourceAlbumsByRef: Thing.common.referenceList(Album),
+
+    countAlbumsFromGroup: {
+      flags: {update: true, expose: true},
+      update: {validate: isCountingNumber},
+    },
+
+    actionLinks: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isString)},
+    },
+
+    // Expose only
+
+    sourceGroup: Thing.common.dynamicThingFromSingleReference(
+      'sourceGroupByRef',
+      'groupData',
+      find.group
+    ),
+
+    sourceAlbums: Thing.common.dynamicThingsFromReferenceList(
+      'sourceAlbumsByRef',
+      'albumData',
+      find.album
+    ),
+  });
+}
diff --git a/src/data/things/index.js b/src/data/things/index.js
new file mode 100644
index 00000000..11b6b1a9
--- /dev/null
+++ b/src/data/things/index.js
@@ -0,0 +1,173 @@
+import {logError} from '../../util/cli.js';
+import {openAggregate, showAggregate} from '../../util/sugar.js';
+
+import * as path from 'path';
+import {fileURLToPath} from 'url';
+
+import Thing from './thing.js';
+import * as validators from './validators.js';
+import * as serialize from '../serialize.js';
+
+import * as albumClasses from './album.js';
+import * as artTagClasses from './art-tag.js';
+import * as artistClasses from './artist.js';
+import * as flashClasses from './flash.js';
+import * as groupClasses from './group.js';
+import * as homepageLayoutClasses from './homepage-layout.js';
+import * as languageClasses from './language.js';
+import * as newsEntryClasses from './news-entry.js';
+import * as staticPageClasses from './static-page.js';
+import * as trackClasses from './track.js';
+import * as wikiInfoClasses from './wiki-info.js';
+
+const allClassLists = {
+  'album.js': albumClasses,
+  'art-tag.js': artTagClasses,
+  'artist.js': artistClasses,
+  'flash.js': flashClasses,
+  'group.js': groupClasses,
+  'homepage-layout.js': homepageLayoutClasses,
+  'language.js': languageClasses,
+  'news-entry.js': newsEntryClasses,
+  'static-page.js': staticPageClasses,
+  'track.js': trackClasses,
+  'wiki-info.js': wikiInfoClasses,
+};
+
+let allClasses = Object.create(null);
+
+// src/data/things/index.js -> src/
+const __dirname = path.dirname(
+  path.resolve(
+    fileURLToPath(import.meta.url),
+    '../..'));
+
+function niceShowAggregate(error, ...opts) {
+  showAggregate(error, {
+    pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
+    ...opts,
+  });
+}
+
+function errorDuplicateClassNames() {
+  const locationDict = Object.create(null);
+
+  for (const [location, classes] of Object.entries(allClassLists)) {
+    for (const className of Object.keys(classes)) {
+      if (className in locationDict) {
+        locationDict[className].push(location);
+      } else {
+        locationDict[className] = [location];
+      }
+    }
+  }
+
+  let success = true;
+
+  for (const [className, locations] of Object.entries(locationDict)) {
+    if (locations.length === 1) {
+      continue;
+    }
+
+    logError`Thing class name ${`"${className}"`} is defined more than once: ${locations.join(', ')}`;
+    success = false;
+  }
+
+  return success;
+}
+
+function flattenClassLists() {
+  for (const classes of Object.values(allClassLists)) {
+    for (const [name, constructor] of Object.entries(classes)) {
+      allClasses[name] = constructor;
+    }
+  }
+}
+
+function descriptorAggregateHelper({
+  showFailedClasses,
+  message,
+  op,
+}) {
+  const failureSymbol = Symbol();
+  const aggregate = openAggregate({
+    message,
+    returnOnFail: failureSymbol,
+  });
+
+  const failedClasses = [];
+
+  for (const [name, constructor] of Object.entries(allClasses)) {
+    const result = aggregate.call(op, constructor);
+
+    if (result === failureSymbol) {
+      failedClasses.push(name);
+    }
+  }
+
+  try {
+    aggregate.close();
+    return true;
+  } catch (error) {
+    niceShowAggregate(error);
+    showFailedClasses(failedClasses);
+    return false;
+  }
+}
+
+function evaluatePropertyDescriptors() {
+  const opts = {...allClasses, validators};
+
+  return descriptorAggregateHelper({
+    message: `Errors evaluating Thing class property descriptors`,
+
+    op(constructor) {
+      if (!constructor[Thing.getPropertyDescriptors]) {
+        throw new Error(`Missing [Thing.getPropertyDescriptors] function`);
+      }
+
+      constructor.propertyDescriptors =
+        constructor[Thing.getPropertyDescriptors](opts);
+    },
+
+    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(', ')}`;
+    },
+  });
+}
+
+if (!errorDuplicateClassNames())
+  process.exit(1);
+
+flattenClassLists();
+
+if (!evaluatePropertyDescriptors())
+  process.exit(1);
+
+if (!evaluateSerializeDescriptors())
+  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..21524993
--- /dev/null
+++ b/src/data/things/language.js
@@ -0,0 +1,321 @@
+import Thing from './thing.js';
+
+export class Language extends Thing {
+  static [Thing.getPropertyDescriptors] = ({
+    validators: {
+      isLanguageCode,
+    },
+  }) => ({
+    // 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: Thing.common.simpleString(),
+
+    // 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: Thing.common.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'],
+        transform(strings, {inheritedStrings}) {
+          if (strings || inheritedStrings) {
+            return {...(inheritedStrings ?? {}), ...(strings ?? {})};
+          } else {
+            return null;
+          }
+        },
+      },
+    },
+
+    // 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'},
+    },
+
+    // Update only
+
+    escapeHTML: Thing.common.externalFunction(),
+
+    // Expose only
+
+    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 ?? {}),
+            ])
+          ),
+      },
+    },
+
+    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]);
+        },
+      },
+    };
+  }
+
+  $(key, args = {}) {
+    return this.formatString(key, 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(key, args = {}) {
+    if (this.strings && !this.strings_htmlEscaped) {
+      throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`);
+    }
+
+    return this.formatStringHelper(this.strings_htmlEscaped, key, args);
+  }
+
+  formatStringNoHTMLEscape(key, args = {}) {
+    return this.formatStringHelper(this.strings, key, args);
+  }
+
+  formatStringHelper(strings, key, args = {}) {
+    if (!strings) {
+      throw new Error(`Strings unavailable`);
+    }
+
+    if (!this.validKeys.includes(key)) {
+      throw new Error(`Invalid key ${key} accessed`);
+    }
+
+    const template = strings[key];
+
+    // Convert the keys on the args dict from camelCase to CONSTANT_CASE.
+    // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
+    // like, who cares, dude?) Also, this is an array, 8ecause it's handy
+    // for the iterating we're a8out to do.
+    const processedArgs = Object.entries(args).map(([k, v]) => [
+      k.replace(/[A-Z]/g, '_$&').toUpperCase(),
+      v,
+    ]);
+
+    // Replacement time! Woot. Reduce comes in handy here!
+    const output = processedArgs.reduce(
+      (x, [k, v]) => x.replaceAll(`{${k}}`, v),
+      template
+    );
+
+    // Post-processing: if any expected arguments *weren't* replaced, that
+    // is almost definitely an error.
+    if (output.match(/\{[A-Z_]+\}/)) {
+      throw new Error(`Args in ${key} were missing - output: ${output}`);
+    }
+
+    return output;
+  }
+
+  formatDate(date) {
+    this.assertIntlAvailable('intl_date');
+    return this.intl_date.format(date);
+  }
+
+  formatDateRange(startDate, endDate) {
+    this.assertIntlAvailable('intl_date');
+    return this.intl_date.formatRange(startDate, endDate);
+  }
+
+  formatDuration(secTotal, {approximate = false, unit = false} = {}) {
+    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;
+  }
+
+  formatIndex(value) {
+    this.assertIntlAvailable('intl_pluralOrdinal');
+    return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
+  }
+
+  formatNumber(value) {
+    this.assertIntlAvailable('intl_number');
+    return this.intl_number.format(value);
+  }
+
+  formatWordCount(value) {
+    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});
+  }
+
+  // Conjunction list: A, B, and C
+  formatConjunctionList(array) {
+    this.assertIntlAvailable('intl_listConjunction');
+    return this.intl_listConjunction.format(array);
+  }
+
+  // Disjunction lists: A, B, or C
+  formatDisjunctionList(array) {
+    this.assertIntlAvailable('intl_listDisjunction');
+    return this.intl_listDisjunction.format(array);
+  }
+
+  // Unit lists: A, B, C
+  formatUnitList(array) {
+    this.assertIntlAvailable('intl_listUnit');
+    return this.intl_listUnit.format(array);
+  }
+
+  // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
+  formatFileSize(bytes) {
+    if (!bytes) return '';
+
+    bytes = parseInt(bytes);
+    if (isNaN(bytes)) return '';
+
+    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});
+    }
+  }
+
+}
+
+const countHelper = (stringKey, argName = stringKey) =>
+  function(value, {unit = false} = {}) {
+    return this.formatString(
+      unit
+        ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
+        : `count.${stringKey}`,
+      {[argName]: this.formatNumber(value)});
+  };
+
+// TODO: These are hard-coded. Is there a better way?
+Object.assign(Language.prototype, {
+  countAdditionalFiles: countHelper('additionalFiles', 'files'),
+  countAlbums: countHelper('albums'),
+  countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
+  countContributions: countHelper('contributions'),
+  countCoverArts: countHelper('coverArts'),
+  countTimesReferenced: countHelper('timesReferenced'),
+  countTimesUsed: countHelper('timesUsed'),
+  countTracks: countHelper('tracks'),
+});
diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js
new file mode 100644
index 00000000..43911410
--- /dev/null
+++ b/src/data/things/news-entry.js
@@ -0,0 +1,27 @@
+import Thing from './thing.js';
+
+export class NewsEntry extends Thing {
+  static [Thing.referenceType] = 'news-entry';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    name: Thing.common.name('Unnamed News Entry'),
+    directory: Thing.common.directory(),
+    date: Thing.common.simpleDate(),
+
+    content: Thing.common.simpleString(),
+
+    // Expose only
+
+    contentShort: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['content'],
+
+        compute: ({content}) => content.split('<hr class="split">')[0],
+      },
+    },
+  });
+}
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
new file mode 100644
index 00000000..226e0b61
--- /dev/null
+++ b/src/data/things/static-page.js
@@ -0,0 +1,30 @@
+import Thing from './thing.js';
+
+export class StaticPage extends Thing {
+  static [Thing.referenceType] = 'static';
+
+  static [Thing.getPropertyDescriptors] = ({
+    validators: {
+      isName,
+    },
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Static Page'),
+
+    nameShort: {
+      flags: {update: true, expose: true},
+      update: {validate: isName},
+
+      expose: {
+        dependencies: ['name'],
+        transform: (value, {name}) => value ?? name,
+      },
+    },
+
+    directory: Thing.common.directory(),
+    content: Thing.common.simpleString(),
+    stylesheet: Thing.common.simpleString(),
+    showInNavigationBar: Thing.common.flag(true),
+  });
+}
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
new file mode 100644
index 00000000..b9fa60c6
--- /dev/null
+++ b/src/data/things/thing.js
@@ -0,0 +1,385 @@
+// Thing: base class for wiki data types, providing wiki-specific utility
+// functions on top of essential CacheableObject behavior.
+
+import CacheableObject from './cacheable-object.js';
+
+import {
+  isAdditionalFileList,
+  isBoolean,
+  isCommentary,
+  isColor,
+  isContributionList,
+  isDate,
+  isDirectory,
+  isFileExtension,
+  isName,
+  isString,
+  isURL,
+  validateArrayItems,
+  validateInstanceOf,
+  validateReference,
+  validateReferenceList,
+} from './validators.js';
+
+import {inspect} from 'util';
+import {color} from '../../util/cli.js';
+import {getKebabCase} from '../../util/wiki-data.js';
+
+import find from '../../util/find.js';
+
+export default class Thing extends CacheableObject {
+  static referenceType = Symbol('Thing.referenceType');
+
+  static getPropertyDescriptors = Symbol('Thing.getPropertyDescriptors');
+  static getSerializeDescriptors = Symbol('Thing.getSerializeDescriptors');
+
+  // Regularly reused property descriptors, for ease of access and generally
+  // duplicating less code across wiki data types. These are specialized utility
+  // functions, so check each for how its own arguments behave!
+  static common = {
+    name: (defaultName) => ({
+      flags: {update: true, expose: true},
+      update: {validate: isName, default: defaultName},
+    }),
+
+    color: () => ({
+      flags: {update: true, expose: true},
+      update: {validate: isColor},
+    }),
+
+    directory: () => ({
+      flags: {update: true, expose: true},
+      update: {validate: isDirectory},
+      expose: {
+        dependencies: ['name'],
+        transform(directory, {name}) {
+          if (directory === null && name === null) return null;
+          else if (directory === null) return getKebabCase(name);
+          else return directory;
+        },
+      },
+    }),
+
+    urls: () => ({
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isURL)},
+    }),
+
+    // A file extension! Or the default, if provided when calling this.
+    fileExtension: (defaultFileExtension = null) => ({
+      flags: {update: true, expose: true},
+      update: {validate: isFileExtension},
+      expose: {transform: (value) => value ?? defaultFileExtension},
+    }),
+
+    // Straightforward flag descriptor for a variety of property purposes.
+    // Provide a default value, true or false!
+    flag: (defaultValue = false) => {
+      if (typeof defaultValue !== 'boolean') {
+        throw new TypeError(`Always set explicit defaults for flags!`);
+      }
+
+      return {
+        flags: {update: true, expose: true},
+        update: {validate: isBoolean, default: defaultValue},
+      };
+    },
+
+    // General date type, used as the descriptor for a bunch of properties.
+    // This isn't dynamic though - it won't inherit from a date stored on
+    // another object, for example.
+    simpleDate: () => ({
+      flags: {update: true, expose: true},
+      update: {validate: isDate},
+    }),
+
+    // General string type. This should probably generally be avoided in favor
+    // of more specific validation, but using it makes it easy to find where we
+    // might want to improve later, and it's a useful shorthand meanwhile.
+    simpleString: () => ({
+      flags: {update: true, expose: true},
+      update: {validate: isString},
+    }),
+
+    // External function. These should only be used as dependencies for other
+    // properties, so they're left unexposed.
+    externalFunction: () => ({
+      flags: {update: true},
+      update: {validate: (t) => typeof t === 'function'},
+    }),
+
+    // Super simple "contributions by reference" list, used for a variety of
+    // properties (Artists, Cover Artists, etc). This is the property which is
+    // externally provided, in the form:
+    //
+    //     [
+    //         {who: 'Artist Name', what: 'Viola'},
+    //         {who: 'artist:john-cena', what: null},
+    //         ...
+    //     ]
+    //
+    // ...processed from YAML, spreadsheet, or any other kind of input.
+    contribsByRef: () => ({
+      flags: {update: true, expose: true},
+      update: {validate: isContributionList},
+    }),
+
+    // Artist commentary! Generally present on tracks and albums.
+    commentary: () => ({
+      flags: {update: true, expose: true},
+      update: {validate: isCommentary},
+    }),
+
+    // This is a somewhat more involved data structure - it's for additional
+    // or "bonus" files associated with albums or tracks (or anything else).
+    // It's got this form:
+    //
+    //     [
+    //         {title: 'Booklet', files: ['Booklet.pdf']},
+    //         {
+    //             title: 'Wallpaper',
+    //             description: 'Cool Wallpaper!',
+    //             files: ['1440x900.png', '1920x1080.png']
+    //         },
+    //         {title: 'Alternate Covers', description: null, files: [...]},
+    //         ...
+    //     ]
+    //
+    additionalFiles: () => ({
+      flags: {update: true, expose: true},
+      update: {validate: isAdditionalFileList},
+    }),
+
+    // A reference list! Keep in mind this is for general references to wiki
+    // objects of (usually) other Thing subclasses, not specifically leitmotif
+    // references in tracks (although that property uses referenceList too!).
+    //
+    // The underlying function validateReferenceList expects a string like
+    // 'artist' or 'track', but this utility keeps from having to hard-code the
+    // string in multiple places by referencing the value saved on the class
+    // instead.
+    referenceList: (thingClass) => {
+      const {[Thing.referenceType]: referenceType} = thingClass;
+      if (!referenceType) {
+        throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
+      }
+
+      return {
+        flags: {update: true, expose: true},
+        update: {validate: validateReferenceList(referenceType)},
+      };
+    },
+
+    // Corresponding function for a single reference.
+    singleReference: (thingClass) => {
+      const {[Thing.referenceType]: referenceType} = thingClass;
+      if (!referenceType) {
+        throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
+      }
+
+      return {
+        flags: {update: true, expose: true},
+        update: {validate: validateReference(referenceType)},
+      };
+    },
+
+    // Corresponding dynamic property to referenceList, which takes the values
+    // in the provided property and searches the specified wiki data for
+    // matching actual Thing-subclass objects.
+    dynamicThingsFromReferenceList: (
+      referenceListProperty,
+      thingDataProperty,
+      findFn
+    ) => ({
+      flags: {expose: true},
+
+      expose: {
+        dependencies: [referenceListProperty, thingDataProperty],
+        compute: ({
+          [referenceListProperty]: refs,
+          [thingDataProperty]: thingData,
+        }) =>
+          refs && thingData
+            ? refs
+                .map((ref) => findFn(ref, thingData, {mode: 'quiet'}))
+                .filter(Boolean)
+            : [],
+      },
+    }),
+
+    // Corresponding function for a single reference.
+    dynamicThingFromSingleReference: (
+      singleReferenceProperty,
+      thingDataProperty,
+      findFn
+    ) => ({
+      flags: {expose: true},
+
+      expose: {
+        dependencies: [singleReferenceProperty, thingDataProperty],
+        compute: ({
+          [singleReferenceProperty]: ref,
+          [thingDataProperty]: thingData,
+        }) => (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null),
+      },
+    }),
+
+    // Corresponding dynamic property to contribsByRef, which takes the values
+    // in the provided property and searches the object's artistData for
+    // matching actual Artist objects. The computed structure has the same form
+    // as contribsByRef, but with Artist objects instead of string references:
+    //
+    //     [
+    //         {who: (an Artist), what: 'Viola'},
+    //         {who: (an Artist), what: null},
+    //         ...
+    //     ]
+    //
+    // Contributions whose "who" values don't match anything in artistData are
+    // filtered out. (So if the list is all empty, chances are that either the
+    // reference list is somehow messed up, or artistData isn't being provided
+    // properly.)
+    dynamicContribs: (contribsByRefProperty) => ({
+      flags: {expose: true},
+      expose: {
+        dependencies: ['artistData', contribsByRefProperty],
+        compute: ({artistData, [contribsByRefProperty]: contribsByRef}) =>
+          contribsByRef && artistData
+            ? contribsByRef
+                .map(({who: ref, what}) => ({
+                  who: find.artist(ref, artistData),
+                  what,
+                }))
+                .filter(({who}) => who)
+            : [],
+      },
+    }),
+
+    // Dynamically inherit a contribution list from some other object, if it
+    // hasn't been overridden on this object. This is handy for solo albums
+    // where all tracks have the same artist, for example.
+    //
+    // Note: The arguments of this function aren't currently final! The final
+    // format will look more like (contribsByRef, parentContribsByRef), e.g.
+    // ('artistContribsByRef', '@album/artistContribsByRef').
+    dynamicInheritContribs: (
+      contribsByRefProperty,
+      parentContribsByRefProperty,
+      thingDataProperty,
+      findFn
+    ) => ({
+      flags: {expose: true},
+      expose: {
+        dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'],
+        compute({
+          [Thing.instance]: thing,
+          [contribsByRefProperty]: contribsByRef,
+          [thingDataProperty]: thingData,
+          artistData,
+        }) {
+          if (!artistData) return [];
+          const refs =
+            contribsByRef ??
+            findFn(thing, thingData, {mode: 'quiet'})?.[parentContribsByRefProperty];
+          if (!refs) return [];
+          return refs
+            .map(({who: ref, what}) => ({
+              who: find.artist(ref, artistData),
+              what,
+            }))
+            .filter(({who}) => who);
+        },
+      },
+    }),
+
+    // Neat little shortcut for "reversing" the reference lists stored on other
+    // things - for example, tracks specify a "referenced tracks" property, and
+    // you would use this to compute a corresponding "referenced *by* tracks"
+    // property. Naturally, the passed ref list property is of the things in the
+    // wiki data provided, not the requesting Thing itself.
+    reverseReferenceList: (thingDataProperty, referencerRefListProperty) => ({
+      flags: {expose: true},
+
+      expose: {
+        dependencies: [thingDataProperty],
+
+        compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) =>
+          thingData?.filter(t => t[referencerRefListProperty].includes(thing)) ?? [],
+      },
+    }),
+
+    // Corresponding function for single references. Note that the return value
+    // is still a list - this is for matching all the objects whose single
+    // reference (in the given property) matches this Thing.
+    reverseSingleReference: (thingDataProperty, referencerRefListProperty) => ({
+      flags: {expose: true},
+
+      expose: {
+        dependencies: [thingDataProperty],
+
+        compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) =>
+          thingData?.filter((t) => t[referencerRefListProperty] === thing) ?? [],
+      },
+    }),
+
+    // General purpose wiki data constructor, for properties like artistData,
+    // trackData, etc.
+    wikiData: (thingClass) => ({
+      flags: {update: true},
+      update: {
+        validate: validateArrayItems(validateInstanceOf(thingClass)),
+      },
+    }),
+
+    // This one's kinda tricky: it parses artist "references" from the
+    // commentary content, and finds the matching artist for each reference.
+    // This is mostly useful for credits and listings on artist pages.
+    commentatorArtists: () => ({
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['artistData', 'commentary'],
+
+        compute: ({artistData, commentary}) =>
+          artistData && commentary
+            ? Array.from(
+                new Set(
+                  Array.from(
+                    commentary
+                      .replace(/<\/?b>/g, '')
+                      .matchAll(/<i>(?<who>.*?):<\/i>/g)
+                  ).map(({groups: {who}}) =>
+                    find.artist(who, artistData, {mode: 'quiet'})
+                  )
+                )
+              )
+            : [],
+      },
+    }),
+  };
+
+  // Default custom inspect function, which may be overridden by Thing
+  // subclasses. This will be used when displaying aggregate errors and other
+  // command-line logging - it's the place to provide information useful in
+  // identifying the Thing being presented.
+  [inspect.custom]() {
+    const cname = this.constructor.name;
+
+    return (
+      (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) +
+      (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '')
+    );
+  }
+
+  static getReference(thing) {
+    if (!thing.constructor[Thing.referenceType]) {
+      throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`);
+    }
+
+    if (!thing.directory) {
+      throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
+    }
+
+    return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
+  }
+}
diff --git a/src/data/things/track.js b/src/data/things/track.js
new file mode 100644
index 00000000..d2930ff1
--- /dev/null
+++ b/src/data/things/track.js
@@ -0,0 +1,332 @@
+import Thing from './thing.js';
+
+import {inspect} from 'util';
+import {color} from '../../util/cli.js';
+
+import find from '../../util/find.js';
+
+export class Track extends Thing {
+  static [Thing.referenceType] = 'track';
+
+  static [Thing.getPropertyDescriptors] = ({
+    Album,
+    ArtTag,
+    Artist,
+    Flash,
+
+    validators: {
+      isBoolean,
+      isDate,
+      isDuration,
+      isFileExtension,
+    },
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Track'),
+    directory: Thing.common.directory(),
+
+    duration: {
+      flags: {update: true, expose: true},
+      update: {validate: isDuration},
+    },
+
+    urls: Thing.common.urls(),
+    dateFirstReleased: Thing.common.simpleDate(),
+
+    hasURLs: Thing.common.flag(true),
+
+    artistContribsByRef: Thing.common.contribsByRef(),
+    contributorContribsByRef: Thing.common.contribsByRef(),
+    coverArtistContribsByRef: Thing.common.contribsByRef(),
+
+    referencedTracksByRef: Thing.common.referenceList(Track),
+    sampledTracksByRef: Thing.common.referenceList(Track),
+    artTagsByRef: Thing.common.referenceList(ArtTag),
+
+    hasCoverArt: {
+      flags: {update: true, expose: true},
+
+      update: {validate: isBoolean},
+
+      expose: {
+        dependencies: ['albumData', 'coverArtistContribsByRef'],
+        transform: (hasCoverArt, {
+          albumData,
+          coverArtistContribsByRef,
+          [Track.instance]: track,
+        }) =>
+          Track.hasCoverArt(
+            track,
+            albumData,
+            coverArtistContribsByRef,
+            hasCoverArt
+          ),
+      },
+    },
+
+    coverArtFileExtension: {
+      flags: {update: true, expose: true},
+
+      update: {validate: isFileExtension},
+
+      expose: {
+        dependencies: ['albumData', 'coverArtistContribsByRef'],
+        transform: (coverArtFileExtension, {
+          albumData,
+          coverArtistContribsByRef,
+          hasCoverArt,
+          [Track.instance]: track,
+        }) =>
+          coverArtFileExtension ??
+          (Track.hasCoverArt(
+            track,
+            albumData,
+            coverArtistContribsByRef,
+            hasCoverArt
+          )
+            ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension
+            : Track.findAlbum(track, albumData)?.coverArtFileExtension) ??
+          'jpg',
+      },
+    },
+
+    // Previously known as: (track).aka
+    originalReleaseTrackByRef: Thing.common.singleReference(Track),
+
+    dataSourceAlbumByRef: Thing.common.singleReference(Album),
+
+    commentary: Thing.common.commentary(),
+    lyrics: Thing.common.simpleString(),
+    additionalFiles: Thing.common.additionalFiles(),
+
+    // Update only
+
+    albumData: Thing.common.wikiData(Album),
+    artistData: Thing.common.wikiData(Artist),
+    artTagData: Thing.common.wikiData(ArtTag),
+    flashData: Thing.common.wikiData(Flash),
+    trackData: Thing.common.wikiData(Track),
+
+    // Expose only
+
+    commentatorArtists: Thing.common.commentatorArtists(),
+
+    album: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['albumData'],
+        compute: ({[Track.instance]: track, albumData}) =>
+          albumData?.find((album) => album.tracks.includes(track)) ?? null,
+      },
+    },
+
+    // Note - this is an internal property used only to help identify a track.
+    // It should not be assumed in general that the album and dataSourceAlbum match
+    // (i.e. a track may dynamically be moved from one album to another, at
+    // which point dataSourceAlbum refers to where it was originally from, and is
+    // not generally relevant information). It's also not guaranteed that
+    // dataSourceAlbum is available (depending on the Track creator to optionally
+    // provide dataSourceAlbumByRef).
+    dataSourceAlbum: Thing.common.dynamicThingFromSingleReference(
+      'dataSourceAlbumByRef',
+      'albumData',
+      find.album
+    ),
+
+    date: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['albumData', 'dateFirstReleased'],
+        compute: ({albumData, dateFirstReleased, [Track.instance]: track}) =>
+          dateFirstReleased ?? Track.findAlbum(track, albumData)?.date ?? null,
+      },
+    },
+
+    color: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['albumData'],
+
+        compute: ({albumData, [Track.instance]: track}) =>
+          Track.findAlbum(track, albumData)?.trackGroups.find((tg) =>
+            tg.tracks.includes(track)
+          )?.color ?? null,
+      },
+    },
+
+    coverArtDate: {
+      flags: {update: true, expose: true},
+
+      update: {validate: isDate},
+
+      expose: {
+        dependencies: ['albumData', 'dateFirstReleased'],
+        transform: (coverArtDate, {
+          albumData,
+          dateFirstReleased,
+          [Track.instance]: track,
+        }) =>
+          coverArtDate ??
+          dateFirstReleased ??
+          Track.findAlbum(track, albumData)?.trackArtDate ??
+          Track.findAlbum(track, albumData)?.date ??
+          null,
+      },
+    },
+
+    originalReleaseTrack: Thing.common.dynamicThingFromSingleReference(
+      'originalReleaseTrackByRef',
+      'trackData',
+      find.track
+    ),
+
+    otherReleases: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['originalReleaseTrackByRef', 'trackData'],
+
+        compute: ({
+          originalReleaseTrackByRef: t1origRef,
+          trackData,
+          [Track.instance]: t1,
+        }) => {
+          if (!trackData) {
+            return [];
+          }
+
+          const t1orig = find.track(t1origRef, trackData);
+
+          return [
+            t1orig,
+            ...trackData.filter((t2) => {
+              const {originalReleaseTrack: t2orig} = t2;
+              return t2 !== t1 && t2orig && (t2orig === t1orig || t2orig === t1);
+            }),
+          ].filter(Boolean);
+        },
+      },
+    },
+
+    // Previously known as: (track).artists
+    artistContribs: Thing.common.dynamicInheritContribs(
+      'artistContribsByRef',
+      'artistContribsByRef',
+      'albumData',
+      Track.findAlbum
+    ),
+
+    // Previously known as: (track).contributors
+    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
+
+    // Previously known as: (track).coverArtists
+    coverArtistContribs: Thing.common.dynamicInheritContribs(
+      'coverArtistContribsByRef',
+      'trackCoverArtistContribsByRef',
+      'albumData',
+      Track.findAlbum
+    ),
+
+    // Previously known as: (track).references
+    referencedTracks: Thing.common.dynamicThingsFromReferenceList(
+      'referencedTracksByRef',
+      'trackData',
+      find.track
+    ),
+
+    sampledTracks: Thing.common.dynamicThingsFromReferenceList(
+      'sampledTracksByRef',
+      'trackData',
+      find.track
+    ),
+
+    // Specifically exclude re-releases from this list - while it's useful to
+    // get from a re-release to the tracks it references, re-releases aren't
+    // generally relevant from the perspective of the tracks being referenced.
+    // Filtering them from data here hides them from the corresponding field
+    // on the site (obviously), and has the bonus of not counting them when
+    // counting the number of times a track has been referenced, for use in
+    // the "Tracks - by Times Referenced" listing page (or other data
+    // processing).
+    referencedByTracks: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['trackData'],
+
+        compute: ({trackData, [Track.instance]: track}) =>
+          trackData
+            ? trackData
+                .filter((t) => !t.originalReleaseTrack)
+                .filter((t) => t.referencedTracks?.includes(track))
+            : [],
+      },
+    },
+
+    // For the same reasoning, exclude re-releases from sampled tracks too.
+    sampledByTracks: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['trackData'],
+
+        compute: ({trackData, [Track.instance]: track}) =>
+          trackData
+            ? trackData
+                .filter((t) => !t.originalReleaseTrack)
+                .filter((t) => t.sampledTracks?.includes(track))
+            : [],
+      },
+    },
+
+    // Previously known as: (track).flashes
+    featuredInFlashes: Thing.common.reverseReferenceList(
+      'flashData',
+      'featuredTracks'
+    ),
+
+    artTags: Thing.common.dynamicThingsFromReferenceList(
+      'artTagsByRef',
+      'artTagData',
+      find.artTag
+    ),
+  });
+
+  // This is a quick utility function for now, since the same code is reused in
+  // several places. Ideally it wouldn't be - we'd just reuse the `album`
+  // property - but support for that hasn't been coded yet :P
+  static findAlbum = (track, albumData) =>
+    albumData?.find((album) => album.tracks.includes(track));
+
+  // Another reused utility function. This one's logic is a bit more complicated.
+  static hasCoverArt = (
+    track,
+    albumData,
+    coverArtistContribsByRef,
+    hasCoverArt
+  ) => (
+    hasCoverArt ??
+    (coverArtistContribsByRef?.length > 0 || null) ??
+    Track.findAlbum(track, albumData)?.hasTrackArt ??
+    true
+  );
+
+  [inspect.custom]() {
+    const base = Thing.prototype[inspect.custom].apply(this);
+
+    const {album, dataSourceAlbum} = this;
+    const albumName = album ? album.name : dataSourceAlbum?.name;
+    const albumIndex =
+      albumName &&
+      (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this));
+    const trackNum = albumIndex === -1 ? '#?' : `#${albumIndex + 1}`;
+
+    return albumName
+      ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})`
+      : base;
+  }
+}
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
new file mode 100644
index 00000000..cc603d48
--- /dev/null
+++ b/src/data/things/validators.js
@@ -0,0 +1,367 @@
+import {withAggregate} from '../../util/sugar.js';
+
+import {color, ENABLE_COLOR} from '../../util/cli.js';
+
+import {inspect as nodeInspect} from 'util';
+
+function inspect(value) {
+  return nodeInspect(value, {colors: ENABLE_COLOR});
+}
+
+// Basic types (primitives)
+
+function a(noun) {
+  return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`;
+}
+
+function isType(value, type) {
+  if (typeof value !== type)
+    throw new TypeError(`Expected ${a(type)}, got ${typeof value}`);
+
+  return true;
+}
+
+export function isBoolean(value) {
+  return isType(value, 'boolean');
+}
+
+export function isNumber(value) {
+  return isType(value, 'number');
+}
+
+export function isPositive(number) {
+  isNumber(number);
+
+  if (number <= 0) throw new TypeError(`Expected positive number`);
+
+  return true;
+}
+
+export function isNegative(number) {
+  isNumber(number);
+
+  if (number >= 0) throw new TypeError(`Expected negative number`);
+
+  return true;
+}
+
+export function isPositiveOrZero(number) {
+  isNumber(number);
+
+  if (number < 0) throw new TypeError(`Expected positive number or zero`);
+
+  return true;
+}
+
+export function isNegativeOrZero(number) {
+  isNumber(number);
+
+  if (number > 0) throw new TypeError(`Expected negative number or zero`);
+
+  return true;
+}
+
+export function isInteger(number) {
+  isNumber(number);
+
+  if (number % 1 !== 0) throw new TypeError(`Expected integer`);
+
+  return true;
+}
+
+export function isCountingNumber(number) {
+  isInteger(number);
+  isPositive(number);
+
+  return true;
+}
+
+export function isWholeNumber(number) {
+  isInteger(number);
+  isPositiveOrZero(number);
+
+  return true;
+}
+
+export function isString(value) {
+  return isType(value, 'string');
+}
+
+export function isStringNonEmpty(value) {
+  isString(value);
+
+  if (value.trim().length === 0)
+    throw new TypeError(`Expected non-empty string`);
+
+  return true;
+}
+
+// Complex types (non-primitives)
+
+export function isInstance(value, constructor) {
+  isObject(value);
+
+  if (!(value instanceof constructor))
+    throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`);
+
+  return true;
+}
+
+export function isDate(value) {
+  return isInstance(value, Date);
+}
+
+export function isObject(value) {
+  isType(value, 'object');
+
+  // Note: Please remember that null is always a valid value for properties
+  // held by a CacheableObject. This assertion is exclusively for use in other
+  // contexts.
+  if (value === null) throw new TypeError(`Expected an object, got null`);
+
+  return true;
+}
+
+export function isArray(value) {
+  if (typeof value !== 'object' || value === null || !Array.isArray(value))
+    throw new TypeError(`Expected an array, got ${value}`);
+
+  return true;
+}
+
+function validateArrayItemsHelper(itemValidator) {
+  return (item, index) => {
+    try {
+      const value = itemValidator(item);
+
+      if (value !== true) {
+        throw new Error(`Expected validator to return true`);
+      }
+    } catch (error) {
+      error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`;
+      throw error;
+    }
+  };
+}
+
+export function validateArrayItems(itemValidator) {
+  const fn = validateArrayItemsHelper(itemValidator);
+
+  return (array) => {
+    isArray(array);
+
+    withAggregate({message: 'Errors validating array items'}, ({wrap}) => {
+      array.forEach(wrap(fn));
+    });
+
+    return true;
+  };
+}
+
+export function validateInstanceOf(constructor) {
+  return (object) => isInstance(object, constructor);
+}
+
+// Wiki data (primitives & non-primitives)
+
+export function isColor(color) {
+  isStringNonEmpty(color);
+
+  if (color.startsWith('#')) {
+    if (![4, 5, 7, 9].includes(color.length))
+      throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`);
+
+    if (/[^0-9a-fA-F]/.test(color.slice(1)))
+      throw new TypeError(`Expected hexadecimal digits`);
+
+    return true;
+  }
+
+  throw new TypeError(`Unknown color format`);
+}
+
+export function isCommentary(commentary) {
+  return isString(commentary);
+}
+
+const isArtistRef = validateReference('artist');
+
+export function validateProperties(spec) {
+  const specEntries = Object.entries(spec);
+  const specKeys = Object.keys(spec);
+
+  return (object) => {
+    isObject(object);
+
+    if (Array.isArray(object))
+      throw new TypeError(`Expected an object, got array`);
+
+    withAggregate({message: `Errors validating object properties`}, ({call}) => {
+      for (const [specKey, specValidator] of specEntries) {
+        call(() => {
+          const value = object[specKey];
+          try {
+            specValidator(value);
+          } catch (error) {
+            error.message = `(key: ${color.green(specKey)}, value: ${inspect(value)}) ${error.message}`;
+            throw error;
+          }
+        });
+      }
+
+      const unknownKeys = Object.keys(object).filter((key) => !specKeys.includes(key));
+      if (unknownKeys.length > 0) {
+        call(() => {
+          throw new Error(`Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`);
+        });
+      }
+    });
+
+    return true;
+  };
+}
+
+export const isContribution = validateProperties({
+  who: isArtistRef,
+  what: (value) =>
+    value === undefined ||
+    value === null ||
+    isStringNonEmpty(value),
+});
+
+export const isContributionList = validateArrayItems(isContribution);
+
+export const isAdditionalFile = validateProperties({
+  title: isString,
+  description: (value) =>
+    value === undefined ||
+    value === null ||
+    isString(value),
+  files: validateArrayItems(isString),
+});
+
+export const isAdditionalFileList = validateArrayItems(isAdditionalFile);
+
+export function isDimensions(dimensions) {
+  isArray(dimensions);
+
+  if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`);
+
+  isPositive(dimensions[0]);
+  isInteger(dimensions[0]);
+  isPositive(dimensions[1]);
+  isInteger(dimensions[1]);
+
+  return true;
+}
+
+export function isDirectory(directory) {
+  isStringNonEmpty(directory);
+
+  if (directory.match(/[^a-zA-Z0-9_-]/))
+    throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`);
+
+  return true;
+}
+
+export function isDuration(duration) {
+  isNumber(duration);
+  isPositiveOrZero(duration);
+
+  return true;
+}
+
+export function isFileExtension(string) {
+  isStringNonEmpty(string);
+
+  if (string[0] === '.')
+    throw new TypeError(`Expected no dot (.) at the start of file extension`);
+
+  if (string.match(/[^a-zA-Z0-9_]/))
+    throw new TypeError(`Expected only alphanumeric and underscore`);
+
+  return true;
+}
+
+export function isLanguageCode(string) {
+  // TODO: This is a stub function because really we don't need a detailed
+  // is-language-code parser right now.
+
+  isString(string);
+
+  return true;
+}
+
+export function isName(name) {
+  return isString(name);
+}
+
+export function isURL(string) {
+  isStringNonEmpty(string);
+
+  new URL(string);
+
+  return true;
+}
+
+export function validateReference(type = 'track') {
+  return (ref) => {
+    isStringNonEmpty(ref);
+
+    const match = ref
+      .trim()
+      .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
+
+    if (!match) throw new TypeError(`Malformed reference`);
+
+    const {groups: {typePart, directoryPart}} = match;
+
+    if (typePart) {
+      if (typePart !== type)
+        throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`);
+
+      isDirectory(directoryPart);
+    }
+
+    isName(ref);
+
+    return true;
+  };
+}
+
+export function validateReferenceList(type = '') {
+  return validateArrayItems(validateReference(type));
+}
+
+// Compositional utilities
+
+export function oneOf(...checks) {
+  return (value) => {
+    const errorMeta = [];
+
+    for (let i = 0, check; (check = checks[i]); i++) {
+      try {
+        const result = check(value);
+
+        if (result !== true) {
+          throw new Error(`Check returned false`);
+        }
+
+        return true;
+      } catch (error) {
+        errorMeta.push([check, i, error]);
+      }
+    }
+
+    // Don't process error messages until every check has failed.
+    const errors = [];
+    for (const [check, i, error] of errorMeta) {
+      error.message = check.name
+        ? `(#${i} "${check.name}") ${error.message}`
+        : `(#${i}) ${error.message}`;
+      error.check = check;
+      errors.push(error);
+    }
+    throw new AggregateError(errors, `Expected one of ${checks.length} possible checks, but none were true`);
+  };
+}
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
new file mode 100644
index 00000000..adf085e5
--- /dev/null
+++ b/src/data/things/wiki-info.js
@@ -0,0 +1,68 @@
+import Thing from './thing.js';
+
+import find from '../../util/find.js';
+
+export class WikiInfo extends Thing {
+  static [Thing.getPropertyDescriptors] = ({
+    Group,
+
+    validators: {
+      isLanguageCode,
+      isName,
+      isURL,
+    },
+  }) => ({
+    // Update & expose
+
+    name: Thing.common.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: Thing.common.color(),
+
+    // One-line description used for <meta rel="description"> tag.
+    description: Thing.common.simpleString(),
+
+    footerContent: Thing.common.simpleString(),
+
+    defaultLanguage: {
+      flags: {update: true, expose: true},
+      update: {validate: isLanguageCode},
+    },
+
+    canonicalBase: {
+      flags: {update: true, expose: true},
+      update: {validate: isURL},
+    },
+
+    divideTrackListsByGroupsByRef: Thing.common.referenceList(Group),
+
+    // Feature toggles
+    enableFlashesAndGames: Thing.common.flag(false),
+    enableListings: Thing.common.flag(false),
+    enableNews: Thing.common.flag(false),
+    enableArtTagUI: Thing.common.flag(false),
+    enableGroupUI: Thing.common.flag(false),
+
+    // Update only
+
+    groupData: Thing.common.wikiData(Group),
+
+    // Expose only
+
+    divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList(
+      'divideTrackListsByGroupsByRef',
+      'groupData',
+      find.group
+    ),
+  });
+}