« 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
diff options
context:
space:
mode:
Diffstat (limited to 'src/data')
-rw-r--r--src/data/cacheable-object.js393
-rw-r--r--src/data/patches.js616
-rw-r--r--src/data/serialize.js30
-rw-r--r--src/data/things.js2751
-rw-r--r--src/data/validators.js404
-rw-r--r--src/data/yaml.js2188
6 files changed, 3387 insertions, 2995 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index 4afb036..76efbd8 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -74,21 +74,21 @@
 //      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';
+import { inspect as nodeInspect } from "util";
 
 function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
+  return nodeInspect(value, { colors: ENABLE_COLOR });
 }
 
 export default class CacheableObject {
-    static instance = Symbol('CacheableObject `this` instance');
+  static instance = Symbol("CacheableObject `this` instance");
 
-    #propertyUpdateValues = Object.create(null);
-    #propertyUpdateCacheInvalidators = Object.create(null);
+  #propertyUpdateValues = Object.create(null);
+  #propertyUpdateCacheInvalidators = Object.create(null);
 
-    /*
+  /*
     // Note the constructor doesn't take an initial data source. Due to a quirk
     // of JavaScript, private members can't be accessed before the superclass's
     // constructor is finished processing - so if we call the overridden
@@ -99,211 +99,238 @@ export default class CacheableObject {
     // after constructing the new instance of the Thing (sub)class.
     */
 
-    constructor() {
-        this.#defineProperties();
-        this.#initializeUpdatingPropertyValues();
-
-        if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-            return new Proxy(this, {
-                get: (obj, key) => {
-                    if (!Object.hasOwn(obj, key)) {
-                        if (key !== 'constructor') {
-                            CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`);
-                        }
-                    }
-                    return obj[key];
-                }
-            });
-        }
-    }
-
-    #initializeUpdatingPropertyValues() {
-        for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) {
-            const { flags, update } = descriptor;
-
-            if (!flags.update) {
-                continue;
-            }
-
-            if (update?.default) {
-                this[property] = update?.default;
-            } else {
-                this[property] = null;
+  constructor() {
+    this.#defineProperties();
+    this.#initializeUpdatingPropertyValues();
+
+    if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
+      return new Proxy(this, {
+        get: (obj, key) => {
+          if (!Object.hasOwn(obj, key)) {
+            if (key !== "constructor") {
+              CacheableObject._invalidAccesses.add(
+                `(${obj.constructor.name}).${key}`
+              );
             }
-        }
+          }
+          return obj[key];
+        },
+      });
     }
-
-    #defineProperties() {
-        if (!this.constructor.propertyDescriptors) {
-            throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`);
-        }
-
-        for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) {
-            const { flags } = descriptor;
-
-            const definition = {
-                configurable: false,
-                enumerable: true
-            };
-
-            if (flags.update) {
-                definition.set = this.#getUpdateObjectDefinitionSetterFunction(property);
-            }
-
-            if (flags.expose) {
-                definition.get = this.#getExposeObjectDefinitionGetterFunction(property);
-            }
-
-            Object.defineProperty(this, property, definition);
-        }
-
-        Object.seal(this);
+  }
+
+  #initializeUpdatingPropertyValues() {
+    for (const [property, descriptor] of Object.entries(
+      this.constructor.propertyDescriptors
+    )) {
+      const { flags, update } = descriptor;
+
+      if (!flags.update) {
+        continue;
+      }
+
+      if (update?.default) {
+        this[property] = update?.default;
+      } else {
+        this[property] = null;
+      }
     }
+  }
 
-    #getUpdateObjectDefinitionSetterFunction(property) {
-        const { update } = this.#getPropertyDescriptor(property);
-        const validate = update?.validate;
-        const allowNull = update?.allowNull;
-
-        return (newValue) => {
-            const oldValue = this.#propertyUpdateValues[property];
+  #defineProperties() {
+    if (!this.constructor.propertyDescriptors) {
+      throw new Error(
+        `Expected constructor ${this.constructor.name} to define propertyDescriptors`
+      );
+    }
 
-            if (newValue === undefined) {
-                throw new TypeError(`Properties cannot be set to undefined`);
-            }
+    for (const [property, descriptor] of Object.entries(
+      this.constructor.propertyDescriptors
+    )) {
+      const { flags } = descriptor;
 
-            if (newValue === oldValue) {
-                return;
-            }
+      const definition = {
+        configurable: false,
+        enumerable: true,
+      };
 
-            if (newValue !== null && validate) {
-                try {
-                    const result = validate(newValue);
-                    if (result === undefined) {
-                        throw new TypeError(`Validate function returned undefined`);
-                    } else if (result !== true) {
-                        throw new TypeError(`Validation failed for value ${newValue}`);
-                    }
-                } catch (error) {
-                    error.message = `Property ${color.green(property)} (${inspect(this[property])} -> ${inspect(newValue)}): ${error.message}`;
-                    throw error;
-                }
-            }
+      if (flags.update) {
+        definition.set =
+          this.#getUpdateObjectDefinitionSetterFunction(property);
+      }
 
-            this.#propertyUpdateValues[property] = newValue;
-            this.#invalidateCachesDependentUpon(property);
-        };
-    }
+      if (flags.expose) {
+        definition.get =
+          this.#getExposeObjectDefinitionGetterFunction(property);
+      }
 
-    #getUpdatePropertyValidateFunction(property) {
-        const descriptor = this.#getPropertyDescriptor(property);
+      Object.defineProperty(this, property, definition);
     }
 
-    #getPropertyDescriptor(property) {
-        return this.constructor.propertyDescriptors[property];
-    }
-
-    #invalidateCachesDependentUpon(property) {
-        for (const invalidate of this.#propertyUpdateCacheInvalidators[property] || []) {
-            invalidate();
-        }
-    }
-
-    #getExposeObjectDefinitionGetterFunction(property) {
-        const { flags } = this.#getPropertyDescriptor(property);
-        const compute = this.#getExposeComputeFunction(property);
-
-        if (compute) {
-            let cachedValue;
-            const checkCacheValid = this.#getExposeCheckCacheValidFunction(property);
-            return () => {
-                if (checkCacheValid()) {
-                    return cachedValue;
-                } else {
-                    return (cachedValue = compute());
-                }
-            };
-        } else if (!flags.update && !compute) {
-            throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-        } else {
-            return () => this.#propertyUpdateValues[property];
+    Object.seal(this);
+  }
+
+  #getUpdateObjectDefinitionSetterFunction(property) {
+    const { update } = this.#getPropertyDescriptor(property);
+    const validate = update?.validate;
+    const allowNull = update?.allowNull;
+
+    return (newValue) => {
+      const oldValue = this.#propertyUpdateValues[property];
+
+      if (newValue === undefined) {
+        throw new TypeError(`Properties cannot be set to undefined`);
+      }
+
+      if (newValue === oldValue) {
+        return;
+      }
+
+      if (newValue !== null && validate) {
+        try {
+          const result = validate(newValue);
+          if (result === undefined) {
+            throw new TypeError(`Validate function returned undefined`);
+          } else if (result !== true) {
+            throw new TypeError(`Validation failed for value ${newValue}`);
+          }
+        } catch (error) {
+          error.message = `Property ${color.green(property)} (${inspect(
+            this[property]
+          )} -> ${inspect(newValue)}): ${error.message}`;
+          throw error;
         }
-    }
-
-    #getExposeComputeFunction(property) {
-        const { flags, expose } = this.#getPropertyDescriptor(property);
+      }
 
-        const compute = expose?.compute;
-        const transform = expose?.transform;
+      this.#propertyUpdateValues[property] = newValue;
+      this.#invalidateCachesDependentUpon(property);
+    };
+  }
 
-        if (flags.update && !transform) {
-            return null;
-        } else if (flags.update && compute) {
-            throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
-        } else if (!flags.update && !compute) {
-            throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-        }
+  #getUpdatePropertyValidateFunction(property) {
+    const descriptor = this.#getPropertyDescriptor(property);
+  }
 
-        const dependencyKeys = expose.dependencies || [];
-        const dependencyGetters = dependencyKeys.map(key => () => [key, this.#propertyUpdateValues[key]]);
-        const getAllDependencies = () => Object.fromEntries(dependencyGetters.map(f => f())
-            .concat([[this.constructor.instance, this]]));
+  #getPropertyDescriptor(property) {
+    return this.constructor.propertyDescriptors[property];
+  }
 
-        if (flags.update) {
-            return () => transform(this.#propertyUpdateValues[property], getAllDependencies());
+  #invalidateCachesDependentUpon(property) {
+    for (const invalidate of this.#propertyUpdateCacheInvalidators[property] ||
+      []) {
+      invalidate();
+    }
+  }
+
+  #getExposeObjectDefinitionGetterFunction(property) {
+    const { flags } = this.#getPropertyDescriptor(property);
+    const compute = this.#getExposeComputeFunction(property);
+
+    if (compute) {
+      let cachedValue;
+      const checkCacheValid = this.#getExposeCheckCacheValidFunction(property);
+      return () => {
+        if (checkCacheValid()) {
+          return cachedValue;
         } else {
-            return () => compute(getAllDependencies());
+          return (cachedValue = compute());
         }
+      };
+    } else if (!flags.update && !compute) {
+      throw new Error(
+        `Exposed property ${property} does not update and is missing compute function`
+      );
+    } else {
+      return () => this.#propertyUpdateValues[property];
+    }
+  }
+
+  #getExposeComputeFunction(property) {
+    const { flags, expose } = this.#getPropertyDescriptor(property);
+
+    const compute = expose?.compute;
+    const transform = expose?.transform;
+
+    if (flags.update && !transform) {
+      return null;
+    } else if (flags.update && compute) {
+      throw new Error(
+        `Updating property ${property} has compute function, should be formatted as transform`
+      );
+    } else if (!flags.update && !compute) {
+      throw new Error(
+        `Exposed property ${property} does not update and is missing compute function`
+      );
     }
 
-    #getExposeCheckCacheValidFunction(property) {
-        const { flags, expose } = this.#getPropertyDescriptor(property);
-
-        let valid = false;
+    const dependencyKeys = expose.dependencies || [];
+    const dependencyGetters = dependencyKeys.map((key) => () => [
+      key,
+      this.#propertyUpdateValues[key],
+    ]);
+    const getAllDependencies = () =>
+      Object.fromEntries(
+        dependencyGetters
+          .map((f) => f())
+          .concat([[this.constructor.instance, this]])
+      );
+
+    if (flags.update) {
+      return () =>
+        transform(this.#propertyUpdateValues[property], getAllDependencies());
+    } else {
+      return () => compute(getAllDependencies());
+    }
+  }
 
-        const invalidate = () => {
-            valid = false;
-        };
+  #getExposeCheckCacheValidFunction(property) {
+    const { flags, expose } = this.#getPropertyDescriptor(property);
 
-        const dependencyKeys = new Set(expose?.dependencies);
+    let valid = false;
 
-        if (flags.update) {
-            dependencyKeys.add(property);
-        }
+    const invalidate = () => {
+      valid = false;
+    };
 
-        for (const key of dependencyKeys) {
-            if (this.#propertyUpdateCacheInvalidators[key]) {
-                this.#propertyUpdateCacheInvalidators[key].push(invalidate);
-            } else {
-                this.#propertyUpdateCacheInvalidators[key] = [invalidate];
-            }
-        }
+    const dependencyKeys = new Set(expose?.dependencies);
 
-        return () => {
-            if (!valid) {
-                valid = true;
-                return false;
-            } else {
-                return true;
-            }
-        };
+    if (flags.update) {
+      dependencyKeys.add(property);
     }
 
-    static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false;
-    static _invalidAccesses = new Set();
+    for (const key of dependencyKeys) {
+      if (this.#propertyUpdateCacheInvalidators[key]) {
+        this.#propertyUpdateCacheInvalidators[key].push(invalidate);
+      } else {
+        this.#propertyUpdateCacheInvalidators[key] = [invalidate];
+      }
+    }
 
-    static showInvalidAccesses() {
-        if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-            return;
-        }
+    return () => {
+      if (!valid) {
+        valid = true;
+        return false;
+      } else {
+        return true;
+      }
+    };
+  }
+
+  static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false;
+  static _invalidAccesses = new Set();
+
+  static showInvalidAccesses() {
+    if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
+      return;
+    }
 
-        if (!this._invalidAccesses.size) {
-            return;
-        }
+    if (!this._invalidAccesses.size) {
+      return;
+    }
 
-        console.log(`${this._invalidAccesses.size} unique invalid accesses:`);
-        for (const line of this._invalidAccesses) {
-            console.log(` - ${line}`);
-        }
+    console.log(`${this._invalidAccesses.size} unique invalid accesses:`);
+    for (const line of this._invalidAccesses) {
+      console.log(` - ${line}`);
     }
+  }
 }
