« get me outta code hell

data: tidy things folder & imports, nicer fields yaml spec - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data/things
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2024-01-20 17:23:37 -0400
committer(quasar) nebula <qznebula@protonmail.com>2024-01-30 07:59:39 -0400
commit98c2012c0c6233fe3f70ba215c19f6d39d7e1e34 (patch)
treea64dfa62e4134785ad5a4b9b03baaed47aa0854c /src/data/things
parent4739ac5fae824c6c985fca9ae34f6335f5c9c13e (diff)
data: tidy things folder & imports, nicer fields yaml spec
Diffstat (limited to 'src/data/things')
-rw-r--r--src/data/things/album.js158
-rw-r--r--src/data/things/art-tag.js17
-rw-r--r--src/data/things/artist.js23
-rw-r--r--src/data/things/cacheable-object.js369
-rw-r--r--src/data/things/composite.js1307
-rw-r--r--src/data/things/flash.js62
-rw-r--r--src/data/things/group.js21
-rw-r--r--src/data/things/homepage-layout.js39
-rw-r--r--src/data/things/index.js4
-rw-r--r--src/data/things/language.js11
-rw-r--r--src/data/things/news-entry.js28
-rw-r--r--src/data/things/static-page.js27
-rw-r--r--src/data/things/thing.js83
-rw-r--r--src/data/things/track.js148
-rw-r--r--src/data/things/validators.js984
-rw-r--r--src/data/things/wiki-info.js40
16 files changed, 293 insertions, 3028 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 02d3454..3a05ac8 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -1,6 +1,9 @@
 import {input} from '#composite';
 import find from '#find';
+import Thing from '#thing';
 import {isDate} from '#validators';
