« get me outta code hell

divide things.js into modular files (hilariously) - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2022-11-28 23:25:05 -0400
committer(quasar) nebula <qznebula@protonmail.com>2022-11-28 23:25:05 -0400
commit690a7b53a72ac71f9f76260fa50c634566c4e984 (patch)
tree841653cf0e474c6edd437ec36884f2130b5b7b43
parentae9dba60c4bbb327b402c500cc042922a954de74 (diff)
divide things.js into modular files (hilariously)
-rw-r--r--.eslintrc.json1
-rw-r--r--src/data/things.js1882
-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.js (renamed from src/data/cacheable-object.js)2
-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.js (renamed from src/data/validators.js)4
-rw-r--r--src/data/things/wiki-info.js68
-rw-r--r--src/data/yaml.js82
-rw-r--r--src/misc-templates.js4
-rw-r--r--src/static/client.js2
-rw-r--r--src/static/lazy-loading.js2
-rwxr-xr-xsrc/upd8.js8
-rw-r--r--src/util/link.js27
-rw-r--r--src/util/sugar.js6
24 files changed, 2201 insertions, 1961 deletions
diff --git a/.eslintrc.json b/.eslintrc.json
index e0b84311..f104d38c 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,6 +1,5 @@
 {
   "env": {
-    "browser": true,
     "es2021": true,
     "node": true
   },
diff --git a/src/data/things.js b/src/data/things.js
deleted file mode 100644
index 2037faca..00000000
--- a/src/data/things.js
+++ /dev/null
@@ -1,1882 +0,0 @@
-// things.js: class definitions for various object types used across the wiki,
-// most of which correspond to an output page, such as Track, Album, Artist
-
-import CacheableObject from './cacheable-object.js';
-
-import {
-  isAdditionalFileList,
-  isBoolean,
-  isColor,
-  isCommentary,
-  isCountingNumber,
-  isContributionList,
-  isDate,
-  isDimensions,
-  isDirectory,
-  isDuration,
-  isFileExtension,
-  isLanguageCode,
-  isName,
-  isNumber,
-  isURL,
-  isString,
-  oneOf,
-  validateArrayItems,
-  validateInstanceOf,
-  validateReference,
-  validateReferenceList,
-} from './validators.js';
-
-import * as S from './serialize.js';
-
-import {
-  getKebabCase,
-  sortAlbumsTracksChronologically,
-} from '../util/wiki-data.js';
-
-import find from '../util/find.js';
-
-import {inspect} from 'util';
-import {color} from '../util/cli.js';
-
-// Stub classes (and their exports) at the top of the file - these are
-// referenced later when we actually define static class fields. We deliberately
-// define the classes and set their static fields in two separate steps so that
-// every class coexists from the outset, and can be directly referenced in field
-// definitions later.
-
-// This list also acts as a quick table of contents for this JS file - use
-// ctrl+F or similar to skip to a section.
-
-// -> Thing
-export class Thing extends CacheableObject {}
-
-// -> Album
-export class Album extends Thing {}
-export class TrackGroup extends CacheableObject {}
-
-// -> Track
-export class Track extends Thing {}
-
-// -> Artist
-export class Artist extends Thing {}
-
-// -> Group
-export class Group extends Thing {}
-export class GroupCategory extends CacheableObject {}
-
-// -> ArtTag
-export class ArtTag extends Thing {}
-
-// -> NewsEntry
-export class NewsEntry extends Thing {}
-
-// -> StaticPage
-export class StaticPage extends Thing {}
-
-// -> HomepageLayout
-export class HomepageLayout extends CacheableObject {}
-export class HomepageLayoutRow extends CacheableObject {}
-export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {}
-
-// -> Flash
-export class Flash extends Thing {}
-export class FlashAct extends CacheableObject {}
-
-// -> WikiInfo
-export class WikiInfo extends CacheableObject {}
-
-// -> Language
-export class Language extends CacheableObject {}
-
-// Before initializing property descriptors, set additional independent
-// constants on the classes (which are referenced later).
-
-Thing.referenceType = Symbol('Thing.referenceType');
-
-Album[Thing.referenceType] = 'album';
-Track[Thing.referenceType] = 'track';
-Artist[Thing.referenceType] = 'artist';
-Group[Thing.referenceType] = 'group';
-ArtTag[Thing.referenceType] = 'tag';
-NewsEntry[Thing.referenceType] = 'news-entry';
-StaticPage[Thing.referenceType] = 'static';
-Flash[Thing.referenceType] = 'flash';
-
-// -> Thing: base class for wiki data types, providing wiki-specific utility
-// functions on top of essential CacheableObject behavior.
-
-// 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!
-Thing.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'})
-                )
-              )
-            )
-          : [],
-    },
-  }),
-};
-
-// Get a reference to a thing (e.g. track:showtime-piano-refrain), using its
-// constructor's [Thing.referenceType] as the prefix. This will throw an error
-// if the thing's directory isn't yet provided/computable.
-Thing.getReference = function (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}`;
-};
-
-// Default custom inspect function, which may be overridden by Thing subclasses.
-// This will be used when displaying aggregate errors and other in command-line
-// logging - it's the place to provide information useful in identifying the
-// Thing being presented.
-Thing.prototype[inspect.custom] = function () {
-  const cname = this.constructor.name;
-
-  return (
-    (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) +
-    (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '')
-  );
-};
-
-// -> Album
-
-Album.propertyDescriptors = {
-  // 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
-  ),
-};
-
-Album[S.serializeDescriptors] = {
-  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,
-};
-
-TrackGroup.propertyDescriptors = {
-  // 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),
-    },
-  },
-};
-
-// -> Track
-
-// 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
-Track.findAlbum = (track, albumData) => {
-  return albumData?.find((album) => album.tracks.includes(track));
-};
-
-// Another reused utility function. This one's logic is a bit more complicated.
-Track.hasCoverArt = (
-  track,
-  albumData,
-  coverArtistContribsByRef,
-  hasCoverArt
-) => {
-  return (
-    hasCoverArt ??
-    (coverArtistContribsByRef?.length > 0 || null) ??
-    Track.findAlbum(track, albumData)?.hasTrackArt ??
-    true
-  );
-};
-
-Track.propertyDescriptors = {
-  // 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
-  ),
-};
-
-Track.prototype[inspect.custom] = function () {
-  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;
-};
-
-// -> Artist
-
-Artist.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)) ?? [],
-  },
-});
-
-Artist.propertyDescriptors = {
-  // 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'
-  ),
-};
-
-Artist[S.serializeDescriptors] = {
-  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,
-};
-
-// -> Group
-
-Group.propertyDescriptors = {
-  // 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,
-    },
-  },
-};
-
-GroupCategory.propertyDescriptors = {
-  // 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
-  ),
-};
-
-// -> ArtTag
-
-ArtTag.propertyDescriptors = {
-  // 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}),
-    },
-  },
-};
-
-// -> NewsEntry
-
-NewsEntry.propertyDescriptors = {
-  // 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],
-    },
-  },
-};
-
-// -> StaticPage
-
-StaticPage.propertyDescriptors = {
-  // 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),
-};
-
-// -> HomepageLayout
-
-HomepageLayout.propertyDescriptors = {
-  // Update & expose
-
-  sidebarContent: Thing.common.simpleString(),
-
-  rows: {
-    flags: {update: true, expose: true},
-
-    update: {
-      validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)),
-    },
-  },
-};
-
-HomepageLayoutRow.propertyDescriptors = {
-  // 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),
-};
-
-HomepageLayoutAlbumsRow.propertyDescriptors = {
-  ...HomepageLayoutRow.propertyDescriptors,
-
-  // 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
-  ),
-};
-
-// -> Flash
-
-Flash.propertyDescriptors = {
-  // 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,
-    },
-  },
-};
-
-Flash[S.serializeDescriptors] = {
-  name: S.id,
-  page: S.id,
-  directory: S.id,
-  date: S.id,
-  contributors: S.toContribRefs,
-  tracks: S.toRefs,
-  urls: S.id,
-  color: S.id,
-};
-
-FlashAct.propertyDescriptors = {
-  // 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
-  ),
-};
-
-// -> WikiInfo
-
-WikiInfo.propertyDescriptors = {
-  // 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
-  ),
-};
-
-// -> Language
-
-const intlHelper = (constructor, opts) => ({
-  flags: {expose: true},
-  expose: {
-    dependencies: ['code', 'intlCode'],
-    compute: ({code, intlCode}) => {
-      const constructCode = intlCode ?? code;
-      if (!constructCode) return null;
-      return Reflect.construct(constructor, [constructCode, opts]);
-    },
-  },
-});
-
-Language.propertyDescriptors = {
-  // 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: intlHelper(Intl.DateTimeFormat, {full: true}),
-  intl_number: intlHelper(Intl.NumberFormat),
-  intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}),
-  intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}),
-  intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}),
-  intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}),
-  intl_pluralOrdinal: 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)])
-        );
-      },
-    },
-  },
-};
-
-const countHelper = (stringKey, argName = stringKey) =>
-  function (value, {unit = false} = {}) {
-    return this.$(
-      unit
-        ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
-        : `count.${stringKey}`,
-      {[argName]: this.formatNumber(value)}
-    );
-  };
-
-Object.assign(Language.prototype, {
-  $(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});
-    }
-  },
-
-  // TODO: These are hard-coded. Is there a better way?
-  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/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/cacheable-object.js b/src/data/things/cacheable-object.js
index 04e029f0..6a210cc1 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/things/cacheable-object.js
@@ -74,7 +74,7 @@
 //      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 {color, ENABLE_COLOR} from '../../util/cli.js';
 
 import {inspect as nodeInspect} from 'util';
 
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/validators.js b/src/data/things/validators.js
index 5c357c83..cc603d48 100644
--- a/src/data/validators.js
+++ b/src/data/things/validators.js
@@ -1,6 +1,6 @@
-import {withAggregate} from '../util/sugar.js';
+import {withAggregate} from '../../util/sugar.js';
 
-import {color, ENABLE_COLOR} from '../util/cli.js';
+import {color, ENABLE_COLOR} from '../../util/cli.js';
 
 import {inspect as nodeInspect} from 'util';
 
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
+    ),
+  });
+}
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 6ba19c06..ab97ab76 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -7,23 +7,7 @@ import yaml from 'js-yaml';
 import {readFile} from 'fs/promises';
 import {inspect as nodeInspect} from 'util';
 
-import {
-  Album,
-  Artist,
-  ArtTag,
-  Flash,
-  FlashAct,
-  Group,
-  GroupCategory,
-  HomepageLayout,
-  HomepageLayoutAlbumsRow,
-  NewsEntry,
-  StaticPage,
-  Thing,
-  Track,
-  TrackGroup,
-  WikiInfo,
-} from './things.js';
+import T from './things/index.js';
 
 import {color, ENABLE_COLOR, logInfo, logWarn} from '../util/cli.js';
 
@@ -101,6 +85,10 @@ function makeProcessDocument(
     ignoredFields = [],
   }
 ) {
+  if (!thingClass) {
+    throw new Error(`Missing Thing class`);
+  }
+
   if (!propertyFieldMapping) {
     throw new Error(`Expected propertyFieldMapping to be provided`);
   }
@@ -178,7 +166,7 @@ makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error
   }
 };
 
-export const processAlbumDocument = makeProcessDocument(Album, {
+export const processAlbumDocument = makeProcessDocument(T.Album, {
   fieldTransformations: {
     'Artists': parseContributors,
     'Cover Artists': parseContributors,
@@ -238,7 +226,7 @@ export const processAlbumDocument = makeProcessDocument(Album, {
   },
 });
 
-export const processTrackGroupDocument = makeProcessDocument(TrackGroup, {
+export const processTrackGroupDocument = makeProcessDocument(T.TrackGroup, {
   fieldTransformations: {
     'Date Originally Released': (value) => new Date(value),
   },
@@ -250,7 +238,7 @@ export const processTrackGroupDocument = makeProcessDocument(TrackGroup, {
   },
 });
 
-export const processTrackDocument = makeProcessDocument(Track, {
+export const processTrackDocument = makeProcessDocument(T.Track, {
   fieldTransformations: {
     'Duration': getDurationInSeconds,
 
@@ -292,7 +280,7 @@ export const processTrackDocument = makeProcessDocument(Track, {
   },
 });
 
-export const processArtistDocument = makeProcessDocument(Artist, {
+export const processArtistDocument = makeProcessDocument(T.Artist, {
   propertyFieldMapping: {
     name: 'Artist',
 
@@ -309,7 +297,7 @@ export const processArtistDocument = makeProcessDocument(Artist, {
   ignoredFields: ['Dead URLs'],
 });
 
-export const processFlashDocument = makeProcessDocument(Flash, {
+export const processFlashDocument = makeProcessDocument(T.Flash, {
   fieldTransformations: {
     'Date': (value) => new Date(value),
 
@@ -330,7 +318,7 @@ export const processFlashDocument = makeProcessDocument(Flash, {
   },
 });
 
-export const processFlashActDocument = makeProcessDocument(FlashAct, {
+export const processFlashActDocument = makeProcessDocument(T.FlashAct, {
   propertyFieldMapping: {
     name: 'Act',
     color: 'Color',
@@ -340,7 +328,7 @@ export const processFlashActDocument = makeProcessDocument(FlashAct, {
   },
 });
 
-export const processNewsEntryDocument = makeProcessDocument(NewsEntry, {
+export const processNewsEntryDocument = makeProcessDocument(T.NewsEntry, {
   fieldTransformations: {
     'Date': (value) => new Date(value),
   },
@@ -353,7 +341,7 @@ export const processNewsEntryDocument = makeProcessDocument(NewsEntry, {
   },
 });
 
-export const processArtTagDocument = makeProcessDocument(ArtTag, {
+export const processArtTagDocument = makeProcessDocument(T.ArtTag, {
   propertyFieldMapping: {
     name: 'Tag',
     directory: 'Directory',
@@ -362,7 +350,7 @@ export const processArtTagDocument = makeProcessDocument(ArtTag, {
   },
 });
 
-export const processGroupDocument = makeProcessDocument(Group, {
+export const processGroupDocument = makeProcessDocument(T.Group, {
   propertyFieldMapping: {
     name: 'Group',
     directory: 'Directory',
@@ -371,14 +359,14 @@ export const processGroupDocument = makeProcessDocument(Group, {
   },
 });
 
-export const processGroupCategoryDocument = makeProcessDocument(GroupCategory, {
+export const processGroupCategoryDocument = makeProcessDocument(T.GroupCategory, {
   propertyFieldMapping: {
     name: 'Category',
     color: 'Color',
   },
 });
 
-export const processStaticPageDocument = makeProcessDocument(StaticPage, {
+export const processStaticPageDocument = makeProcessDocument(T.StaticPage, {
   propertyFieldMapping: {
     name: 'Name',
     nameShort: 'Short Name',
@@ -391,7 +379,7 @@ export const processStaticPageDocument = makeProcessDocument(StaticPage, {
   },
 });
 
-export const processWikiInfoDocument = makeProcessDocument(WikiInfo, {
+export const processWikiInfoDocument = makeProcessDocument(T.WikiInfo, {
   propertyFieldMapping: {
     name: 'Name',
     nameShort: 'Short Name',
@@ -409,7 +397,7 @@ export const processWikiInfoDocument = makeProcessDocument(WikiInfo, {
   },
 });
 
-export const processHomepageLayoutDocument = makeProcessDocument(HomepageLayout, {
+export const processHomepageLayoutDocument = makeProcessDocument(T.HomepageLayout, {
   propertyFieldMapping: {
     sidebarContent: 'Sidebar Content',
   },
@@ -431,7 +419,7 @@ export function makeProcessHomepageLayoutRowDocument(rowClass, spec) {
 }
 
 export const homepageLayoutRowTypeProcessMapping = {
-  albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, {
+  albums: makeProcessHomepageLayoutRowDocument(T.HomepageLayoutAlbumsRow, {
     propertyFieldMapping: {
       sourceGroupByRef: 'Group',
       countAlbumsFromGroup: 'Count',
@@ -678,7 +666,7 @@ export const dataSteps = [
         let currentTracksByRef = null;
         let currentTrackGroup = null;
 
-        const albumRef = Thing.getReference(album);
+        const albumRef = T.Thing.getReference(album);
 
         const closeCurrentTrackGroup = () => {
           if (currentTracksByRef) {
@@ -687,7 +675,7 @@ export const dataSteps = [
             if (currentTrackGroup) {
               trackGroup = currentTrackGroup;
             } else {
-              trackGroup = new TrackGroup();
+              trackGroup = new T.TrackGroup();
               trackGroup.name = `Default Track Group`;
               trackGroup.isDefaultTrackGroup = true;
             }
@@ -699,7 +687,7 @@ export const dataSteps = [
         };
 
         for (const entry of entries) {
-          if (entry instanceof TrackGroup) {
+          if (entry instanceof T.TrackGroup) {
             closeCurrentTrackGroup();
             currentTracksByRef = [];
             currentTrackGroup = entry;
@@ -710,7 +698,7 @@ export const dataSteps = [
 
           entry.dataSourceAlbumByRef = albumRef;
 
-          const trackRef = Thing.getReference(entry);
+          const trackRef = T.Thing.getReference(entry);
           if (currentTracksByRef) {
             currentTracksByRef.push(trackRef);
           } else {
@@ -739,9 +727,9 @@ export const dataSteps = [
       const artistData = results;
 
       const artistAliasData = results.flatMap((artist) => {
-        const origRef = Thing.getReference(artist);
+        const origRef = T.Thing.getReference(artist);
         return artist.aliasNames?.map((name) => {
-          const alias = new Artist();
+          const alias = new T.Artist();
           alias.name = name;
           alias.isAlias = true;
           alias.aliasedArtistRef = origRef;
@@ -770,12 +758,12 @@ export const dataSteps = [
       let flashAct;
       let flashesByRef = [];
 
-      if (results[0] && !(results[0] instanceof FlashAct)) {
+      if (results[0] && !(results[0] instanceof T.FlashAct)) {
         throw new Error(`Expected an act at top of flash data file`);
       }
 
       for (const thing of results) {
-        if (thing instanceof FlashAct) {
+        if (thing instanceof T.FlashAct) {
           if (flashAct) {
             Object.assign(flashAct, {flashesByRef});
           }
@@ -783,7 +771,7 @@ export const dataSteps = [
           flashAct = thing;
           flashesByRef = [];
         } else {
-          flashesByRef.push(Thing.getReference(thing));
+          flashesByRef.push(T.Thing.getReference(thing));
         }
       }
 
@@ -791,8 +779,8 @@ export const dataSteps = [
         Object.assign(flashAct, {flashesByRef});
       }
 
-      const flashData = results.filter((x) => x instanceof Flash);
-      const flashActData = results.filter((x) => x instanceof FlashAct);
+      const flashData = results.filter((x) => x instanceof T.Flash);
+      const flashActData = results.filter((x) => x instanceof T.FlashAct);
 
       return {flashData, flashActData};
     },
@@ -813,12 +801,12 @@ export const dataSteps = [
       let groupCategory;
       let groupsByRef = [];
 
-      if (results[0] && !(results[0] instanceof GroupCategory)) {
+      if (results[0] && !(results[0] instanceof T.GroupCategory)) {
         throw new Error(`Expected a category at top of group data file`);
       }
 
       for (const thing of results) {
-        if (thing instanceof GroupCategory) {
+        if (thing instanceof T.GroupCategory) {
           if (groupCategory) {
             Object.assign(groupCategory, {groupsByRef});
           }
@@ -826,7 +814,7 @@ export const dataSteps = [
           groupCategory = thing;
           groupsByRef = [];
         } else {
-          groupsByRef.push(Thing.getReference(thing));
+          groupsByRef.push(T.Thing.getReference(thing));
         }
       }
 
@@ -834,8 +822,8 @@ export const dataSteps = [
         Object.assign(groupCategory, {groupsByRef});
       }
 
-      const groupData = results.filter((x) => x instanceof Group);
-      const groupCategoryData = results.filter((x) => x instanceof GroupCategory);
+      const groupData = results.filter((x) => x instanceof T.Group);
+      const groupCategoryData = results.filter((x) => x instanceof T.GroupCategory);
 
       return {groupData, groupCategoryData};
     },
diff --git a/src/misc-templates.js b/src/misc-templates.js
index 0a33648c..83aae190 100644
--- a/src/misc-templates.js
+++ b/src/misc-templates.js
@@ -2,7 +2,7 @@
 // These are made available right on a page spec's ({wikiData, language, ...})
 // args object!
 
-import {Track, Album} from './data/things.js';
+import T from './data/things/index.js';
 
 import {
   empty,
@@ -173,7 +173,7 @@ function unbound_generateChronologyLinks(currentThing, {
       // right function to use here.
       const args = [thingsUnsorted, {getDate: (t) => t[dateKey]}];
       const things = (
-        thingsUnsorted.every(t => t instanceof Album || t instanceof Track)
+        thingsUnsorted.every(t => t instanceof T.Album || t instanceof T.Track)
           ? sortAlbumsTracksChronologically(...args)
           : sortChronologically(...args));
 
diff --git a/src/static/client.js b/src/static/client.js
index e9286ab0..b5ed6868 100644
--- a/src/static/client.js
+++ b/src/static/client.js
@@ -1,3 +1,5 @@
+/* eslint-env browser */
+
 // This is the JS file that gets loaded on the client! It's only really used for
 // the random track feature right now - the idea is we only use it for stuff
 // that cannot 8e done at static-site compile time, 8y its fundamentally
diff --git a/src/static/lazy-loading.js b/src/static/lazy-loading.js
index b5b0a368..b04ad7c2 100644
--- a/src/static/lazy-loading.js
+++ b/src/static/lazy-loading.js
@@ -1,3 +1,5 @@
+/* eslint-env browser */
+
 // Lazy loading! Roll your own. Woot.
 // This file includes a 8unch of fall8acks and stuff like that, and is written
 // with fairly Olden JavaScript(TM), so as to work on pretty much any 8rowser
diff --git a/src/upd8.js b/src/upd8.js
index 6d63b1b1..89984c08 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -61,11 +61,11 @@ import * as html from './util/html.js';
 import {getColors} from './util/colors.js';
 import {findFiles} from './util/io.js';
 
-import CacheableObject from './data/cacheable-object.js';
+import CacheableObject from './data/things/cacheable-object.js';
 
 import {serializeThings} from './data/serialize.js';
 
-import {Language} from './data/things.js';
+import T from './data/things/index.js';
 
 import {
   filterDuplicateDirectories,
@@ -1625,7 +1625,7 @@ async function processLanguageFile(file) {
     delete json['meta.baseDirectory'];
   }
 
-  const language = new Language();
+  const language = new T.Language();
   language.code = code;
   language.intlCode = intlCode;
   language.name = name;
@@ -1834,7 +1834,7 @@ async function main() {
   const niceShowAggregate = (error, ...opts) => {
     showAggregate(error, {
       showTraces: showAggregateTraces,
-      pathToFile: (f) => path.relative(__dirname, f),
+      pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
       ...opts,
     });
   };
diff --git a/src/util/link.js b/src/util/link.js
index 41ac9131..bc3bd50a 100644
--- a/src/util/link.js
+++ b/src/util/link.js
@@ -11,16 +11,7 @@
 
 import * as html from './html.js';
 
-import {
-  Album,
-  Artist,
-  ArtTag,
-  Flash,
-  Group,
-  NewsEntry,
-  StaticPage,
-  Track,
-} from '../data/things.js';
+import T from '../data/things/index.js';
 
 export function unbound_getLinkThemeString(color, {
   getColors,
@@ -98,14 +89,14 @@ const linkIndex = (key, conf) =>
 // the given thing based on what it's an instance of. This is used for the
 // link.anything() function.
 const linkAnythingMapping = [
-  [Album, 'album'],
-  [Artist, 'artist'],
-  [ArtTag, 'tag'],
-  [Flash, 'flash'],
-  [Group, 'groupInfo'],
-  [NewsEntry, 'newsEntry'],
-  [StaticPage, 'staticPage'],
-  [Track, 'track'],
+  [T.Album, 'album'],
+  [T.Artist, 'artist'],
+  [T.ArtTag, 'tag'],
+  [T.Flash, 'flash'],
+  [T.Group, 'groupInfo'],
+  [T.NewsEntry, 'newsEntry'],
+  [T.StaticPage, 'staticPage'],
+  [T.Track, 'track'],
 ];
 
 const link = {
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 808a7e1c..e8fdf932 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -405,7 +405,7 @@ export function _withAggregate(mode, aggregateOpts, fn) {
 }
 
 export function showAggregate(topError, {
-  pathToFile = (p) => p,
+  pathToFileURL = f => f,
   showTraces = true,
 } = {}) {
   const recursive = (error, {level}) => {
@@ -429,9 +429,7 @@ export function showAggregate(topError, {
         ? '- ' +
           stackLine
             .trim()
-            .replace(/file:\/\/(.*\.js)/, (match, pathname) =>
-              pathToFile(pathname)
-            )
+            .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match))
         : '(no stack trace)';
       header += ` ${color.dim(tracePart)}`;
     }