diff --git a/src/data/patches.js b/src/data/patches.js
index 3ed4fad..0ff56ad 100644
--- a/src/data/patches.js
+++ b/src/data/patches.js
@@ -1,291 +1,309 @@
 // --> Patch
 
 export class Patch {
-    static INPUT_NONE = 0;
-    static INPUT_CONSTANT = 1;
-    static INPUT_DIRECT_CONNECTION = 2;
-    static INPUT_MANAGED_CONNECTION = 3;
+  static INPUT_NONE = 0;
+  static INPUT_CONSTANT = 1;
+  static INPUT_DIRECT_CONNECTION = 2;
+  static INPUT_MANAGED_CONNECTION = 3;
 
-    static INPUT_UNAVAILABLE = 0;
-    static INPUT_AVAILABLE = 1;
+  static INPUT_UNAVAILABLE = 0;
+  static INPUT_AVAILABLE = 1;
 
-    static OUTPUT_UNAVAILABLE = 0;
-    static OUTPUT_AVAILABLE = 1;
+  static OUTPUT_UNAVAILABLE = 0;
+  static OUTPUT_AVAILABLE = 1;
 
-    static inputNames = []; inputNames = null;
-    static outputNames = []; outputNames = null;
+  static inputNames = [];
+  inputNames = null;
+  static outputNames = [];
+  outputNames = null;
 
-    manager = null;
-    inputs = Object.create(null);
+  manager = null;
+  inputs = Object.create(null);
 
-    constructor({
-        manager,
+  constructor({
+    manager,
 
-        inputNames,
-        outputNames,
+    inputNames,
+    outputNames,
 
-        inputs,
-    } = {}) {
-        this.inputNames = inputNames ?? this.constructor.inputNames;
-        this.outputNames = outputNames ?? this.constructor.outputNames;
+    inputs,
+  } = {}) {
+    this.inputNames = inputNames ?? this.constructor.inputNames;
+    this.outputNames = outputNames ?? this.constructor.outputNames;
 
-        manager?.addManagedPatch(this);
+    manager?.addManagedPatch(this);
 
-        if (inputs) {
-            Object.assign(this.inputs, inputs);
-        }
-
-        this.initializeInputs();
+    if (inputs) {
+      Object.assign(this.inputs, inputs);
     }
 
-    initializeInputs() {
-        for (const inputName of this.inputNames) {
-            if (!this.inputs[inputName]) {
-                this.inputs[inputName] = [Patch.INPUT_NONE];
-            }
-        }
-    }
+    this.initializeInputs();
+  }
 
-    computeInputs() {
-        const inputs = Object.create(null);
-
-        for (const inputName of this.inputNames) {
-            const input = this.inputs[inputName];
-            switch (input[0]) {
-                case Patch.INPUT_NONE:
-                    inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
-                    break;
-
-                case Patch.INPUT_CONSTANT:
-                    inputs[inputName] = [Patch.INPUT_AVAILABLE, input[1]];
-                    break;
-
-                case Patch.INPUT_DIRECT_CONNECTION: {
-                    const patch = input[1];
-                    const outputName = input[2];
-                    const output = patch.computeOutputs()[outputName];
-                    switch (output[0]) {
-                        case Patch.OUTPUT_UNAVAILABLE:
-                            inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
-                            break;
-                        case Patch.OUTPUT_AVAILABLE:
-                            inputs[inputName] = [Patch.INPUT_AVAILABLE, output[1]];
-                            break;
-                    }
-                    throw new Error('Unreachable');
-                }
-
-                case Patch.INPUT_MANAGED_CONNECTION: {
-                    if (!this.manager) {
-                        inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
-                        break;
-                    }
-
-                    inputs[inputName] = this.manager.getManagedInput(input[1]);
-                    break;
-                }
-            }
+  initializeInputs() {
+    for (const inputName of this.inputNames) {
+      if (!this.inputs[inputName]) {
+        this.inputs[inputName] = [Patch.INPUT_NONE];
+      }
+    }
+  }
+
+  computeInputs() {
+    const inputs = Object.create(null);
+
+    for (const inputName of this.inputNames) {
+      const input = this.inputs[inputName];
+      switch (input[0]) {
+        case Patch.INPUT_NONE:
+          inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
+          break;
+
+        case Patch.INPUT_CONSTANT:
+          inputs[inputName] = [Patch.INPUT_AVAILABLE, input[1]];
+          break;
+
+        case Patch.INPUT_DIRECT_CONNECTION: {
+          const patch = input[1];
+          const outputName = input[2];
+          const output = patch.computeOutputs()[outputName];
+          switch (output[0]) {
+            case Patch.OUTPUT_UNAVAILABLE:
+              inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
+              break;
+            case Patch.OUTPUT_AVAILABLE:
+              inputs[inputName] = [Patch.INPUT_AVAILABLE, output[1]];
+              break;
+          }
+          throw new Error("Unreachable");
         }
 
-        return inputs;
-    }
+        case Patch.INPUT_MANAGED_CONNECTION: {
+          if (!this.manager) {
+            inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
+            break;
+          }
 
-    computeOutputs() {
-        const inputs = this.computeInputs();
-        const outputs = Object.create(null);
-        console.log(`Compute: ${this.constructor.name}`);
-        this.compute(inputs, outputs);
-        return outputs;
+          inputs[inputName] = this.manager.getManagedInput(input[1]);
+          break;
+        }
+      }
     }
 
-    compute(inputs, outputs) {
-        // No-op. Return all outputs as unavailable. This should be overridden
-        // in subclasses.
+    return inputs;
+  }
 
-        for (const outputName of this.constructor.outputNames) {
-            outputs[outputName] = [Patch.OUTPUT_UNAVAILABLE];
-        }
-    }
+  computeOutputs() {
+    const inputs = this.computeInputs();
+    const outputs = Object.create(null);
+    console.log(`Compute: ${this.constructor.name}`);
+    this.compute(inputs, outputs);
+    return outputs;
+  }
 
-    attachToManager(manager) {
-        manager.addManagedPatch(this);
+  compute(inputs, outputs) {
+    // No-op. Return all outputs as unavailable. This should be overridden
+    // in subclasses.
+
+    for (const outputName of this.constructor.outputNames) {
+      outputs[outputName] = [Patch.OUTPUT_UNAVAILABLE];
     }
+  }
 
-    detachFromManager() {
-        if (this.manager) {
-            this.manager.removeManagedPatch(this);
-        }
+  attachToManager(manager) {
+    manager.addManagedPatch(this);
+  }
+
+  detachFromManager() {
+    if (this.manager) {
+      this.manager.removeManagedPatch(this);
     }
+  }
 }
 
 // --> PatchManager
 
 export class PatchManager extends Patch {
-    managedPatches = [];
-    managedInputs = {};
-
-    #externalInputPatch = null;
-    #externalOutputPatch = null;
-
-    constructor(...args) {
-        super(...args);
-
-        this.#externalInputPatch = new PatchManagerExternalInputPatch({manager: this});
-        this.#externalOutputPatch = new PatchManagerExternalOutputPatch({manager: this});
+  managedPatches = [];
+  managedInputs = {};
+
+  #externalInputPatch = null;
+  #externalOutputPatch = null;
+
+  constructor(...args) {
+    super(...args);
+
+    this.#externalInputPatch = new PatchManagerExternalInputPatch({
+      manager: this,
+    });
+    this.#externalOutputPatch = new PatchManagerExternalOutputPatch({
+      manager: this,
+    });
+  }
+
+  addManagedPatch(patch) {
+    if (patch.manager === this) {
+      return false;
     }
 
-    addManagedPatch(patch) {
-        if (patch.manager === this) {
-            return false;
-        }
-
-        patch.detachFromManager();
-        patch.manager = this;
+    patch.detachFromManager();
+    patch.manager = this;
 
-        if (patch.manager === this) {
-            this.managedPatches.push(patch);
-            return true;
-        } else {
-            return false;
-        }
+    if (patch.manager === this) {
+      this.managedPatches.push(patch);
+      return true;
+    } else {
+      return false;
     }
+  }
 
-    removeManagedPatch(patch) {
-        if (patch.manager !== this) {
-            return false;
-        }
-
-        patch.manager = null;
-
-        if (patch.manager === this) {
-            return false;
-        }
-
-        for (const inputNames of patch.inputNames) {
-            const input = patch.inputs[inputName];
-            if (input[0] === Patch.INPUT_MANAGED_CONNECTION) {
-                this.dropManagedInput(input[1]);
-                patch.inputs[inputName] = [Patch.INPUT_NONE];
-            }
-        }
-
-        this.managedPatches.splice(this.managedPatches.indexOf(patch), 1);
-
-        return true;
+  removeManagedPatch(patch) {
+    if (patch.manager !== this) {
+      return false;
     }
 
-    addManagedInput(patchWithInput, inputName, patchWithOutput, outputName) {
-        if (patchWithInput.manager !== this || patchWithOutput.manager !== this) {
-            throw new Error(`Input and output patches must belong to same manager (this)`);
-        }
-
-        const input = patchWithInput.inputs[inputName];
-        if (input[0] === Patch.INPUT_MANAGED_CONNECTION) {
-            this.managedInputs[input[1]] = [patchWithOutput, outputName, {}];
-        } else {
-            const key = this.getManagedConnectionIdentifier();
-            this.managedInputs[key] = [patchWithOutput, outputName, {}];
-            patchWithInput.inputs[inputName] = [Patch.INPUT_MANAGED_CONNECTION, key];
-        }
+    patch.manager = null;
 
-        return true;
+    if (patch.manager === this) {
+      return false;
     }
 
-    dropManagedInput(identifier) {
-        return delete this.managedInputs[key];
+    for (const inputNames of patch.inputNames) {
+      const input = patch.inputs[inputName];
+      if (input[0] === Patch.INPUT_MANAGED_CONNECTION) {
+        this.dropManagedInput(input[1]);
+        patch.inputs[inputName] = [Patch.INPUT_NONE];
+      }
     }
 
-    getManagedInput(identifier) {
-        const connection = this.managedInputs[identifier];
-        const patch = connection[0];
-        const outputName = connection[1];
-        const memory = connection[2];
-        return this.computeManagedInput(patch, outputName, memory);
-    }
-
-    computeManagedInput(patch, outputName, memory) {
-        // Override this function in subclasses to alter behavior of the "wire"
-        // used for connecting patches.
-
-        const output = patch.computeOutputs()[outputName];
-        switch (output[0]) {
-            case Patch.OUTPUT_UNAVAILABLE:
-                return [Patch.INPUT_UNAVAILABLE];
-            case Patch.OUTPUT_AVAILABLE:
-                return [Patch.INPUT_AVAILABLE, output[1]];
-        }
-    }
+    this.managedPatches.splice(this.managedPatches.indexOf(patch), 1);
 
-    #managedConnectionIdentifier = 0;
-    getManagedConnectionIdentifier() {
-        return this.#managedConnectionIdentifier++;
-    }
+    return true;
+  }
 
-    addExternalInput(patchWithInput, patchInputName, managerInputName) {
-        return this.addManagedInput(patchWithInput, patchInputName, this.#externalInputPatch, managerInputName);
+  addManagedInput(patchWithInput, inputName, patchWithOutput, outputName) {
+    if (patchWithInput.manager !== this || patchWithOutput.manager !== this) {
+      throw new Error(
+        `Input and output patches must belong to same manager (this)`
+      );
     }
 
-    setExternalOutput(managerOutputName, patchWithOutput, patchOutputName) {
-        return this.addManagedInput(this.#externalOutputPatch, managerOutputName, patchWithOutput, patchOutputName);
+    const input = patchWithInput.inputs[inputName];
+    if (input[0] === Patch.INPUT_MANAGED_CONNECTION) {
+      this.managedInputs[input[1]] = [patchWithOutput, outputName, {}];
+    } else {
+      const key = this.getManagedConnectionIdentifier();
+      this.managedInputs[key] = [patchWithOutput, outputName, {}];
+      patchWithInput.inputs[inputName] = [Patch.INPUT_MANAGED_CONNECTION, key];
     }
 
-    compute(inputs, outputs) {
-        Object.assign(outputs, this.#externalOutputPatch.computeOutputs());
+    return true;
+  }
+
+  dropManagedInput(identifier) {
+    return delete this.managedInputs[key];
+  }
+
+  getManagedInput(identifier) {
+    const connection = this.managedInputs[identifier];
+    const patch = connection[0];
+    const outputName = connection[1];
+    const memory = connection[2];
+    return this.computeManagedInput(patch, outputName, memory);
+  }
+
+  computeManagedInput(patch, outputName, memory) {
+    // Override this function in subclasses to alter behavior of the "wire"
+    // used for connecting patches.
+
+    const output = patch.computeOutputs()[outputName];
+    switch (output[0]) {
+      case Patch.OUTPUT_UNAVAILABLE:
+        return [Patch.INPUT_UNAVAILABLE];
+      case Patch.OUTPUT_AVAILABLE:
+        return [Patch.INPUT_AVAILABLE, output[1]];
     }
+  }
+
+  #managedConnectionIdentifier = 0;
+  getManagedConnectionIdentifier() {
+    return this.#managedConnectionIdentifier++;
+  }
+
+  addExternalInput(patchWithInput, patchInputName, managerInputName) {
+    return this.addManagedInput(
+      patchWithInput,
+      patchInputName,
+      this.#externalInputPatch,
+      managerInputName
+    );
+  }
+
+  setExternalOutput(managerOutputName, patchWithOutput, patchOutputName) {
+    return this.addManagedInput(
+      this.#externalOutputPatch,
+      managerOutputName,
+      patchWithOutput,
+      patchOutputName
+    );
+  }
+
+  compute(inputs, outputs) {
+    Object.assign(outputs, this.#externalOutputPatch.computeOutputs());
+  }
 }
 
 class PatchManagerExternalInputPatch extends Patch {
-    constructor({manager, ...rest}) {
-        super({
-            manager,
-            inputNames: manager.inputNames,
-            outputNames: manager.inputNames,
-            ...rest
-        });
-    }
-
-    computeInputs() {
-        return this.manager.computeInputs();
-    }
-
-    compute(inputs, outputs) {
-        for (const name of this.inputNames) {
-            const input = inputs[name];
-            switch (input[0]) {
-                case Patch.INPUT_UNAVAILABLE:
-                    outputs[name] = [Patch.OUTPUT_UNAVAILABLE];
-                    break;
-                case Patch.INPUT_AVAILABLE:
-                    outputs[name] = [Patch.INPUT_AVAILABLE, input[1]];
-                    break;
-            }
-        }
+  constructor({ manager, ...rest }) {
+    super({
+      manager,
+      inputNames: manager.inputNames,
+      outputNames: manager.inputNames,
+      ...rest,
+    });
+  }
+
+  computeInputs() {
+    return this.manager.computeInputs();
+  }
+
+  compute(inputs, outputs) {
+    for (const name of this.inputNames) {
+      const input = inputs[name];
+      switch (input[0]) {
+        case Patch.INPUT_UNAVAILABLE:
+          outputs[name] = [Patch.OUTPUT_UNAVAILABLE];
+          break;
+        case Patch.INPUT_AVAILABLE:
+          outputs[name] = [Patch.INPUT_AVAILABLE, input[1]];
+          break;
+      }
     }
+  }
 }
 
 class PatchManagerExternalOutputPatch extends Patch {
-    constructor({manager, ...rest}) {
-        super({
-            manager,
-            inputNames: manager.outputNames,
-            outputNames: manager.outputNames,
-            ...rest
-        });
-    }
-
-    compute(inputs, outputs) {
-        for (const name of this.inputNames) {
-            const input = inputs[name];
-            switch (input[0]) {
-                case Patch.INPUT_UNAVAILABLE:
-                    outputs[name] = [Patch.OUTPUT_UNAVAILABLE];
-                    break;
-                case Patch.INPUT_AVAILABLE:
-                    outputs[name] = [Patch.INPUT_AVAILABLE, input[1]];
-                    break;
-            }
-        }
+  constructor({ manager, ...rest }) {
+    super({
+      manager,
+      inputNames: manager.outputNames,
+      outputNames: manager.outputNames,
+      ...rest,
+    });
+  }
+
+  compute(inputs, outputs) {
+    for (const name of this.inputNames) {
+      const input = inputs[name];
+      switch (input[0]) {
+        case Patch.INPUT_UNAVAILABLE:
+          outputs[name] = [Patch.OUTPUT_UNAVAILABLE];
+          break;
+        case Patch.INPUT_AVAILABLE:
+          outputs[name] = [Patch.INPUT_AVAILABLE, input[1]];
+          break;
+      }
     }
+  }
 }
 
 // --> demo
@@ -295,84 +313,84 @@ const common = Symbol();
 const hsmusic = Symbol();
 
 Patch[caches] = {
-    WireCachedPatchManager: class extends PatchManager {
-        // "Wire" caching for PatchManager: Remembers the last outputs to come
-        // from each patch. As long as the inputs for a patch do not change, its
-        // cached outputs are reused.
-
-        // TODO: This has a unique cache for each managed input. It should
-        // re-use a cache for the same patch and output name. How can we ensure
-        // the cache is dropped when the patch is removed, though? (Spoilers:
-        // probably just override removeManagedPatch)
-        computeManagedInput(patch, outputName, memory) {
-            let cache = true;
-
-            const { previousInputs } = memory;
-            const { inputs } = patch;
-            if (memory.previousInputs) {
-                for (const inputName of patch.inputNames) {
-                    // TODO: This doesn't account for connections whose values
-                    // have changed (analogous to bubbling cache invalidation).
-                    if (inputs[inputName] !== previousInputs[inputName]) {
-                        cache = false;
-                        break;
-                    }
-                }
-            } else {
-                cache = false;
-            }
-
-            if (cache) {
-                return memory.previousOutputs[outputName];
-            }
-
-            const outputs = patch.computeOutputs();
-            memory.previousOutputs = outputs;
-            memory.previousInputs = {...inputs};
-            return outputs[outputName];
+  WireCachedPatchManager: class extends PatchManager {
+    // "Wire" caching for PatchManager: Remembers the last outputs to come
+    // from each patch. As long as the inputs for a patch do not change, its
+    // cached outputs are reused.
+
+    // TODO: This has a unique cache for each managed input. It should
+    // re-use a cache for the same patch and output name. How can we ensure
+    // the cache is dropped when the patch is removed, though? (Spoilers:
+    // probably just override removeManagedPatch)
+    computeManagedInput(patch, outputName, memory) {
+      let cache = true;
+
+      const { previousInputs } = memory;
+      const { inputs } = patch;
+      if (memory.previousInputs) {
+        for (const inputName of patch.inputNames) {
+          // TODO: This doesn't account for connections whose values
+          // have changed (analogous to bubbling cache invalidation).
+          if (inputs[inputName] !== previousInputs[inputName]) {
+            cache = false;
+            break;
+          }
         }
-    },
+      } else {
+        cache = false;
+      }
+
+      if (cache) {
+        return memory.previousOutputs[outputName];
+      }
+
+      const outputs = patch.computeOutputs();
+      memory.previousOutputs = outputs;
+      memory.previousInputs = { ...inputs };
+      return outputs[outputName];
+    }
+  },
 };
 
 Patch[common] = {
-    Stringify: class extends Patch {
-        static inputNames = ['value'];
-        static outputNames = ['value'];
-
-        compute(inputs, outputs) {
-            if (inputs.value[0] === Patch.INPUT_AVAILABLE) {
-                outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1].toString()];
-            } else {
-                outputs.value = [Patch.OUTPUT_UNAVAILABLE];
-            }
-        }
-    },
-
-    Echo: class extends Patch {
-        static inputNames = ['value'];
-        static outputNames = ['value'];
-
-        compute(inputs, outputs) {
-            if (inputs.value[0] === Patch.INPUT_AVAILABLE) {
-                outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1]];
-            } else {
-                outputs.value = [Patch.OUTPUT_UNAVAILABLE];
-            }
-        }
-    },
+  Stringify: class extends Patch {
+    static inputNames = ["value"];
+    static outputNames = ["value"];
+
+    compute(inputs, outputs) {
+      if (inputs.value[0] === Patch.INPUT_AVAILABLE) {
+        outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1].toString()];
+      } else {
+        outputs.value = [Patch.OUTPUT_UNAVAILABLE];
+      }
+    }
+  },
+
+  Echo: class extends Patch {
+    static inputNames = ["value"];
+    static outputNames = ["value"];
+
+    compute(inputs, outputs) {
+      if (inputs.value[0] === Patch.INPUT_AVAILABLE) {
+        outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1]];
+      } else {
+        outputs.value = [Patch.OUTPUT_UNAVAILABLE];
+      }
+    }
+  },
 };
 
 const PM = new Patch[caches].WireCachedPatchManager({
-    inputNames: ['externalInput'],
-    outputNames: ['externalOutput'],
+  inputNames: ["externalInput"],
+  outputNames: ["externalOutput"],
 });
 
-const P1 = new Patch[common].Stringify({manager: PM});
-const P2 = new Patch[common].Echo({manager: PM});
+const P1 = new Patch[common].Stringify({ manager: PM });
+const P2 = new Patch[common].Echo({ manager: PM });
 
-PM.addExternalInput(P1, 'value', 'externalInput');
-PM.addManagedInput(P2, 'value', P1, 'value');
-PM.setExternalOutput('externalOutput', P2, 'value');
+PM.addExternalInput(P1, "value", "externalInput");
+PM.addManagedInput(P2, "value", P1, "value");
+PM.setExternalOutput("externalOutput", P2, "value");
 
 PM.inputs.externalInput = [Patch.INPUT_CONSTANT, 123];
 console.log(PM.computeOutputs());
diff --git a/src/data/serialize.js b/src/data/serialize.js
index 9d4e888..fc84d1e 100644
--- a/src/data/serialize.js
+++ b/src/data/serialize.js
@@ -4,19 +4,19 @@
 // Utility functions
 
 export function id(x) {
-    return x;
+  return x;
 }
 
 export function toRef(thing) {
-    return thing?.constructor.getReference(thing);
+  return thing?.constructor.getReference(thing);
 }
 
 export function toRefs(things) {
-    return things?.map(toRef);
+  return things?.map(toRef);
 }
 
 export function toContribRefs(contribs) {
-    return contribs?.map(({ who, what }) => ({who: toRef(who), what}));
+  return contribs?.map(({ who, what }) => ({ who: toRef(who), what }));
 }
 
 // Interface
@@ -24,15 +24,21 @@ export function toContribRefs(contribs) {
 export const serializeDescriptors = Symbol();
 
 export function serializeThing(thing) {
-    const descriptors = thing.constructor[serializeDescriptors];
-    if (!descriptors) {
-        throw new Error(`Constructor ${thing.constructor.name} does not provide serialize descriptors`);
-    }
-
-    return Object.fromEntries(Object.entries(descriptors)
-        .map(([ property, transform ]) => [property, transform(thing[property])]));
+  const descriptors = thing.constructor[serializeDescriptors];
+  if (!descriptors) {
+    throw new Error(
+      `Constructor ${thing.constructor.name} does not provide serialize descriptors`
+    );
+  }
+
+  return Object.fromEntries(
+    Object.entries(descriptors).map(([property, transform]) => [
+      property,
+      transform(thing[property]),
+    ])
+  );
 }
 
 export function serializeThings(things) {
-    return things.map(serializeThing);
+  return things.map(serializeThing);
 }
diff --git a/src/data/things.js b/src/data/things.js
index 6a5cdb5..62c0141 100644
--- a/src/data/things.js
+++ b/src/data/things.js
@@ -1,45 +1,45 @@
 // 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 CacheableObject from "./cacheable-object.js";
 
 import {
-    isAdditionalFileList,
-    isBoolean,
-    isColor,
-    isCommentary,
-    isCountingNumber,
-    isContributionList,
-    isDate,
-    isDimensions,
-    isDirectory,
-    isDuration,
-    isInstance,
-    isFileExtension,
-    isLanguageCode,
-    isName,
-    isNumber,
-    isURL,
-    isString,
-    isWholeNumber,
-    oneOf,
-    validateArrayItems,
-    validateInstanceOf,
-    validateReference,
-    validateReferenceList,
-} from './validators.js';
-
-import * as S from './serialize.js';
+  isAdditionalFileList,
+  isBoolean,
+  isColor,
+  isCommentary,
+  isCountingNumber,
+  isContributionList,
+  isDate,
+  isDimensions,
+  isDirectory,
+  isDuration,
+  isInstance,
+  isFileExtension,
+  isLanguageCode,
+  isName,
+  isNumber,
+  isURL,
+  isString,
+  isWholeNumber,
+  oneOf,
+  validateArrayItems,
+  validateInstanceOf,
+  validateReference,
+  validateReferenceList,
+} from "./validators.js";
+
+import * as S from "./serialize.js";
 
 import {
-    getKebabCase,
-    sortAlbumsTracksChronologically,
-} from '../util/wiki-data.js';
+  getKebabCase,
+  sortAlbumsTracksChronologically,
+} from "../util/wiki-data.js";
 
-import find from '../util/find.js';
+import find from "../util/find.js";
 
-import { inspect } from 'util';
-import { color } from '../util/cli.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
@@ -94,16 +94,16 @@ 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');
+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';
+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.
@@ -112,551 +112,580 @@ Flash[Thing.referenceType] = 'flash';
 // 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!`);
-        }
+  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}
-        };
-    },
+    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!`
+      );
+    }
 
-    // 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: validateReferenceList(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 },
 
-    // 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!`);
-        }
+    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 },
 