+import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions}
+  from '#yaml';
 
 import {exposeDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
@@ -27,14 +30,6 @@ import {
 
 import {withTracks, withTrackSections} from '#composite/things/album';
 
-import {
-  parseAdditionalFiles,
-  parseContributors,
-  parseDimensions,
-} from '#yaml';
-
-import Thing from './thing.js';
-
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
 
@@ -205,58 +200,85 @@ export class Album extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    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',
-      directory: 'Directory',
-      date: 'Date',
-      color: 'Color',
-      urls: 'URLs',
-
-      hasTrackNumbers: 'Has Track Numbers',
-      isListedOnHomepage: 'Listed on Homepage',
-      isListedInGalleries: 'Listed in Galleries',
-
-      coverArtDate: 'Cover Art Date',
-      trackArtDate: 'Default Track Cover Art Date',
-      dateAddedToWiki: 'Date Added',
-
-      coverArtFileExtension: 'Cover Art File Extension',
-      trackCoverArtFileExtension: 'Track Art File Extension',
-
-      wallpaperArtistContribs: 'Wallpaper Artists',
-      wallpaperStyle: 'Wallpaper Style',
-      wallpaperFileExtension: 'Wallpaper File Extension',
-
-      bannerArtistContribs: 'Banner Artists',
-      bannerStyle: 'Banner Style',
-      bannerFileExtension: 'Banner File Extension',
-      bannerDimensions: 'Banner Dimensions',
-
-      commentary: 'Commentary',
-      additionalFiles: 'Additional Files',
-
-      artistContribs: 'Artists',
-      coverArtistContribs: 'Cover Artists',
-      trackCoverArtistContribs: 'Default Track Cover Artists',
-      groups: 'Groups',
-      artTags: 'Art Tags',
+    fields: {
+      'Album': {property: 'name'},
+      'Directory': {property: 'directory'},
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
+
+      'Color': {property: 'color'},
+      'URLs': {property: 'urls'},
+
+      'Has Track Numbers': {property: 'hasTrackNumbers'},
+      'Listed on Homepage': {property: 'isListedOnHomepage'},
+      'Listed in Galleries': {property: 'isListedInGalleries'},
+
+      'Cover Art Date': {
+        property: 'coverArtDate',
+        transform: parseDate,
+      },
+
+      'Default Track Cover Art Date': {
+        property: 'trackArtDate',
+        transform: parseDate,
+      },
+
+      'Date Added': {
+        property: 'dateAddedToWiki',
+        transform: parseDate,
+      },
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
+
+      'Wallpaper Artists': {
+        property: 'wallpaperArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Wallpaper Style': {property: 'wallpaperStyle'},
+      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
+
+      'Banner Artists': {
+        property: 'bannerArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Banner Style': {property: 'bannerStyle'},
+      'Banner File Extension': {property: 'bannerFileExtension'},
+
+      'Banner Dimensions': {
+        property: 'bannerDimensions',
+        transform: parseDimensions,
+      },
+
+      'Commentary': {property: 'commentary'},
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Default Track Cover Artists': {
+        property: 'trackCoverArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Groups': {property: 'groups'},
+      'Art Tags': {property: 'artTags'},
     },
 
     ignoredFields: ['Review Points'],
@@ -274,14 +296,14 @@ export class TrackSectionHelper extends Thing {
   })
 
   static [Thing.yamlDocumentSpec] = {
-    fieldTransformations: {
-      'Date Originally Released': (value) => new Date(value),
-    },
-
-    propertyFieldMapping: {
-      name: 'Section',
-      color: 'Color',
-      dateOriginallyReleased: 'Date Originally Released',
+    fields: {
+      'Section': {property: 'name'},
+      'Color': {property: 'color'},
+
+      'Date Originally Released': {
+        property: 'dateOriginallyReleased',
+        transform: parseDate,
+      },
     },
   };
 }
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index c0b4a6d..af6677f 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,6 +1,7 @@
 import {input} from '#composite';
-import {sortAlbumsTracksChronologically} from '#wiki-data';
+import Thing from '#thing';
 import {isName} from '#validators';
+import {sortAlbumsTracksChronologically} from '#wiki-data';
 
 import {exposeUpdateValueOrContinue} from '#composite/control-flow';
 
@@ -12,8 +13,6 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import Thing from './thing.js';
-
 export class ArtTag extends Thing {
   static [Thing.referenceType] = 'tag';
   static [Thing.friendlyName] = `Art Tag`;
@@ -65,13 +64,13 @@ export class ArtTag extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    propertyFieldMapping: {
-      name: 'Tag',
-      nameShort: 'Short Name',
-      directory: 'Directory',
+    fields: {
+      'Tag': {property: 'name'},
+      'Short Name': {property: 'nameShort'},
+      'Directory': {property: 'directory'},
 
-      color: 'Color',
-      isContentWarning: 'Is CW',
+      'Color': {property: 'color'},
+      'Is CW': {property: 'isContentWarning'},
     },
   };
 }
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 4209055..502510a 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -1,8 +1,11 @@
 import {input} from '#composite';
 import find from '#find';
 import {unique} from '#sugar';
+import Thing from '#thing';
 import {isName, validateArrayItems} from '#validators';
 
+import {withReverseContributionList} from '#composite/wiki-data';
+
 import {
   contentString,
   directory,
@@ -16,10 +19,6 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withReverseContributionList} from '#composite/wiki-data';
-
-import Thing from './thing.js';
-
 export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
 
@@ -242,16 +241,16 @@ export class Artist extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    propertyFieldMapping: {
-      name: 'Artist',
-      directory: 'Directory',
-      urls: 'URLs',
-      contextNotes: 'Context Notes',
+    fields: {
+      'Artist': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'URLs': {property: 'urls'},
+      'Context Notes': {property: 'contextNotes'},
 
-      hasAvatar: 'Has Avatar',
-      avatarFileExtension: 'Avatar File Extension',
+      'Has Avatar': {property: 'hasAvatar'},
+      'Avatar File Extension': {property: 'avatarFileExtension'},
 
-      aliasNames: 'Aliases',
+      'Aliases': {property: 'aliasNames'},
     },
 
     ignoredFields: ['Dead URLs', 'Review Points'],
diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js
deleted file mode 100644
index 1e7c7aa..0000000
--- a/src/data/things/cacheable-object.js
+++ /dev/null
@@ -1,369 +0,0 @@
-// Generally extendable class for caching properties and handling dependencies,
-// with a few key properties:
-//
-// 1) The behavior of every property is defined by its descriptor, which is a
-//    static value stored on the subclass (all instances share the same property
-//    descriptors).
-//
-//  1a) Additional properties may not be added past the time of object
-//      construction, and attempts to do so (including externally setting a
-//      property name which has no corresponding descriptor) will throw a
-//      TypeError. (This is done via an Object.seal(this) call after a newly
-//      created instance defines its own properties according to the descriptor
-//      on its constructor class.)
-//
-// 2) Properties may have two flags set: update and expose. Properties which
-//    update are provided values from the external. Properties which expose
-//    provide values to the external, generally dependent on other update
-//    properties (within the same object).
-//
-//  2a) Properties may be flagged as both updating and exposing. This is so
-//      that the same name may be used for both "output" and "input".
-//
-// 3) Exposed properties have values which are computations dependent on other
-//    properties, as described by a `compute` function on the descriptor.
-//    Depended-upon properties are explicitly listed on the descriptor next to
-//    this function, and are only provided as arguments to the function once
-//    listed.
-//
-//  3a) An exposed property may depend only upon updating properties, not other
-//      exposed properties (within the same object). This is to force the
-//      general complexity of a single object to be fairly simple: inputs
-//      directly determine outputs, with the only in-between step being the
-//      `compute` function, no multiple-layer dependencies. Note that this is
-//      only true within a given object - externally, values provided to one
-//      object's `update` may be (and regularly are) the exposed values of
-//      another object.
-//
-//  3b) If a property both updates and exposes, it is automatically regarded as
-//      a dependancy. (That is, its exposed value will depend on the value it is
-//      updated with.) Rather than a required `compute` function, these have an
-//      optional `transform` function, which takes the update value as its first
-//      argument and then the usual key-value dependencies as its second. If no
-//      `transform` function is provided, the expose value is the same as the
-//      update value.
-//
-// 4) Exposed properties are cached; that is, if no depended-upon properties are
-//    updated, the value of an exposed property is not recomputed.
-//
-//  4a) The cache for an exposed property is invalidated as soon as any of its
-//      dependencies are updated, but the cache itself is lazy: the exposed
-//      value will not be recomputed until it is again accessed. (Likewise, an
-//      exposed value won't be computed for the first time until it is first
-//      accessed.)
-//
-// 5) Updating a property may optionally apply validation checks before passing,
-//    declared by a `validate` function on the `update` block. This function
-//    should either throw an error (e.g. TypeError) or return false if the value
-//    is invalid.
-//
-// 6) Objects do not expect all updating properties to be provided at once.
-//    Incomplete objects are deliberately supported and enabled.
-//
-//  6a) The default value for every updating property is null; undefined is not
-//      accepted as a property value under any circumstances (it always errors).
-//      However, this default may be overridden by specifying a `default` value
-//      on a property's `update` block. (This value will be checked against
-//      the property's validate function.) Note that a property may always be
-//      updated to null, even if the default is non-null. (Null always bypasses
-//      the validate check.)
-//
-//  6b) It's required by the external consumer of an object to determine whether
-//      or not the object is ready for use (within the larger program). This is
-//      convenienced by the static CacheableObject.listAccessibleProperties()
-//      function, which provides a mapping of exposed property names to whether
-//      or not their dependencies are yet met.
-
-import {inspect as nodeInspect} from 'node:util';
-
-import {colors, ENABLE_COLOR} from '#cli';
-
-function inspect(value) {
-  return nodeInspect(value, {colors: ENABLE_COLOR});
-}
-
-export default class CacheableObject {
-  #propertyUpdateValues = Object.create(null);
-  #propertyUpdateCacheInvalidators = Object.create(null);
-
-  // Note the constructor doesn't take an initial data source. Due to a quirk
-  // of JavaScript, private members can't be accessed before the superclass's
-  // constructor is finished processing - so if we call the overridden
-  // update() function from inside this constructor, it will error when
-  // writing to private members. Pretty bad!
-  //
-  // That means initial data must be provided by following up with update()
-  // after constructing the new instance of the Thing (sub)class.
-
-  constructor() {
-    this.#defineProperties();
-    this.#initializeUpdatingPropertyValues();
-
-    if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-      return new Proxy(this, {
-        get: (obj, key) => {
-          if (!Object.hasOwn(obj, key)) {
-            if (key !== 'constructor') {
-              CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`);
-            }
-          }
-          return obj[key];
-        },
-      });
-    }
-  }
-
-  #initializeUpdatingPropertyValues() {
-    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
-      const {flags, update} = descriptor;
-
-      if (!flags.update) {
-        continue;
-      }
-
-      if (update?.default) {
-        this[property] = update?.default;
-      } else {
-        this[property] = null;
-      }
-    }
-  }
-
-  #defineProperties() {
-    if (!this.constructor.propertyDescriptors) {
-      throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`);
-    }
-
-    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
-      const {flags} = descriptor;
-
-      const definition = {
-        configurable: false,
-        enumerable: flags.expose,
-      };
-
-      if (flags.update) {
-        definition.set = this.#getUpdateObjectDefinitionSetterFunction(property);
-      }
-
-      if (flags.expose) {
-        definition.get = this.#getExposeObjectDefinitionGetterFunction(property);
-      }
-
-      Object.defineProperty(this, property, definition);
-    }
-
-    Object.seal(this);
-  }
-
-  #getUpdateObjectDefinitionSetterFunction(property) {
-    const {update} = this.#getPropertyDescriptor(property);
-    const validate = update?.validate;
-
-    return (newValue) => {
-      const oldValue = this.#propertyUpdateValues[property];
-
-      if (newValue === undefined) {
-        throw new TypeError(`Properties cannot be set to undefined`);
-      }
-
-      if (newValue === oldValue) {
-        return;
-      }
-
-      if (newValue !== null && validate) {
-        try {
-          const result = validate(newValue);
-          if (result === undefined) {
-            throw new TypeError(`Validate function returned undefined`);
-          } else if (result !== true) {
-            throw new TypeError(`Validation failed for value ${newValue}`);
-          }
-        } catch (caughtError) {
-          throw new CacheableObjectPropertyValueError(
-            property, oldValue, newValue, {cause: caughtError});
-        }
-      }
-
-      this.#propertyUpdateValues[property] = newValue;
-      this.#invalidateCachesDependentUpon(property);
-    };
-  }
-
-  #getPropertyDescriptor(property) {
-    return this.constructor.propertyDescriptors[property];
-  }
-
-  #invalidateCachesDependentUpon(property) {
-    const invalidators = this.#propertyUpdateCacheInvalidators[property];
-    if (!invalidators) {
-      return;
-    }
-
-    for (const invalidate of invalidators) {
-      invalidate();
-    }
-  }
-
-  #getExposeObjectDefinitionGetterFunction(property) {
-    const {flags} = this.#getPropertyDescriptor(property);
-    const compute = this.#getExposeComputeFunction(property);
-
-    if (compute) {
-      let cachedValue;
-      const checkCacheValid = this.#getExposeCheckCacheValidFunction(property);
-      return () => {
-        if (checkCacheValid()) {
-          return cachedValue;
-        } else {
-          return (cachedValue = compute());
-        }
-      };
-    } else if (!flags.update && !compute) {
-      throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-    } else {
-      return () => this.#propertyUpdateValues[property];
-    }
-  }
-
-  #getExposeComputeFunction(property) {
-    const {flags, expose} = this.#getPropertyDescriptor(property);
-
-    const compute = expose?.compute;
-    const transform = expose?.transform;
-
-    if (flags.update && !transform) {
-      return null;
-    } else if (flags.update && compute) {
-      throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
-    } else if (!flags.update && !compute) {
-      throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-    }
-
-    let getAllDependencies;
-
-    if (expose.dependencies?.length > 0) {
-      const dependencyKeys = expose.dependencies.slice();
-      const shouldReflect = dependencyKeys.includes('this');
-
-      getAllDependencies = () => {
-        const dependencies = Object.create(null);
-
-        for (const key of dependencyKeys) {
-          dependencies[key] = this.#propertyUpdateValues[key];
-        }
-
-        if (shouldReflect) {
-          dependencies.this = this;
-        }
-
-        return dependencies;
-      };
-    } else {
-      const dependencies = Object.create(null);
-      Object.freeze(dependencies);
-      getAllDependencies = () => dependencies;
-    }
-
-    if (flags.update) {
-      return () => transform(this.#propertyUpdateValues[property], getAllDependencies());
-    } else {
-      return () => compute(getAllDependencies());
-    }
-  }
-
-  #getExposeCheckCacheValidFunction(property) {
-    const {flags, expose} = this.#getPropertyDescriptor(property);
-
-    let valid = false;
-
-    const invalidate = () => {
-      valid = false;
-    };
-
-    const dependencyKeys = new Set(expose?.dependencies);
-
-    if (flags.update) {
-      dependencyKeys.add(property);
-    }
-
-    for (const key of dependencyKeys) {
-      if (this.#propertyUpdateCacheInvalidators[key]) {
-        this.#propertyUpdateCacheInvalidators[key].push(invalidate);
-      } else {
-        this.#propertyUpdateCacheInvalidators[key] = [invalidate];
-      }
-    }
-
-    return () => {
-      if (!valid) {
-        valid = true;
-        return false;
-      } else {
-        return true;
-      }
-    };
-  }
-
-  static cacheAllExposedProperties(obj) {
-    if (!(obj instanceof CacheableObject)) {
-      console.warn('Not a CacheableObject:', obj);
-      return;
-    }
-
-    const {propertyDescriptors} = obj.constructor;
-
-    if (!propertyDescriptors) {
-      console.warn('Missing property descriptors:', obj);
-      return;
-    }
-
-    for (const [property, descriptor] of Object.entries(propertyDescriptors)) {
-      const {flags} = descriptor;
-
-      if (!flags.expose) {
-        continue;
-      }
-
-      obj[property];
-    }
-  }
-
-  static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false;
-  static _invalidAccesses = new Set();
-
-  static showInvalidAccesses() {
-    if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-      return;
-    }
-
-    if (!this._invalidAccesses.size) {
-      return;
-    }
-
-    console.log(`${this._invalidAccesses.size} unique invalid accesses:`);
-    for (const line of this._invalidAccesses) {
-      console.log(` - ${line}`);
-    }
-  }
-
-  static getUpdateValue(object, key) {
-    if (!Object.hasOwn(object, key)) {
-      return undefined;
-    }
-
-    return object.#propertyUpdateValues[key] ?? null;
-  }
-}
-
-export class CacheableObjectPropertyValueError extends Error {
-  [Symbol.for('hsmusic.aggregate.translucent')] = true;
-
-  constructor(property, oldValue, newValue, options) {
-    super(
-      `Error setting ${colors.green(property)} (${inspect(oldValue)} -> ${inspect(newValue)})`,
-      options);
-
-    this.property = property;
-  }
-}
diff --git a/src/data/things/composite.js b/src/data/things/composite.js
deleted file mode 100644
index 113f0a4..0000000
--- a/src/data/things/composite.js
+++ /dev/null
@@ -1,1307 +0,0 @@
-import {inspect} from 'node:util';
-
-import {colors} from '#cli';
-import {TupleMap} from '#wiki-data';
-import {a} from '#validators';
-
-import {
-  decorateErrorWithIndex,
-  empty,
-  filterProperties,
-  openAggregate,
-  stitchArrays,
-  typeAppearance,
-  unique,
-  withAggregate,
-} from '#sugar';
-
-const globalCompositeCache = {};
-
-const _valueIntoToken = shape =>
-  (value = null) =>
-    (value === null
-      ? Symbol.for(`hsmusic.composite.${shape}`)
-   : typeof value === 'string'
-      ? Symbol.for(`hsmusic.composite.${shape}:${value}`)
-      : {
-          symbol: Symbol.for(`hsmusic.composite.input`),
-          shape,
-          value,
-        });
-
-export const input = _valueIntoToken('input');
-input.symbol = Symbol.for('hsmusic.composite.input');
-
-input.value = _valueIntoToken('input.value');
-input.dependency = _valueIntoToken('input.dependency');
-
-input.myself = () => Symbol.for(`hsmusic.composite.input.myself`);
-
-input.updateValue = _valueIntoToken('input.updateValue');
-
-input.staticDependency = _valueIntoToken('input.staticDependency');
-input.staticValue = _valueIntoToken('input.staticValue');
-
-function isInputToken(token) {
-  if (token === null) {
-    return false;
-  } else if (typeof token === 'object') {
-    return token.symbol === Symbol.for('hsmusic.composite.input');
-  } else if (typeof token === 'symbol') {
-    return token.description.startsWith('hsmusic.composite.input');
-  } else {
-    return false;
-  }
-}
-
-function getInputTokenShape(token) {
-  if (!isInputToken(token)) {
-    throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`);
-  }
-
-  if (typeof token === 'object') {
-    return token.shape;
-  } else {
-    return token.description.match(/hsmusic\.composite\.(input.*?)(:|$)/)[1];
-  }
-}
-
-function getInputTokenValue(token) {
-  if (!isInputToken(token)) {
-    throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`);
-  }
-
-  if (typeof token === 'object') {
-    return token.value;
-  } else {
-    return token.description.match(/hsmusic\.composite\.input.*?:(.*)/)?.[1] ?? null;
-  }
-}
-
-function getStaticInputMetadata(inputOptions) {
-  const metadata = {};
-
-  for (const [name, token] of Object.entries(inputOptions)) {
-    if (typeof token === 'string') {
-      metadata[input.staticDependency(name)] = token;
-      metadata[input.staticValue(name)] = null;
-    } else if (isInputToken(token)) {
-      const tokenShape = getInputTokenShape(token);
-      const tokenValue = getInputTokenValue(token);
-
-      metadata[input.staticDependency(name)] =
-        (tokenShape === 'input.dependency'
-          ? tokenValue
-          : null);
-
-      metadata[input.staticValue(name)] =
-        (tokenShape === 'input.value'
-          ? tokenValue
-          : null);
-    } else {
-      metadata[input.staticDependency(name)] = null;
-      metadata[input.staticValue(name)] = null;
-    }
-  }
-
-  return metadata;
-}
-
-function getCompositionName(description) {
-  return (
-    (description.annotation
-      ? description.annotation
-      : `unnamed composite`));
-}
-
-function validateInputValue(value, description) {
-  const tokenValue = getInputTokenValue(description);
-
-  const {acceptsNull, defaultValue, type, validate} = tokenValue || {};
-
-  if (value === null || value === undefined) {
-    if (acceptsNull || defaultValue === null) {
-      return true;
-    } else {
-      throw new TypeError(
-        (type
-          ? `Expected ${a(type)}, got ${typeAppearance(value)}`
-          : `Expected a value, got ${typeAppearance(value)}`));
-    }
-  }
-
-  if (type) {
-    // Note: null is already handled earlier in this function, so it won't
-    // cause any trouble here.
-    const typeofValue =
-      (typeof value === 'object'
-        ? Array.isArray(value) ? 'array' : 'object'
-        : typeof value);
-
-    if (typeofValue !== type) {
-      throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`);
-    }
-  }
-
-  if (validate) {
-    validate(value);
-  }
-
-  return true;
-}
-
-export function templateCompositeFrom(description) {
-  const compositionName = getCompositionName(description);
-
-  withAggregate({message: `Errors in description for ${compositionName}`}, ({map, nest, push}) => {
-    if ('steps' in description) {
-      if (Array.isArray(description.steps)) {
-        push(new TypeError(`Wrap steps array in a function`));
-      } else if (typeof description.steps !== 'function') {
-        push(new TypeError(`Expected steps to be a function (returning an array)`));
-      }
-    }
-
-    validateInputs:
-    if ('inputs' in description) {
-      if (
-        Array.isArray(description.inputs) ||
-        typeof description.inputs !== 'object'
-      ) {
-        push(new Error(`Expected inputs to be object, got ${typeAppearance(description.inputs)}`));
-        break validateInputs;
-      }
-
-      nest({message: `Errors in static input descriptions for ${compositionName}`}, ({push}) => {
-        const missingCallsToInput = [];
-        const wrongCallsToInput = [];
-
-        for (const [name, value] of Object.entries(description.inputs)) {
-          if (!isInputToken(value)) {
-            missingCallsToInput.push(name);
-            continue;
-          }
-
-          if (!['input', 'input.staticDependency', 'input.staticValue'].includes(getInputTokenShape(value))) {
-            wrongCallsToInput.push(name);
-          }
-        }
-
-        for (const name of missingCallsToInput) {
-          push(new Error(`${name}: Missing call to input()`));
-        }
-
-        for (const name of wrongCallsToInput) {
-          const shape = getInputTokenShape(description.inputs[name]);
-          push(new Error(`${name}: Expected call to input, input.staticDependency, or input.staticValue, got ${shape}`));
-        }
-      });
-    }
-
-    validateOutputs:
-    if ('outputs' in description) {
-      if (
-        !Array.isArray(description.outputs) &&
-        typeof description.outputs !== 'function'
-      ) {
-        push(new Error(`Expected outputs to be array or function, got ${typeAppearance(description.outputs)}`));
-        break validateOutputs;
-      }
-
-      if (Array.isArray(description.outputs)) {
-        map(
-          description.outputs,
-          decorateErrorWithIndex(value => {
-            if (typeof value !== 'string') {
-              throw new Error(`${value}: Expected string, got ${typeAppearance(value)}`)
-            } else if (!value.startsWith('#')) {
-              throw new Error(`${value}: Expected "#" at start`);
-            }
-          }),
-          {message: `Errors in output descriptions for ${compositionName}`});
-      }
-    }
-  });
-
-  const expectedInputNames =
-    (description.inputs
-      ? Object.keys(description.inputs)
-      : []);
-
-  const instantiate = (inputOptions = {}) => {
-    withAggregate({message: `Errors in input options passed to ${compositionName}`}, ({push}) => {
-      const providedInputNames = Object.keys(inputOptions);
-
-      const misplacedInputNames =
-        providedInputNames
-          .filter(name => !expectedInputNames.includes(name));
-
-      const missingInputNames =
-        expectedInputNames
-          .filter(name => !providedInputNames.includes(name))
-          .filter(name => {
-            const inputDescription = getInputTokenValue(description.inputs[name]);
-            if (!inputDescription) return true;
-            if ('defaultValue' in inputDescription) return false;
-            if ('defaultDependency' in inputDescription) return false;
-            return true;
-          });
-
-      const wrongTypeInputNames = [];
-
-      const expectedStaticValueInputNames = [];
-      const expectedStaticDependencyInputNames = [];
-      const expectedValueProvidingTokenInputNames = [];
-
-      const validateFailedErrors = [];
-
-      for (const [name, value] of Object.entries(inputOptions)) {
-        if (misplacedInputNames.includes(name)) {
-          continue;
-        }
-
-        if (typeof value !== 'string' && !isInputToken(value)) {
-          wrongTypeInputNames.push(name);
-          continue;
-        }
-
-        const descriptionShape = getInputTokenShape(description.inputs[name]);
-
-        const tokenShape = (isInputToken(value) ? getInputTokenShape(value) : null);
-        const tokenValue = (isInputToken(value) ? getInputTokenValue(value) : null);
-
-        switch (descriptionShape) {
-          case'input.staticValue':
-            if (tokenShape !== 'input.value') {
-              expectedStaticValueInputNames.push(name);
-              continue;
-            }
-            break;
-
-          case 'input.staticDependency':
-            if (typeof value !== 'string' && tokenShape !== 'input.dependency') {
-              expectedStaticDependencyInputNames.push(name);
-              continue;
-            }
-            break;
-
-          case 'input':
-            if (typeof value !== 'string' && ![
-              'input',
-              'input.value',
-              'input.dependency',
-              'input.myself',
-              'input.updateValue',
-            ].includes(tokenShape)) {
-              expectedValueProvidingTokenInputNames.push(name);
-              continue;
-            }
-            break;
-        }
-
-        if (tokenShape === 'input.value') {
-          try {
-            validateInputValue(tokenValue, description.inputs[name]);
-          } catch (error) {
-            error.message = `${name}: ${error.message}`;
-            validateFailedErrors.push(error);
-          }
-        }
-      }
-
-      if (!empty(misplacedInputNames)) {
-        push(new Error(`Unexpected input names: ${misplacedInputNames.join(', ')}`));
-      }
-
-      if (!empty(missingInputNames)) {
-        push(new Error(`Required these inputs: ${missingInputNames.join(', ')}`));
-      }
-
-      const inputAppearance = name =>
-        (isInputToken(inputOptions[name])
-          ? `${getInputTokenShape(inputOptions[name])}() call`
-          : `dependency name`);
-
-      for (const name of expectedStaticDependencyInputNames) {
-        const appearance = inputAppearance(name);
-        push(new Error(`${name}: Expected dependency name, got ${appearance}`));
-      }
-
-      for (const name of expectedStaticValueInputNames) {
-        const appearance = inputAppearance(name)
-        push(new Error(`${name}: Expected input.value() call, got ${appearance}`));
-      }
-
-      for (const name of expectedValueProvidingTokenInputNames) {
-        const appearance = getInputTokenShape(inputOptions[name]);
-        push(new Error(`${name}: Expected dependency name or value-providing input() call, got ${appearance}`));
-      }
-
-      for (const name of wrongTypeInputNames) {
-        const type = typeAppearance(inputOptions[name]);
-        push(new Error(`${name}: Expected dependency name or input() call, got ${type}`));
-      }
-
-      for (const error of validateFailedErrors) {
-        push(error);
-      }
-    });
-
-    const inputMetadata = getStaticInputMetadata(inputOptions);
-
-    const expectedOutputNames =
-      (Array.isArray(description.outputs)
-        ? description.outputs
-     : typeof description.outputs === 'function'
-        ? description.outputs(inputMetadata)
-            .map(name =>
-              (name.startsWith('#')
-                ? name
-                : '#' + name))
-        : []);
-
-    const ownUpdateDescription =
-      (typeof description.update === 'object'
-        ? description.update
-     : typeof description.update === 'function'
-        ? description.update(inputMetadata)
-        : null);
-
-    const outputOptions = {};
-
-    const instantiatedTemplate = {
-      symbol: templateCompositeFrom.symbol,
-
-      outputs(providedOptions) {
-        withAggregate({message: `Errors in output options passed to ${compositionName}`}, ({push}) => {
-          const misplacedOutputNames = [];
-          const wrongTypeOutputNames = [];
-
-          for (const [name, value] of Object.entries(providedOptions)) {
-            if (!expectedOutputNames.includes(name)) {
-              misplacedOutputNames.push(name);
-              continue;
-            }
-
-            if (typeof value !== 'string') {
-              wrongTypeOutputNames.push(name);
-              continue;
-            }
-          }
-
-          if (!empty(misplacedOutputNames)) {
-            push(new Error(`Unexpected output names: ${misplacedOutputNames.join(', ')}`));
-          }
-
-          for (const name of wrongTypeOutputNames) {
-            const appearance = typeAppearance(providedOptions[name]);
-            push(new Error(`${name}: Expected string, got ${appearance}`));
-          }
-        });
-
-        Object.assign(outputOptions, providedOptions);
-        return instantiatedTemplate;
-      },
-
-      toDescription() {
-        const finalDescription = {};
-
-        if ('annotation' in description) {
-          finalDescription.annotation = description.annotation;
-        }
-
-        if ('compose' in description) {
-          finalDescription.compose = description.compose;
-        }
-
-        if (ownUpdateDescription) {
-          finalDescription.update = ownUpdateDescription;
-        }
-
-        if ('inputs' in description) {
-          const inputMapping = {};
-
-          for (const [name, token] of Object.entries(description.inputs)) {
-            const tokenValue = getInputTokenValue(token);
-            if (name in inputOptions) {
-              if (typeof inputOptions[name] === 'string') {
-                inputMapping[name] = input.dependency(inputOptions[name]);
-              } else {
-                inputMapping[name] = inputOptions[name];
-              }
-            } else if (tokenValue.defaultValue) {
-              inputMapping[name] = input.value(tokenValue.defaultValue);
-            } else if (tokenValue.defaultDependency) {
-              inputMapping[name] = input.dependency(tokenValue.defaultDependency);
-            } else {
-              inputMapping[name] = input.value(null);
-            }
-          }
-
-          finalDescription.inputMapping = inputMapping;
-          finalDescription.inputDescriptions = description.inputs;
-        }
-
-        if ('outputs' in description) {
-          const finalOutputs = {};
-
-          for (const name of expectedOutputNames) {
-            if (name in outputOptions) {
-              finalOutputs[name] = outputOptions[name];
-            } else {
-              finalOutputs[name] = name;
-            }
-          }
-
-          finalDescription.outputs = finalOutputs;
-        }
-
-        if ('steps' in description) {
-          finalDescription.steps = description.steps;
-        }
-
-        return finalDescription;
-      },
-
-      toResolvedComposition() {
-        const ownDescription = instantiatedTemplate.toDescription();
-
-        const finalDescription = {...ownDescription};
-
-        const aggregate = openAggregate({message: `Errors resolving ${compositionName}`});
-
-        const steps = ownDescription.steps();
-
-        const resolvedSteps =
-          aggregate.map(
-            steps,
-            decorateErrorWithIndex(step =>
-              (step.symbol === templateCompositeFrom.symbol
-                ? compositeFrom(step.toResolvedComposition())
-                : step)),
-            {message: `Errors resolving steps`});
-
-        aggregate.close();
-
-        finalDescription.steps = resolvedSteps;
-
-        return finalDescription;
-      },
-    };
-
-    return instantiatedTemplate;
-  };
-
-  instantiate.inputs = instantiate;
-
-  return instantiate;
-}
-
-templateCompositeFrom.symbol = Symbol();
-
-export const continuationSymbol = Symbol.for('compositeFrom: continuation symbol');
-export const noTransformSymbol = Symbol.for('compositeFrom: no-transform symbol');
-
-export function compositeFrom(description) {
-  const {annotation} = description;
-  const compositionName = getCompositionName(description);
-
-  const debug = fn => {
-    if (compositeFrom.debug === true) {
-      const label =
-        (annotation
-          ? colors.dim(`[composite: ${annotation}]`)
-          : colors.dim(`[composite]`));
-      const result = fn();
-      if (Array.isArray(result)) {
-        console.log(label, ...result.map(value =>
-          (typeof value === 'object'
-            ? inspect(value, {depth: 1, colors: true, compact: true, breakLength: Infinity})
-            : value)));
-      } else {
-        console.log(label, result);
-      }
-    }
-  };
-
-  if (!Array.isArray(description.steps)) {
-    throw new TypeError(
-      `Expected steps to be array, got ${typeAppearance(description.steps)}` +
-      (annotation ? ` (${annotation})` : ''));
-  }
-
-  const composition =
-    description.steps.map(step =>
-      ('toResolvedComposition' in step
-        ? compositeFrom(step.toResolvedComposition())
-        : step));
-
-  const inputMetadata = getStaticInputMetadata(description.inputMapping ?? {});
-
-  function _mapDependenciesToOutputs(providedDependencies) {
-    if (!description.outputs) {
-      return {};
-    }
-
-    if (!providedDependencies) {
-      return {};
-    }
-
-    return (
-      Object.fromEntries(
-        Object.entries(description.outputs)
-          .map(([continuationName, outputName]) => [
-            outputName,
-            (continuationName in providedDependencies
-              ? providedDependencies[continuationName]
-              : providedDependencies[continuationName.replace(/^#/, '')]),
-          ])));
-  }
-
-  // These dependencies were all provided by the composition which this one is
-  // nested inside, so input('name')-shaped tokens are going to be evaluated
-  // in the context of the containing composition.
-  const dependenciesFromInputs =
-    Object.values(description.inputMapping ?? {})
-      .map(token => {
-        const tokenShape = getInputTokenShape(token);
-        const tokenValue = getInputTokenValue(token);
-        switch (tokenShape) {
-          case 'input.dependency':
-            return tokenValue;
-          case 'input':
-          case 'input.updateValue':
-            return token;
-          case 'input.myself':
-            return 'this';
-          default:
-            return null;
-        }
-      })
-      .filter(Boolean);
-
-  const anyInputsUseUpdateValue =
-    dependenciesFromInputs
-      .filter(dependency => isInputToken(dependency))
-      .some(token => getInputTokenShape(token) === 'input.updateValue');
-
-  const inputNames =
-    Object.keys(description.inputMapping ?? {});
-
-  const inputSymbols =
-    inputNames.map(name => input(name));
-
-  const inputsMayBeDynamicValue =
-    stitchArrays({
-      mappingToken: Object.values(description.inputMapping ?? {}),
-      descriptionToken: Object.values(description.inputDescriptions ?? {}),
-    }).map(({mappingToken, descriptionToken}) => {
-        if (getInputTokenShape(descriptionToken) === 'input.staticValue') return false;
-        if (getInputTokenShape(mappingToken) === 'input.value') return false;
-        return true;
-      });
-
-  const inputDescriptions =
-    Object.values(description.inputDescriptions ?? {});
-
-  /*
-  const inputsAcceptNull =
-    Object.values(description.inputDescriptions ?? {})
-      .map(token => {
-        const tokenValue = getInputTokenValue(token);
-        if (!tokenValue) return false;
-        if ('acceptsNull' in tokenValue) return tokenValue.acceptsNull;
-        if ('defaultValue' in tokenValue) return tokenValue.defaultValue === null;
-        return false;
-      });
-  */
-
-  // Update descriptions passed as the value in an input.updateValue() token,
-  // as provided as inputs for this composition.
-  const inputUpdateDescriptions =
-    Object.values(description.inputMapping ?? {})
-      .map(token =>
-        (getInputTokenShape(token) === 'input.updateValue'
-          ? getInputTokenValue(token)
-          : null))
-      .filter(Boolean);
-
-  const base = composition.at(-1);
-  const steps = composition.slice();
-
-  const aggregate = openAggregate({
-    message:
-      `Errors preparing composition` +
-      (annotation ? ` (${annotation})` : ''),
-  });
-
-  const compositionNests = description.compose ?? true;
-
-  if (compositionNests && empty(steps)) {
-    aggregate.push(new TypeError(`Expected at least one step`));
-  }
-
-  // Steps default to exposing if using a shorthand syntax where flags aren't
-  // specified at all.
-  const stepsExpose =
-    steps
-      .map(step =>
-        (step.flags
-          ? step.flags.expose ?? false
-          : true));
-
-  // Steps default to composing if using a shorthand syntax where flags aren't
-  // specified at all - *and* aren't the base (final step), unless the whole
-  // composition is nestable.
-  const stepsCompose =
-    steps
-      .map((step, index, {length}) =>
-        (step.flags
-          ? step.flags.compose ?? false
-          : (index === length - 1
-              ? compositionNests
-              : true)));
-
-  // Steps update if the corresponding flag is explicitly set, if a transform
-  // function is provided, or if the dependencies include an input.updateValue
-  // token.
-  const stepsUpdate =
-    steps
-      .map(step =>
-        (step.flags
-          ? step.flags.update ?? false
-          : !!step.transform ||
-            !!step.dependencies?.some(dependency =>
-                isInputToken(dependency) &&
-                getInputTokenShape(dependency) === 'input.updateValue')));
-
-  // The expose description for a step is just the entire step object, when
-  // using the shorthand syntax where {flags: {expose: true}} is left implied.
-  const stepExposeDescriptions =
-    steps
-      .map((step, index) =>
-        (stepsExpose[index]
-          ? (step.flags
-              ? step.expose ?? null
-              : step)
-          : null));
-
-  // The update description for a step, if present at all, is always set
-  // explicitly. There may be multiple per step - namely that step's own
-  // {update} description, and any descriptions passed as the value in an
-  // input.updateValue({...}) token.
-  const stepUpdateDescriptions =
-    steps
-      .map((step, index) =>
-        (stepsUpdate[index]
-          ? [
-              step.update ?? null,
-              ...(stepExposeDescriptions[index]?.dependencies ?? [])
-                .filter(dependency => isInputToken(dependency))
-                .filter(token => getInputTokenShape(token) === 'input.updateValue')
-                .map(token => getInputTokenValue(token)),
-            ].filter(Boolean)
-          : []));
-
-  // Indicates presence of a {compute} function on the expose description.
-  const stepsCompute =
-    stepExposeDescriptions
-      .map(expose => !!expose?.compute);
-
-  // Indicates presence of a {transform} function on the expose description.
-  const stepsTransform =
-    stepExposeDescriptions
-      .map(expose => !!expose?.transform);
-
-  const dependenciesFromSteps =
-    unique(
-      stepExposeDescriptions
-        .flatMap(expose => expose?.dependencies ?? [])
-        .map(dependency => {
-          if (typeof dependency === 'string')
-            return (dependency.startsWith('#') ? null : dependency);
-
-          const tokenShape = getInputTokenShape(dependency);
-          const tokenValue = getInputTokenValue(dependency);
-          switch (tokenShape) {
-            case 'input.dependency':
-              return (tokenValue.startsWith('#') ? null : tokenValue);
-            case 'input.myself':
-              return 'this';
-            default:
-              return null;
-          }
-        })
-        .filter(Boolean));
-
-  const anyStepsUseUpdateValue =
-    stepExposeDescriptions
-      .some(expose =>
-        (expose?.dependencies
-          ? expose.dependencies.includes(input.updateValue())
-          : false));
-
-  const anyStepsExpose =
-    stepsExpose.includes(true);
-
-  const anyStepsUpdate =
-    stepsUpdate.includes(true);
-
-  const anyStepsCompute =
-    stepsCompute.includes(true);
-
-  const anyStepsTransform =
-    stepsTransform.includes(true);
-
-  const compositionExposes =
-    anyStepsExpose;
-
-  const compositionUpdates =
-    'update' in description ||
-    anyInputsUseUpdateValue ||
-    anyStepsUseUpdateValue ||
-    anyStepsUpdate;
-
-  const stepEntries = stitchArrays({
-    step: steps,
-    stepComposes: stepsCompose,
-    stepComputes: stepsCompute,
-    stepTransforms: stepsTransform,
-  });
-
-  for (let i = 0; i < stepEntries.length; i++) {
-    const {
-      step,
-      stepComposes,
-      stepComputes,
-      stepTransforms,
-    } = stepEntries[i];
-
-    const isBase = i === stepEntries.length - 1;
-    const message =
-      `Errors in step #${i + 1}` +
-      (isBase ? ` (base)` : ``) +
-      (step.annotation ? ` (${step.annotation})` : ``);
-
-    aggregate.nest({message}, ({push}) => {
-      if (isBase && stepComposes !== compositionNests) {
-        return push(new TypeError(
-          (compositionNests
-            ? `Base must compose, this composition is nestable`
-            : `Base must not compose, this composition isn't nestable`)));
-      } else if (!isBase && !stepComposes) {
-        return push(new TypeError(
-          (compositionNests
-            ? `All steps must compose`
-            : `All steps (except base) must compose`)));
-      }
-
-      if (
-        !compositionNests && !compositionUpdates &&
-        stepTransforms && !stepComputes
-      ) {
-        return push(new TypeError(
-          `Steps which only transform can't be used in a composition that doesn't update`));
-      }
-    });
-  }
-
-  if (!compositionNests && !compositionUpdates && !anyStepsCompute) {
-    aggregate.push(new TypeError(`Expected at least one step to compute`));
-  }
-
-  aggregate.close();
-
-  function _prepareContinuation(callingTransformForThisStep) {
-    const continuationStorage = {
-      returnedWith: null,
-      providedDependencies: undefined,
-      providedValue: undefined,
-    };
-
-    const continuation =
-      (callingTransformForThisStep
-        ? (providedValue, providedDependencies = null) => {
-            continuationStorage.returnedWith = 'continuation';
-            continuationStorage.providedDependencies = providedDependencies;
-            continuationStorage.providedValue = providedValue;
-            return continuationSymbol;
-          }
-        : (providedDependencies = null) => {
-            continuationStorage.returnedWith = 'continuation';
-            continuationStorage.providedDependencies = providedDependencies;
-            return continuationSymbol;
-          });
-
-    continuation.exit = (providedValue) => {
-      continuationStorage.returnedWith = 'exit';
-      continuationStorage.providedValue = providedValue;
-      return continuationSymbol;
-    };
-
-    if (compositionNests) {
-      const makeRaiseLike = returnWith =>
-        (callingTransformForThisStep
-          ? (providedValue, providedDependencies = null) => {
-              continuationStorage.returnedWith = returnWith;
-              continuationStorage.providedDependencies = providedDependencies;
-              continuationStorage.providedValue = providedValue;
-              return continuationSymbol;
-            }
-          : (providedDependencies = null) => {
-              continuationStorage.returnedWith = returnWith;
-              continuationStorage.providedDependencies = providedDependencies;
-              return continuationSymbol;
-            });
-
-      continuation.raiseOutput = makeRaiseLike('raiseOutput');
-      continuation.raiseOutputAbove = makeRaiseLike('raiseOutputAbove');
-    }
-
-    return {continuation, continuationStorage};
-  }
-
-  function _computeOrTransform(initialValue, continuationIfApplicable, initialDependencies) {
-    const expectingTransform = initialValue !== noTransformSymbol;
-
-    let valueSoFar =
-      (expectingTransform
-        ? initialValue
-        : undefined);
-
-    const availableDependencies = {...initialDependencies};
-
-    const inputValues =
-      Object.values(description.inputMapping ?? {})
-        .map(token => {
-          const tokenShape = getInputTokenShape(token);
-          const tokenValue = getInputTokenValue(token);
-          switch (tokenShape) {
-            case 'input.dependency':
-              return initialDependencies[tokenValue];
-            case 'input.value':
-              return tokenValue;
-            case 'input.updateValue':
-              if (!expectingTransform)
-                throw new Error(`Unexpected input.updateValue() accessed on non-transform call`);
-              return valueSoFar;
-            case 'input.myself':
-              return initialDependencies['this'];
-            case 'input':
-              return initialDependencies[token];
-            default:
-              throw new TypeError(`Unexpected input shape ${tokenShape}`);
-          }
-        });
-
-    withAggregate({message: `Errors in input values provided to ${compositionName}`}, ({push}) => {
-      for (const {dynamic, name, value, description} of stitchArrays({
-        dynamic: inputsMayBeDynamicValue,
-        name: inputNames,
-        value: inputValues,
-        description: inputDescriptions,
-      })) {
-        if (!dynamic) continue;
-        try {
-          validateInputValue(value, description);
-        } catch (error) {
-          error.message = `${name}: ${error.message}`;
-          push(error);
-        }
-      }
-    });
-
-    if (expectingTransform) {
-      debug(() => [colors.bright(`begin composition - transforming from:`), initialValue]);
-    } else {
-      debug(() => colors.bright(`begin composition - not transforming`));
-    }
-
-    for (let i = 0; i < steps.length; i++) {
-      const step = steps[i];
-      const isBase = i === steps.length - 1;
-
-      debug(() => [
-        `step #${i+1}` +
-        (isBase
-          ? ` (base):`
-          : ` of ${steps.length}:`),
-        step]);
-
-      const expose =
-        (step.flags
-          ? step.expose
-          : step);
-
-      if (!expose) {
-        if (!isBase) {
-          debug(() => `step #${i+1} - no expose description, nothing to do for this step`);
-          continue;
-        }
-
-        if (expectingTransform) {
-          debug(() => `step #${i+1} (base) - no expose description, returning so-far update value:`, valueSoFar);
-          if (continuationIfApplicable) {
-            debug(() => colors.bright(`end composition - raise (inferred - composing)`));
-            return continuationIfApplicable(valueSoFar);
-          } else {
-            debug(() => colors.bright(`end composition - exit (inferred - not composing)`));
-            return valueSoFar;
-          }
-        } else {
-          debug(() => `step #${i+1} (base) - no expose description, nothing to continue with`);
-          if (continuationIfApplicable) {
-            debug(() => colors.bright(`end composition - raise (inferred - composing)`));
-            return continuationIfApplicable();
-          } else {
-            debug(() => colors.bright(`end composition - exit (inferred - not composing)`));
-            return null;
-          }
-        }
-      }
-
-      const callingTransformForThisStep =
-        expectingTransform && expose.transform;
-
-      let continuationStorage;
-
-      const inputDictionary =
-        Object.fromEntries(
-          stitchArrays({symbol: inputSymbols, value: inputValues})
-            .map(({symbol, value}) => [symbol, value]));
-
-      const filterableDependencies = {
-        ...availableDependencies,
-        ...inputMetadata,
-        ...inputDictionary,
-        ...
-          (expectingTransform
-            ? {[input.updateValue()]: valueSoFar}
-            : {}),
-        [input.myself()]: initialDependencies?.['this'] ?? null,
-      };
-
-      const selectDependencies =
-        (expose.dependencies ?? []).map(dependency => {
-          if (!isInputToken(dependency)) return dependency;
-          const tokenShape = getInputTokenShape(dependency);
-          const tokenValue = getInputTokenValue(dependency);
-          switch (tokenShape) {
-            case 'input':
-            case 'input.staticDependency':
-            case 'input.staticValue':
-              return dependency;
-            case 'input.myself':
-              return input.myself();
-            case 'input.dependency':
-              return tokenValue;
-            case 'input.updateValue':
-              return input.updateValue();
-            default:
-              throw new Error(`Unexpected token ${tokenShape} as dependency`);
-          }
-        })
-
-      const filteredDependencies =
-        filterProperties(filterableDependencies, selectDependencies);
-
-      debug(() => [
-        `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`,
-        `with dependencies:`, filteredDependencies,
-        `selecting:`, selectDependencies,
-        `from available:`, filterableDependencies,
-        ...callingTransformForThisStep ? [`from value:`, valueSoFar] : []]);
-
-      let result;
-
-      const getExpectedEvaluation = () =>
-        (callingTransformForThisStep
-          ? (filteredDependencies
-              ? ['transform', valueSoFar, continuationSymbol, filteredDependencies]
-              : ['transform', valueSoFar, continuationSymbol])
-          : (filteredDependencies
-              ? ['compute', continuationSymbol, filteredDependencies]
-              : ['compute', continuationSymbol]));
-
-      const naturalEvaluate = () => {
-        const [name, ...argsLayout] = getExpectedEvaluation();
-
-        let args;
-
-        if (isBase && !compositionNests) {
-          args =
-            argsLayout.filter(arg => arg !== continuationSymbol);
-        } else {
-          let continuation;
-
-          ({continuation, continuationStorage} =
-            _prepareContinuation(callingTransformForThisStep));
-
-          args =
-            argsLayout.map(arg =>
-              (arg === continuationSymbol
-                ? continuation
-                : arg));
-        }
-
-        return expose[name](...args);
-      }
-
-      switch (step.cache) {
-        // Warning! Highly WIP!
-        case 'aggressive': {
-          const hrnow = () => {
-            const hrTime = process.hrtime();
-            return hrTime[0] * 1000000000 + hrTime[1];
-          };
-
-          const [name, ...args] = getExpectedEvaluation();
-
-          let cache = globalCompositeCache[step.annotation];
-          if (!cache) {
-            cache = globalCompositeCache[step.annotation] = {
-              transform: new TupleMap(),
-              compute: new TupleMap(),
-              times: {
-                read: [],
-                evaluate: [],
-              },
-            };
-          }
-
-          const tuplefied = args
-            .flatMap(arg => [
-              Symbol.for('compositeFrom: tuplefied arg divider'),
-              ...(typeof arg !== 'object' || Array.isArray(arg)
-                ? [arg]
-                : Object.entries(arg).flat()),
-            ]);
-
-          const readTime = hrnow();
-          const cacheContents = cache[name].get(tuplefied);
-          cache.times.read.push(hrnow() - readTime);
-
-          if (cacheContents) {
-            ({result, continuationStorage} = cacheContents);
-          } else {
-            const evaluateTime = hrnow();
-            result = naturalEvaluate();
-            cache.times.evaluate.push(hrnow() - evaluateTime);
-            cache[name].set(tuplefied, {result, continuationStorage});
-          }
-
-          break;
-        }
-
-        default: {
-          result = naturalEvaluate();
-          break;
-        }
-      }
-
-      if (result !== continuationSymbol) {
-        debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]);
-
-        if (compositionNests) {
-          throw new TypeError(`Inferred early-exit is disallowed in nested compositions`);
-        }
-
-        debug(() => colors.bright(`end composition - exit (inferred)`));
-
-        return result;
-      }
-
-      const {returnedWith} = continuationStorage;
-
-      if (returnedWith === 'exit') {
-        const {providedValue} = continuationStorage;
-
-        debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]);
-        debug(() => colors.bright(`end composition - exit (explicit)`));
-
-        if (compositionNests) {
-          return continuationIfApplicable.exit(providedValue);
-        } else {
-          return providedValue;
-        }
-      }
-
-      const {providedValue, providedDependencies} = continuationStorage;
-
-      const continuationArgs = [];
-      if (expectingTransform) {
-        continuationArgs.push(
-          (callingTransformForThisStep
-            ? providedValue ?? null
-            : valueSoFar ?? null));
-      }
-
-      debug(() => {
-        const base = `step #${i+1} - result: ` + returnedWith;
-        const parts = [];
-
-        if (callingTransformForThisStep) {
-          parts.push('value:', providedValue);
-        }
-
-        if (providedDependencies !== null) {
-          parts.push(`deps:`, providedDependencies);
-        } else {
-          parts.push(`(no deps)`);
-        }
-
-        if (empty(parts)) {
-          return base;
-        } else {
-          return [base + ' ->', ...parts];
-        }
-      });
-
-      switch (returnedWith) {
-        case 'raiseOutput':
-          debug(() =>
-            (isBase
-              ? colors.bright(`end composition - raiseOutput (base: explicit)`)
-              : colors.bright(`end composition - raiseOutput`)));
-          continuationArgs.push(_mapDependenciesToOutputs(providedDependencies));
-          return continuationIfApplicable(...continuationArgs);
-
-        case 'raiseOutputAbove':
-          debug(() => colors.bright(`end composition - raiseOutputAbove`));
-          continuationArgs.push(_mapDependenciesToOutputs(providedDependencies));
-          return continuationIfApplicable.raiseOutput(...continuationArgs);
-
-        case 'continuation':
-          if (isBase) {
-            debug(() => colors.bright(`end composition - raiseOutput (inferred)`));
-            continuationArgs.push(_mapDependenciesToOutputs(providedDependencies));
-            return continuationIfApplicable(...continuationArgs);
-          } else {
-            Object.assign(availableDependencies, providedDependencies);
-            if (callingTransformForThisStep && providedValue !== null) {
-              valueSoFar = providedValue;
-            }
-            break;
-          }
-      }
-    }
-  }
-
-  const constructedDescriptor = {};
-
-  if (annotation) {
-    constructedDescriptor.annotation = annotation;
-  }
-
-  constructedDescriptor.flags = {
-    update: compositionUpdates,
-    expose: compositionExposes,
-    compose: compositionNests,
-  };
-
-  if (compositionUpdates) {
-    // TODO: This is a dumb assign statement, and it could probably do more
-    // interesting things, like combining validation functions.
-    constructedDescriptor.update =
-      Object.assign(
-        {...description.update ?? {}},
-        ...inputUpdateDescriptions,
-        ...stepUpdateDescriptions.flat());
-  }
-
-  if (compositionExposes) {
-    const expose = constructedDescriptor.expose = {};
-
-    expose.dependencies =
-      unique([
-        ...dependenciesFromInputs,
-        ...dependenciesFromSteps,
-      ]);
-
-    const _wrapper = (...args) => {
-      try {
-        return _computeOrTransform(...args);
-      } catch (thrownError) {
-        const error = new Error(
-          `Error computing composition` +
-          (annotation ? ` ${annotation}` : ''));
-        error.cause = thrownError;
-        throw error;
-      }
-    };
-
-    if (compositionNests) {
-      if (compositionUpdates) {
-        expose.transform = (value, continuation, dependencies) =>
-          _wrapper(value, continuation, dependencies);
-      }
-
-      if (anyStepsCompute && !anyStepsUseUpdateValue && !anyInputsUseUpdateValue) {
-        expose.compute = (continuation, dependencies) =>
-          _wrapper(noTransformSymbol, continuation, dependencies);
-      }
-
-      if (base.cacheComposition) {
-        expose.cache = base.cacheComposition;
-      }
-    } else if (compositionUpdates) {
-      if (!empty(steps)) {
-        expose.transform = (value, dependencies) =>
-          _wrapper(value, null, dependencies);
-      }
-    } else {
-      expose.compute = (dependencies) =>
-        _wrapper(noTransformSymbol, null, dependencies);
-    }
-  }
-
-  return constructedDescriptor;
-}
-
-export function displayCompositeCacheAnalysis() {
-  const showTimes = (cache, key) => {
-    const times = cache.times[key].slice().sort();
-
-    const all = times;
-    const worst10pc = times.slice(-times.length / 10);
-    const best10pc = times.slice(0, times.length / 10);
-    const middle50pc = times.slice(times.length / 4, -times.length / 4);
-    const middle80pc = times.slice(times.length / 10, -times.length / 10);
-
-    const fmt = val => `${(val / 1000).toFixed(2)}ms`.padStart(9);
-    const avg = times => times.reduce((a, b) => a + b, 0) / times.length;
-
-    const left = ` - ${key}: `;
-    const indn = ' '.repeat(left.length);
-    console.log(left + `${fmt(avg(all))} (all ${all.length})`);
-    console.log(indn + `${fmt(avg(worst10pc))} (worst 10%)`);
-    console.log(indn + `${fmt(avg(best10pc))} (best 10%)`);
-    console.log(indn + `${fmt(avg(middle80pc))} (middle 80%)`);
-    console.log(indn + `${fmt(avg(middle50pc))} (middle 50%)`);
-  };
-
-  for (const [annotation, cache] of Object.entries(globalCompositeCache)) {
-    console.log(`Cached ${annotation}:`);
-    showTimes(cache, 'evaluate');
-    showTimes(cache, 'read');
-  }
-}
-
-// Evaluates a function with composite debugging enabled, turns debugging
-// off again, and returns the result of the function. This is mostly syntax
-// sugar, but also helps avoid unit tests avoid accidentally printing debug
-// info for a bunch of unrelated composites (due to property enumeration
-// when displaying an unexpected result). Use as so:
-//
-//   Without debugging:
-//     t.same(thing.someProp, value)
-//
-//   With debugging:
-//     t.same(debugComposite(() => thing.someProp), value)
-//
-export function debugComposite(fn) {
-  compositeFrom.debug = true;
-  const value = fn();
-  compositeFrom.debug = false;
-  return value;
-}
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index d7e8bb4..7e859bf 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -1,13 +1,8 @@
 import {input} from '#composite';
 import find from '#find';
