« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data/yaml.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/yaml.js')
-rw-r--r--src/data/yaml.js1735
1 files changed, 709 insertions, 1026 deletions
diff --git a/src/data/yaml.js b/src/data/yaml.js
index ab97ab7..86f3014 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -1,108 +1,109 @@
 // yaml.js - specification for HSMusic YAML data file format and utilities for
-// loading and processing YAML files and documents
+// loading, processing, and validating YAML files and documents
 
-import * as path from 'path';
-import yaml from 'js-yaml';
+import {readFile, stat} from 'node:fs/promises';
+import * as path from 'node:path';
+import {inspect as nodeInspect} from 'node:util';
 
-import {readFile} from 'fs/promises';
-import {inspect as nodeInspect} from 'util';
+import yaml from 'js-yaml';
 
-import T from './things/index.js';
+import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
+import {sortByName} from '#sort';
+import {atOffset, empty, filterProperties, typeAppearance, withEntries}
+  from '#sugar';
+import Thing from '#thing';
+import thingConstructors from '#things';
 
-import {color, ENABLE_COLOR, logInfo, logWarn} from '../util/cli.js';
+import {
+  filterReferenceErrors,
+  reportContentTextErrors,
+  reportDuplicateDirectories,
+} from '#data-checks';
 
 import {
+  annotateErrorWithFile,
   decorateErrorWithIndex,
-  empty,
-  mapAggregate,
+  decorateErrorWithAnnotation,
   openAggregate,
   showAggregate,
   withAggregate,
-} from '../util/sugar.js';
-
-import {
-  sortAlbumsTracksChronologically,
-  sortAlphabetically,
-  sortChronologically,
-} from '../util/wiki-data.js';
+} from '#aggregate';
 