-        return {
-            flags: {update: true, expose: true},
-            update: {validate: validateReference(referenceType)}
-        };
-    },
+    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: (wikiDataProperty, referencerRefListProperty) => ({
+    flags: { expose: true },
 
-    // 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: (wikiDataProperty, referencerRefListProperty) => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: [wikiDataProperty],
-
-            compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => (
-                (wikiData
-                    ? wikiData.filter(t => t[referencerRefListProperty]?.includes(thing))
-                    : [])
+    expose: {
+      dependencies: [wikiDataProperty],
+
+      compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) =>
+        wikiData
+          ? wikiData.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: (wikiDataProperty, referencerRefListProperty) => ({
-        flags: {expose: true},
+  // 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: (wikiDataProperty, referencerRefListProperty) => ({
+    flags: { expose: true },
 
-        expose: {
-            dependencies: [wikiDataProperty],
+    expose: {
+      dependencies: [wikiDataProperty],
 
-            compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => (
-                wikiData?.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'})))))
-                    : []))
-        }
-    }),
+      compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) =>
+        wikiData?.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}`;
+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))})`
-            : '');
+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
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Album'),
-    color: Thing.common.color(),
-    directory: Thing.common.directory(),
-    urls: Thing.common.urls(),
+  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(),
+  date: Thing.common.simpleDate(),
+  trackArtDate: Thing.common.simpleDate(),
+  dateAddedToWiki: Thing.common.simpleDate(),
 
-    coverArtDate: {
-        flags: {update: true, expose: true},
+  coverArtDate: {
+    flags: { update: true, expose: true },
 
-        update: {validate: isDate},
+    update: { validate: isDate },
 
-        expose: {
-            dependencies: ['date'],
-            transform: (coverArtDate, { date }) => coverArtDate ?? date ?? null
-        }
+    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(),
+  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),
+  groupsByRef: Thing.common.referenceList(Group),
+  artTagsByRef: Thing.common.referenceList(ArtTag),
 
-    trackGroups: {
-        flags: {update: true, expose: true},
+  trackGroups: {
+    flags: { update: true, expose: true },
 
-        update: {
-            validate: validateArrayItems(validateInstanceOf(TrackGroup))
-        }
+    update: {
+      validate: validateArrayItems(validateInstanceOf(TrackGroup)),
     },
+  },
 
-    coverArtFileExtension: Thing.common.fileExtension('jpg'),
-    trackCoverArtFileExtension: Thing.common.fileExtension('jpg'),
+  coverArtFileExtension: Thing.common.fileExtension("jpg"),
+  trackCoverArtFileExtension: Thing.common.fileExtension("jpg"),
 
-    wallpaperStyle: Thing.common.simpleString(),
-    wallpaperFileExtension: 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}
-    },
+  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),
+  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(),
+  commentary: Thing.common.commentary(),
+  additionalFiles: Thing.common.additionalFiles(),
 
-    // Update only
+  // Update only
 
-    artistData: Thing.common.wikiData(Artist),
-    artTagData: Thing.common.wikiData(ArtTag),
-    groupData: Thing.common.wikiData(Group),
-    trackData: Thing.common.wikiData(Track),
+  artistData: Thing.common.wikiData(Artist),
+  artTagData: Thing.common.wikiData(ArtTag),
+  groupData: Thing.common.wikiData(Group),
+  trackData: Thing.common.wikiData(Track),
 
-    // Expose only
+  // 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'),
+  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(),
+  commentatorArtists: Thing.common.commentatorArtists(),
 
-    tracks: {
-        flags: {expose: true},
+  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),
+    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,
+  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
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Track Group'),
+  name: Thing.common.name("Unnamed Track Group"),
 
-    color: {
-        flags: {update: true, expose: true},
+  color: {
+    flags: { update: true, expose: true },
 
-        update: {validate: isColor},
+    update: { validate: isColor },
 
-        expose: {
-            dependencies: ['album'],
+    expose: {
+      dependencies: ["album"],
 
-            transform(color, { album }) {
-                return color ?? album?.color ?? null;
-            }
-        }
+      transform(color, { album }) {
+        return color ?? album?.color ?? null;
+      },
     },
+  },
 
-    dateOriginallyReleased: Thing.common.simpleDate(),
+  dateOriginallyReleased: Thing.common.simpleDate(),
 
-    tracksByRef: Thing.common.referenceList(Track),
+  tracksByRef: Thing.common.referenceList(Track),
 
-    isDefaultTrackGroup: Thing.common.flag(false),
+  isDefaultTrackGroup: Thing.common.flag(false),
 
-    // Update only
+  // Update only
 
-    album: {
-        flags: {update: true},
-        update: {validate: validateInstanceOf(Album)}
-    },
+  album: {
+    flags: { update: true },
+    update: { validate: validateInstanceOf(Album) },
+  },
 
-    trackData: Thing.common.wikiData(Track),
+  trackData: Thing.common.wikiData(Track),
 
-    // Expose only
+  // Expose only
 
-    tracks: {
-        flags: {expose: true},
+  tracks: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['tracksByRef', 'trackData'],
-            compute: ({ tracksByRef, trackData }) => (
-                (tracksByRef && trackData
-                    ? (tracksByRef
-                        .map(ref => find.track(ref, trackData))
-                        .filter(Boolean))
-                    : [])
-            )
-        }
+    expose: {
+      dependencies: ["tracksByRef", "trackData"],
+      compute: ({ tracksByRef, trackData }) =>
+        tracksByRef && trackData
+          ? tracksByRef.map((ref) => find.track(ref, trackData)).filter(Boolean)
+          : [],
     },
+  },
 
-    startIndex: {
-        flags: {expose: true},
+  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))
-        }
+    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
@@ -665,1059 +694,1191 @@ TrackGroup.propertyDescriptors = {
 // 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));
+  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.hasCoverArt = (
+  track,
+  albumData,
+  coverArtistContribsByRef,
+  hasCoverArt
+) => {
+  return (
+    hasCoverArt ??
+    (coverArtistContribsByRef?.length > 0 || null) ??
+    Track.findAlbum(track, albumData)?.hasTrackArt ??
+    true
+  );
 };
 
 Track.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Track'),
-    directory: Thing.common.directory(),
+  name: Thing.common.name("Unnamed Track"),
+  directory: Thing.common.directory(),
 
-    duration: {
-        flags: {update: true, expose: true},
-        update: {validate: isDuration}
-    },
+  duration: {
+    flags: { update: true, expose: true },
+    update: { validate: isDuration },
+  },
 
-    urls: Thing.common.urls(),
-    dateFirstReleased: Thing.common.simpleDate(),
+  urls: Thing.common.urls(),
+  dateFirstReleased: Thing.common.simpleDate(),
 
-    hasURLs: Thing.common.flag(true),
+  hasURLs: Thing.common.flag(true),
 
-    artistContribsByRef: Thing.common.contribsByRef(),
-    contributorContribsByRef: Thing.common.contribsByRef(),
-    coverArtistContribsByRef: Thing.common.contribsByRef(),
+  artistContribsByRef: Thing.common.contribsByRef(),
+  contributorContribsByRef: Thing.common.contribsByRef(),
+  coverArtistContribsByRef: Thing.common.contribsByRef(),
 
-    referencedTracksByRef: Thing.common.referenceList(Track),
-    artTagsByRef: Thing.common.referenceList(ArtTag),
+  referencedTracksByRef: Thing.common.referenceList(Track),
+  artTagsByRef: Thing.common.referenceList(ArtTag),
 
-    hasCoverArt: {
-        flags: {update: true, expose: true},
+  hasCoverArt: {
+    flags: { update: true, expose: true },
 
-        update: {validate: isBoolean},
+    update: { validate: isBoolean },
 
-        expose: {
-            dependencies: ['albumData', 'coverArtistContribsByRef'],
-            transform: (hasCoverArt, { albumData, coverArtistContribsByRef, [Track.instance]: track }) => (
-                Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt))
-        }
+    expose: {
+      dependencies: ["albumData", "coverArtistContribsByRef"],
+      transform: (
+        hasCoverArt,
+        { albumData, coverArtistContribsByRef, [Track.instance]: track }
+      ) =>
+        Track.hasCoverArt(
+          track,
+          albumData,
+          coverArtistContribsByRef,
+          hasCoverArt
+        ),
     },
+  },
 
-    coverArtFileExtension: {
-        flags: {update: true, expose: true},
+  coverArtFileExtension: {
+    flags: { update: true, expose: true },
 
-        update: {validate: isFileExtension},
+    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')
+    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),
+  // Previously known as: (track).aka
+  originalReleaseTrackByRef: Thing.common.singleReference(Track),
 
-    dataSourceAlbumByRef: Thing.common.singleReference(Album),
+  dataSourceAlbumByRef: Thing.common.singleReference(Album),
 
-    commentary: Thing.common.commentary(),
-    lyrics: Thing.common.simpleString(),
-    additionalFiles: Thing.common.additionalFiles(),
+  commentary: Thing.common.commentary(),
+  lyrics: Thing.common.simpleString(),
+  additionalFiles: Thing.common.additionalFiles(),
 
-    // Update only
+  // 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),
+  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
+  // Expose only
 
-    commentatorArtists: Thing.common.commentatorArtists(),
+  commentatorArtists: Thing.common.commentatorArtists(),
 
-    album: {
-        flags: {expose: true},
+  album: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['albumData'],
-            compute: ({ [Track.instance]: track, albumData }) => (
-                albumData?.find(album => album.tracks.includes(track)) ?? null)
-        }
-    },
+    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 },
 
-    // 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
-            )
-        }
+    expose: {
+      dependencies: ["albumData", "dateFirstReleased"],
+      compute: ({ albumData, dateFirstReleased, [Track.instance]: track }) =>
+        dateFirstReleased ?? Track.findAlbum(track, albumData)?.date ?? null,
     },
+  },
 
-    color: {
-        flags: {expose: true},
+  color: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['albumData'],
+    expose: {
+      dependencies: ["albumData"],
 
-            compute: ({ albumData, [Track.instance]: track }) => (
-                (Track.findAlbum(track, albumData)?.trackGroups
-                    .find(tg => tg.tracks.includes(track))?.color)
-                ?? null
-            )
-        }
+      compute: ({ albumData, [Track.instance]: track }) =>
+        Track.findAlbum(track, albumData)?.trackGroups.find((tg) =>
+          tg.tracks.includes(track)
+        )?.color ?? null,
     },
+  },
 
-    coverArtDate: {
-        flags: {update: true, expose: true},
+  coverArtDate: {
+    flags: { update: true, expose: true },
 
-        update: {validate: isDate},
+    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);
-            }
-        }
-    },
+    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 },
 
-    // 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),
-
-    // 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)))
-                : [])
+    expose: {
+      dependencies: ["originalReleaseTrackByRef", "trackData"],
+
+      compute: ({
+        originalReleaseTrackByRef: t1origRef,
+        trackData,
+        [Track.instance]: t1,
+      }) => {
+        if (!trackData) {
+          return [];
         }
-    },
 
-    // Previously known as: (track).flashes
-    featuredInFlashes: Thing.common.reverseReferenceList('flashData', 'featuredTracks'),
+        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
+  ),
+
+  // 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 },
 