-
-import {
-  anyOf,
-  isColor,
-  isDirectory,
-  isNumber,
-  isString,
-} from '#validators';
+import Thing from '#thing';
+import {anyOf, isColor, isDirectory, isNumber, isString} from '#validators';
+import {parseDate, parseContributors} from '#yaml';
 
 import {exposeDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
@@ -28,10 +23,6 @@ import {
 
 import {withFlashAct} from '#composite/things/flash';
 
-import {parseContributors} from '#yaml';
-
-import Thing from './thing.js';
-
 export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
 
@@ -137,24 +128,25 @@ export class Flash extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    fieldTransformations: {
-      'Date': (value) => new Date(value),
-
-      'Contributors': parseContributors,
-    },
-
-    propertyFieldMapping: {
-      name: 'Flash',
-      directory: 'Directory',
-      page: 'Page',
-      color: 'Color',
-      urls: 'URLs',
+    fields: {
+      'Flash': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'Page': {property: 'page'},
+      'Color': {property: 'color'},
+      'URLs': {property: 'urls'},
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
 
-      date: 'Date',
-      coverArtFileExtension: 'Cover Art File Extension',
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
-      featuredTracks: 'Featured Tracks',
-      contributorContribs: 'Contributors',
+      'Featured Tracks': {property: 'featuredTracks'},
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
     },
 
     ignoredFields: ['Review Points'],
@@ -199,15 +191,15 @@ export class FlashAct extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    propertyFieldMapping: {
-      name: 'Act',
-      directory: 'Directory',
+    fields: {
+      'Act': {property: 'name'},
+      'Directory': {property: 'directory'},
 
-      color: 'Color',
-      listTerminology: 'List Terminology',
+      'Color': {property: 'color'},
+      'List Terminology': {property: 'listTerminology'},
 
-      jump: 'Jump',
-      jumpColor: 'Jump Color',
+      'Jump': {property: 'jump'},
+      'Jump Color': {property: 'jumpColor'},
     },
 
     ignoredFields: ['Review Points'],
diff --git a/src/data/things/group.js b/src/data/things/group.js
index a9708fb..fe04dfa 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -1,5 +1,6 @@
 import {input} from '#composite';
 import find from '#find';
+import Thing from '#thing';
 
 import {
   color,
@@ -11,8 +12,6 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import Thing from './thing.js';
-
 export class Group extends Thing {
   static [Thing.referenceType] = 'group';
 
@@ -87,13 +86,13 @@ export class Group extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    propertyFieldMapping: {
-      name: 'Group',
-      directory: 'Directory',
-      description: 'Description',
-      urls: 'URLs',
+    fields: {
+      'Group': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'Description': {property: 'description'},
+      'URLs': {property: 'urls'},
 
-      featuredAlbums: 'Featured Albums',
+      'Featured Albums': {property: 'featuredAlbums'},
     },
 
     ignoredFields: ['Review Points'],
@@ -126,9 +125,9 @@ export class GroupCategory extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    propertyFieldMapping: {
-      name: 'Category',
-      color: 'Color',
+    fields: {
+      'Category': {property: 'name'},
+      'Color': {property: 'color'},
     },
   };
 }
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index b4fb97d..bd0970f 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -1,5 +1,6 @@
 import {input} from '#composite';
 import find from '#find';
+import Thing from '#thing';
 
 import {
   anyOf,
@@ -14,16 +15,8 @@ import {
 
 import {exposeDependency} from '#composite/control-flow';
 import {withResolvedReference} from '#composite/wiki-data';
-
-import {
-  color,
-  contentString,
-  name,
-  referenceList,
-  wikiData,
-} from '#composite/wiki-properties';
-
-import Thing from './thing.js';
+import {color, contentString, name, referenceList, wikiData}
+  from '#composite/wiki-properties';
 
 export class HomepageLayout extends Thing {
   static [Thing.friendlyName] = `Homepage Layout`;
@@ -48,9 +41,9 @@ export class HomepageLayout extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    propertyFieldMapping: {
-      sidebarContent: 'Sidebar Content',
-      navbarLinks: 'Navbar Links',
+    fields: {
+      'Sidebar Content': {property: 'sidebarContent'},
+      'Navbar Links': {property: 'navbarLinks'},
     },
 
     ignoredFields: ['Homepage'],
@@ -93,10 +86,10 @@ export class HomepageLayoutRow extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    propertyFieldMapping: {
-      name: 'Row',
-      color: 'Color',
-      type: 'Type',
+    fields: {
+      'Row': {property: 'name'},
+      'Color': {property: 'color'},
+      'Type': {property: 'type'},
     },
   };
 }
@@ -181,12 +174,12 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
   });
 
   static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
-    propertyFieldMapping: {
-      displayStyle: 'Display Style',
-      sourceGroup: 'Group',
-      countAlbumsFromGroup: 'Count',
-      sourceAlbums: 'Albums',
-      actionLinks: 'Actions',
+    fields: {
+      'Display Style': {property: 'displayStyle'},
+      'Group': {property: 'sourceGroup'},
+      'Count': {property: 'countAlbumsFromGroup'},
+      'Albums': {property: 'sourceAlbums'},
+      'Actions': {property: 'actionLinks'},
     },
   });
 }
diff --git a/src/data/things/index.js b/src/data/things/index.js
index d1143b0..9a36eaa 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -6,7 +6,7 @@ import {compositeFrom} from '#composite';
 import * as serialize from '#serialize';
 import {openAggregate, showAggregate} from '#sugar';
 
-import Thing from './thing.js';
+import Thing from '#thing';
 
 import * as albumClasses from './album.js';
 import * as artTagClasses from './art-tag.js';
@@ -20,8 +20,6 @@ import * as staticPageClasses from './static-page.js';
 import * as trackClasses from './track.js';
 import * as wikiInfoClasses from './wiki-info.js';
 
-export {default as Thing} from './thing.js';
-
 const allClassLists = {
   'album.js': albumClasses,
   'art-tag.js': artTagClasses,
diff --git a/src/data/things/language.js b/src/data/things/language.js
index b7841ac..c576a31 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,8 +1,10 @@
 import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
 
+import CacheableObject from '#cacheable-object';
 import * as html from '#html';
 import {empty, withAggregate} from '#sugar';
 import {isLanguageCode} from '#validators';
+import Thing from '#thing';
 
 import {
   getExternalLinkStringOfStyleFromDescriptors,
@@ -12,14 +14,7 @@ import {
   isExternalLinkStyle,
 } from '#external-links';
 
-import {
-  externalFunction,
-  flag,
-  name,
-} from '#composite/wiki-properties';
-
-import CacheableObject from './cacheable-object.js';
-import Thing from './thing.js';
+import {externalFunction, flag, name} from '#composite/wiki-properties';
 
 export class Language extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js
index 06dad62..5a02244 100644
--- a/src/data/things/news-entry.js
+++ b/src/data/things/news-entry.js
@@ -1,11 +1,8 @@
-import {
-  contentString,
-  directory,
-  name,
-  simpleDate,
-} from '#composite/wiki-properties';
+import Thing from '#thing';
+import {parseDate} from '#yaml';
 
-import Thing from './thing.js';
+import {contentString, directory, name, simpleDate}
+  from '#composite/wiki-properties';
 
 export class NewsEntry extends Thing {
   static [Thing.referenceType] = 'news-entry';
@@ -34,15 +31,16 @@ export class NewsEntry extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    fieldTransformations: {
-      'Date': (value) => new Date(value),
-    },
+    fields: {
+      'Name': {property: 'name'},
+      'Directory': {property: 'directory'},
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
 
-    propertyFieldMapping: {
-      name: 'Name',
-      directory: 'Directory',
-      date: 'Date',
-      content: 'Content',
+      'Content': {property: 'content'},
     },
   };
 }
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
index 00c0b09..7f8b7c9 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -1,13 +1,8 @@
+import Thing from '#thing';
 import {isName} from '#validators';
 
-import {
-  contentString,
-  directory,
-  name,
-  simpleString,
-} from '#composite/wiki-properties';
-
-import Thing from './thing.js';
+import {contentString, directory, name, simpleString}
+  from '#composite/wiki-properties';
 
 export class StaticPage extends Thing {
   static [Thing.referenceType] = 'static';
@@ -35,14 +30,14 @@ export class StaticPage extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    propertyFieldMapping: {
-      name: 'Name',
-      nameShort: 'Short Name',
-      directory: 'Directory',
-
-      stylesheet: 'Style',
-      script: 'Script',
-      content: 'Content',
+    fields: {
+      'Name': {property: 'name'},
+      'Short Name': {property: 'nameShort'},
+      'Directory': {property: 'directory'},
+
+      'Style': {property: 'stylesheet'},
+      'Script': {property: 'script'},
+      'Content': {property: 'content'},
     },
 
     ignoredFields: ['Review Points'],
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
deleted file mode 100644
index e1f488e..0000000
--- a/src/data/things/thing.js
+++ /dev/null
@@ -1,83 +0,0 @@
-// Thing: base class for wiki data types, providing interfaces generally useful
-// to all wiki data objects on top of foundational CacheableObject behavior.
-
-import {inspect} from 'node:util';
-
-import {colors} from '#cli';
-
-import CacheableObject from './cacheable-object.js';
-
-export default class Thing extends CacheableObject {
-  static referenceType = Symbol.for('Thing.referenceType');
-  static friendlyName = Symbol.for('Thing.friendlyName');
-
-  static getPropertyDescriptors = Symbol.for('Thing.getPropertyDescriptors');
-  static getSerializeDescriptors = Symbol.for('Thing.getSerializeDescriptors');
-
-  static yamlDocumentSpec = Symbol.for('Thing.yamlDocumentSpec');
-
-  // Default custom inspect function, which may be overridden by Thing
-  // subclasses. This will be used when displaying aggregate errors and other
-  // command-line logging - it's the place to provide information useful in
-  // identifying the Thing being presented.
-  [inspect.custom]() {
-    const cname = this.constructor.name;
-
-    return (
-      (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) +
-      (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '')
-    );
-  }
-
-  static getReference(thing) {
-    if (!thing.constructor[Thing.referenceType]) {
-      throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`);
-    }
-
-    if (!thing.directory) {
-      throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
-    }
-
-    return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
-  }
-
-  static extendDocumentSpec(thingClass, subspec) {
-    const superspec = thingClass[Thing.yamlDocumentSpec];
-
-    const {
-      fieldTransformations,
-      propertyFieldMapping,
-      ignoredFields,
-      invalidFieldCombinations,
-      ...restOfSubspec
-    } = subspec;
-
-    const newFields =
-      Object.values(subspec.propertyFieldMapping ?? {});
-
-    return {
-      ...superspec,
-      ...restOfSubspec,
-
-      fieldTransformations: {
-        ...superspec.fieldTransformations,
-        ...fieldTransformations,
-      },
-
-      propertyFieldMapping: {
-        ...superspec.propertyFieldMapping,
-        ...propertyFieldMapping,
-      },
-
-      ignoredFields:
-        (superspec.ignoredFields ?? [])
-          .filter(field => newFields.includes(field))
-          .concat(ignoredFields ?? []),
-
-      invalidFieldCombinations: [
-        ...superspec.invalidFieldCombinations ?? [],
-        ...invalidFieldCombinations ?? [],
-      ],
-    };
-  }
-}
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 3621510..9f44bd8 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -1,15 +1,20 @@
 import {inspect} from 'node:util';
 
+import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
 import {input} from '#composite';
 import find from '#find';
+import Thing from '#thing';
+import {isColor, isContributionList, isDate, isFileExtension}
+  from '#validators';
 
 import {
-  isColor,
-  isContributionList,
-  isDate,
-  isFileExtension,
-} from '#validators';
+  parseAdditionalFiles,
+  parseAdditionalNames,
+  parseContributors,
+  parseDate,
+  parseDuration,
+} from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
 import {withResolvedContribs} from '#composite/wiki-data';
@@ -55,16 +60,6 @@ import {
   withPropertyFromAlbum,
 } from '#composite/things/track';
 
-import {
-  parseAdditionalFiles,
-  parseAdditionalNames,
-  parseContributors,
-  parseDuration,
-} from '#yaml';
-
-import CacheableObject from './cacheable-object.js';
-import Thing from './thing.js';
-
 export class Track extends Thing {
   static [Thing.referenceType] = 'track';
 
@@ -340,54 +335,83 @@ export class Track extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    fieldTransformations: {
-      'Additional Names': parseAdditionalNames,
-      'Duration': parseDuration,
-
-      'Date First Released': (value) => new Date(value),
-      'Cover Art Date': (value) => new Date(value),
-      'Has Cover Art': (value) =>
-        (value === true ? false :
-         value === false ? true :
-         value),
-
-      'Artists': parseContributors,
-      'Contributors': parseContributors,
-      'Cover Artists': parseContributors,
-
-      'Additional Files': parseAdditionalFiles,
-      'Sheet Music Files': parseAdditionalFiles,
-      'MIDI Project Files': parseAdditionalFiles,
-    },
+    fields: {
+      'Track': {property: 'name'},
+      'Directory': {property: 'directory'},
+
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Duration': {
+        property: 'duration',
+        transform: parseDuration,
+      },
+
+      'Color': {property: 'color'},
+      'URLs': {property: 'urls'},
+
+      'Date First Released': {
+        property: 'dateFirstReleased',
+        transform: parseDate,
+      },
+
+      'Cover Art Date': {
+        property: 'coverArtDate',
+        transform: parseDate,
+      },
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+
+      'Has Cover Art': {
+        property: 'disableUniqueCoverArt',
+        transform: value =>
+          (typeof value === 'boolean'
+            ? !value
+            : value),
+      },
+
+      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+
+      'Lyrics': {property: 'lyrics'},
+      'Commentary': {property: 'commentary'},
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'Sheet Music Files': {
+        property: 'sheetMusicFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'MIDI Project Files': {
+        property: 'midiProjectFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'Originally Released As': {property: 'originalReleaseTrack'},
+      'Referenced Tracks': {property: 'referencedTracks'},
+      'Sampled Tracks': {property: 'sampledTracks'},
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
+
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
 
-    propertyFieldMapping: {
-      name: 'Track',
-      directory: 'Directory',
-      additionalNames: 'Additional Names',
-      duration: 'Duration',
-      color: 'Color',
-      urls: 'URLs',
-
-      dateFirstReleased: 'Date First Released',
-      coverArtDate: 'Cover Art Date',
-      coverArtFileExtension: 'Cover Art File Extension',
-      disableUniqueCoverArt: 'Has Cover Art', // This gets transformed to flip true/false.
-
-      alwaysReferenceByDirectory: 'Always Reference By Directory',
-
-      lyrics: 'Lyrics',
-      commentary: 'Commentary',
-      additionalFiles: 'Additional Files',
-      sheetMusicFiles: 'Sheet Music Files',
-      midiProjectFiles: 'MIDI Project Files',
-
-      originalReleaseTrack: 'Originally Released As',
-      referencedTracks: 'Referenced Tracks',
-      sampledTracks: 'Sampled Tracks',
-      artistContribs: 'Artists',
-      contributorContribs: 'Contributors',
-      coverArtistContribs: 'Cover Artists',
-      artTags: 'Art Tags',
+      'Art Tags': {property: 'artTags'},
     },
 
     ignoredFields: ['Review Points'],
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
deleted file mode 100644
index efe76fe..0000000
--- a/src/data/things/validators.js
+++ /dev/null
@@ -1,984 +0,0 @@
-import {inspect as nodeInspect} from 'node:util';
-
-// Heresy.
-import printable_characters from 'printable-characters';
-const {strlen} = printable_characters;
-
-import {colors, ENABLE_COLOR} from '#cli';
-import {commentaryRegex} from '#wiki-data';
-
-import {
-  cut,
-  empty,
-  matchMultiline,
-  openAggregate,
-  typeAppearance,
-  withAggregate,
-} from '#sugar';
-
-function inspect(value) {
-  return nodeInspect(value, {colors: ENABLE_COLOR});
-}
-
-export function getValidatorCreator(validator) {
-  return validator[Symbol.for(`hsmusic.validator.creator`)] ?? null;
-}
-
-export function getValidatorCreatorMeta(validator) {
-  return validator[Symbol.for(`hsmusic.validator.creatorMeta`)] ?? null;
-}
-
-export function setValidatorCreatorMeta(validator, creator, meta) {
-  validator[Symbol.for(`hsmusic.validator.creator`)] = creator;
-  validator[Symbol.for(`hsmusic.validator.creatorMeta`)] = meta;
-  return validator;
-}
-
-// Basic types (primitives)
-
-export function a(noun) {
-  return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`;
-}
-
-export function validateType(type) {
-  const fn = value => {
-    if (typeof value !== type)
-      throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`);
-
-    return true;
-  };
-
-  setValidatorCreatorMeta(fn, validateType, {type});
-
-  return fn;
-}
-
-export const isBoolean =
-  validateType('boolean');
-
-export const isFunction =
-  validateType('function');
-
-export const isNumber =
-  validateType('number');
-
-export const isString =
-  validateType('string');
-
-export const isSymbol =
-  validateType('symbol');
-
-// Use isObject instead, which disallows null.
-export const isTypeofObject =
-  validateType('object');
-
-export function isPositive(number) {
-  isNumber(number);
-
-  if (number <= 0) throw new TypeError(`Expected positive number`);
-
-  return true;
-}
-
-export function isNegative(number) {
-  isNumber(number);
-
-  if (number >= 0) throw new TypeError(`Expected negative number`);
-
-  return true;
-}
-
-export function isPositiveOrZero(number) {
-  isNumber(number);
-
-  if (number < 0) throw new TypeError(`Expected positive number or zero`);
-
-  return true;
-}
-
-export function isNegativeOrZero(number) {
-  isNumber(number);
-
-  if (number > 0) throw new TypeError(`Expected negative number or zero`);
-
-  return true;
-}
-
-export function isInteger(number) {
-  isNumber(number);
-
-  if (number % 1 !== 0) throw new TypeError(`Expected integer`);
-
-  return true;
-}
-
-export function isCountingNumber(number) {
-  isInteger(number);
-  isPositive(number);
-
-  return true;
-}
-
-export function isWholeNumber(number) {
-  isInteger(number);
-  isPositiveOrZero(number);
-
-  return true;
-}
-
-export function isStringNonEmpty(value) {
-  isString(value);
-
-  if (value.trim().length === 0)
-    throw new TypeError(`Expected non-empty string`);
-
-  return true;
-}
-
-export function optional(validator) {
-  return value =>
-    value === null ||
-    value === undefined ||
-    validator(value);
-}
-
-// Complex types (non-primitives)
-
-export function isInstance(value, constructor) {
-  isObject(value);
-
-  if (!(value instanceof constructor))
-    throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`);
-
-  return true;
-}
-
-export function isDate(value) {
-  isInstance(value, Date);
-
-  if (isNaN(value))
-    throw new TypeError(`Expected valid date`);
-
-  return true;
-}
-
-export function isObject(value) {
-  isTypeofObject(value);
-
-  // Note: Please remember that null is always a valid value for properties
-  // held by a CacheableObject. This assertion is exclusively for use in other
-  // contexts.
-  if (value === null)
-    throw new TypeError(`Expected an object, got null`);
-
-  return true;
-}
-
-export function isArray(value) {
-  if (typeof value !== 'object' || value === null || !Array.isArray(value))
-    throw new TypeError(`Expected an array, got ${typeAppearance(value)}`);
-
-  return true;
-}
-
-// This one's shaped a bit different from other "is" functions.
-// More like validate functions, it returns a function.
-export function is(...values) {
-  if (Array.isArray(values)) {
-    values = new Set(values);
-  }
-
-  if (values.size === 1) {
-    const expected = Array.from(values)[0];
-
-    return (value) => {
-      if (value !== expected) {
-        throw new TypeError(`Expected ${expected}, got ${value}`);
-      }
-
-      return true;
-    };
-  }
-
-  const fn = (value) => {
-    if (!values.has(value)) {
-      throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`);
-    }
-
-    return true;
-  };
-
-  setValidatorCreatorMeta(fn, is, {values});
-
-  return fn;
-}
-
-function validateArrayItemsHelper(itemValidator) {
-  return (item, index, array) => {
-    try {
-      const value = itemValidator(item, index, array);
-
-      if (value !== true) {
-        throw new Error(`Expected validator to return true`);
-      }
-    } catch (caughtError) {
-      const indexPart = colors.yellow(`zero-index ${index}`)
-      const itemPart = inspect(item);
-      const message = `Error at ${indexPart}: ${itemPart}`;
-      const error = new Error(message, {cause: caughtError});
-      error[Symbol.for('hsmusic.annotateError.indexInSourceArray')] = index;
-      throw error;
-    }
-  };
-}
-
-export function validateArrayItems(itemValidator) {
-  const helper = validateArrayItemsHelper(itemValidator);
-
-  return (array) => {
-    isArray(array);
-
-    withAggregate({message: 'Errors validating array items'}, ({call}) => {
-      for (let index = 0; index < array.length; index++) {
-        call(helper, array[index], index, array);
-      }
-    });
-
-    return true;
-  };
-}
-
-export function strictArrayOf(itemValidator) {
-  return validateArrayItems(itemValidator);
-}
-
-export function sparseArrayOf(itemValidator) {
-  return validateArrayItems((item, index, array) => {
-    if (item === false || item === null) {
-      return true;
-    }
-
-    return itemValidator(item, index, array);
-  });
-}
-
-export function looseArrayOf(itemValidator) {
-  return validateArrayItems((item, index, array) => {
-    if (item === false || item === null || item === undefined) {
-      return true;
-    }
-
-    return itemValidator(item, index, array);
-  });
-}
-
-export function validateInstanceOf(constructor) {
-  const fn = (object) => isInstance(object, constructor);
-
-  setValidatorCreatorMeta(fn, validateInstanceOf, {constructor});
-
-  return fn;
-}
-
-// Wiki data (primitives & non-primitives)
-
-export function isColor(color) {
-  isStringNonEmpty(color);
-
-  if (color.startsWith('#')) {
-    if (![4, 5, 7, 9].includes(color.length))
-      throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`);
-
-    if (/[^0-9a-fA-F]/.test(color.slice(1)))
-      throw new TypeError(`Expected hexadecimal digits`);
-
-    return true;
-  }
-
-  throw new TypeError(`Unknown color format`);
-}
-
-export function isCommentary(commentaryText) {
-  isContentString(commentaryText);
-
-  const rawMatches =
-    Array.from(commentaryText.matchAll(commentaryRegex));
-
-  if (empty(rawMatches)) {
-    throw new TypeError(`Expected at least one commentary heading`);
-  }
-
-  const niceMatches =
-    rawMatches.map(match => ({
-      position: match.index,
-      length: match[0].length,
-    }));
-
-  validateArrayItems(({position, length}, index) => {
-    if (index === 0 && position > 0) {
-      throw new TypeError(`Expected first commentary heading to be at top`);
-    }
-
-    const ownInput = commentaryText.slice(position, position + length);
-    const restOfInput = commentaryText.slice(position + length);
-    const nextLineBreak = restOfInput.indexOf('\n');
-    const upToNextLineBreak = restOfInput.slice(0, nextLineBreak);
-
-    if (/\S/.test(upToNextLineBreak)) {
-      throw new TypeError(
-        `Expected commentary heading to occupy entire line, got extra text:\n` +
-        `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
-        `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
-        `(Check for missing "|-" in YAML, or a misshapen annotation)`);
-    }
-
-    const nextHeading =
-      (index === niceMatches.length - 1
-        ? commentaryText.length
-        : niceMatches[index + 1].position);
-
-    const upToNextHeading =
-      commentaryText.slice(position + length, nextHeading);
-
-    if (!/\S/.test(upToNextHeading)) {
-      throw new TypeError(
-        `Expected commentary entry to have body text, only got a heading`);
-    }
-
-    return true;
-  })(niceMatches);
-
-  return true;
-}
-
-const isArtistRef = validateReference('artist');
-
-export function validateProperties(spec) {
-  const {
-    [validateProperties.validateOtherKeys]: validateOtherKeys = null,
-    [validateProperties.allowOtherKeys]: allowOtherKeys = false,
-  } = 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`}, ({push}) => {
-      const testEntries = specEntries.slice();
-
-      const unknownKeys = Object.keys(object).filter((key) => !specKeys.includes(key));
-      if (validateOtherKeys) {
-        for (const key of unknownKeys) {
-          testEntries.push([key, validateOtherKeys]);
-        }
-      }
-
-      for (const [specKey, specValidator] of testEntries) {
-        const value = object[specKey];
-        try {
-          specValidator(value);
-        } catch (caughtError) {
-          const keyPart = colors.green(specKey);
-          const valuePart = inspect(value);
-          const message = `Error for key ${keyPart}: ${valuePart}`;
-          push(new Error(message, {cause: caughtError}));
-        }
-      }
-
-      if (!validateOtherKeys && !allowOtherKeys && !empty(unknownKeys)) {
-        push(new Error(
-          `Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`));
-      }
-    });
-
-    return true;
-  };
-}
-
-validateProperties.validateOtherKeys = Symbol();
-validateProperties.allowOtherKeys = Symbol();
-
-export const validateAllPropertyValues = (validator) =>
-  validateProperties({
-    [validateProperties.validateOtherKeys]: validator,
-  });
-
-const illeaglInvisibleSpace = {
-  action: 'delete',
-};
-
-const illegalVisibleSpace = {
-  action: 'replace',
-  with: ' ',
-  withAnnotation: `normal space`,
-};
-
-const illegalContentSpec = [
-  {illegal: '\u200b', annotation: `zero-width space`, ...illeaglInvisibleSpace},
-  {illegal: '\u2005', annotation: `four-per-em space`, ...illegalVisibleSpace},
-  {illegal: '\u205f', annotation: `medium mathematical space`, ...illegalVisibleSpace},
-  {illegal: '\xa0', annotation: `non-breaking space`, ...illegalVisibleSpace},
-];
-
-for (const entry of illegalContentSpec) {
-  entry.test = string =>
-    string.startsWith(entry.illegal);
-
-  if (entry.action === 'replace') {
-    entry.enact = string =>
-      string.replaceAll(entry.illegal, entry.with);
-  }
-}
-
-const illegalContentRegexp =
-  new RegExp(
-    illegalContentSpec
-      .map(entry => entry.illegal)
-      .map(illegal => `${illegal}+`)
-      .join('|'),
-    'g');
-
-const illegalCharactersInContent =
-  illegalContentSpec
-    .map(entry => entry.illegal)
-    .join('');
-
-const legalContentNearEndRegexp =
-  new RegExp(`[^\n${illegalCharactersInContent}]+$`);
-
-const legalContentNearStartRegexp =
-  new RegExp(`^[^\n${illegalCharactersInContent}]+`);
-
-const trimWhitespaceNearBothSidesRegexp =
-  /^ +| +$/gm;
-
-const trimWhitespaceNearEndRegexp =
-  / +$/gm;
-
-export function isContentString(content) {
-  isStringNonEmpty(content);
-
-  const mainAggregate = openAggregate({
-    message: `Errors validating content string`,
-    translucent: 'single',
-  });
-
-  const illegalAggregate = openAggregate({
-    message: `Illegal characters found in content string`,
-  });
-
-  for (const {match, where} of matchMultiline(content, illegalContentRegexp)) {
-    const {annotation, action, ...options} =
-      illegalContentSpec
-        .find(entry => entry.test(match[0]));
-
-    const matchStart = match.index;
-    const matchEnd = match.index + match[0].length;
-
-    const before =
-      content
-        .slice(Math.max(0, matchStart - 3), matchStart)
-        .match(legalContentNearEndRegexp)
-        ?.[0];
-
-    const after =
-      content
-        .slice(matchEnd, Math.min(content.length, matchEnd + 3))
-        .match(legalContentNearStartRegexp)
-        ?.[0];
-
-    const beforePart =
-      before && `"${before}"`;
-
-    const afterPart =
-      after && `"${after}"`;
-
-    const surroundings =
-      (before && after
-        ? `between ${beforePart} and ${afterPart}`
-     : before
-        ? `after ${beforePart}`
-     : after
-        ? `before ${afterPart}`
-        : ``);
-
-    const illegalPart =
-      colors.red(
-        (annotation
-          ? `"${match[0]}" (${annotation})`
-          : `"${match[0]}"`));
-
-    const replacement =
-      (action === 'replace'
-        ? options.enact(match[0])
-        : null);
-
-    const replaceWithPart =
-      (action === 'replace'
-        ? colors.green(
-            (options.withAnnotation
-              ? `"${replacement}" (${options.withAnnotation})`
-              : `"${replacement}"`))
-        : null);
-
-    const actionPart =
-      (action === `delete`
-        ? `Delete ${illegalPart}`
-     : action === 'replace'
-        ? `Replace ${illegalPart} with ${replaceWithPart}`
-        : `Matched ${illegalPart}`);
-
-    const parts = [
-      actionPart,
-      surroundings,
-      `(${where})`,
-    ].filter(Boolean);
-
-    illegalAggregate.push(new TypeError(parts.join(` `)));
-  }
-
-  const isMultiline = content.includes('\n');
-
-  const trimWhitespaceAggregate = openAggregate({
-    message:
-      (isMultiline
-        ? `Whitespace found at end of line`
-        : `Whitespace found at start or end`),
-  });
-
-  const trimWhitespaceRegexp =
-    (isMultiline
-      ? trimWhitespaceNearEndRegexp
-      : trimWhitespaceNearBothSidesRegexp);
-
-  for (
-    const {match, lineNumber, columnNumber, containingLine} of
-    matchMultiline(content, trimWhitespaceRegexp, {
-      formatWhere: false,
-      getContainingLine: true,
-    })
-  ) {
-    const linePart =
-      colors.yellow(`line ${lineNumber + 1}`);
-
-    const where =
-      (match[0].length === containingLine.length
-        ? `as all of ${linePart}`
-     : columnNumber === 0
-        ? (isMultiline
-            ? `at start of ${linePart}`
-            : `at start`)
-        : (isMultiline
-            ? `at end of ${linePart}`
-            : `at end`));
-
-    const whitespacePart =
-      colors.red(`"${match[0]}"`);
-
-    const parts = [
-      `Matched ${whitespacePart}`,
-      where,
-    ];
-
-    trimWhitespaceAggregate.push(new TypeError(parts.join(` `)));
-  }
-
-  mainAggregate.call(() => illegalAggregate.close());
-  mainAggregate.call(() => trimWhitespaceAggregate.close());
-  mainAggregate.close();
-
-  return true;
-}
-
-export function isThingClass(thingClass) {
-  isFunction(thingClass);
-
-  if (!Object.hasOwn(thingClass, Symbol.for('Thing.referenceType'))) {
-    throw new TypeError(`Expected a Thing constructor, missing Thing.referenceType`);
-  }
-
-  return true;
-}
-
-export const isContribution = validateProperties({
-  who: isArtistRef,
-  what: optional(isStringNonEmpty),
-});
-
-export const isContributionList = validateArrayItems(isContribution);
-
-export const isAdditionalFile = validateProperties({
-  title: isName,
-  description: optional(isContentString),
-  files: validateArrayItems(isString),
-});
-
-export const isAdditionalFileList = validateArrayItems(isAdditionalFile);
-
-export const isTrackSection = validateProperties({
-  name: optional(isName),
-  color: optional(isColor),
-  dateOriginallyReleased: optional(isDate),
-  isDefaultTrackSection: optional(isBoolean),
-  tracks: optional(validateReferenceList('track')),
-});
-
-export const isTrackSectionList = validateArrayItems(isTrackSection);
-
-export function isDimensions(dimensions) {
-  isArray(dimensions);
-
-  if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`);
-
-  isPositive(dimensions[0]);
-  isInteger(dimensions[0]);
-  isPositive(dimensions[1]);
-  isInteger(dimensions[1]);
-
-  return true;
-}
-
-export function isDirectory(directory) {
-  isStringNonEmpty(directory);
-
-  if (directory.match(/[^a-zA-Z0-9_-]/))
-    throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`);
-
-  return true;
-}
-
-export function isDuration(duration) {
-  isNumber(duration);
-  isPositiveOrZero(duration);
-
-  return true;
-}
-
-export function isFileExtension(string) {
-  isStringNonEmpty(string);
-
-  if (string[0] === '.')
-    throw new TypeError(`Expected no dot (.) at the start of file extension`);
-
-  if (string.match(/[^a-zA-Z0-9_]/))
-    throw new TypeError(`Expected only alphanumeric and underscore`);
-
-  return true;
-}
-
-export function isLanguageCode(string) {
-  // TODO: This is a stub function because really we don't need a detailed
-  // is-language-code parser right now.
-
-  isString(string);
-
-  return true;
-}
-
-export function isName(name) {
-  return isContentString(name);
-}
-
-export function isURL(string) {
-  isStringNonEmpty(string);
-
-  new URL(string);
-
-  return true;
-}
-
-export function validateReference(type = 'track') {
-  return (ref) => {
-    isStringNonEmpty(ref);
-
-    const match = ref
-      .trim()
-      .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
-
-    if (!match) throw new TypeError(`Malformed reference`);
-
-    const {groups: {typePart, directoryPart}} = match;
-
-    if (typePart) {
-      if (typePart !== type)
-        throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`);
-
-      isDirectory(directoryPart);
-    }
-
-    isName(ref);
-
-    return true;
-  };
-}
-
-export function validateReferenceList(type = '') {
-  return validateArrayItems(validateReference(type));
-}
-
-const validateWikiData_cache = {};
-
-export function validateWikiData({
-  referenceType = '',
-  allowMixedTypes = false,
-}) {
-  if (referenceType && allowMixedTypes) {
-    throw new TypeError(`Don't specify both referenceType and allowMixedTypes`);
-  }
-
-  validateWikiData_cache[referenceType] ??= {};
-  validateWikiData_cache[referenceType][allowMixedTypes] ??= new WeakMap();
-
-  const isArrayOfObjects = validateArrayItems(isObject);
-
-  return (array) => {
-    const subcache = validateWikiData_cache[referenceType][allowMixedTypes];
-    if (subcache.has(array)) return subcache.get(array);
-
-    let OK = false;
-
-    try {
-      isArrayOfObjects(array);
-
-      if (empty(array)) {
-        OK = true; return true;
-      }
-
-      const allRefTypes = new Set();
-
-      let foundThing = false;
-      let foundOtherObject = false;
-
-      for (const object of array) {
-        const {[Symbol.for('Thing.referenceType')]: referenceType} = object.constructor;
-
-        if (referenceType === undefined) {
-          foundOtherObject = true;
-
-          // Early-exit if a Thing has been found - nothing more can be learned.
-          if (foundThing) {
-            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
-          }
-        } else {
-          foundThing = true;
-
-          // Early-exit if a non-Thing object has been found - nothing more can
-          // be learned.
-          if (foundOtherObject) {
-            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
-          }
-
-          allRefTypes.add(referenceType);
-        }
-      }
-
-      if (foundOtherObject && !foundThing) {
-        throw new TypeError(`Expected array of wiki data objects, got array of other objects`);
-      }
-
-      if (allRefTypes.size > 1) {
-        if (allowMixedTypes) {
-          OK = true; return true;
-        }
-
-        const types = () => Array.from(allRefTypes).join(', ');
-
-        if (referenceType) {
-          if (allRefTypes.has(referenceType)) {
-            allRefTypes.remove(referenceType);
-            throw new TypeError(`Expected array of only ${referenceType}, also got other types: ${types()}`)
-          } else {
-            throw new TypeError(`Expected array of only ${referenceType}, got other types: ${types()}`);
-          }
-        }
-
-        throw new TypeError(`Expected array of unmixed reference types, got multiple: ${types()}`);
-      }
-
-      const onlyRefType = Array.from(allRefTypes)[0];
-
-      if (referenceType && onlyRefType !== referenceType) {
-        throw new TypeError(`Expected array of ${referenceType}, got array of ${onlyRefType}`)
-      }
-
-      OK = true; return true;
-    } finally {
-      subcache.set(array, OK);
-    }
-  };
-}
-
-export const isAdditionalName = validateProperties({
-  name: isName,
-  annotation: optional(isContentString),
-
-  // TODO: This only allows indicating sourcing from a track.
-  // That's okay for the current limited use of "from", but
-  // could be expanded later.
-  from:
-    // Double TODO: Explicitly allowing both references and
-    // live objects to co-exist is definitely weird, and
-    // altogether questions the way we define validators...
-    optional(anyOf(
-      validateReferenceList('track'),
-      validateWikiData({referenceType: 'track'}))),
-});
-
-export const isAdditionalNameList = validateArrayItems(isAdditionalName);
-
-// Compositional utilities
-
-export function anyOf(...validators) {
-  const validConstants = new Set();
-  const validConstructors = new Set();
-  const validTypes = new Set();
-
-  const constantValidators = [];
-  const constructorValidators = [];
-  const typeValidators = [];
-
-  const leftoverValidators = [];
-
-  for (const validator of validators) {
-    const creator = getValidatorCreator(validator);
-    const creatorMeta = getValidatorCreatorMeta(validator);
-
-    switch (creator) {
-      case is:
-        for (const value of creatorMeta.values) {
-          validConstants.add(value);
-        }
-
-        constantValidators.push(validator);
-        break;
-
-      case validateInstanceOf:
-        validConstructors.add(creatorMeta.constructor);
-        constructorValidators.push(validator);
-        break;
-
-      case validateType:
-        validTypes.add(creatorMeta.type);
-        typeValidators.push(validator);
-        break;
-
-      default:
-        leftoverValidators.push(validator);
-        break;
-    }
-  }
-
-  return (value) => {
-    const errorInfo = [];
-
-    if (validConstants.has(value)) {
-      return true;
-    }
-
-    if (!empty(validTypes)) {
-      if (validTypes.has(typeof value)) {
-        return true;
-      }
-    }
-
-    for (const constructor of validConstructors) {
-      if (value instanceof constructor) {
-        return true;
-      }
-    }
-
-    for (const [i, validator] of leftoverValidators.entries()) {
-      try {
-        const result = validator(value);
-
-        if (result !== true) {
-          throw new Error(`Check returned false`);
-        }
-
-        return true;
-      } catch (error) {
-        errorInfo.push([validator, i, error]);
-      }
-    }
-
-    // Don't process error messages until every validator has failed.
-
-    const errors = [];
-    const prefaceErrorInfo = [];
-
-    let offset = 0;
-
-    if (!empty(validConstants)) {
-      const constants =
-        Array.from(validConstants);
-
-      const gotPart = `, got ${value}`;
-
-      prefaceErrorInfo.push([
-        constantValidators,
-        offset++,
-        new TypeError(
-          `Expected any of ${constants.join(' ')}` + gotPart),
-      ]);
-    }
-
-    if (!empty(validTypes)) {
-      const types =
-        Array.from(validTypes);
-
-      const gotType = typeAppearance(value);
-      const gotPart = `, got ${gotType}`;
-
-      prefaceErrorInfo.push([
-        typeValidators,
-        offset++,
-        new TypeError(
-          `Expected any of ${types.join(', ')}` + gotPart),
-      ]);
-    }
-
-    if (!empty(validConstructors)) {
-      const names =
-        Array.from(validConstructors)
-          .map(constructor => constructor.name);
-
-      const gotName = value?.constructor?.name;
-      const gotPart = (gotName ? `, got ${gotName}` : ``);
-
-      prefaceErrorInfo.push([
-        constructorValidators,
-        offset++,
-        new TypeError(
-          `Expected any of ${names.join(', ')}` + gotPart),
-      ]);
-    }
-
-    for (const info of errorInfo) {
-      info[1] += offset;
-    }
-
-    for (const [validator, i, error] of prefaceErrorInfo.concat(errorInfo)) {
-      error.message =
-        (validator?.name
-          ? `${i + 1}. "${validator.name}": ${error.message}`
-          : `${i + 1}. ${error.message}`);
-
-      error.check =
-        (Array.isArray(validator) && validator.length === 1
-          ? validator[0]
-          : validator);
-
-      errors.push(error);
-    }
-
-    const total = offset + leftoverValidators.length;
-    throw new AggregateError(errors,
-      `Expected any of ${total} possible checks, ` +
-      `but none were true`);
-  };
-}
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 8079355..fd6c239 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -1,16 +1,10 @@
 import {input} from '#composite';
 import find from '#find';
+import Thing from '#thing';
 import {isColor, isLanguageCode, isName, isURL} from '#validators';
 
-import {
-  contentString,
-  flag,
-  name,
-  referenceList,
-  wikiData,
-} from '#composite/wiki-properties';
-
-import Thing from './thing.js';
+import {contentString, flag, name, referenceList, wikiData}
+  from '#composite/wiki-properties';
 
 export class WikiInfo extends Thing {
   static [Thing.friendlyName] = `Wiki Info`;
@@ -76,20 +70,20 @@ export class WikiInfo extends Thing {
   });
 
   static [Thing.yamlDocumentSpec] = {
-    propertyFieldMapping: {
-      name: 'Name',
-      nameShort: 'Short Name',
-      color: 'Color',
-      description: 'Description',
-      footerContent: 'Footer Content',
-      defaultLanguage: 'Default Language',
-      canonicalBase: 'Canonical Base',
-      divideTrackListsByGroups: 'Divide Track Lists By Groups',
-      enableFlashesAndGames: 'Enable Flashes & Games',
-      enableListings: 'Enable Listings',
-      enableNews: 'Enable News',
-      enableArtTagUI: 'Enable Art Tag UI',
-      enableGroupUI: 'Enable Group UI',
+    fields: {
+      'Name': {property: 'name'},
+      'Short Name': {property: 'nameShort'},
+      'Color': {property: 'color'},
+      'Description': {property: 'description'},
+      'Footer Content': {property: 'footerContent'},
+      'Default Language': {property: 'defaultLanguage'},
+      'Canonical Base': {property: 'canonicalBase'},
+      'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'},
+      'Enable Flashes & Games': {property: 'enableFlashesAndGames'},
+      'Enable Listings': {property: 'enableListings'},
+      'Enable News': {property: 'enableNews'},
+      'Enable Art Tag UI': {property: 'enableArtTagUI'},
+      'Enable Group UI': {property: 'enableGroupUI'},
     },
   };
 }