-import find, {bindFind} from '../util/find.js';
-import {findFiles} from '../util/io.js';
-
-// --> General supporting stuff
-
-function inspect(value) {
-  return nodeInspect(value, {colors: ENABLE_COLOR});
+function inspect(value, opts = {}) {
+  return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
 
-// --> YAML data repository structure constants
-
-export const WIKI_INFO_FILE = 'wiki-info.yaml';
-export const BUILD_DIRECTIVE_DATA_FILE = 'build-directives.yaml';
-export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml';
-export const ARTIST_DATA_FILE = 'artists.yaml';
-export const FLASH_DATA_FILE = 'flashes.yaml';
-export const NEWS_DATA_FILE = 'news.yaml';
-export const ART_TAG_DATA_FILE = 'tags.yaml';
-export const GROUP_DATA_FILE = 'groups.yaml';
-
-export const DATA_ALBUM_DIRECTORY = 'album';
-export const DATA_STATIC_PAGE_DIRECTORY = 'static-page';
-
-// --> Document processing functions
-
 // General function for inputting a single document (usually loaded from YAML)
 // and outputting an instance of a provided Thing subclass.
 //
 // makeProcessDocument is a factory function: the returned function will take a
 // document and apply the configuration passed to makeProcessDocument in order
 // to construct a Thing subclass.
-function makeProcessDocument(
-  thingClass,
-  {
-    // Optional early step for transforming field values before providing them
-    // to the Thing's update() method. This is useful when the input format
-    // (i.e. values in the document) differ from the format the actual Thing
-    // expects.
-    //
-    // Each key and value are a field name (not an update() property) and a
-    // function which takes the value for that field and returns the value which
-    // will be passed on to update().
-    fieldTransformations = {},
-
-    // Mapping of Thing.update() source properties to field names.
-    //
-    // Note this is property -> field, not field -> property. This is a
-    // shorthand convenience because properties are generally typical
-    // camel-cased JS properties, while fields may contain whitespace and be
-    // more easily represented as quoted strings.
-    propertyFieldMapping,
-
-    // Completely ignored fields. These won't throw an unknown field error if
-    // they're present in a document, but they won't be used for Thing property
-    // generation, either. Useful for stuff that's present in data files but not
-    // yet implemented as part of a Thing's data model!
-    ignoredFields = [],
-  }
-) {
-  if (!thingClass) {
+//
+function makeProcessDocument(thingConstructor, {
+  // The bulk of configuration happens here in the spec's `fields` property.
+  // Each key is a field that's expected on the source document; fields that
+  // don't match one of these keys will cause an error. Values are object
+  // entries describing what to do with the field.
+  //
+  // A field entry's `property` tells what property the value for this field
+  // will be put into, on the respective Thing (subclass) instance.
+  //
+  // A field entry's `transform` optionally allows converting the raw value in
+  // YAML into some other format before providing setting it on the Thing
+  // instance.
+  //
+  // If a field entry has `ignore: true`, it will be completely skipped by the
+  // YAML parser - it won't be validated, read, or loaded into data objects.
+  // This is mainly useful for fields that are purely annotational or are
+  // currently placeholders.
+  //
+  fields: fieldSpecs = {},
+
+  // List of fields which are invalid when coexisting in a document.
+  // Data objects are generally allowing with regards to what properties go
+  // together, allowing for properties to be set separately from each other
+  // instead of complaining about invalid or unused-data cases. But it's
+  // useful to see these kinds of errors when actually validating YAML files!
+  //
+  // Each item of this array should itself be an object with a descriptive
+  // message and a list of fields. Of those fields, none should ever coexist
+  // with any other. For example:
+  //
+  //   [
+  //     {message: '...', fields: ['A', 'B', 'C']},
+  //     {message: '...', fields: ['C', 'D']},
+  //   ]
+  //
+  // ...means A can't coexist with B or C, B can't coexist with A or C, and
+  // C can't coexist iwth A, B, or D - but it's okay for D to coexist with
+  // A or B.
+  //
+  invalidFieldCombinations = [],
+}) {
+  if (!thingConstructor) {
     throw new Error(`Missing Thing class`);
   }
 
-  if (!propertyFieldMapping) {
-    throw new Error(`Expected propertyFieldMapping to be provided`);
+  if (!fieldSpecs) {
+    throw new Error(`Expected fields to be provided`);
   }
 
-  const knownFields = Object.values(propertyFieldMapping);
+  const knownFields = Object.keys(fieldSpecs);
 
-  // Invert the property-field mapping, since it'll come in handy for
-  // assigning update() source values later.
-  const fieldPropertyMapping = Object.fromEntries(
-    Object.entries(propertyFieldMapping)
-      .map(([property, field]) => [field, property]));
+  const ignoredFields =
+    Object.entries(fieldSpecs)
+      .filter(([, {ignore}]) => ignore)
+      .map(([field]) => field);
 
+  const propertyToField =
+    withEntries(fieldSpecs, entries => entries
+      .map(([field, {property}]) => [property, field]));
+
+  // TODO: Is this function even necessary??
+  // Aren't we doing basically the same work in the function it's decorating???
   const decorateErrorWithName = (fn) => {
-    const nameField = propertyFieldMapping['name'];
+    const nameField = propertyToField.name;
     if (!nameField) return fn;
 
     return (document) => {
@@ -112,345 +113,245 @@ function makeProcessDocument(
         const name = document[nameField];
         error.message = name
           ? `(name: ${inspect(name)}) ${error.message}`
-          : `(${color.dim(`no name found`)}) ${error.message}`;
+          : `(${colors.dim(`no name found`)}) ${error.message}`;
         throw error;
       }
     };
   };
 
   return decorateErrorWithName((document) => {
+    const nameField = propertyToField.name;
+    const namePart =
+      (nameField
+        ? (document[nameField]
+          ? ` named ${colors.green(`"${document[nameField]}"`)}`
+          : ` (name field, "${nameField}", not specified)`)
+        : ``);
+
+    const constructorPart =
+      (thingConstructor[Thing.friendlyName]
+        ? thingConstructor[Thing.friendlyName]
+     : thingConstructor.name
+        ? thingConstructor.name
+        : `document`);
+
+    const aggregate = openAggregate({
+      message: `Errors processing ${constructorPart}` + namePart,
+    });
+
     const documentEntries = Object.entries(document)
       .filter(([field]) => !ignoredFields.includes(field));
 
+    const skippedFields = new Set();
+
     const unknownFields = documentEntries
       .map(([field]) => field)
       .filter((field) => !knownFields.includes(field));
 
     if (!empty(unknownFields)) {
-      throw new makeProcessDocument.UnknownFieldsError(unknownFields);
+      aggregate.push(new UnknownFieldsError(unknownFields));
+
+      for (const field of unknownFields) {
+        skippedFields.add(field);
+      }
+    }
+
+    const presentFields = Object.keys(document);
+
+    const fieldCombinationErrors = [];
+
+    for (const {message, fields} of invalidFieldCombinations) {
+      const fieldsPresent =
+        presentFields.filter(field => fields.includes(field));
+
+      if (fieldsPresent.length >= 2) {
+        const filteredDocument =
+          filterProperties(
+            document,
+            fieldsPresent,
+            {preserveOriginalOrder: true});
+
+        fieldCombinationErrors.push(
+          new FieldCombinationError(filteredDocument, message));
+
+        for (const field of Object.keys(filteredDocument)) {
+          skippedFields.add(field);
+        }
+      }
+    }
+
+    if (!empty(fieldCombinationErrors)) {
+      aggregate.push(new FieldCombinationAggregateError(fieldCombinationErrors));
     }
 
     const fieldValues = {};
 
-    for (const [field, value] of documentEntries) {
-      if (Object.hasOwn(fieldTransformations, field)) {
-        fieldValues[field] = fieldTransformations[field](value);
-      } else {
-        fieldValues[field] = value;
+    for (const [field, documentValue] of documentEntries) {
+      if (skippedFields.has(field)) continue;
+
+      // This variable would like to certify itself as "not into capitalism".
+      let propertyValue =
+        (fieldSpecs[field].transform
+          ? fieldSpecs[field].transform(documentValue)
+          : documentValue);
+
+      // Completely blank items in a YAML list are read as null.
+      // They're handy to have around when filling out a document and shouldn't
+      // be considered an error (or data at all).
+      if (Array.isArray(propertyValue)) {
+        const wasEmpty = empty(propertyValue);
+
+        propertyValue =
+          propertyValue.filter(item => item !== null);
+
+        const isEmpty = empty(propertyValue);
+
+        // Don't set arrays which are empty as a result of the above filter.
+        // Arrays which were originally empty, i.e. `Field: []`, are still
+        // valid data, but if it's just an array not containing any filled out
+        // items, it should be treated as a placeholder and skipped over.
+        if (isEmpty && !wasEmpty) {
+          propertyValue = null;
+        }
       }
+
+      fieldValues[field] = propertyValue;
     }
 
-    const sourceProperties = {};
+    const thing = Reflect.construct(thingConstructor, []);
+
+    const fieldValueErrors = [];
 
     for (const [field, value] of Object.entries(fieldValues)) {
-      const property = fieldPropertyMapping[field];
-      sourceProperties[property] = value;
+      const {property} = fieldSpecs[field];
+
+      try {
+        thing[property] = value;
+      } catch (caughtError) {
+        skippedFields.add(field);
+        fieldValueErrors.push(new FieldValueError(
+          field, value, {cause: caughtError}));
+      }
     }
 
-    const thing = Reflect.construct(thingClass, []);
+    if (!empty(fieldValueErrors)) {
+      aggregate.push(new FieldValueAggregateError(
+        fieldValueErrors, thingConstructor));
+    }
 
-    withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({call}) => {
-      for (const [property, value] of Object.entries(sourceProperties)) {
-        call(() => (thing[property] = value));
-      }
-    });
+    if (skippedFields.size >= 1) {
+      aggregate.push(
+        new SkippedFieldsSummaryError(
+          filterProperties(
+            document,
+            Array.from(skippedFields),
+            {preserveOriginalOrder: true})));
+    }
 
-    return thing;
+    return {thing, aggregate};
   });
 }
 
-makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error {
+export class UnknownFieldsError extends Error {
   constructor(fields) {
-    super(`Unknown fields present: ${fields.join(', ')}`);
+    super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`);
     this.fields = fields;
   }
-};
+}
 
-export const processAlbumDocument = makeProcessDocument(T.Album, {
-  fieldTransformations: {
-    'Artists': parseContributors,
-    'Cover Artists': parseContributors,
-    'Default Track Cover Artists': parseContributors,
-    'Wallpaper Artists': parseContributors,
-    'Banner Artists': parseContributors,
-
-    'Date': (value) => new Date(value),
-    'Date Added': (value) => new Date(value),
-    'Cover Art Date': (value) => new Date(value),
-    'Default Track Cover Art Date': (value) => new Date(value),
-
-    'Banner Dimensions': parseDimensions,
-
-    'Additional Files': parseAdditionalFiles,
-  },
-
-  propertyFieldMapping: {
-    name: 'Album',
-
-    color: 'Color',
-    directory: 'Directory',
-    urls: 'URLs',
-
-    artistContribsByRef: 'Artists',
-    coverArtistContribsByRef: 'Cover Artists',
-    trackCoverArtistContribsByRef: 'Default Track Cover Artists',
-
-    coverArtFileExtension: 'Cover Art File Extension',
-    trackCoverArtFileExtension: 'Track Art File Extension',
-
-    wallpaperArtistContribsByRef: 'Wallpaper Artists',
-    wallpaperStyle: 'Wallpaper Style',
-    wallpaperFileExtension: 'Wallpaper File Extension',
-
-    bannerArtistContribsByRef: 'Banner Artists',
-    bannerStyle: 'Banner Style',
-    bannerFileExtension: 'Banner File Extension',
-    bannerDimensions: 'Banner Dimensions',
+export class FieldCombinationAggregateError extends AggregateError {
+  constructor(errors) {
+    super(errors, `Invalid field combinations - all involved fields ignored`);
+  }
+}
 
-    date: 'Date',
-    trackArtDate: 'Default Track Cover Art Date',
-    coverArtDate: 'Cover Art Date',
-    dateAddedToWiki: 'Date Added',
+export class FieldCombinationError extends Error {
+  constructor(fields, message) {
+    const fieldNames = Object.keys(fields);
+
+    const fieldNamesText =
+      fieldNames
+        .map(field => colors.red(field))
+        .join(', ');
+
+    const mainMessage = `Don't combine ${fieldNamesText}`;
+
+    const causeMessage =
+      (typeof message === 'function'
+        ? message(fields)
+     : typeof message === 'string'
+        ? message
+        : null);
+
+    super(mainMessage, {
+      cause:
+        (causeMessage
+          ? new Error(causeMessage)
+          : null),
+    });
 
-    hasCoverArt: 'Has Cover Art',
-    hasTrackArt: 'Has Track Art',
-    hasTrackNumbers: 'Has Track Numbers',
-    isMajorRelease: 'Major Release',
-    isListedOnHomepage: 'Listed on Homepage',
-
-    groupsByRef: 'Groups',
-    artTagsByRef: 'Art Tags',
-    commentary: 'Commentary',
-
-    additionalFiles: 'Additional Files',
-  },
-});
-
-export const processTrackGroupDocument = makeProcessDocument(T.TrackGroup, {
-  fieldTransformations: {
-    'Date Originally Released': (value) => new Date(value),
-  },
-
-  propertyFieldMapping: {
-    name: 'Group',
-    color: 'Color',
-    dateOriginallyReleased: 'Date Originally Released',
-  },
-});
-
-export const processTrackDocument = makeProcessDocument(T.Track, {
-  fieldTransformations: {
-    'Duration': getDurationInSeconds,
-
-    'Date First Released': (value) => new Date(value),
-    'Cover Art Date': (value) => new Date(value),
-
-    'Artists': parseContributors,
-    'Contributors': parseContributors,
-    'Cover Artists': parseContributors,
-
-    'Additional Files': parseAdditionalFiles,
-  },
-
-  propertyFieldMapping: {
-    name: 'Track',
-
-    directory: 'Directory',
-    duration: 'Duration',
-    urls: 'URLs',
-
-    coverArtDate: 'Cover Art Date',
-    coverArtFileExtension: 'Cover Art File Extension',
-    dateFirstReleased: 'Date First Released',
-    hasCoverArt: 'Has Cover Art',
-    hasURLs: 'Has URLs',
-
-    referencedTracksByRef: 'Referenced Tracks',
-    sampledTracksByRef: 'Sampled Tracks',
-    artistContribsByRef: 'Artists',
-    contributorContribsByRef: 'Contributors',
-    coverArtistContribsByRef: 'Cover Artists',
-    artTagsByRef: 'Art Tags',
-    originalReleaseTrackByRef: 'Originally Released As',
-
-    commentary: 'Commentary',
-    lyrics: 'Lyrics',
-
-    additionalFiles: 'Additional Files',
-  },
-});
-
-export const processArtistDocument = makeProcessDocument(T.Artist, {
-  propertyFieldMapping: {
-    name: 'Artist',
-
-    directory: 'Directory',
-    urls: 'URLs',
-    hasAvatar: 'Has Avatar',
-    avatarFileExtension: 'Avatar File Extension',
-
-    aliasNames: 'Aliases',
-
-    contextNotes: 'Context Notes',
-  },
-
-  ignoredFields: ['Dead URLs'],
-});
-
-export const processFlashDocument = makeProcessDocument(T.Flash, {
-  fieldTransformations: {
-    'Date': (value) => new Date(value),
-
-    'Contributors': parseContributors,
-  },
-
-  propertyFieldMapping: {
-    name: 'Flash',
-
-    directory: 'Directory',
-    page: 'Page',
-    date: 'Date',
-    coverArtFileExtension: 'Cover Art File Extension',
-
-    featuredTracksByRef: 'Featured Tracks',
-    contributorContribsByRef: 'Contributors',
-    urls: 'URLs',
-  },
-});
-
-export const processFlashActDocument = makeProcessDocument(T.FlashAct, {
-  propertyFieldMapping: {
-    name: 'Act',
-    color: 'Color',
-    anchor: 'Anchor',
-    jump: 'Jump',
-    jumpColor: 'Jump Color',
-  },
-});
-
-export const processNewsEntryDocument = makeProcessDocument(T.NewsEntry, {
-  fieldTransformations: {
-    'Date': (value) => new Date(value),
-  },
-
-  propertyFieldMapping: {
-    name: 'Name',
-    directory: 'Directory',
-    date: 'Date',
-    content: 'Content',
-  },
-});
-
-export const processArtTagDocument = makeProcessDocument(T.ArtTag, {
-  propertyFieldMapping: {
-    name: 'Tag',
-    directory: 'Directory',
-    color: 'Color',
-    isContentWarning: 'Is CW',
-  },
-});
-
-export const processGroupDocument = makeProcessDocument(T.Group, {
-  propertyFieldMapping: {
-    name: 'Group',
-    directory: 'Directory',
-    description: 'Description',
-    urls: 'URLs',
-  },
-});
-
-export const processGroupCategoryDocument = makeProcessDocument(T.GroupCategory, {
-  propertyFieldMapping: {
-    name: 'Category',
-    color: 'Color',
-  },
-});
-
-export const processStaticPageDocument = makeProcessDocument(T.StaticPage, {
-  propertyFieldMapping: {
-    name: 'Name',
-    nameShort: 'Short Name',
-    directory: 'Directory',
-
-    content: 'Content',
-    stylesheet: 'Style',
-
-    showInNavigationBar: 'Show in Navigation Bar',
-  },
-});
-
-export const processWikiInfoDocument = makeProcessDocument(T.WikiInfo, {
-  propertyFieldMapping: {
-    name: 'Name',
-    nameShort: 'Short Name',
-    color: 'Color',
-    description: 'Description',
-    footerContent: 'Footer Content',
-    defaultLanguage: 'Default Language',
-    canonicalBase: 'Canonical Base',
-    divideTrackListsByGroupsByRef: 'Divide Track Lists By Groups',
-    enableFlashesAndGames: 'Enable Flashes & Games',
-    enableListings: 'Enable Listings',
-    enableNews: 'Enable News',
-    enableArtTagUI: 'Enable Art Tag UI',
-    enableGroupUI: 'Enable Group UI',
-  },
-});
-
-export const processHomepageLayoutDocument = makeProcessDocument(T.HomepageLayout, {
-  propertyFieldMapping: {
-    sidebarContent: 'Sidebar Content',
-  },
-
-  ignoredFields: ['Homepage'],
-});
-
-export function makeProcessHomepageLayoutRowDocument(rowClass, spec) {
-  return makeProcessDocument(rowClass, {
-    ...spec,
-
-    propertyFieldMapping: {
-      name: 'Row',
-      color: 'Color',
-      type: 'Type',
-      ...spec.propertyFieldMapping,
-    },
-  });
+    this.fields = fields;
+  }
 }
 
-export const homepageLayoutRowTypeProcessMapping = {
-  albums: makeProcessHomepageLayoutRowDocument(T.HomepageLayoutAlbumsRow, {
-    propertyFieldMapping: {
-      sourceGroupByRef: 'Group',
-      countAlbumsFromGroup: 'Count',
-      sourceAlbumsByRef: 'Albums',
-      actionLinks: 'Actions',
-    },
-  }),
-};
-
-export function processHomepageLayoutRowDocument(document) {
-  const type = document['Type'];
+export class FieldValueAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
 
-  const match = Object.entries(homepageLayoutRowTypeProcessMapping)
-    .find(([key]) => key === type);
+  constructor(errors, thingConstructor) {
+    const constructorText =
+      colors.green(thingConstructor.name);
 
-  if (!match) {
-    throw new TypeError(`No processDocument function for row type ${type}!`);
+    super(
+      errors,
+      `Errors processing field values for ${constructorText}`);
   }
-
-  return match[1](document);
 }
 
-// --> Utilities shared across document parsing functions
+export class FieldValueError extends Error {
+  constructor(field, value, options) {
+    const fieldText =
+      colors.green(`"${field}"`);
 
-export function getDurationInSeconds(string) {
-  if (typeof string === 'number') {
-    return string;
+    const valueText =
+      inspect(value, {maxStringLength: 40});
+
+    super(
+      `Failed to set ${fieldText} field to ${valueText}`,
+      options);
   }
+}
 
+export class SkippedFieldsSummaryError extends Error {
+  constructor(filteredDocument) {
+    const entries = Object.entries(filteredDocument);
+
+    const lines =
+      entries.map(([field, value]) =>
+        ` - ${field}: ` +
+        inspect(value, {maxStringLength: 70})
+          .split('\n')
+          .map((line, index) => index === 0 ? line : `   ${line}`)
+          .join('\n'));
+
+    const numFieldsText =
+      (entries.length === 1
+        ? `1 field`
+        : `${entries.length} fields`);
+
+    super(
+      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) +
+      lines.join('\n') + '\n' +
+      colors.bright(colors.yellow(`See above errors for details.`)));
+  }
+}
+
+export function parseDate(date) {
+  return new Date(date);
+}
+
+export function parseDuration(string) {
   if (typeof string !== 'string') {
-    throw new TypeError(`Expected a string or number, got ${string}`);
+    return string;
   }
 
   const parts = string.split(':').map((n) => parseInt(n));
@@ -464,7 +365,6 @@ export function getDurationInSeconds(string) {
 }
 
 export function parseAdditionalFiles(array) {
-  if (!array) return null;
   if (!Array.isArray(array)) {
     // Error will be caught when validating against whatever this value is
     return array;
@@ -477,56 +377,63 @@ export function parseAdditionalFiles(array) {
   }));
 }
 
-export function parseCommentary(text) {
-  if (text) {
-    const lines = String(text.trim()).split('\n');
-    if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) {
-      throw new Error(`Missing commentary citation: "${lines[0].slice(0, 40)}..."`);
-    }
-    return text;
-  } else {
-    return null;
-  }
-}
+export const extractAccentRegex =
+  /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
 
-export function parseContributors(contributors) {
-  if (!contributors) {
-    return null;
-  }
+export const extractPrefixAccentRegex =
+  /^(?:\((?<accent>.*)\) )?(?<main>.*?)$/;
 
-  if (contributors.length === 1 && contributors[0].startsWith('<i>')) {
-    const arr = [];
-    arr.textContent = contributors[0];
-    return arr;
+export function parseContributors(contributionStrings) {
+  // If this isn't something we can parse, just return it as-is.
+  // The Thing object's validators will handle the data error better
+  // than we're able to here.
+  if (!Array.isArray(contributionStrings)) {
+    return contributionStrings;
   }
 
-  contributors = contributors.map((contrib) => {
-    // 8asically, the format is "Who (What)", or just "Who". 8e sure to
-    // keep in mind that "what" doesn't necessarily have a value!
-    const match = contrib.match(/^(.*?)( \((.*)\))?$/);
-    if (!match) {
-      return contrib;
-    }
-    const who = match[1];
-    const what = match[3] || null;
-    return {who, what};
+  return contributionStrings.map(item => {
+    if (typeof item === 'object' && item['Who'])
+      return {who: item['Who'], what: item['What'] ?? null};
+
+    if (typeof item !== 'string') return item;
+
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      who: match.groups.main,
+      what: match.groups.accent ?? null,
+    };
   });
+}
 
-  const badContributor = contributors.find((val) => typeof val === 'string');
-  if (badContributor) {
-    throw new Error(`Incorrectly formatted contribution: "${badContributor}".`);
+export function parseAdditionalNames(additionalNameStrings) {
+  if (!Array.isArray(additionalNameStrings)) {
+    return additionalNameStrings;
   }
 
-  if (contributors.length === 1 && contributors[0].who === 'none') {
-    return null;
-  }
+  return additionalNameStrings.map(item => {
+    if (typeof item === 'object' && item['Name'])
+      return {name: item['Name'], annotation: item['Annotation'] ?? null};
+
+    if (typeof item !== 'string') return item;
 
-  return contributors;
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      name: match.groups.main,
+      annotation: match.groups.accent ?? null,
+    };
+  });
 }
 
-function parseDimensions(string) {
-  if (!string) {
-    return null;
+export function parseDimensions(string) {
+  // It's technically possible to pass an array like [30, 40] through here.
+  // That's not really an issue because if it isn't of the appropriate shape,
+  // the Thing object's validators will handle the error.
+  if (typeof string !== 'string') {
+    return string;
   }
 
   const parts = string.split(/[x,* ]+/g);
@@ -544,8 +451,6 @@ function parseDimensions(string) {
   return nums;
 }
 
-// --> Data repository loading functions and descriptors
-
 // documentModes: Symbols indicating sets of behavior for loading and processing
 // data files.
 export const documentModes = {
@@ -618,313 +523,152 @@ export const documentModes = {
 //   them to each other, setting additional properties, etc). Input argument
 //   format depends on documentMode.
 //
-export const dataSteps = [
-  {
-    title: `Process wiki info file`,
-    file: WIKI_INFO_FILE,
+export const getDataSteps = () => {
+  const steps = [];
 
-    documentMode: documentModes.oneDocumentTotal,
-    processDocument: processWikiInfoDocument,
+  for (const thingConstructor of Object.values(thingConstructors)) {
+    const getSpecFn = thingConstructor[Thing.getYamlLoadingSpec];
+    if (!getSpecFn) continue;
 
-    save(wikiInfo) {
-      if (!wikiInfo) {
-        return;
-      }
+    steps.push(getSpecFn({
+      documentModes,
+      thingConstructors,
+    }));
+  }
 
-      return {wikiInfo};
-    },
-  },
+  sortByName(steps, {getName: step => step.title});
 
-  {
-    title: `Process album files`,
-    files: async (dataPath) =>
-      (
-        await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY), {
-          filter: (f) => path.extname(f) === '.yaml',
-          joinParentDirectory: false,
-        })
-      ).map(file => path.join(DATA_ALBUM_DIRECTORY, file)),
-
-    documentMode: documentModes.headerAndEntries,
-    processHeaderDocument: processAlbumDocument,
-    processEntryDocument(document) {
-      return 'Group' in document
-        ? processTrackGroupDocument(document)
-        : processTrackDocument(document);
-    },
-
-    save(results) {
-      const albumData = [];
-      const trackData = [];
-
-      for (const {header: album, entries} of results) {
-        // We can't mutate an array once it's set as a property
-        // value, so prepare the tracks and track groups that will
-        // show up in a track list all the way before actually
-        // applying them.
-        const trackGroups = [];
-        let currentTracksByRef = null;
-        let currentTrackGroup = null;
-
-        const albumRef = T.Thing.getReference(album);
-
-        const closeCurrentTrackGroup = () => {
-          if (currentTracksByRef) {
-            let trackGroup;
-
-            if (currentTrackGroup) {
-              trackGroup = currentTrackGroup;
-            } else {
-              trackGroup = new T.TrackGroup();
-              trackGroup.name = `Default Track Group`;
-              trackGroup.isDefaultTrackGroup = true;
-            }
+  return steps;
+};
 
-            trackGroup.album = album;
-            trackGroup.tracksByRef = currentTracksByRef;
-            trackGroups.push(trackGroup);
-          }
-        };
+export async function loadAndProcessDataDocuments({dataPath}) {
+  const processDataAggregate = openAggregate({
+    message: `Errors processing data files`,
+  });
+  const wikiDataResult = {};
 
-        for (const entry of entries) {
-          if (entry instanceof T.TrackGroup) {
-            closeCurrentTrackGroup();
-            currentTracksByRef = [];
-            currentTrackGroup = entry;
-            continue;
-          }
+  function decorateErrorWithFile(fn) {
+    return decorateErrorWithAnnotation(fn,
+      (caughtError, firstArg) =>
+        annotateErrorWithFile(
+          caughtError,
+          path.relative(
+            dataPath,
+            (typeof firstArg === 'object'
+              ? firstArg.file
+              : firstArg))));
+  }
 
-          trackData.push(entry);
+  function asyncDecorateErrorWithFile(fn) {
+    return decorateErrorWithFile(fn).async;
+  }
 
-          entry.dataSourceAlbumByRef = albumRef;
+  for (const dataStep of getDataSteps()) {
+    await processDataAggregate.nestAsync(
+      {
+        message: `Errors during data step: ${colors.bright(dataStep.title)}`,
+        translucent: true,
+      },
+      async ({call, callAsync, map, mapAsync, push}) => {
+        const {documentMode} = dataStep;
 
-          const trackRef = T.Thing.getReference(entry);
-          if (currentTracksByRef) {
-            currentTracksByRef.push(trackRef);
-          } else {
-            currentTracksByRef = [trackRef];
-          }
+        if (!Object.values(documentModes).includes(documentMode)) {
+          throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
         }
 
-        closeCurrentTrackGroup();
-
-        album.trackGroups = trackGroups;
-        albumData.push(album);
-      }
+        // Hear me out, it's been like 1200 years since I wrote the rest of
+        // this beautifully error-containing code and I don't know how to
+        // integrate this nicely. So I'm just returning the result and the
+        // error that should be thrown. Yes, we're back in callback hell,
+        // just without the callbacks. Thank you.
+        const filterBlankDocuments = documents => {
+          const aggregate = openAggregate({
+            message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`,
+          });
 
-      return {albumData, trackData};
-    },
-  },
+          const filteredDocuments =
+            documents
+              .filter(doc => doc !== null);
+
+          if (filteredDocuments.length !== documents.length) {
+            const blankIndexRangeInfo =
+              documents
+                .map((doc, index) => [doc, index])
+                .filter(([doc]) => doc === null)
+                .map(([doc, index]) => index)
+                .reduce((accumulator, index) => {
+                  if (accumulator.length === 0) {
+                    return [[index, index]];
+                  }
+                  const current = accumulator.at(-1);
+                  const rest = accumulator.slice(0, -1);
+                  if (current[1] === index - 1) {
+                    return rest.concat([[current[0], index]]);
+                  } else {
+                    return accumulator.concat([[index, index]]);
+                  }
+                }, [])
+                .map(([start, end]) => ({
+                  start,
+                  end,
+                  count: end - start + 1,
+                  previous: atOffset(documents, start, -1),
+                  next: atOffset(documents, end, +1),
+                }));
+
+            for (const {start, end, count, previous, next} of blankIndexRangeInfo) {
+              const parts = [];
+
+              if (count === 1) {
+                const range = `#${start + 1}`;
+                parts.push(`${count} document (${colors.yellow(range)}), `);
+              } else {
+                const range = `#${start + 1}-${end + 1}`;
+                parts.push(`${count} documents (${colors.yellow(range)}), `);
+              }
 
-  {
-    title: `Process artists file`,
-    file: ARTIST_DATA_FILE,
-
-    documentMode: documentModes.allInOne,
-    processDocument: processArtistDocument,
-
-    save(results) {
-      const artistData = results;
-
-      const artistAliasData = results.flatMap((artist) => {
-        const origRef = T.Thing.getReference(artist);
-        return artist.aliasNames?.map((name) => {
-          const alias = new T.Artist();
-          alias.name = name;
-          alias.isAlias = true;
-          alias.aliasedArtistRef = origRef;
-          alias.artistData = artistData;
-          return alias;
-        }) ?? [];
-      });
-
-      return {artistData, artistAliasData};
-    },
-  },
-
-  // TODO: WD.wikiInfo.enableFlashesAndGames &&
-  {
-    title: `Process flashes file`,
-    file: FLASH_DATA_FILE,
-
-    documentMode: documentModes.allInOne,
-    processDocument(document) {
-      return 'Act' in document
-        ? processFlashActDocument(document)
-        : processFlashDocument(document);
-    },
-
-    save(results) {
-      let flashAct;
-      let flashesByRef = [];
-
-      if (results[0] && !(results[0] instanceof T.FlashAct)) {
-        throw new Error(`Expected an act at top of flash data file`);
-      }
+              if (previous === null) {
+                parts.push(`at start of file`);
+              } else if (next === null) {
+                parts.push(`at end of file`);
+              } else {
+                const previousDescription = Object.entries(previous).at(0).join(': ');
+                const nextDescription = Object.entries(next).at(0).join(': ');
+                parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`);
+              }
 
-      for (const thing of results) {
-        if (thing instanceof T.FlashAct) {
-          if (flashAct) {
-            Object.assign(flashAct, {flashesByRef});
+              aggregate.push(new Error(parts.join('')));
+            }
           }
 
-          flashAct = thing;
-          flashesByRef = [];
-        } else {
-          flashesByRef.push(T.Thing.getReference(thing));
-        }
-      }
-
-      if (flashAct) {
-        Object.assign(flashAct, {flashesByRef});
-      }
-
-      const flashData = results.filter((x) => x instanceof T.Flash);
-      const flashActData = results.filter((x) => x instanceof T.FlashAct);
-
-      return {flashData, flashActData};
-    },
-  },
+          return {documents: filteredDocuments, aggregate};
+        };
 
-  {
-    title: `Process groups file`,
-    file: GROUP_DATA_FILE,
-
-    documentMode: documentModes.allInOne,
-    processDocument(document) {
-      return 'Category' in document
-        ? processGroupCategoryDocument(document)
-        : processGroupDocument(document);
-    },
-
-    save(results) {
-      let groupCategory;
-      let groupsByRef = [];
-
-      if (results[0] && !(results[0] instanceof T.GroupCategory)) {
-        throw new Error(`Expected a category at top of group data file`);
-      }
+        const processDocument = (document, thingClassOrFn) => {
+          const thingClass =
+            (thingClassOrFn.prototype instanceof Thing
+              ? thingClassOrFn
+              : thingClassOrFn(document));
 
-      for (const thing of results) {
-        if (thing instanceof T.GroupCategory) {
-          if (groupCategory) {
-            Object.assign(groupCategory, {groupsByRef});
+          if (typeof thingClass !== 'function') {
+            throw new Error(`Expected a thing class, got ${typeAppearance(thingClass)}`);
           }
 
-          groupCategory = thing;
-          groupsByRef = [];
-        } else {
-          groupsByRef.push(T.Thing.getReference(thing));
-        }
-      }
-
-      if (groupCategory) {
-        Object.assign(groupCategory, {groupsByRef});
-      }
-
-      const groupData = results.filter((x) => x instanceof T.Group);
-      const groupCategoryData = results.filter((x) => x instanceof T.GroupCategory);
-
-      return {groupData, groupCategoryData};
-    },
-  },
-
-  {
-    title: `Process homepage layout file`,
-    files: [HOMEPAGE_LAYOUT_DATA_FILE],
-
-    documentMode: documentModes.headerAndEntries,
-    processHeaderDocument: processHomepageLayoutDocument,
-    processEntryDocument: processHomepageLayoutRowDocument,
-
-    save(results) {
-      if (!results[0]) {
-        return;
-      }
-
-      const {header: homepageLayout, entries: rows} = results[0];
-      Object.assign(homepageLayout, {rows});
-      return {homepageLayout};
-    },
-  },
-
-  // TODO: WD.wikiInfo.enableNews &&
-  {
-    title: `Process news data file`,
-    file: NEWS_DATA_FILE,
-
-    documentMode: documentModes.allInOne,
-    processDocument: processNewsEntryDocument,
-
-    save(newsData) {
-      sortChronologically(newsData);
-      newsData.reverse();
-
-      return {newsData};
-    },
-  },
-
-  {
-    title: `Process art tags file`,
-    file: ART_TAG_DATA_FILE,
-
-    documentMode: documentModes.allInOne,
-    processDocument: processArtTagDocument,
-
-    save(artTagData) {
-      sortAlphabetically(artTagData);
-
-      return {artTagData};
-    },
-  },
-
-  {
-    title: `Process static page files`,
-    files: async (dataPath) =>
-      (
-        await findFiles(path.join(dataPath, DATA_STATIC_PAGE_DIRECTORY), {
-          filter: f => path.extname(f) === '.yaml',
-          joinParentDirectory: false,
-        })
-      ).map(file => path.join(DATA_STATIC_PAGE_DIRECTORY, file)),
-
-    documentMode: documentModes.onePerFile,
-    processDocument: processStaticPageDocument,
-
-    save(staticPageData) {
-      return {staticPageData};
-    },
-  },
-];
-
-export async function loadAndProcessDataDocuments({dataPath}) {
-  const processDataAggregate = openAggregate({
-    message: `Errors processing data files`,
-  });
-  const wikiDataResult = {};
+          if (!(thingClass.prototype instanceof Thing)) {
+            throw new Error(`Expected a thing class, got ${thingClass.name}`);
+          }
 
-  function decorateErrorWithFile(fn) {
-    return (x, index, array) => {
-      try {
-        return fn(x, index, array);
-      } catch (error) {
-        error.message +=
-          (error.message.includes('\n') ? '\n' : ' ') +
-          `(file: ${color.bright(color.blue(path.relative(dataPath, x.file)))})`;
-        throw error;
-      }
-    };
-  }
+          const spec = thingClass[Thing.yamlDocumentSpec];
 
-  for (const dataStep of dataSteps) {
-    await processDataAggregate.nestAsync(
-      {message: `Errors during data step: ${dataStep.title}`},
-      async ({call, callAsync, map, mapAsync, nest}) => {
-        const {documentMode} = dataStep;
+          if (!spec) {
+            throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
+          }
 
-        if (!Object.values(documentModes).includes(documentMode)) {
-          throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
-        }
+          // TODO: Making a function to only call it just like that is
+          // obviously pretty jank! It should be created once per data step.
+          const fn = makeProcessDocument(thingClass, spec);
+          return fn(document);
+        };
 
         if (
           documentMode === documentModes.allInOne ||
@@ -940,34 +684,82 @@ export async function loadAndProcessDataDocuments({dataPath}) {
               ? await callAsync(dataStep.file, dataPath)
               : dataStep.file);
 
-          const readResult = await callAsync(readFile, file, 'utf-8');
+          const statResult = await callAsync(() =>
+            stat(file).then(
+              () => true,
+              error => {
+                if (error.code === 'ENOENT') {
+                  return false;
+                } else {
+                  throw error;
+                }
+              }));
+
+          if (statResult === false) {
+            const saveResult = call(dataStep.save, {
+              [documentModes.allInOne]: [],
+              [documentModes.oneDocumentTotal]: {},
+            }[documentMode]);
+
+            if (!saveResult) return;
+
+            Object.assign(wikiDataResult, saveResult);
 
-          if (!readResult) {
             return;
           }
 
-          const yamlResult =
-            documentMode === documentModes.oneDocumentTotal
-              ? call(yaml.load, readResult)
-              : call(yaml.loadAll, readResult);
+          const readResult = await callAsync(readFile, file, 'utf-8');
 
-          if (!yamlResult) {
+          if (!readResult) {
             return;
           }
 
           let processResults;
 
-          if (documentMode === documentModes.oneDocumentTotal) {
-            nest({message: `Errors processing document`}, ({call}) => {
-              processResults = call(dataStep.processDocument, yamlResult);
-            });
-          } else {
-            const {result, aggregate} = mapAggregate(
-              yamlResult,
-              decorateErrorWithIndex(dataStep.processDocument),
-              {message: `Errors processing documents`});
-            processResults = result;
-            call(aggregate.close);
+          switch (documentMode) {
+            case documentModes.oneDocumentTotal: {
+              const yamlResult = call(yaml.load, readResult);
+
+              if (!yamlResult) {
+                processResults = null;
+                break;
+              }
+
+              const {thing, aggregate} =
+                processDocument(yamlResult, dataStep.documentThing);
+
+              processResults = thing;
+
+              call(() => aggregate.close());
+
+              break;
+            }
+
+            case documentModes.allInOne: {
+              const yamlResults = call(yaml.loadAll, readResult);
+
+              if (!yamlResults) {
+                processResults = [];
+                return;
+              }
+
+              const {documents, aggregate: filterAggregate} =
+                filterBlankDocuments(yamlResults);
+
+              call(filterAggregate.close);
+
+              processResults = [];
+
+              map(documents, decorateErrorWithIndex(document => {
+                const {thing, aggregate} =
+                  processDocument(document, dataStep.documentThing);
+
+                processResults.push(thing);
+                aggregate.close();
+              }), {message: `Errors processing documents`});
+
+              break;
+            }
           }
 
           if (!processResults) return;
@@ -985,106 +777,123 @@ export async function loadAndProcessDataDocuments({dataPath}) {
           throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
         }
 
-        let files = (
-          typeof dataStep.files === 'function'
-            ? await callAsync(dataStep.files, dataPath)
-            : dataStep.files
-        )
+        const filesFromDataStep =
+          (typeof dataStep.files === 'function'
+            ? await callAsync(() =>
+                dataStep.files(dataPath).then(
+                  files => files,
+                  error => {
+                    if (error.code === 'ENOENT') {
+                      return [];
+                    } else {
+                      throw error;
+                    }
+                  }))
+            : dataStep.files);
+
+        const filesUnderDataPath =
+          filesFromDataStep
+            .map(file => path.join(dataPath, file));
+
+        const yamlResults = [];
+
+        await mapAsync(filesUnderDataPath, {message: `Errors loading data files`},
+          asyncDecorateErrorWithFile(async file => {
+            let contents;
+            try {
+              contents = await readFile(file, 'utf-8');
+            } catch (caughtError) {
+              throw new Error(`Failed to read data file`, {cause: caughtError});
+            }
 
-        if (!files) {
-          return;
-        }
+            let documents;
+            try {
+              documents = yaml.loadAll(contents);
+            } catch (caughtError) {
+              throw new Error(`Failed to parse valid YAML`, {cause: caughtError});
+            }
 
-        files = files.map((file) => path.join(dataPath, file));
-
-        const readResults = await mapAsync(
-          files,
-          (file) => readFile(file, 'utf-8').then((contents) => ({file, contents})),
-          {message: `Errors reading data files`}
-        );
-
-        const yamlResults = map(
-          readResults,
-          decorateErrorWithFile(({file, contents}) => ({
-            file,
-            documents: yaml.loadAll(contents),
-          })),
-          {message: `Errors parsing data files as valid YAML`}
-        );
-
-        let processResults;
-
-        if (documentMode === documentModes.headerAndEntries) {
-          nest({message: `Errors processing data files as valid documents`}, ({call, map}) => {
-            processResults = [];
-
-            yamlResults.forEach(({file, documents}) => {
-              const [headerDocument, ...entryDocuments] = documents;
-
-              const header = call(
-                decorateErrorWithFile(({document}) =>
-                  dataStep.processHeaderDocument(document)
-                ),
-                {file, document: headerDocument}
-              );
-
-              // Don't continue processing files whose header
-              // document is invalid - the entire file is excempt
-              // from data in this case.
-              if (!header) {
-                return;
-              }
+            const {documents: filteredDocuments, aggregate: filterAggregate} =
+              filterBlankDocuments(documents);
+
+            try {
+              filterAggregate.close();
+            } catch (caughtError) {
+              // Blank documents aren't a critical error, they're just something
+              // that should be noted - the (filtered) documents still get pushed.
+              const pathToFile = path.relative(dataPath, file);
+              annotateErrorWithFile(caughtError, pathToFile);
+              push(caughtError);
+            }
 
-              const entries = map(
-                entryDocuments.map((document) => ({file, document})),
-                decorateErrorWithFile(
-                  decorateErrorWithIndex(({document}) =>
-                    dataStep.processEntryDocument(document)
-                  )
-                ),
-                {message: `Errors processing entry documents`}
-              );
-
-              // Entries may be incomplete (i.e. any errored
-              // documents won't have a processed output
-              // represented here) - this is intentional! By
-              // principle, partial output is preferred over
-              // erroring an entire file.
-              processResults.push({header, entries});
-            });
-          });
-        }
+            yamlResults.push({file, documents: filteredDocuments});
+          }));
 
-        if (documentMode === documentModes.onePerFile) {
-          nest({message: `Errors processing data files as valid documents`}, ({call}) => {
-            processResults = [];
-
-            yamlResults.forEach(({file, documents}) => {
-              if (documents.length > 1) {
-                call(
-                  decorateErrorWithFile(() => {
-                    throw new Error(
-                      `Only expected one document to be present per file`
-                    );
-                  })
-                );
-                return;
-              }
+        const processResults = [];
 
-              const result = call(
-                decorateErrorWithFile(({document}) =>
-                  dataStep.processDocument(document)
-                ),
-                {file, document: documents[0]}
-              );
+        switch (documentMode) {
+          case documentModes.headerAndEntries:
+            map(yamlResults, {message: `Errors processing documents in data files`, translucent: true},
+              decorateErrorWithFile(({documents}) => {
+                const headerDocument = documents[0];
+                const entryDocuments = documents.slice(1).filter(Boolean);
 
-              if (!result) {
-                return;
-              }
+                if (!headerDocument)
+                  throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
 
-              processResults.push(result);
-            });
-          });
+                withAggregate({message: `Errors processing documents`}, ({push}) => {
+                  const {thing: headerObject, aggregate: headerAggregate} =
+                    processDocument(headerDocument, dataStep.headerDocumentThing);
+
+                  try {
+                    headerAggregate.close();
+                  } catch (caughtError) {
+                    caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
+                    push(caughtError);
+                  }
+
+                  const entryObjects = [];
+
+                  for (let index = 0; index < entryDocuments.length; index++) {
+                    const entryDocument = entryDocuments[index];
+
+                    const {thing: entryObject, aggregate: entryAggregate} =
+                      processDocument(entryDocument, dataStep.entryDocumentThing);
+
+                    entryObjects.push(entryObject);
+
+                    try {
+                      entryAggregate.close();
+                    } catch (caughtError) {
+                      caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
+                      push(caughtError);
+                    }
+                  }
+
+                  processResults.push({
+                    header: headerObject,
+                    entries: entryObjects,
+                  });
+                });
+              }));
+            break;
+
+          case documentModes.onePerFile:
+            map(yamlResults, {message: `Errors processing data files as valid documents`},
+              decorateErrorWithFile(({documents}) => {
+                if (documents.length > 1)
+                  throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
+
+                if (empty(documents) || !documents[0])
+                  throw new Error(`Expected a document, this file is empty`);
+
+                const {thing, aggregate} =
+                  processDocument(documents[0], dataStep.documentThing);
+
+                processResults.push(thing);
+                aggregate.close();
+              }));
+            break;
         }
 
         const saveResult = call(dataStep.save, processResults);
@@ -1104,40 +913,93 @@ export async function loadAndProcessDataDocuments({dataPath}) {
 
 // Data linking! Basically, provide (portions of) wikiData to the Things which
 // require it - they'll expose dynamically computed properties as a result (many
-// of which are required for page HTML generation).
+// of which are required for page HTML generation and other expected behavior).
 export function linkWikiDataArrays(wikiData) {
-  function assignWikiData(things, ...keys) {
-    for (let i = 0; i < things.length; i++) {
-      const thing = things[i];
-      for (let j = 0; j < keys.length; j++) {
-        const key = keys[j];
+  const linkWikiDataSpec = new Map([
+    [wikiData.albumData, [
+      'artTagData',
+      'artistData',
+      'groupData',
+    ]],
+
+    [wikiData.artTagData, [
+      'albumData',
+      'trackData',
+    ]],
+
+    [wikiData.artistData, [
+      'albumData',
+      'artistData',
+      'flashData',
+      'trackData',
+    ]],
+
+    [wikiData.flashData, [
+      'artistData',
+      'flashActData',
+      'trackData',
+    ]],
+
+    [wikiData.flashActData, [
+      'flashData',
+      'flashSideData',
+    ]],
+
+    [wikiData.flashSideData, [
+      'flashActData',
+    ]],
+
+    [wikiData.groupData, [
+      'albumData',
+      'groupCategoryData',
+    ]],
+
+    [wikiData.groupCategoryData, [
+      'groupData',
+    ]],
+
+    [wikiData.homepageLayout?.rows, [
+      'albumData',
+      'groupData',
+    ]],
+
+    [wikiData.trackData, [
+      'albumData',
+      'artTagData',
+      'artistData',
+      'flashData',
+      'trackData',
+    ]],
+
+    [[wikiData.wikiInfo], [
+      'groupData',
+    ]],
+  ]);
+
+  for (const [things, keys] of linkWikiDataSpec.entries()) {
+    if (things === undefined) continue;
+    for (const thing of things) {
+      if (thing === undefined) continue;
+      for (const key of keys) {
+        if (!(key in wikiData)) continue;
         thing[key] = wikiData[key];
       }
     }
   }
-
-  const WD = wikiData;
-
-  assignWikiData([WD.wikiInfo], 'groupData');
-
-  assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData', 'trackData');
-  WD.albumData.forEach((album) => assignWikiData(album.trackGroups, 'trackData'));
-
-  assignWikiData(WD.trackData, 'albumData', 'artistData', 'artTagData', 'flashData', 'trackData');
-  assignWikiData(WD.artistData, 'albumData', 'artistData', 'flashData', 'trackData');
-  assignWikiData(WD.groupData, 'albumData', 'groupCategoryData');
-  assignWikiData(WD.groupCategoryData, 'groupData');
-  assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData');
-  assignWikiData(WD.flashActData, 'flashData');
-  assignWikiData(WD.artTagData, 'albumData', 'trackData');
-  assignWikiData(WD.homepageLayout.rows, 'albumData', 'groupData');
 }
 
 export function sortWikiDataArrays(wikiData) {
-  Object.assign(wikiData, {
-    albumData: sortChronologically(wikiData.albumData.slice()),
-    trackData: sortAlbumsTracksChronologically(wikiData.trackData.slice()),
-  });
+  for (const [key, value] of Object.entries(wikiData)) {
+    if (!Array.isArray(value)) continue;
+    wikiData[key] = value.slice();
+  }
+
+  const steps = getDataSteps();
+
+  for (const step of steps) {
+    if (!step.sort) continue;
+    step.sort(wikiData);
+  }
 
   // Re-link data arrays, so that every object has the new, sorted versions.
   // Note that the sorting step deliberately creates new arrays (mutating
@@ -1147,196 +1009,6 @@ export function sortWikiDataArrays(wikiData) {
   linkWikiDataArrays(wikiData);
 }
 
-// Warn about directories which are reused across more than one of the same type
-// of Thing. Directories are the unique identifier for most data objects across
-// the wiki, so we have to make sure they aren't duplicated!  This also
-// altogether filters out instances of things with duplicate directories (so if
-// two tracks share the directory "megalovania", they'll both be skipped for the
-// build, for example).
-export function filterDuplicateDirectories(wikiData) {
-  const deduplicateSpec = [
-    'albumData',
-    'artTagData',
-    'flashData',
-    'groupData',
-    'newsData',
-    'trackData',
-  ];
-
-  const aggregate = openAggregate({message: `Duplicate directories found`});
-  for (const thingDataProp of deduplicateSpec) {
-    const thingData = wikiData[thingDataProp];
-    aggregate.nest({message: `Duplicate directories found in ${color.green('wikiData.' + thingDataProp)}`}, ({call}) => {
-      const directoryPlaces = Object.create(null);
-      const duplicateDirectories = [];
-
-      for (const thing of thingData) {
-        const {directory} = thing;
-        if (directory in directoryPlaces) {
-          directoryPlaces[directory].push(thing);
-          duplicateDirectories.push(directory);
-        } else {
-          directoryPlaces[directory] = [thing];
-        }
-      }
-
-      if (empty(duplicateDirectories)) return;
-
-      duplicateDirectories.sort((a, b) => {
-        const aL = a.toLowerCase();
-        const bL = b.toLowerCase();
-        return aL < bL ? -1 : aL > bL ? 1 : 0;
-      });
-
-      for (const directory of duplicateDirectories) {
-        const places = directoryPlaces[directory];
-        call(() => {
-          throw new Error(
-            `Duplicate directory ${color.green(directory)}:\n` +
-              places.map((thing) => ` - ` + inspect(thing)).join('\n')
-          );
-        });
-      }
-
-      const allDuplicatedThings = Object.values(directoryPlaces)
-        .filter((arr) => arr.length > 1)
-        .flat();
-
-      const filteredThings = thingData
-        .filter((thing) => !allDuplicatedThings.includes(thing));
-
-      wikiData[thingDataProp] = filteredThings;
-    });
-  }
-
-  // TODO: This code closes the aggregate but it generally gets closed again
-  // by the caller. This works but it might be weird to assume closing an
-  // aggregate twice is okay, maybe there's a better solution? Expose a new
-  // function on aggregates for checking if it *would* error?
-  // (i.e: errors.length > 0)
-  try {
-    aggregate.close();
-  } catch (error) {
-    // Duplicate entries were found and filtered out, resulting in altered
-    // wikiData arrays. These must be re-linked so objects receive the new
-    // data.
-    linkWikiDataArrays(wikiData);
-  }
-  return aggregate;
-}
-
-// Warn about references across data which don't match anything.  This involves
-// using the find() functions on all references, setting it to 'error' mode, and
-// collecting everything in a structured logged (which gets logged if there are
-// any errors). At the same time, we remove errored references from the thing's
-// data array.
-export function filterReferenceErrors(wikiData) {
-  const referenceSpec = [
-    ['wikiInfo', {
-      divideTrackListsByGroupsByRef: 'group',
-    }],
-
-    ['albumData', {
-      artistContribsByRef: '_contrib',
-      coverArtistContribsByRef: '_contrib',
-      trackCoverArtistContribsByRef: '_contrib',
-      wallpaperArtistContribsByRef: '_contrib',
-      bannerArtistContribsByRef: '_contrib',
-      groupsByRef: 'group',
-      artTagsByRef: 'artTag',
-    }],
-
-    ['trackData', {
-      artistContribsByRef: '_contrib',
-      contributorContribsByRef: '_contrib',
-      coverArtistContribsByRef: '_contrib',
-      referencedTracksByRef: 'track',
-      sampledTracksByRef: 'track',
-      artTagsByRef: 'artTag',
-      originalReleaseTrackByRef: 'track',
-    }],
-
-    ['groupCategoryData', {
-      groupsByRef: 'group',
-    }],
-
-    ['homepageLayout.rows', {
-      sourceGroupsByRef: 'group',
-      sourceAlbumsByRef: 'album',
-    }],
-
-    ['flashData', {
-      contributorContribsByRef: '_contrib',
-      featuredTracksByRef: 'track',
-    }],
-
-    ['flashActData', {
-      flashesByRef: 'flash',
-    }],
-  ];
-
-  function getNestedProp(obj, key) {
-    const recursive = (o, k) =>
-      k.length === 1 ? o[k[0]] : recursive(o[k[0]], k.slice(1));
-    const keys = key.split(/(?<=(?<!\\)(?:\\\\)*)\./);
-    return recursive(obj, keys);
-  }
-
-  const aggregate = openAggregate({message: `Errors validating between-thing references in data`});
-  const boundFind = bindFind(wikiData, {mode: 'error'});
-  for (const [thingDataProp, propSpec] of referenceSpec) {
-    const thingData = getNestedProp(wikiData, thingDataProp);
-
-    aggregate.nest({message: `Reference errors in ${color.green('wikiData.' + thingDataProp)}`}, ({nest}) => {
-      const things = Array.isArray(thingData) ? thingData : [thingData];
-
-      for (const thing of things) {
-        nest({message: `Reference errors in ${inspect(thing)}`}, ({filter}) => {
-          for (const [property, findFnKey] of Object.entries(propSpec)) {
-            if (!thing[property]) continue;
-
-            if (findFnKey === '_contrib') {
-              thing[property] = filter(
-                thing[property],
-                decorateErrorWithIndex(({who}) => {
-                  const alias = find.artist(who, wikiData.artistAliasData, {mode: 'quiet'});
-                  if (alias) {
-                    const original = find.artist(alias.aliasedArtistRef, wikiData.artistData, {mode: 'quiet'});
-                    throw new Error(`Reference ${color.red(who)} is to an alias, should be ${color.green(original.name)}`);
-                  }
-                  return boundFind.artist(who);
-                }),
-                {message: `Reference errors in contributions ${color.green(property)} (${color.green('find.artist')})`});
-              continue;
-            }
-
-            const findFn = boundFind[findFnKey];
-            const value = thing[property];
-
-            if (Array.isArray(value)) {
-              thing[property] = filter(
-                value,
-                decorateErrorWithIndex(findFn),
-                {message: `Reference errors in property ${color.green(property)} (${color.green('find.' + findFnKey)})`});
-            } else {
-              nest({message: `Reference error in property ${color.green(property)} (${color.green('find.' + findFnKey)})`}, ({call}) => {
-                try {
-                  call(findFn, value);
-                } catch (error) {
-                  thing[property] = null;
-                  throw error;
-                }
-              });
-            }
-          }
-        });
-      }
-    });
-  }
-
-  return aggregate;
-}
-
 // Utility function for loading all wiki data from the provided YAML data
 // directory (e.g. the root of the hsmusic-data repository). This doesn't
 // provide much in the way of customization; it's meant to be used more as
@@ -1344,8 +1016,11 @@ export function filterReferenceErrors(wikiData) {
 // where reporting info about data loading isn't as relevant as during the
 // main wiki build process.
 export async function quickLoadAllFromYAML(dataPath, {
+  bindFind,
+  getAllFindSpecs,
+
   showAggregate: customShowAggregate = showAggregate,
-} = {}) {
+}) {
   const showAggregate = customShowAggregate;
 
   let wikiData;
@@ -1367,7 +1042,7 @@ export async function quickLoadAllFromYAML(dataPath, {
   linkWikiDataArrays(wikiData);
 
   try {
-    filterDuplicateDirectories(wikiData).close();
+    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
     logInfo`No duplicate directories found. (complete data)`;
   } catch (error) {
     showAggregate(error);
@@ -1375,11 +1050,19 @@ export async function quickLoadAllFromYAML(dataPath, {
   }
 
   try {
-    filterReferenceErrors(wikiData).close();
+    filterReferenceErrors(wikiData, {bindFind}).close();
     logInfo`No reference errors found. (complete data)`;
   } catch (error) {
     showAggregate(error);
-    logWarn`Duplicate directories found. (partial data)`;
+    logWarn`Reference errors found. (partial data)`;
+  }
+
+  try {
+    reportContentTextErrors(wikiData, {bindFind});
+    logInfo`No content text errors found.`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Content text errors found.`;
   }
 
   sortWikiDataArrays(wikiData);