-    artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag),
+    expose: {
+      dependencies: ["trackData"],
+
+      compute: ({ trackData, [Track.instance]: track }) =>
+        trackData
+          ? trackData
+              .filter((t) => !t.originalReleaseTrack)
+              .filter((t) => t.referencedTracks?.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);
+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}`);
+  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);
+  return albumName
+    ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})`
+    : base;
 };
 
 // -> Artist
 
 Artist.filterByContrib = (thingDataProperty, contribsProperty) => ({
-    flags: {expose: true},
+  flags: { expose: true },
 
-    expose: {
-        dependencies: [thingDataProperty],
+  expose: {
+    dependencies: [thingDataProperty],
 
-        compute: ({ [thingDataProperty]: thingData, [Artist.instance]: artist }) => (
-            thingData?.filter(({ [contribsProperty]: contribs }) => (
-                contribs?.some(contrib => contrib.who === artist))))
-    }
+    compute: ({ [thingDataProperty]: thingData, [Artist.instance]: artist }) =>
+      thingData?.filter(({ [contribsProperty]: contribs }) =>
+        contribs?.some((contrib) => contrib.who === artist)
+      ),
+  },
 });
 
 Artist.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Artist'),
-    directory: Thing.common.directory(),
-    urls: Thing.common.urls(),
-    contextNotes: Thing.common.simpleString(),
+  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'),
+  hasAvatar: Thing.common.flag(false),
+  avatarFileExtension: Thing.common.fileExtension("jpg"),
 
-    aliasNames: {
-        flags: {update: true, expose: true},
-        update: {
-            validate: validateArrayItems(isName)
-        }
+  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)
-            )
-        }
-    },
+  isAlias: Thing.common.flag(),
+  aliasedArtistRef: Thing.common.singleReference(Artist),
 
-    tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'),
-    tracksAsContributor: Artist.filterByContrib('trackData', 'contributorContribs'),
-    tracksAsCoverArtist: Artist.filterByContrib('trackData', 'coverArtistContribs'),
+  // Update only
 
-    tracksAsAny: {
-        flags: {expose: true},
+  albumData: Thing.common.wikiData(Album),
+  artistData: Thing.common.wikiData(Artist),
+  flashData: Thing.common.wikiData(Flash),
+  trackData: Thing.common.wikiData(Track),
 
-        expose: {
-            dependencies: ['trackData'],
+  // Expose only
 
-            compute: ({ trackData, [Artist.instance]: artist }) => (
-                trackData?.filter(track => (
-                    [
-                        ...track.artistContribs,
-                        ...track.contributorContribs,
-                        ...track.coverArtistContribs
-                    ].some(({ who }) => who === artist))))
-        }
-    },
+  aliasedArtist: {
+    flags: { expose: true },
 
-    tracksAsCommentator: {
-        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'],
+    expose: {
+      dependencies: ["trackData"],
 
-            compute: ({ trackData, [Artist.instance]: artist }) => (
-                trackData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist)))
-        }
+      compute: ({ trackData, [Artist.instance]: artist }) =>
+        trackData?.filter((track) =>
+          [
+            ...track.artistContribs,
+            ...track.contributorContribs,
+            ...track.coverArtistContribs,
+          ].some(({ who }) => who === artist)
+        ),
     },
+  },
 
-    albumsAsAlbumArtist: Artist.filterByContrib('albumData', 'artistContribs'),
-    albumsAsCoverArtist: Artist.filterByContrib('albumData', 'coverArtistContribs'),
-    albumsAsWallpaperArtist: Artist.filterByContrib('albumData', 'wallpaperArtistContribs'),
-    albumsAsBannerArtist: Artist.filterByContrib('albumData', 'bannerArtistContribs'),
+  tracksAsCommentator: {
+    flags: { expose: true },
 
-    albumsAsCommentator: {
-        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'],
+    expose: {
+      dependencies: ["albumData"],
 
-            compute: ({ albumData, [Artist.instance]: artist }) => (
-                albumData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist)))
-        }
+      compute: ({ albumData, [Artist.instance]: artist }) =>
+        albumData.filter(({ commentatorArtists }) =>
+          commentatorArtists?.includes(artist)
+        ),
     },
+  },
 
-    flashesAsContributor: Artist.filterByContrib('flashData', 'contributorContribs'),
+  flashesAsContributor: Artist.filterByContrib(
+    "flashData",
+    "contributorContribs"
+  ),
 };
 
 Artist[S.serializeDescriptors] = {
-    name: S.id,
-    directory: S.id,
-    urls: S.id,
-    contextNotes: S.id,
+  name: S.id,
+  directory: S.id,
+  urls: S.id,
+  contextNotes: S.id,
 
-    hasAvatar: S.id,
-    avatarFileExtension: S.id,
+  hasAvatar: S.id,
+  avatarFileExtension: S.id,
 
-    aliasNames: S.id,
+  aliasNames: S.id,
 
-    tracksAsArtist: S.toRefs,
-    tracksAsContributor: S.toRefs,
-    tracksAsCoverArtist: S.toRefs,
-    tracksAsCommentator: S.toRefs,
+  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,
+  albumsAsAlbumArtist: S.toRefs,
+  albumsAsCoverArtist: S.toRefs,
+  albumsAsWallpaperArtist: S.toRefs,
+  albumsAsBannerArtist: S.toRefs,
+  albumsAsCommentator: S.toRefs,
 
-    flashesAsContributor: S.toRefs,
+  flashesAsContributor: S.toRefs,
 };
 
 // -> Group
 
 Group.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Group'),
-    directory: Thing.common.directory(),
+  name: Thing.common.name("Unnamed Group"),
+  directory: Thing.common.directory(),
 
-    description: Thing.common.simpleString(),
+  description: Thing.common.simpleString(),
 
-    urls: Thing.common.urls(),
+  urls: Thing.common.urls(),
 
-    // Update only
+  // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    groupCategoryData: Thing.common.wikiData(GroupCategory),
+  albumData: Thing.common.wikiData(Album),
+  groupCategoryData: Thing.common.wikiData(GroupCategory),
 
-    // Expose only
+  // Expose only
 
-    descriptionShort: {
-        flags: {expose: true},
+  descriptionShort: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['description'],
-            compute: ({ description }) => description.split('<hr class="split">')[0]
-        }
+    expose: {
+      dependencies: ["description"],
+      compute: ({ description }) => description.split('<hr class="split">')[0],
     },
+  },
 
-    albums: {
-        flags: {expose: true},
+  albums: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['albumData'],
-            compute: ({ albumData, [Group.instance]: group }) => (
-                albumData?.filter(album => album.groups.includes(group)) ?? [])
-        }
+    expose: {
+      dependencies: ["albumData"],
+      compute: ({ albumData, [Group.instance]: group }) =>
+        albumData?.filter((album) => album.groups.includes(group)) ?? [],
     },
+  },
 
-    color: {
-        flags: {expose: true},
+  color: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['groupCategoryData'],
+    expose: {
+      dependencies: ["groupCategoryData"],
 
-            compute: ({ groupCategoryData, [Group.instance]: group }) => (
-                groupCategoryData.find(category => category.groups.includes(group))?.color ?? null)
-        }
+      compute: ({ groupCategoryData, [Group.instance]: group }) =>
+        groupCategoryData.find((category) => category.groups.includes(group))
+          ?.color ?? null,
     },
+  },
 
-    category: {
-        flags: {expose: true},
+  category: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['groupCategoryData'],
-            compute: ({ groupCategoryData, [Group.instance]: group }) => (
-                groupCategoryData.find(category => category.groups.includes(group)) ?? null)
-        }
+    expose: {
+      dependencies: ["groupCategoryData"],
+      compute: ({ groupCategoryData, [Group.instance]: group }) =>
+        groupCategoryData.find((category) => category.groups.includes(group)) ??
+        null,
     },
+  },
 };
 
 GroupCategory.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Group Category'),
-    color: Thing.common.color(),
+  name: Thing.common.name("Unnamed Group Category"),
+  color: Thing.common.color(),
 
-    groupsByRef: Thing.common.referenceList(Group),
+  groupsByRef: Thing.common.referenceList(Group),
 
-    // Update only
+  // Update only
 
-    groupData: Thing.common.wikiData(Group),
+  groupData: Thing.common.wikiData(Group),
 
-    // Expose only
+  // Expose only
 
-    groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group),
+  groups: Thing.common.dynamicThingsFromReferenceList(
+    "groupsByRef",
+    "groupData",
+    find.group
+  ),
 };
 
 // -> ArtTag
 
 ArtTag.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Art Tag'),
-    directory: Thing.common.directory(),
-    color: Thing.common.color(),
-    isContentWarning: Thing.common.flag(false),
+  name: Thing.common.name("Unnamed Art Tag"),
+  directory: Thing.common.directory(),
+  color: Thing.common.color(),
+  isContentWarning: Thing.common.flag(false),
 
-    // Update only
+  // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    trackData: Thing.common.wikiData(Track),
+  albumData: Thing.common.wikiData(Album),
+  trackData: Thing.common.wikiData(Track),
 
-    // Expose only
+  // Expose only
 
-    // Previously known as: (tag).things
-    taggedInThings: {
-        flags: {expose: true},
+  // Previously known as: (tag).things
+  taggedInThings: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['albumData', 'trackData'],
-            compute: ({ albumData, trackData, [ArtTag.instance]: artTag }) => (
-                sortAlbumsTracksChronologically(
-                    ([...albumData, ...trackData]
-                        .filter(thing => thing.artTags?.includes(artTag))),
-                    {getDate: o => o.coverArtDate}))
-        }
-    }
+    expose: {
+      dependencies: ["albumData", "trackData"],
+      compute: ({ albumData, trackData, [ArtTag.instance]: artTag }) =>
+        sortAlbumsTracksChronologically(
+          [...albumData, ...trackData].filter((thing) =>
+            thing.artTags?.includes(artTag)
+          ),
+          { getDate: (o) => o.coverArtDate }
+        ),
+    },
+  },
 };
 
 // -> NewsEntry
 
 NewsEntry.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed News Entry'),
-    directory: Thing.common.directory(),
-    date: Thing.common.simpleDate(),
+  name: Thing.common.name("Unnamed News Entry"),
+  directory: Thing.common.directory(),
+  date: Thing.common.simpleDate(),
 
-    content: Thing.common.simpleString(),
+  content: Thing.common.simpleString(),
 
-    // Expose only
+  // Expose only
 
-    contentShort: {
-        flags: {expose: true},
+  contentShort: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['content'],
+    expose: {
+      dependencies: ["content"],
 
-            compute: ({ content }) => content.split('<hr class="split">')[0]
-        }
+      compute: ({ content }) => content.split('<hr class="split">')[0],
     },
+  },
 };
 
 // -> StaticPage
 
 StaticPage.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Static Page'),
+  name: Thing.common.name("Unnamed Static Page"),
 
-    nameShort: {
-        flags: {update: true, expose: true},
-        update: {validate: isName},
+  nameShort: {
+    flags: { update: true, expose: true },
+    update: { validate: isName },
 
-        expose: {
-            dependencies: ['name'],
-            transform: (value, { name }) => value ?? name
-        }
+    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),
+  directory: Thing.common.directory(),
+  content: Thing.common.simpleString(),
+  stylesheet: Thing.common.simpleString(),
+  showInNavigationBar: Thing.common.flag(true),
 };
 
 // -> HomepageLayout
 
 HomepageLayout.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    sidebarContent: Thing.common.simpleString(),
+  sidebarContent: Thing.common.simpleString(),
 
-    rows: {
-        flags: {update: true, expose: true},
+  rows: {
+    flags: { update: true, expose: true },
 
-        update: {
-            validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow))
-        }
+    update: {
+      validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)),
     },
+  },
 };
 
 HomepageLayoutRow.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Homepage Row'),
+  name: Thing.common.name("Unnamed Homepage Row"),
 
-    type: {
-        flags: {update: true, expose: true},
+  type: {
+    flags: { update: true, expose: true },
 
-        update: {
-            validate(value) {
-                throw new Error(`'type' property validator must be overridden`);
-            }
-        }
+    update: {
+      validate(value) {
+        throw new Error(`'type' property validator must be overridden`);
+      },
     },
+  },
 
-    color: Thing.common.color(),
+  color: Thing.common.color(),
 
-    // Update only
+  // 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),
+  // 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
+  ...HomepageLayoutRow.propertyDescriptors,
 
-    type: {
-        flags: {update: true, expose: true},
-        update: {
-            validate(value) {
-                if (value !== 'albums') {
-                    throw new TypeError(`Expected 'albums'`);
-                }
+  // Update & expose
 
-                return true;
-            }
+  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),
+  sourceGroupByRef: Thing.common.singleReference(Group),
+  sourceAlbumsByRef: Thing.common.referenceList(Album),
 
-    countAlbumsFromGroup: {
-        flags: {update: true, expose: true},
-        update: {validate: isCountingNumber}
-    },
+  countAlbumsFromGroup: {
+    flags: { update: true, expose: true },
+    update: { validate: isCountingNumber },
+  },
 
-    actionLinks: {
-        flags: {update: true, expose: true},
-        update: {validate: validateArrayItems(isString)}
-    },
+  actionLinks: {
+    flags: { update: true, expose: true },
+    update: { validate: validateArrayItems(isString) },
+  },
 
-    // Expose only
+  // Expose only
 
-    sourceGroup: Thing.common.dynamicThingFromSingleReference('sourceGroupByRef', 'groupData', find.group),
-    sourceAlbums: Thing.common.dynamicThingsFromReferenceList('sourceAlbumsByRef', 'albumData', find.album),
+  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;
-            }
-        }
+  // 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)},
+  page: {
+    flags: { update: true, expose: true },
+    update: { validate: oneOf(isString, isNumber) },
 
-        expose: {
-            transform: value => (value === null ? null : value.toString())
-        }
+    expose: {
+      transform: (value) => (value === null ? null : value.toString()),
     },
+  },
 
-    date: Thing.common.simpleDate(),
+  date: Thing.common.simpleDate(),
 
-    coverArtFileExtension: Thing.common.fileExtension('jpg'),
+  coverArtFileExtension: Thing.common.fileExtension("jpg"),
 
-    contributorContribsByRef: Thing.common.contribsByRef(),
+  contributorContribsByRef: Thing.common.contribsByRef(),
 
-    featuredTracksByRef: Thing.common.referenceList(Track),
+  featuredTracksByRef: Thing.common.referenceList(Track),
 
-    urls: Thing.common.urls(),
+  urls: Thing.common.urls(),
 
-    // Update only
+  // Update only
 
-    artistData: Thing.common.wikiData(Artist),
-    trackData: Thing.common.wikiData(Track),
-    flashActData: Thing.common.wikiData(FlashAct),
+  artistData: Thing.common.wikiData(Artist),
+  trackData: Thing.common.wikiData(Track),
+  flashActData: Thing.common.wikiData(FlashAct),
 
-    // Expose only
+  // Expose only
 
-    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
+  contributorContribs: Thing.common.dynamicContribs("contributorContribsByRef"),
 
-    featuredTracks: Thing.common.dynamicThingsFromReferenceList('featuredTracksByRef', 'trackData', find.track),
+  featuredTracks: Thing.common.dynamicThingsFromReferenceList(
+    "featuredTracksByRef",
+    "trackData",
+    find.track
+  ),
 
-    act: {
-        flags: {expose: true},
+  act: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['flashActData'],
+    expose: {
+      dependencies: ["flashActData"],
 
-            compute: ({ flashActData, [Flash.instance]: flash }) => (
-                flashActData.find(act => act.flashes.includes(flash)) ?? null)
-        }
+      compute: ({ flashActData, [Flash.instance]: flash }) =>
+        flashActData.find((act) => act.flashes.includes(flash)) ?? null,
     },
+  },
 
-    color: {
-        flags: {expose: true},
+  color: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['flashActData'],
+    expose: {
+      dependencies: ["flashActData"],
 
-            compute: ({ flashActData, [Flash.instance]: flash }) => (
-                flashActData.find(act => act.flashes.includes(flash))?.color ?? null)
-        }
+      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,
+  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
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Flash Act'),
-    color: Thing.common.color(),
-    anchor: Thing.common.simpleString(),
-    jump: Thing.common.simpleString(),
-    jumpColor: Thing.common.color(),
+  name: Thing.common.name("Unnamed Flash Act"),
+  color: Thing.common.color(),
+  anchor: Thing.common.simpleString(),
+  jump: Thing.common.simpleString(),
+  jumpColor: Thing.common.color(),
 
-    flashesByRef: Thing.common.referenceList(Flash),
+  flashesByRef: Thing.common.referenceList(Flash),
 
-    // Update only
+  // Update only
 
-    flashData: Thing.common.wikiData(Flash),
+  flashData: Thing.common.wikiData(Flash),
 
-    // Expose only
+  // Expose only
 
-    flashes: Thing.common.dynamicThingsFromReferenceList('flashesByRef', 'flashData', find.flash),
+  flashes: Thing.common.dynamicThingsFromReferenceList(
+    "flashesByRef",
+    "flashData",
+    find.flash
+  ),
 };
 
 // -> WikiInfo
 
 WikiInfo.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Wiki'),
+  name: Thing.common.name("Unnamed Wiki"),
 
-    // Displayed in nav bar.
-    nameShort: {
-        flags: {update: true, expose: true},
-        update: {validate: isName},
+  // Displayed in nav bar.
+  nameShort: {
+    flags: { update: true, expose: true },
+    update: { validate: isName },
 
-        expose: {
-            dependencies: ['name'],
-            transform: (value, { name }) => value ?? name
-        }
+    expose: {
+      dependencies: ["name"],
+      transform: (value, { name }) => value ?? name,
     },
+  },
 
-    color: Thing.common.color(),
+  color: Thing.common.color(),
 
-    // One-line description used for <meta rel="description"> tag.
-    description: Thing.common.simpleString(),
+  // One-line description used for <meta rel="description"> tag.
+  description: Thing.common.simpleString(),
 
-    footerContent: Thing.common.simpleString(),
+  footerContent: Thing.common.simpleString(),
 
-    defaultLanguage: {
-        flags: {update: true, expose: true},
-        update: {validate: isLanguageCode}
-    },
+  defaultLanguage: {
+    flags: { update: true, expose: true },
+    update: { validate: isLanguageCode },
+  },
 
-    canonicalBase: {
-        flags: {update: true, expose: true},
-        update: {validate: isURL}
-    },
+  canonicalBase: {
+    flags: { update: true, expose: true },
+    update: { validate: isURL },
+  },
 
-    divideTrackListsByGroupsByRef: Thing.common.referenceList(Group),
+  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),
+  // 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
+  // Update only
 
-    groupData: Thing.common.wikiData(Group),
+  groupData: Thing.common.wikiData(Group),
 
-    // Expose only
+  // Expose only
 
-    divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList('divideTrackListsByGroupsByRef', 'groupData', find.group),
+  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]);
-        }
-    }
+  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;
-                }
-            }
+  // 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'}
-    },
+  // 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
+  // Update only
 
-    escapeHTML: Thing.common.externalFunction(),
+  escapeHTML: Thing.common.externalFunction(),
 
-    // Expose only
+  // 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'}),
+  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 ?? {})
-            ]))
-        }
-    },
+  validKeys: {
+    flags: { expose: true },
 
-    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)]));
-            }
-        }
-    },
+    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} = {}) {
+const countHelper = (stringKey, argName = stringKey) =>
+  function (value, { unit = false } = {}) {
     return this.$(
-        (unit
-            ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
-            : `count.${stringKey}`),
-        {[argName]: this.formatNumber(value)});
-};
+      unit
+        ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
+        : `count.${stringKey}`,
+      { [argName]: this.formatNumber(value) }
+    );
+  };
 
 Object.assign(Language.prototype, {
-    $(key, args = {}) {
-        return this.formatString(key, args);
-    },
+  $(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);
+  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`
+      );
+    }
 
-        const words = (value > 1000
-            ? this.formatString('count.words.thousand', {words: num})
-            : this.formatString('count.words', {words: num}));
+    return this.formatStringHelper(this.strings_htmlEscaped, key, args);
+  },
 
-        return this.formatString('count.words.withUnit.' + this.getUnitForm(value), {words});
-    },
+  formatStringNoHTMLEscape(key, args = {}) {
+    return this.formatStringHelper(this.strings, key, args);
+  },
 
-    // Conjunction list: A, B, and C
-    formatConjunctionList(array) {
-        this.assertIntlAvailable('intl_listConjunction');
-        return this.intl_listConjunction.format(array);
-    },
+  formatStringHelper(strings, key, args = {}) {
+    if (!strings) {
+      throw new Error(`Strings unavailable`);
+    }
 
-    // Disjunction lists: A, B, or C
-    formatDisjunctionList(array) {
-        this.assertIntlAvailable('intl_listDisjunction');
-        return this.intl_listDisjunction.format(array);
-    },
+    if (!this.validKeys.includes(key)) {
+      throw new Error(`Invalid key ${key} accessed`);
+    }
 
-    // Unit lists: A, B, C
-    formatUnitList(array) {
-        this.assertIntlAvailable('intl_listUnit');
-        return this.intl_listUnit.format(array);
-    },
+    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}`);
+    }
 
-    // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
-    formatFileSize(bytes) {
-        if (!bytes) return '';
+    return output;
+  },
 
-        bytes = parseInt(bytes);
-        if (isNaN(bytes)) return '';
+  formatDate(date) {
+    this.assertIntlAvailable("intl_date");
+    return this.intl_date.format(date);
+  },
 
-        const round = exp => Math.round(bytes / 10 ** (exp - 1)) / 10;
+  formatDateRange(startDate, endDate) {
+    this.assertIntlAvailable("intl_date");
+    return this.intl_date.formatRange(startDate, endDate);
+  },
 
-        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});
-        }
-    },
+  formatDuration(secTotal, { approximate = false, unit = false } = {}) {
+    if (secTotal === 0) {
+      return this.formatString("count.duration.missing");
+    }
 
-    // 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'),
+    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/validators.js b/src/data/validators.js
index 0d325ae..714dc3a 100644
--- a/src/data/validators.js
+++ b/src/data/validators.js
@@ -1,367 +1,387 @@
-import { withAggregate } from '../util/sugar.js';
+import { withAggregate } from "../util/sugar.js";
 
-import { color, ENABLE_COLOR, decorateTime } from '../util/cli.js';
+import { color, ENABLE_COLOR, decorateTime } from "../util/cli.js";
 
-import { inspect as nodeInspect } from 'util';
+import { inspect as nodeInspect } from "util";
 
 function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
+  return nodeInspect(value, { colors: ENABLE_COLOR });
 }
 
 // Basic types (primitives)
 
 function a(noun) {
-    return (/[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`);
+  return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`;
 }
 
 function isType(value, type) {
-    if (typeof value !== type)
-        throw new TypeError(`Expected ${a(type)}, got ${typeof value}`);
+  if (typeof value !== type)
+    throw new TypeError(`Expected ${a(type)}, got ${typeof value}`);
 
-    return true;
+  return true;
 }
 
 export function isBoolean(value) {
-    return isType(value, 'boolean');
+  return isType(value, "boolean");
 }
 
 export function isNumber(value) {
-    return isType(value, 'number');
+  return isType(value, "number");
 }
 
 export function isPositive(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number <= 0)
-        throw new TypeError(`Expected positive number`);
+  if (number <= 0) throw new TypeError(`Expected positive number`);
 
-    return true;
+  return true;
 }
 
 export function isNegative(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number >= 0)
-        throw new TypeError(`Expected negative number`);
+  if (number >= 0) throw new TypeError(`Expected negative number`);
 
-    return true;
+  return true;
 }
 
 export function isPositiveOrZero(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number < 0)
-        throw new TypeError(`Expected positive number or zero`);
+  if (number < 0) throw new TypeError(`Expected positive number or zero`);
 
-    return true;
+  return true;
 }
 
 export function isNegativeOrZero(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number > 0)
-        throw new TypeError(`Expected negative number or zero`);
+  if (number > 0) throw new TypeError(`Expected negative number or zero`);
 
-    return true;
+  return true;
 }
 
 export function isInteger(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number % 1 !== 0)
-        throw new TypeError(`Expected integer`);
+  if (number % 1 !== 0) throw new TypeError(`Expected integer`);
 
-    return true;
+  return true;
 }
 
 export function isCountingNumber(number) {
-    isInteger(number);
-    isPositive(number);
+  isInteger(number);
+  isPositive(number);
 
-    return true;
+  return true;
 }
 
 export function isWholeNumber(number) {
-    isInteger(number);
-    isPositiveOrZero(number);
+  isInteger(number);
+  isPositiveOrZero(number);
 
-    return true;
+  return true;
 }
 
 export function isString(value) {
-    return isType(value, 'string');
+  return isType(value, "string");
 }
 
 export function isStringNonEmpty(value) {
-    isString(value);
+  isString(value);
 
-    if (value.trim().length === 0)
-        throw new TypeError(`Expected non-empty string`);
+  if (value.trim().length === 0)
+    throw new TypeError(`Expected non-empty string`);
 
-    return true;
+  return true;
 }
 
 // Complex types (non-primitives)
 
 export function isInstance(value, constructor) {
-    isObject(value);
+  isObject(value);
 
-    if (!(value instanceof constructor))
-        throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`);
+  if (!(value instanceof constructor))
+    throw new TypeError(
+      `Expected ${constructor.name}, got ${value.constructor.name}`
+    );
 
-    return true;
+  return true;
 }
 
 export function isDate(value) {
-    return isInstance(value, Date);
+  return isInstance(value, Date);
 }
 
 export function isObject(value) {
-    isType(value, 'object');
+  isType(value, "object");
 
-    // Note: Please remember that null is always a valid value for properties
-    // held by a CacheableObject. This assertion is exclusively for use in other
-    // contexts.
-    if (value === null)
-        throw new TypeError(`Expected an object, got null`);
+  // Note: Please remember that null is always a valid value for properties
+  // held by a CacheableObject. This assertion is exclusively for use in other
+  // contexts.
+  if (value === null) throw new TypeError(`Expected an object, got null`);
 
-    return true;
+  return true;
 }
 
 export function isArray(value) {
-    if (typeof value !== 'object' || value === null || !Array.isArray(value))
-        throw new TypeError(`Expected an array, got ${value}`);
+  if (typeof value !== "object" || value === null || !Array.isArray(value))
+    throw new TypeError(`Expected an array, got ${value}`);
 
-    return true;
+  return true;
 }
 
 function validateArrayItemsHelper(itemValidator) {
-    return (item, index) => {
-        try {
-            const value = itemValidator(item);
-
-            if (value !== true) {
-                throw new Error(`Expected validator to return true`);
-            }
-        } catch (error) {
-            error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`;
-            throw error;
-        }
-    };
+  return (item, index) => {
+    try {
+      const value = itemValidator(item);
+
+      if (value !== true) {
+        throw new Error(`Expected validator to return true`);
+      }
+    } catch (error) {
+      error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${
+        error.message
+      }`;
+      throw error;
+    }
+  };
 }
 
 export function validateArrayItems(itemValidator) {
-    const fn = validateArrayItemsHelper(itemValidator);
+  const fn = validateArrayItemsHelper(itemValidator);
 
-    return array => {
-        isArray(array);
+  return (array) => {
+    isArray(array);
 
-        withAggregate({message: 'Errors validating array items'}, ({ wrap }) => {
-            array.forEach(wrap(fn));
-        });
+    withAggregate({ message: "Errors validating array items" }, ({ wrap }) => {
+      array.forEach(wrap(fn));
+    });
 
-        return true;
-    };
+    return true;
+  };
 }
 
 export function validateInstanceOf(constructor) {
-    return object => isInstance(object, constructor);
+  return (object) => isInstance(object, constructor);
 }
 
 // Wiki data (primitives & non-primitives)
 
 export function isColor(color) {
-    isStringNonEmpty(color);
+  isStringNonEmpty(color);
 
-    if (color.startsWith('#')) {
-        if (![1 + 3, 1 + 4, 1 + 6, 1 + 8].includes(color.length))
-            throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`);
+  if (color.startsWith("#")) {
+    if (![1 + 3, 1 + 4, 1 + 6, 1 + 8].includes(color.length))
+      throw new TypeError(
+        `Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`
+      );
 
-        if (/[^0-9a-fA-F]/.test(color.slice(1)))
-            throw new TypeError(`Expected hexadecimal digits`);
+    if (/[^0-9a-fA-F]/.test(color.slice(1)))
+      throw new TypeError(`Expected hexadecimal digits`);
 
-        return true;
-    }
+    return true;
+  }
 
-    throw new TypeError(`Unknown color format`);
+  throw new TypeError(`Unknown color format`);
 }
 
 export function isCommentary(commentary) {
-    return isString(commentary);
+  return isString(commentary);
 }
 
-const isArtistRef = validateReference('artist');
+const isArtistRef = validateReference("artist");
 
 export function validateProperties(spec) {
-    const specEntries = Object.entries(spec);
-    const specKeys = Object.keys(spec);
-
-    return object => {
-        isObject(object);
-
-        if (Array.isArray(object))
-            throw new TypeError(`Expected an object, got array`);
-
-        withAggregate({message: `Errors validating object properties`}, ({ call }) => {
-            for (const [ specKey, specValidator ] of specEntries) {
-                call(() => {
-                    const value = object[specKey];
-                    try {
-                        specValidator(value);
-                    } catch (error) {
-                        error.message = `(key: ${color.green(specKey)}, value: ${inspect(value)}) ${error.message}`;
-                        throw error;
-                    }
-                });
-            }
+  const specEntries = Object.entries(spec);
+  const specKeys = Object.keys(spec);
 
-            const unknownKeys = Object.keys(object).filter(key => !specKeys.includes(key));
-            if (unknownKeys.length > 0) {
-                call(() => {
-                    throw new Error(`Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`);
-                });
+  return (object) => {
+    isObject(object);
+
+    if (Array.isArray(object))
+      throw new TypeError(`Expected an object, got array`);
+
+    withAggregate(
+      { message: `Errors validating object properties` },
+      ({ call }) => {
+        for (const [specKey, specValidator] of specEntries) {
+          call(() => {
+            const value = object[specKey];
+            try {
+              specValidator(value);
+            } catch (error) {
+              error.message = `(key: ${color.green(specKey)}, value: ${inspect(
+                value
+              )}) ${error.message}`;
+              throw error;
             }
-        });
+          });
+        }
 
-        return true;
-    };
-}
+        const unknownKeys = Object.keys(object).filter(
+          (key) => !specKeys.includes(key)
+        );
+        if (unknownKeys.length > 0) {
+          call(() => {
+            throw new Error(
+              `Unknown keys present (${
+                unknownKeys.length
+              }): [${unknownKeys.join(", ")}]`
+            );
+          });
+        }
+      }
+    );
 
+    return true;
+  };
+}
 
 export const isContribution = validateProperties({
-    who: isArtistRef,
-    what: value => value === undefined || value === null || isStringNonEmpty(value),
+  who: isArtistRef,
+  what: (value) =>
+    value === undefined || value === null || isStringNonEmpty(value),
 });
 
 export const isContributionList = validateArrayItems(isContribution);
 
 export const isAdditionalFile = validateProperties({
-    title: isString,
-    description: value => (value === undefined || value === null || isString(value)),
-    files: validateArrayItems(isString)
+  title: isString,
+  description: (value) =>
+    value === undefined || value === null || isString(value),
+  files: validateArrayItems(isString),
 });
 
 export const isAdditionalFileList = validateArrayItems(isAdditionalFile);
 
 export function isDimensions(dimensions) {
-    isArray(dimensions);
+  isArray(dimensions);
 
-    if (dimensions.length !== 2)
-        throw new TypeError(`Expected 2 item array`);
+  if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`);
 
-    isPositive(dimensions[0]);
-    isInteger(dimensions[0]);
-    isPositive(dimensions[1]);
-    isInteger(dimensions[1]);
+  isPositive(dimensions[0]);
+  isInteger(dimensions[0]);
+  isPositive(dimensions[1]);
+  isInteger(dimensions[1]);
 
-    return true;
+  return true;
 }
 
 export function isDirectory(directory) {
-    isStringNonEmpty(directory);
+  isStringNonEmpty(directory);
 
-    if (directory.match(/[^a-zA-Z0-9_\-]/))
-        throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`);
+  if (directory.match(/[^a-zA-Z0-9_\-]/))
+    throw new TypeError(
+      `Expected only letters, numbers, dash, and underscore, got "${directory}"`
+    );
 
-    return true;
+  return true;
 }
 
 export function isDuration(duration) {
-    isNumber(duration);
-    isPositiveOrZero(duration);
+  isNumber(duration);
+  isPositiveOrZero(duration);
 
-    return true;
+  return true;
 }
 
 export function isFileExtension(string) {
-    isStringNonEmpty(string);
+  isStringNonEmpty(string);
 
-    if (string[0] === '.')
-        throw new TypeError(`Expected no dot (.) at the start of file extension`);
+  if (string[0] === ".")
+    throw new TypeError(`Expected no dot (.) at the start of file extension`);
 
-    if (string.match(/[^a-zA-Z0-9_]/))
-        throw new TypeError(`Expected only alphanumeric and underscore`);
+  if (string.match(/[^a-zA-Z0-9_]/))
+    throw new TypeError(`Expected only alphanumeric and underscore`);
 
-    return true;
+  return true;
 }
 
 export function isLanguageCode(string) {
-    // TODO: This is a stub function because really we don't need a detailed
-    // is-language-code parser right now.
+  // TODO: This is a stub function because really we don't need a detailed
+  // is-language-code parser right now.
 
-    isString(string);
+  isString(string);
 
-    return true;
+  return true;
 }
 
 export function isName(name) {
-    return isString(name);
+  return isString(name);
 }
 
 export function isURL(string) {
-    isStringNonEmpty(string);
+  isStringNonEmpty(string);
 
-    new URL(string);
+  new URL(string);
 
-    return true;
+  return true;
 }
 
-export function validateReference(type = 'track') {
-    return ref => {
-        isStringNonEmpty(ref);
+export function validateReference(type = "track") {
+  return (ref) => {
+    isStringNonEmpty(ref);
 
-        const match = ref.trim().match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
+    const match = ref
+      .trim()
+      .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
 
-        if (!match)
-            throw new TypeError(`Malformed reference`);
+    if (!match) throw new TypeError(`Malformed reference`);
 
-        const { groups: { typePart, directoryPart } } = match;
+    const {
+      groups: { typePart, directoryPart },
+    } = match;
 
-        if (typePart && typePart !== type)
-            throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`);
+    if (typePart && typePart !== type)
+      throw new TypeError(
+        `Expected ref to begin with "${type}:", got "${typePart}:"`
+      );
 
-        if (typePart)
-            isDirectory(directoryPart);
+    if (typePart) isDirectory(directoryPart);
 
-        isName(ref);
+    isName(ref);
 
-        return true;
-    };
+    return true;
+  };
 }
 
-export function validateReferenceList(type = '') {
-    return validateArrayItems(validateReference(type));
+export function validateReferenceList(type = "") {
+  return validateArrayItems(validateReference(type));
 }
 
 // Compositional utilities
 
 export function oneOf(...checks) {
-    return value => {
-        const errorMeta = [];
+  return (value) => {
+    const errorMeta = [];
 
-        for (let i = 0, check; check = checks[i]; i++) {
-            try {
-                const result = check(value);
-
-                if (result !== true) {
-                    throw new Error(`Check returned false`);
-                }
+    for (let i = 0, check; (check = checks[i]); i++) {
+      try {
+        const result = check(value);
 
-                return true;
-            } catch (error) {
-                errorMeta.push([check, i, error]);
-            }
+        if (result !== true) {
+          throw new Error(`Check returned false`);
         }
 
-        // Don't process error messages until every check has failed.
-        const errors = [];
-        for (const [ check, i, error ] of errorMeta) {
-            error.message = (check.name
-                ? `(#${i} "${check.name}") ${error.message}`
-                : `(#${i}) ${error.message}`);
-            error.check = check;
-            errors.push(error);
-        }
-        throw new AggregateError(errors, `Expected one of ${checks.length} possible checks, but none were true`);
-    };
+        return true;
+      } catch (error) {
+        errorMeta.push([check, i, error]);
+      }
+    }
+
+    // Don't process error messages until every check has failed.
+    const errors = [];
+    for (const [check, i, error] of errorMeta) {
+      error.message = check.name
+        ? `(#${i} "${check.name}") ${error.message}`
+        : `(#${i}) ${error.message}`;
+      error.check = check;
+      errors.push(error);
+    }
+    throw new AggregateError(
+      errors,
+      `Expected one of ${checks.length} possible checks, but none were true`
+    );
+  };
 }
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 763dfd2..cfbb985 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -1,74 +1,69 @@
 // yaml.js - specification for HSMusic YAML data file format and utilities for
 // loading and processing YAML files and documents
 
-import * as path from 'path';
-import yaml from 'js-yaml';
+import * as path from "path";
+import yaml from "js-yaml";
 
-import { readFile } from 'fs/promises';
-import { inspect as nodeInspect } from 'util';
+import { readFile } from "fs/promises";
+import { inspect as nodeInspect } from "util";
 
 import {
-    Album,
-    Artist,
-    ArtTag,
-    Flash,
-    FlashAct,
-    Group,
-    GroupCategory,
-    HomepageLayout,
-    HomepageLayoutAlbumsRow,
-    HomepageLayoutRow,
-    NewsEntry,
-    StaticPage,
-    Thing,
-    Track,
-    TrackGroup,
-    WikiInfo,
-} from './things.js';
+  Album,
+  Artist,
+  ArtTag,
+  Flash,
+  FlashAct,
+  Group,
+  GroupCategory,
+  HomepageLayout,
+  HomepageLayoutAlbumsRow,
+  HomepageLayoutRow,
+  NewsEntry,
+  StaticPage,
+  Thing,
+  Track,
+  TrackGroup,
+  WikiInfo,
+} from "./things.js";
+
+import { color, ENABLE_COLOR, logInfo, logWarn } from "../util/cli.js";
 
 import {
-    color,
-    ENABLE_COLOR,
-    logInfo,
-    logWarn,
-} from '../util/cli.js';
+  decorateErrorWithIndex,
+  mapAggregate,
+  openAggregate,
+  showAggregate,
+  withAggregate,
+} from "../util/sugar.js";
 
 import {
-    decorateErrorWithIndex,
-    mapAggregate,
-    openAggregate,
-    showAggregate,
-    withAggregate,
-} from '../util/sugar.js';
+  sortAlbumsTracksChronologically,
+  sortAlphabetically,
+  sortChronologically,
+} from "../util/wiki-data.js";
 
-import {
-    sortAlbumsTracksChronologically,
-    sortAlphabetically,
-    sortChronologically,
-} from '../util/wiki-data.js';
-
-import find, { bindFind } from '../util/find.js';
-import { findFiles } from '../util/io.js';
+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});
+  return nodeInspect(value, { colors: ENABLE_COLOR });
 }
 
 // --> 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 STATIC_PAGE_DATA_FILE = 'static-pages.yaml';
+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 STATIC_PAGE_DATA_FILE = "static-pages.yaml";
 
-export const DATA_ALBUM_DIRECTORY = 'album';
+export const DATA_ALBUM_DIRECTORY = "album";
 
 // --> Document processing functions
 
@@ -78,7 +73,9 @@ export const DATA_ALBUM_DIRECTORY = 'album';
 // 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, {
+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
@@ -101,454 +98,479 @@ function makeProcessDocument(thingClass, {
     // 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 (!propertyFieldMapping) {
-        throw new Error(`Expected propertyFieldMapping to be provided`);
-    }
-
-    const knownFields = Object.values(propertyFieldMapping);
-
-    // 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 decorateErrorWithName = fn => {
-        const nameField = propertyFieldMapping['name'];
-        if (!nameField) return fn;
-
-        return document => {
-            try {
-                return fn(document);
-            } catch (error) {
-                const name = document[nameField];
-                error.message = (name
-                    ? `(name: ${inspect(name)}) ${error.message}`
-                    : `(${color.dim(`no name found`)}) ${error.message}`);
-                throw error;
-            }
-        };
+    ignoredFields = [],
+  }
+) {
+  if (!propertyFieldMapping) {
+    throw new Error(`Expected propertyFieldMapping to be provided`);
+  }
+
+  const knownFields = Object.values(propertyFieldMapping);
+
+  // 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 decorateErrorWithName = (fn) => {
+    const nameField = propertyFieldMapping["name"];
+    if (!nameField) return fn;
+
+    return (document) => {
+      try {
+        return fn(document);
+      } catch (error) {
+        const name = document[nameField];
+        error.message = name
+          ? `(name: ${inspect(name)}) ${error.message}`
+          : `(${color.dim(`no name found`)}) ${error.message}`;
+        throw error;
+      }
     };
+  };
 
-    return decorateErrorWithName(document => {
-        const documentEntries = Object.entries(document)
-            .filter(([ field ]) => !ignoredFields.includes(field));
+  return decorateErrorWithName((document) => {
+    const documentEntries = Object.entries(document).filter(
+      ([field]) => !ignoredFields.includes(field)
+    );
 
-        const unknownFields = documentEntries
-            .map(([ field ]) => field)
-            .filter(field => !knownFields.includes(field));
+    const unknownFields = documentEntries
+      .map(([field]) => field)
+      .filter((field) => !knownFields.includes(field));
 
-        if (unknownFields.length) {
-            throw new makeProcessDocument.UnknownFieldsError(unknownFields);
-        }
+    if (unknownFields.length) {
+      throw new makeProcessDocument.UnknownFieldsError(unknownFields);
+    }
 
-        const fieldValues = {};
+    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, value] of documentEntries) {
+      if (Object.hasOwn(fieldTransformations, field)) {
+        fieldValues[field] = fieldTransformations[field](value);
+      } else {
+        fieldValues[field] = value;
+      }
+    }
 
-        const sourceProperties = {};
+    const sourceProperties = {};
 
-        for (const [ field, value ] of Object.entries(fieldValues)) {
-            const property = fieldPropertyMapping[field];
-            sourceProperties[property] = value;
-        }
+    for (const [field, value] of Object.entries(fieldValues)) {
+      const property = fieldPropertyMapping[field];
+      sourceProperties[property] = value;
+    }
 
-        const thing = Reflect.construct(thingClass, []);
+    const thing = Reflect.construct(thingClass, []);
 
-        withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({ call }) => {
-            for (const [ property, value ] of Object.entries(sourceProperties)) {
-                call(() => (thing[property] = value));
-            }
-        });
+    withAggregate(
+      { message: `Errors applying ${color.green(thingClass.name)} properties` },
+      ({ call }) => {
+        for (const [property, value] of Object.entries(sourceProperties)) {
+          call(() => (thing[property] = value));
+        }
+      }
+    );
 
-        return thing;
-    });
+    return thing;
+  });
 }
 
-makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error {
-    constructor(fields) {
-        super(`Unknown fields present: ${fields.join(', ')}`);
-        this.fields = fields;
-    }
+makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends (
+  Error
+) {
+  constructor(fields) {
+    super(`Unknown fields present: ${fields.join(", ")}`);
+    this.fields = fields;
+  }
 };
 
 export const processAlbumDocument = makeProcessDocument(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',
-
-        date: 'Date',
-        trackArtDate: 'Default Track Cover Art Date',
-        coverArtDate: 'Cover Art Date',
-        dateAddedToWiki: 'Date Added',
-
-        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',
-    }
+  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",
+
+    date: "Date",
+    trackArtDate: "Default Track Cover Art Date",
+    coverArtDate: "Cover Art Date",
+    dateAddedToWiki: "Date Added",
+
+    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(TrackGroup, {
-    fieldTransformations: {
-        'Date Originally Released': value => new Date(value),
-    },
-
-    propertyFieldMapping: {
-        name: 'Group',
-        color: 'Color',
-        dateOriginallyReleased: 'Date Originally Released',
-    }
+  fieldTransformations: {
+    "Date Originally Released": (value) => new Date(value),
+  },
+
+  propertyFieldMapping: {
+    name: "Group",
+    color: "Color",
+    dateOriginallyReleased: "Date Originally Released",
+  },
 });
 
 export const processTrackDocument = makeProcessDocument(Track, {
-    fieldTransformations: {
-        'Duration': getDurationInSeconds,
+  fieldTransformations: {
+    Duration: getDurationInSeconds,
 
-        'Date First Released': value => new Date(value),
-        'Cover Art Date': value => new Date(value),
+    "Date First Released": (value) => new Date(value),
+    "Cover Art Date": (value) => new Date(value),
 
-        'Artists': parseContributors,
-        'Contributors': parseContributors,
-        'Cover Artists': parseContributors,
+    Artists: parseContributors,
+    Contributors: parseContributors,
+    "Cover Artists": parseContributors,
 
-        'Additional Files': parseAdditionalFiles,
-    },
+    "Additional Files": parseAdditionalFiles,
+  },
 
-    propertyFieldMapping: {
-        name: 'Track',
+  propertyFieldMapping: {
+    name: "Track",
 
-        directory: 'Directory',
-        duration: 'Duration',
-        urls: 'URLs',
+    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',
+    coverArtDate: "Cover Art Date",
+    coverArtFileExtension: "Cover Art File Extension",
+    dateFirstReleased: "Date First Released",
+    hasCoverArt: "Has Cover Art",
+    hasURLs: "Has URLs",
 
-        referencedTracksByRef: 'Referenced Tracks',
-        artistContribsByRef: 'Artists',
-        contributorContribsByRef: 'Contributors',
-        coverArtistContribsByRef: 'Cover Artists',
-        artTagsByRef: 'Art Tags',
-        originalReleaseTrackByRef: 'Originally Released As',
+    referencedTracksByRef: "Referenced Tracks",
+    artistContribsByRef: "Artists",
+    contributorContribsByRef: "Contributors",
+    coverArtistContribsByRef: "Cover Artists",
+    artTagsByRef: "Art Tags",
+    originalReleaseTrackByRef: "Originally Released As",
 
-        commentary: 'Commentary',
-        lyrics: 'Lyrics',
+    commentary: "Commentary",
+    lyrics: "Lyrics",
 
-        additionalFiles: 'Additional Files',
-    },
+    additionalFiles: "Additional Files",
+  },
 
-    ignoredFields: ['Sampled Tracks']
+  ignoredFields: ["Sampled Tracks"],
 });
 
 export const processArtistDocument = makeProcessDocument(Artist, {
-    propertyFieldMapping: {
-        name: 'Artist',
+  propertyFieldMapping: {
+    name: "Artist",
 
-        directory: 'Directory',
-        urls: 'URLs',
-        hasAvatar: 'Has Avatar',
-        avatarFileExtension: 'Avatar File Extension',
+    directory: "Directory",
+    urls: "URLs",
+    hasAvatar: "Has Avatar",
+    avatarFileExtension: "Avatar File Extension",
 
-        aliasNames: 'Aliases',
+    aliasNames: "Aliases",
 
-        contextNotes: 'Context Notes'
-    },
+    contextNotes: "Context Notes",
+  },
 
-    ignoredFields: ['Dead URLs']
+  ignoredFields: ["Dead URLs"],
 });
 
 export const processFlashDocument = makeProcessDocument(Flash, {
-    fieldTransformations: {
-        'Date': value => new Date(value),
+  fieldTransformations: {
+    Date: (value) => new Date(value),
 
-        'Contributors': parseContributors,
-    },
+    Contributors: parseContributors,
+  },
 
-    propertyFieldMapping: {
-        name: 'Flash',
+  propertyFieldMapping: {
+    name: "Flash",
 
-        directory: 'Directory',
-        page: 'Page',
-        date: 'Date',
-        coverArtFileExtension: 'Cover Art File Extension',
+    directory: "Directory",
+    page: "Page",
+    date: "Date",
+    coverArtFileExtension: "Cover Art File Extension",
 
-        featuredTracksByRef: 'Featured Tracks',
-        contributorContribsByRef: 'Contributors',
-        urls: 'URLs'
-    },
+    featuredTracksByRef: "Featured Tracks",
+    contributorContribsByRef: "Contributors",
+    urls: "URLs",
+  },
 });
 
 export const processFlashActDocument = makeProcessDocument(FlashAct, {
-    propertyFieldMapping: {
-        name: 'Act',
-        color: 'Color',
-        anchor: 'Anchor',
-        jump: 'Jump',
-        jumpColor: 'Jump Color'
-    }
+  propertyFieldMapping: {
+    name: "Act",
+    color: "Color",
+    anchor: "Anchor",
+    jump: "Jump",
+    jumpColor: "Jump Color",
+  },
 });
 
 export const processNewsEntryDocument = makeProcessDocument(NewsEntry, {
-    fieldTransformations: {
-        'Date': value => new Date(value)
-    },
-
-    propertyFieldMapping: {
-        name: 'Name',
-        directory: 'Directory',
-        date: 'Date',
-        content: 'Content',
-    }
+  fieldTransformations: {
+    Date: (value) => new Date(value),
+  },
+
+  propertyFieldMapping: {
+    name: "Name",
+    directory: "Directory",
+    date: "Date",
+    content: "Content",
+  },
 });
 
 export const processArtTagDocument = makeProcessDocument(ArtTag, {
-    propertyFieldMapping: {
-        name: 'Tag',
-        directory: 'Directory',
-        color: 'Color',
-        isContentWarning: 'Is CW'
-    }
+  propertyFieldMapping: {
+    name: "Tag",
+    directory: "Directory",
+    color: "Color",
+    isContentWarning: "Is CW",
+  },
 });
 
 export const processGroupDocument = makeProcessDocument(Group, {
-    propertyFieldMapping: {
-        name: 'Group',
-        directory: 'Directory',
-        description: 'Description',
-        urls: 'URLs',
-    }
+  propertyFieldMapping: {
+    name: "Group",
+    directory: "Directory",
+    description: "Description",
+    urls: "URLs",
+  },
 });
 
 export const processGroupCategoryDocument = makeProcessDocument(GroupCategory, {
-    propertyFieldMapping: {
-        name: 'Category',
-        color: 'Color',
-    }
+  propertyFieldMapping: {
+    name: "Category",
+    color: "Color",
+  },
 });
 
 export const processStaticPageDocument = makeProcessDocument(StaticPage, {
-    propertyFieldMapping: {
-        name: 'Name',
-        nameShort: 'Short Name',
-        directory: 'Directory',
+  propertyFieldMapping: {
+    name: "Name",
+    nameShort: "Short Name",
+    directory: "Directory",
 
-        content: 'Content',
-        stylesheet: 'Style',
+    content: "Content",
+    stylesheet: "Style",
 
-        showInNavigationBar: 'Show in Navigation Bar'
-    }
+    showInNavigationBar: "Show in Navigation Bar",
+  },
 });
 
 export const processWikiInfoDocument = makeProcessDocument(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',
-    }
+  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(HomepageLayout, {
+export const processHomepageLayoutDocument = makeProcessDocument(
+  HomepageLayout,
+  {
     propertyFieldMapping: {
-        sidebarContent: 'Sidebar Content'
+      sidebarContent: "Sidebar Content",
     },
 
-    ignoredFields: ['Homepage']
-});
+    ignoredFields: ["Homepage"],
+  }
+);
 
 export function makeProcessHomepageLayoutRowDocument(rowClass, spec) {
-    return makeProcessDocument(rowClass, {
-        ...spec,
-
-        propertyFieldMapping: {
-            name: 'Row',
-            color: 'Color',
-            type: 'Type',
-            ...spec.propertyFieldMapping,
-        }
-    });
+  return makeProcessDocument(rowClass, {
+    ...spec,
+
+    propertyFieldMapping: {
+      name: "Row",
+      color: "Color",
+      type: "Type",
+      ...spec.propertyFieldMapping,
+    },
+  });
 }
 
 export const homepageLayoutRowTypeProcessMapping = {
-    albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, {
-        propertyFieldMapping: {
-            sourceGroupByRef: 'Group',
-            countAlbumsFromGroup: 'Count',
-            sourceAlbumsByRef: 'Albums',
-            actionLinks: 'Actions'
-        }
-    })
+  albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, {
+    propertyFieldMapping: {
+      sourceGroupByRef: "Group",
+      countAlbumsFromGroup: "Count",
+      sourceAlbumsByRef: "Albums",
+      actionLinks: "Actions",
+    },
+  }),
 };
 
 export function processHomepageLayoutRowDocument(document) {
-    const type = document['Type'];
+  const type = document["Type"];
 
-    const match = Object.entries(homepageLayoutRowTypeProcessMapping)
-        .find(([ key ]) => key === type);
+  const match = Object.entries(homepageLayoutRowTypeProcessMapping).find(
+    ([key]) => key === type
+  );
 
-    if (!match) {
-        throw new TypeError(`No processDocument function for row type ${type}!`);
-    }
+  if (!match) {
+    throw new TypeError(`No processDocument function for row type ${type}!`);
+  }
 
-    return match[1](document);
+  return match[1](document);
 }
 
 // --> Utilities shared across document parsing functions
 
 export function getDurationInSeconds(string) {
-    if (typeof string === 'number') {
-        return string;
-    }
-
-    if (typeof string !== 'string') {
-        throw new TypeError(`Expected a string or number, got ${string}`);
-    }
-
-    const parts = string.split(':').map(n => parseInt(n))
-    if (parts.length === 3) {
-        return parts[0] * 3600 + parts[1] * 60 + parts[2]
-    } else if (parts.length === 2) {
-        return parts[0] * 60 + parts[1]
-    } else {
-        return 0
-    }
+  if (typeof string === "number") {
+    return string;
+  }
+
+  if (typeof string !== "string") {
+    throw new TypeError(`Expected a string or number, got ${string}`);
+  }
+
+  const parts = string.split(":").map((n) => parseInt(n));
+  if (parts.length === 3) {
+    return parts[0] * 3600 + parts[1] * 60 + parts[2];
+  } else if (parts.length === 2) {
+    return parts[0] * 60 + parts[1];
+  } else {
+    return 0;
+  }
 }
 
 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;
-    }
-
-    return array.map(item => ({
-        title: item['Title'],
-        description: item['Description'] ?? null,
-        files: item['Files']
-    }));
+  if (!array) return null;
+  if (!Array.isArray(array)) {
+    // Error will be caught when validating against whatever this value is
+    return array;
+  }
+
+  return array.map((item) => ({
+    title: item["Title"],
+    description: item["Description"] ?? null,
+    files: item["Files"],
+  }));
 }
 
 export function parseCommentary(text) {
-    if (text) {
-        const lines = String(text).split('\n');
-        if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) {
-            return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`};
-        }
-        return text;
-    } else {
-        return null;
+  if (text) {
+    const lines = String(text).split("\n");
+    if (!lines[0].replace(/<\/b>/g, "").includes(":</i>")) {
+      return {
+        error: `An entry is missing commentary citation: "${lines[0].slice(
+          0,
+          40
+        )}..."`,
+      };
     }
+    return text;
+  } else {
+    return null;
+  }
 }
 
 export function parseContributors(contributors) {
-    if (!contributors) {
-        return null;
-    }
-
-    if (contributors.length === 1 && contributors[0].startsWith('<i>')) {
-        const arr = [];
-        arr.textContent = contributors[0];
-        return arr;
+  if (!contributors) {
+    return null;
+  }
+
+  if (contributors.length === 1 && contributors[0].startsWith("<i>")) {
+    const arr = [];
+    arr.textContent = contributors[0];
+    return arr;
+  }
+
+  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 };
+  });
 
-    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};
-    });
-
-    const badContributor = contributors.find(val => typeof val === 'string');
-    if (badContributor) {
-        return {error: `An entry has an incorrectly formatted contributor, "${badContributor}".`};
-    }
+  const badContributor = contributors.find((val) => typeof val === "string");
+  if (badContributor) {
+    return {
+      error: `An entry has an incorrectly formatted contributor, "${badContributor}".`,
+    };
+  }
 
-    if (contributors.length === 1 && contributors[0].who === 'none') {
-        return null;
-    }
+  if (contributors.length === 1 && contributors[0].who === "none") {
+    return null;
+  }
 
-    return contributors;
+  return contributors;
 }
 
 function parseDimensions(string) {
-    if (!string) {
-        return null;
-    }
-
-    const parts = string.split(/[x,* ]+/g);
-    if (parts.length !== 2) throw new Error(`Invalid dimensions: ${string} (expected width & height)`);
-    const nums = parts.map(part => Number(part.trim()));
-    if (nums.includes(NaN)) throw new Error(`Invalid dimensions: ${string} (couldn't parse as numbers)`);
-    return nums;
+  if (!string) {
+    return null;
+  }
+
+  const parts = string.split(/[x,* ]+/g);
+  if (parts.length !== 2)
+    throw new Error(`Invalid dimensions: ${string} (expected width & height)`);
+  const nums = parts.map((part) => Number(part.trim()));
+  if (nums.includes(NaN))
+    throw new Error(
+      `Invalid dimensions: ${string} (couldn't parse as numbers)`
+    );
+  return nums;
 }
 
 // --> Data repository loading functions and descriptors
@@ -556,41 +578,41 @@ function parseDimensions(string) {
 // documentModes: Symbols indicating sets of behavior for loading and processing
 // data files.
 export const documentModes = {
-    // onePerFile: One document per file. Expects files array (or function) and
-    // processDocument function. Obviously, each specified data file should only
-    // contain one YAML document (an error will be thrown otherwise). Calls save
-    // with an array of processed documents (wiki objects).
-    onePerFile: Symbol('Document mode: onePerFile'),
-
-    // headerAndEntries: One or more documents per file; the first document is
-    // treated as a "header" and represents data which pertains to all following
-    // "entry" documents. Expects files array (or function) and
-    // processHeaderDocument and processEntryDocument functions. Calls save with
-    // an array of {header, entries} objects.
-    //
-    // Please note that the final results loaded from each file may be "missing"
-    // data objects corresponding to entry documents if the processEntryDocument
-    // function throws on any entries, resulting in partial data provided to
-    // save() - errors will be caught and thrown in the final buildSteps
-    // aggregate. However, if the processHeaderDocument function fails, all
-    // following documents in the same file will be ignored as well (i.e. an
-    // entire file will be excempt from the save() function's input).
-    headerAndEntries: Symbol('Document mode: headerAndEntries'),
-
-    // allInOne: One or more documents, all contained in one file. Expects file
-    // string (or function) and processDocument function. Calls save with an
-    // array of processed documents (wiki objects).
-    allInOne: Symbol('Document mode: allInOne'),
-
-    // oneDocumentTotal: Just a single document, represented in one file.
-    // Expects file string (or function) and processDocument function. Calls
-    // save with the single processed wiki document (data object).
-    //
-    // Please note that if the single document fails to process, the save()
-    // function won't be called at all, generally resulting in an altogether
-    // missing property from the global wikiData object. This should be caught
-    // and handled externally.
-    oneDocumentTotal: Symbol('Document mode: oneDocumentTotal'),
+  // onePerFile: One document per file. Expects files array (or function) and
+  // processDocument function. Obviously, each specified data file should only
+  // contain one YAML document (an error will be thrown otherwise). Calls save
+  // with an array of processed documents (wiki objects).
+  onePerFile: Symbol("Document mode: onePerFile"),
+
+  // headerAndEntries: One or more documents per file; the first document is
+  // treated as a "header" and represents data which pertains to all following
+  // "entry" documents. Expects files array (or function) and
+  // processHeaderDocument and processEntryDocument functions. Calls save with
+  // an array of {header, entries} objects.
+  //
+  // Please note that the final results loaded from each file may be "missing"
+  // data objects corresponding to entry documents if the processEntryDocument
+  // function throws on any entries, resulting in partial data provided to
+  // save() - errors will be caught and thrown in the final buildSteps
+  // aggregate. However, if the processHeaderDocument function fails, all
+  // following documents in the same file will be ignored as well (i.e. an
+  // entire file will be excempt from the save() function's input).
+  headerAndEntries: Symbol("Document mode: headerAndEntries"),
+
+  // allInOne: One or more documents, all contained in one file. Expects file
+  // string (or function) and processDocument function. Calls save with an
+  // array of processed documents (wiki objects).
+  allInOne: Symbol("Document mode: allInOne"),
+
+  // oneDocumentTotal: Just a single document, represented in one file.
+  // Expects file string (or function) and processDocument function. Calls
+  // save with the single processed wiki document (data object).
+  //
+  // Please note that if the single document fails to process, the save()
+  // function won't be called at all, generally resulting in an altogether
+  // missing property from the global wikiData object. This should be caught
+  // and handled externally.
+  oneDocumentTotal: Symbol("Document mode: oneDocumentTotal"),
 };
 
 // dataSteps: Top-level array of "steps" for loading YAML document files.
@@ -626,499 +648,559 @@ export const documentModes = {
 //   format depends on documentMode.
 //
 export const dataSteps = [
-    {
-        title: `Process wiki info file`,
-        file: WIKI_INFO_FILE,
+  {
+    title: `Process wiki info file`,
+    file: WIKI_INFO_FILE,
 
-        documentMode: documentModes.oneDocumentTotal,
-        processDocument: processWikiInfoDocument,
+    documentMode: documentModes.oneDocumentTotal,
+    processDocument: processWikiInfoDocument,
 
-        save(wikiInfo) {
-            if (!wikiInfo) {
-                return;
-            }
+    save(wikiInfo) {
+      if (!wikiInfo) {
+        return;
+      }
 
-            return {wikiInfo};
-        }
+      return { wikiInfo };
+    },
+  },
+
+  {
+    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);
     },
 
-    {
-        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 = Thing.getReference(album);
-
-                function closeCurrentTrackGroup() {
-                    if (currentTracksByRef) {
-                        let trackGroup;
-
-                        if (currentTrackGroup) {
-                            trackGroup = currentTrackGroup;
-                        } else {
-                            trackGroup = new TrackGroup();
-                            trackGroup.name = `Default Track Group`;
-                            trackGroup.isDefaultTrackGroup = true;
-                        }
-
-                        trackGroup.album = album;
-                        trackGroup.tracksByRef = currentTracksByRef;
-                        trackGroups.push(trackGroup);
-                    }
-                }
+    save(results) {
+      const albumData = [];
+      const trackData = [];
 
-                for (const entry of entries) {
-                    if (entry instanceof TrackGroup) {
-                        closeCurrentTrackGroup();
-                        currentTracksByRef = [];
-                        currentTrackGroup = entry;
-                        continue;
-                    }
+      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;
 
-                    trackData.push(entry);
+        const albumRef = Thing.getReference(album);
 
-                    entry.dataSourceAlbumByRef = albumRef;
+        function closeCurrentTrackGroup() {
+          if (currentTracksByRef) {
+            let trackGroup;
 
-                    const trackRef = Thing.getReference(entry);
-                    if (currentTracksByRef) {
-                        currentTracksByRef.push(trackRef);
-                    } else {
-                        currentTracksByRef = [trackRef];
-                    }
-                }
-
-                closeCurrentTrackGroup();
-
-                album.trackGroups = trackGroups;
-                albumData.push(album);
+            if (currentTrackGroup) {
+              trackGroup = currentTrackGroup;
+            } else {
+              trackGroup = new TrackGroup();
+              trackGroup.name = `Default Track Group`;
+              trackGroup.isDefaultTrackGroup = true;
             }
 
-            return {albumData, trackData};
+            trackGroup.album = album;
+            trackGroup.tracksByRef = currentTracksByRef;
+            trackGroups.push(trackGroup);
+          }
         }
-    },
 
-    {
-        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 = Thing.getReference(artist);
-                return (artist.aliasNames?.map(name => {
-                    const alias = new Artist();
-                    alias.name = name;
-                    alias.isAlias = true;
-                    alias.aliasedArtistRef = origRef;
-                    alias.artistData = artistData;
-                    return alias;
-                }) ?? []);
-            });
+        for (const entry of entries) {
+          if (entry instanceof TrackGroup) {
+            closeCurrentTrackGroup();
+            currentTracksByRef = [];
+            currentTrackGroup = entry;
+            continue;
+          }
 
-            return {artistData, artistAliasData};
-        }
-    },
+          trackData.push(entry);
 
-    // TODO: WD.wikiInfo.enableFlashesAndGames &&
-    {
-        title: `Process flashes file`,
-        file: FLASH_DATA_FILE,
+          entry.dataSourceAlbumByRef = albumRef;
 
-        documentMode: documentModes.allInOne,
-        processDocument(document) {
-            return ('Act' in document
-                ? processFlashActDocument(document)
-                : processFlashDocument(document));
-        },
+          const trackRef = Thing.getReference(entry);
+          if (currentTracksByRef) {
+            currentTracksByRef.push(trackRef);
+          } else {
+            currentTracksByRef = [trackRef];
+          }
+        }
 
-        save(results) {
-            let flashAct;
-            let flashesByRef = [];
+        closeCurrentTrackGroup();
 
-            if (results[0] && !(results[0] instanceof FlashAct)) {
-                throw new Error(`Expected an act at top of flash data file`);
-            }
+        album.trackGroups = trackGroups;
+        albumData.push(album);
+      }
 
-            for (const thing of results) {
-                if (thing instanceof FlashAct) {
-                    if (flashAct) {
-                        Object.assign(flashAct, {flashesByRef});
-                    }
+      return { albumData, trackData };
+    },
+  },
+
+  {
+    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 = Thing.getReference(artist);
+        return (
+          artist.aliasNames?.map((name) => {
+            const alias = new 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);
+    },
 
-                    flashAct = thing;
-                    flashesByRef = [];
-                } else {
-                    flashesByRef.push(Thing.getReference(thing));
-                }
-            }
+    save(results) {
+      let flashAct;
+      let flashesByRef = [];
 
-            if (flashAct) {
-                Object.assign(flashAct, {flashesByRef});
-            }
+      if (results[0] && !(results[0] instanceof FlashAct)) {
+        throw new Error(`Expected an act at top of flash data file`);
+      }
 
-            const flashData = results.filter(x => x instanceof Flash);
-            const flashActData = results.filter(x => x instanceof FlashAct);
+      for (const thing of results) {
+        if (thing instanceof FlashAct) {
+          if (flashAct) {
+            Object.assign(flashAct, { flashesByRef });
+          }
 
-            return {flashData, flashActData};
+          flashAct = thing;
+          flashesByRef = [];
+        } else {
+          flashesByRef.push(Thing.getReference(thing));
         }
-    },
+      }
 
-    {
-        title: `Process groups file`,
-        file: GROUP_DATA_FILE,
+      if (flashAct) {
+        Object.assign(flashAct, { flashesByRef });
+      }
 
-        documentMode: documentModes.allInOne,
-        processDocument(document) {
-            return ('Category' in document
-                ? processGroupCategoryDocument(document)
-                : processGroupDocument(document));
-        },
+      const flashData = results.filter((x) => x instanceof Flash);
+      const flashActData = results.filter((x) => x instanceof FlashAct);
 
-        save(results) {
-            let groupCategory;
-            let groupsByRef = [];
+      return { flashData, flashActData };
+    },
+  },
 
-            if (results[0] && !(results[0] instanceof GroupCategory)) {
-                throw new Error(`Expected a category at top of group data file`);
-            }
+  {
+    title: `Process groups file`,
+    file: GROUP_DATA_FILE,
 
-            for (const thing of results) {
-                if (thing instanceof GroupCategory) {
-                    if (groupCategory) {
-                        Object.assign(groupCategory, {groupsByRef});
-                    }
+    documentMode: documentModes.allInOne,
+    processDocument(document) {
+      return "Category" in document
+        ? processGroupCategoryDocument(document)
+        : processGroupDocument(document);
+    },
 
-                    groupCategory = thing;
-                    groupsByRef = [];
-                } else {
-                    groupsByRef.push(Thing.getReference(thing));
-                }
-            }
+    save(results) {
+      let groupCategory;
+      let groupsByRef = [];
 
-            if (groupCategory) {
-                Object.assign(groupCategory, {groupsByRef});
-            }
+      if (results[0] && !(results[0] instanceof GroupCategory)) {
+        throw new Error(`Expected a category at top of group data file`);
+      }
 
-            const groupData = results.filter(x => x instanceof Group);
-            const groupCategoryData = results.filter(x => x instanceof GroupCategory);
+      for (const thing of results) {
+        if (thing instanceof GroupCategory) {
+          if (groupCategory) {
+            Object.assign(groupCategory, { groupsByRef });
+          }
 
-            return {groupData, groupCategoryData};
+          groupCategory = thing;
+          groupsByRef = [];
+        } else {
+          groupsByRef.push(Thing.getReference(thing));
         }
+      }
+
+      if (groupCategory) {
+        Object.assign(groupCategory, { groupsByRef });
+      }
+
+      const groupData = results.filter((x) => x instanceof Group);
+      const groupCategoryData = results.filter(
+        (x) => x instanceof GroupCategory
+      );
+
+      return { groupData, groupCategoryData };
     },
+  },
 
-    {
-        title: `Process homepage layout file`,
-        files: [HOMEPAGE_LAYOUT_DATA_FILE],
+  {
+    title: `Process homepage layout file`,
+    files: [HOMEPAGE_LAYOUT_DATA_FILE],
 
-        documentMode: documentModes.headerAndEntries,
-        processHeaderDocument: processHomepageLayoutDocument,
-        processEntryDocument: processHomepageLayoutRowDocument,
+    documentMode: documentModes.headerAndEntries,
+    processHeaderDocument: processHomepageLayoutDocument,
+    processEntryDocument: processHomepageLayoutRowDocument,
 
-        save(results) {
-            if (!results[0]) {
-                return;
-            }
+    save(results) {
+      if (!results[0]) {
+        return;
+      }
 
-            const { header: homepageLayout, entries: rows } = results[0];
-            Object.assign(homepageLayout, {rows});
-            return {homepageLayout};
-        }
+      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,
+  // TODO: WD.wikiInfo.enableNews &&
+  {
+    title: `Process news data file`,
+    file: NEWS_DATA_FILE,
 
-        documentMode: documentModes.allInOne,
-        processDocument: processNewsEntryDocument,
+    documentMode: documentModes.allInOne,
+    processDocument: processNewsEntryDocument,
 
-        save(newsData) {
-            sortChronologically(newsData);
-            newsData.reverse();
+    save(newsData) {
+      sortChronologically(newsData);
+      newsData.reverse();
 
-            return {newsData};
-        }
+      return { newsData };
     },
+  },
 
-    {
-        title: `Process art tags file`,
-        file: ART_TAG_DATA_FILE,
+  {
+    title: `Process art tags file`,
+    file: ART_TAG_DATA_FILE,
 
-        documentMode: documentModes.allInOne,
-        processDocument: processArtTagDocument,
+    documentMode: documentModes.allInOne,
+    processDocument: processArtTagDocument,
 
-        save(artTagData) {
-            sortAlphabetically(artTagData);
+    save(artTagData) {
+      sortAlphabetically(artTagData);
 
-            return {artTagData};
-        }
+      return { artTagData };
     },
+  },
 
-    {
-        title: `Process static pages file`,
-        file: STATIC_PAGE_DATA_FILE,
+  {
+    title: `Process static pages file`,
+    file: STATIC_PAGE_DATA_FILE,
 
-        documentMode: documentModes.allInOne,
-        processDocument: processStaticPageDocument,
+    documentMode: documentModes.allInOne,
+    processDocument: processStaticPageDocument,
 
-        save(staticPageData) {
-            return {staticPageData};
-        }
+    save(staticPageData) {
+      return { staticPageData };
     },
+  },
 ];
 
-export async function loadAndProcessDataDocuments({
-    dataPath,
-}) {
-    const processDataAggregate = openAggregate({message: `Errors processing data files`});
-    const wikiDataResult = {};
-
-    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;
-            }
-        };
-    }
+export async function loadAndProcessDataDocuments({ dataPath }) {
+  const processDataAggregate = openAggregate({
+    message: `Errors processing data files`,
+  });
+  const wikiDataResult = {};
+
+  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;
+      }
+    };
+  }
 
-    for (const dataStep of dataSteps) {
-        await processDataAggregate.nestAsync(
-            {message: `Errors during data step: ${dataStep.title}`},
-            async ({call, callAsync, map, mapAsync, nest}) => {
-                const { documentMode } = dataStep;
+  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 (!(Object.values(documentModes).includes(documentMode))) {
-                    throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
-                }
+        if (!Object.values(documentModes).includes(documentMode)) {
+          throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
+        }
 
-                if (documentMode === documentModes.allInOne || documentMode === documentModes.oneDocumentTotal) {
-                    if (!dataStep.file) {
-                        throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
-                    }
+        if (
+          documentMode === documentModes.allInOne ||
+          documentMode === documentModes.oneDocumentTotal
+        ) {
+          if (!dataStep.file) {
+            throw new Error(
+              `Expected 'file' property for ${documentMode.toString()}`
+            );
+          }
+
+          const file = path.join(
+            dataPath,
+            typeof dataStep.file === "function"
+              ? await callAsync(dataStep.file, dataPath)
+              : dataStep.file
+          );
 
-                    const file = path.join(dataPath,
-                        (typeof dataStep.file === 'function'
-                            ? await callAsync(dataStep.file, dataPath)
-                            : dataStep.file));
+          const readResult = await callAsync(readFile, file, "utf-8");
 
-                    const readResult = await callAsync(readFile, file, 'utf-8');
+          if (!readResult) {
+            return;
+          }
 
-                    if (!readResult) {
-                        return;
-                    }
+          const yamlResult =
+            documentMode === documentModes.oneDocumentTotal
+              ? call(yaml.load, readResult)
+              : call(yaml.loadAll, readResult);
 
-                    const yamlResult = (documentMode === documentModes.oneDocumentTotal
-                        ? call(yaml.load, readResult)
-                        : call(yaml.loadAll, readResult));
+          if (!yamlResult) {
+            return;
+          }
 
-                    if (!yamlResult) {
-                        return;
-                    }
+          let processResults;
 
-                    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);
-                    }
+          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);
+          }
 
-                    if (!processResults) return;
+          if (!processResults) return;
 
-                    const saveResult = call(dataStep.save, processResults);
+          const saveResult = call(dataStep.save, processResults);
 
-                    if (!saveResult) return;
+          if (!saveResult) return;
 
-                    Object.assign(wikiDataResult, saveResult);
+          Object.assign(wikiDataResult, saveResult);
 
-                    return;
-                }
+          return;
+        }
+
+        if (!dataStep.files) {
+          throw new Error(
+            `Expected 'files' property for ${documentMode.toString()}`
+          );
+        }
 
-                if (!dataStep.files) {
-                    throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
+        const files = (
+          typeof dataStep.files === "function"
+            ? await callAsync(dataStep.files, dataPath)
+            : dataStep.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 files = (
-                    (typeof dataStep.files === 'function'
-                        ? await callAsync(dataStep.files, dataPath)
-                        : dataStep.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 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});
-                        });
-                    });
+                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 });
+              });
+            }
+          );
+        }
+
+        if (documentMode === documentModes.onePerFile) {
+          nest(
+            { message: `Errors processing data files as valid documents` },
+            ({ call, map }) => {
+              processResults = [];
+
+              yamlResults.forEach(({ file, documents }) => {
+                if (documents.length > 1) {
+                  call(
+                    decorateErrorWithFile(() => {
+                      throw new Error(
+                        `Only expected one document to be present per file`
+                      );
+                    })
+                  );
+                  return;
                 }
 
-                if (documentMode === documentModes.onePerFile) {
-                    nest({message: `Errors processing data files as valid documents`}, ({ call, map }) => {
-                        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 result = call(
-                                decorateErrorWithFile(
-                                    ({ document }) => dataStep.processDocument(document)),
-                                {file, document: documents[0]});
-
-                            if (!result) {
-                                return;
-                            }
-
-                            processResults.push(result);
-                        });
-                    });
+                const result = call(
+                  decorateErrorWithFile(({ document }) =>
+                    dataStep.processDocument(document)
+                  ),
+                  { file, document: documents[0] }
+                );
+
+                if (!result) {
+                  return;
                 }
 
-                const saveResult = call(dataStep.save, processResults);
+                processResults.push(result);
+              });
+            }
+          );
+        }
 
-                if (!saveResult) return;
+        const saveResult = call(dataStep.save, processResults);
 
-                Object.assign(wikiDataResult, saveResult);
-            });
-    }
+        if (!saveResult) return;
 
-    return {
-        aggregate: processDataAggregate,
-        result: wikiDataResult
-    };
+        Object.assign(wikiDataResult, saveResult);
+      }
+    );
+  }
+
+  return {
+    aggregate: processDataAggregate,
+    result: wikiDataResult,
+  };
 }
 
 // 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).
 export function linkWikiDataArrays(wikiData) {
-    function assignWikiData(things, ...keys) {
-        for (let i = 0; i < things.length; i++) {
-            for (let j = 0; j < keys.length; j++) {
-                const key = keys[j];
-                things[i][key] = wikiData[key];
-            }
-        }
+  function assignWikiData(things, ...keys) {
+    for (let i = 0; i < things.length; i++) {
+      for (let j = 0; j < keys.length; j++) {
+        const key = keys[j];
+        things[i][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');
+  }
+
+  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()),
-    });
-
-    // Re-link data arrays, so that every object has the new, sorted versions.
-    // Note that the sorting step deliberately creates new arrays (mutating
-    // slices instead of the original arrays) - this is so that the object
-    // caching system understands that it's working with a new ordering.
-    // We still need to actually provide those updated arrays over again!
-    linkWikiDataArrays(wikiData);
+  Object.assign(wikiData, {
+    albumData: sortChronologically(wikiData.albumData.slice()),
+    trackData: sortAlbumsTracksChronologically(wikiData.trackData.slice()),
+  });
+
+  // Re-link data arrays, so that every object has the new, sorted versions.
+  // Note that the sorting step deliberately creates new arrays (mutating
+  // slices instead of the original arrays) - this is so that the object
+  // caching system understands that it's working with a new ordering.
+  // We still need to actually provide those updated arrays over again!
+  linkWikiDataArrays(wikiData);
 }
 
 // Warn about directories which are reused across more than one of the same type
@@ -1128,63 +1210,76 @@ export function sortWikiDataArrays(wikiData) {
 // 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 (!duplicateDirectories.length) 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;
+  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 (!duplicateDirectories.length) return;
+        duplicateDirectories.sort((a, b) => {
+          const aL = a.toLowerCase();
+          const bL = b.toLowerCase();
+          return aL < bL ? -1 : aL > bL ? 1 : 0;
         });
-    }
-
-    // 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;
+        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
@@ -1193,102 +1288,166 @@ export function filterDuplicateDirectories(wikiData) {
 // 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',
-            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;
-                                }
-                            });
-                        }
+  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",
+        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;
+  return aggregate;
 }
 
 // Utility function for loading all wiki data from the provided YAML data
@@ -1297,48 +1456,49 @@ export function filterReferenceErrors(wikiData) {
 // a boilerplate for more specialized output, or as a quick start in utilities
 // where reporting info about data loading isn't as relevant as during the
 // main wiki build process.
-export async function quickLoadAllFromYAML(dataPath, {
-    showAggregate: customShowAggregate = showAggregate,
-} = {}) {
-    const showAggregate = customShowAggregate;
+export async function quickLoadAllFromYAML(
+  dataPath,
+  { showAggregate: customShowAggregate = showAggregate } = {}
+) {
+  const showAggregate = customShowAggregate;
 
-    let wikiData;
+  let wikiData;
 
-    {
-        const { aggregate, result } = await loadAndProcessDataDocuments({
-            dataPath,
-        });
+  {
+    const { aggregate, result } = await loadAndProcessDataDocuments({
+      dataPath,
+    });
 
-        wikiData = result;
-
-        try {
-            aggregate.close();
-            logInfo`Loaded data without errors. (complete data)`;
-        } catch (error) {
-            showAggregate(error);
-            logWarn`Loaded data with errors. (partial data)`;
-        }
-    }
-
-    linkWikiDataArrays(wikiData);
+    wikiData = result;
 
     try {
-        filterDuplicateDirectories(wikiData).close();
-        logInfo`No duplicate directories found. (complete data)`;
+      aggregate.close();
+      logInfo`Loaded data without errors. (complete data)`;
     } catch (error) {
-        showAggregate(error);
-        logWarn`Duplicate directories found. (partial data)`;
+      showAggregate(error);
+      logWarn`Loaded data with errors. (partial data)`;
     }
+  }
 
-    try {
-        filterReferenceErrors(wikiData).close();
-        logInfo`No reference errors found. (complete data)`;
-    } catch (error) {
-        showAggregate(error);
-        logWarn`Duplicate directories found. (partial data)`;
-    }
+  linkWikiDataArrays(wikiData);
+
+  try {
+    filterDuplicateDirectories(wikiData).close();
+    logInfo`No duplicate directories found. (complete data)`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Duplicate directories found. (partial data)`;
+  }
+
+  try {
+    filterReferenceErrors(wikiData).close();
+    logInfo`No reference errors found. (complete data)`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Duplicate directories found. (partial data)`;
+  }
 
-    sortWikiDataArrays(wikiData);
+  sortWikiDataArrays(wikiData);
 
-    return wikiData;
+  return wikiData;
 }