« get me outta code hell

yaml, data: store document specs statically on Thing subclasses - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2024-01-20 16:13:36 -0400
committer(quasar) nebula <qznebula@protonmail.com>2024-01-30 07:59:34 -0400
commit296a4961a951e44ea53509391ad225d1491197f9 (patch)
tree4bdedf0f85b7af8d3039bb46ccfd2f1f600db5ce /src
parentac277f23abe0d8432a94f72913f4421b0eebaa62 (diff)
yaml, data: store document specs statically on Thing subclasses
Diffstat (limited to 'src')
-rw-r--r--src/data/things/album.js79
-rw-r--r--src/data/things/art-tag.js11
-rw-r--r--src/data/things/artist.js20
-rw-r--r--src/data/things/flash.js43
-rw-r--r--src/data/things/group.js20
-rw-r--r--src/data/things/homepage-layout.js29
-rw-r--r--src/data/things/news-entry.js13
-rw-r--r--src/data/things/static-page.js14
-rw-r--r--src/data/things/thing.js42
-rw-r--r--src/data/things/track.js95
-rw-r--r--src/data/things/wiki-info.js18
-rw-r--r--src/data/yaml.js481
12 files changed, 458 insertions, 407 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js
index e48ad41a..02d34544 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -25,10 +25,13 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
+import {withTracks, withTrackSections} from '#composite/things/album';
+
 import {
-  withTracks,
-  withTrackSections,
-} from '#composite/things/album';
+  parseAdditionalFiles,
+  parseContributors,
+  parseDimensions,
+} from '#yaml';
 
 import Thing from './thing.js';
 
@@ -200,6 +203,64 @@ export class Album extends Thing {
     artTags: S.toRefs,
     commentatorArtists: S.toRefs,
   });
+
+  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',
+    },
+
+    ignoredFields: ['Review Points'],
+  };
 }
 
 export class TrackSectionHelper extends Thing {
@@ -211,4 +272,16 @@ export class TrackSectionHelper extends Thing {
     dateOriginallyReleased: simpleDate(),
     isDefaultTrackGroup: flag(false),
   })
+
+  static [Thing.yamlDocumentSpec] = {
+    fieldTransformations: {
+      'Date Originally Released': (value) => new Date(value),
+    },
+
+    propertyFieldMapping: {
+      name: 'Section',
+      color: 'Color',
+      dateOriginallyReleased: 'Date Originally Released',
+    },
+  };
 }
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index f9e5f0f3..c0b4a6d6 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -63,4 +63,15 @@ export class ArtTag extends Thing {
       },
     },
   });
+
+  static [Thing.yamlDocumentSpec] = {
+    propertyFieldMapping: {
+      name: 'Tag',
+      nameShort: 'Short Name',
+      directory: 'Directory',
+
+      color: 'Color',
+      isContentWarning: 'Is CW',
+    },
+  };
 }
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index a58cebc4..42090557 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -16,9 +16,7 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {
-  withReverseContributionList,
-} from '#composite/wiki-data';
+import {withReverseContributionList} from '#composite/wiki-data';
 
 import Thing from './thing.js';
 
@@ -242,4 +240,20 @@ export class Artist extends Thing {
 
     flashesAsContributor: S.toRefs,
   });
+
+  static [Thing.yamlDocumentSpec] = {
+    propertyFieldMapping: {
+      name: 'Artist',
+      directory: 'Directory',
+      urls: 'URLs',
+      contextNotes: 'Context Notes',
+
+      hasAvatar: 'Has Avatar',
+      avatarFileExtension: 'Avatar File Extension',
+
+      aliasNames: 'Aliases',
+    },
+
+    ignoredFields: ['Dead URLs', 'Review Points'],
+  };
 }
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index 85fe343e..d7e8bb46 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -28,6 +28,8 @@ import {
 
 import {withFlashAct} from '#composite/things/flash';
 
+import {parseContributors} from '#yaml';
+
 import Thing from './thing.js';
 
 export class Flash extends Thing {
@@ -133,6 +135,30 @@ export class Flash extends Thing {
     urls: S.id,
     color: S.id,
   });
+
+  static [Thing.yamlDocumentSpec] = {
+    fieldTransformations: {
+      'Date': (value) => new Date(value),
+
+      'Contributors': parseContributors,
+    },
+
+    propertyFieldMapping: {
+      name: 'Flash',
+      directory: 'Directory',
+      page: 'Page',
+      color: 'Color',
+      urls: 'URLs',
+
+      date: 'Date',
+      coverArtFileExtension: 'Cover Art File Extension',
+
+      featuredTracks: 'Featured Tracks',
+      contributorContribs: 'Contributors',
+    },
+
+    ignoredFields: ['Review Points'],
+  };
 }
 
 export class FlashAct extends Thing {
@@ -170,5 +196,20 @@ export class FlashAct extends Thing {
     flashData: wikiData({
       class: input.value(Flash),
     }),
-  })
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    propertyFieldMapping: {
+      name: 'Act',
+      directory: 'Directory',
+
+      color: 'Color',
+      listTerminology: 'List Terminology',
+
+      jump: 'Jump',
+      jumpColor: 'Jump Color',
+    },
+
+    ignoredFields: ['Review Points'],
+  };
 }
diff --git a/src/data/things/group.js b/src/data/things/group.js
index 38d169de..a9708fb4 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -85,6 +85,19 @@ export class Group extends Thing {
       },
     },
   });
+
+  static [Thing.yamlDocumentSpec] = {
+    propertyFieldMapping: {
+      name: 'Group',
+      directory: 'Directory',
+      description: 'Description',
+      urls: 'URLs',
+
+      featuredAlbums: 'Featured Albums',
+    },
+
+    ignoredFields: ['Review Points'],
+  };
 }
 
 export class GroupCategory extends Thing {
@@ -111,4 +124,11 @@ export class GroupCategory extends Thing {
       class: input.value(Group),
     }),
   });
+
+  static [Thing.yamlDocumentSpec] = {
+    propertyFieldMapping: {
+      name: 'Category',
+      color: 'Color',
+    },
+  };
 }
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index dd6c1d9d..b4fb97db 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -45,7 +45,16 @@ export class HomepageLayout extends Thing {
         validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)),
       },
     },
-  })
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    propertyFieldMapping: {
+      sidebarContent: 'Sidebar Content',
+      navbarLinks: 'Navbar Links',
+    },
+
+    ignoredFields: ['Homepage'],
+  };
 }
 
 export class HomepageLayoutRow extends Thing {
@@ -82,6 +91,14 @@ export class HomepageLayoutRow extends Thing {
       class: input.value(Group),
     }),
   });
+
+  static [Thing.yamlDocumentSpec] = {
+    propertyFieldMapping: {
+      name: 'Row',
+      color: 'Color',
+      type: 'Type',
+    },
+  };
 }
 
 export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
@@ -162,4 +179,14 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
       update: {validate: validateArrayItems(isString)},
     },
   });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+    propertyFieldMapping: {
+      displayStyle: 'Display Style',
+      sourceGroup: 'Group',
+      countAlbumsFromGroup: 'Count',
+      sourceAlbums: 'Albums',
+      actionLinks: 'Actions',
+    },
+  });
 }
diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js
index f220b270..06dad629 100644
--- a/src/data/things/news-entry.js
+++ b/src/data/things/news-entry.js
@@ -32,4 +32,17 @@ export class NewsEntry extends Thing {
       },
     },
   });
+
+  static [Thing.yamlDocumentSpec] = {
+    fieldTransformations: {
+      'Date': (value) => new Date(value),
+    },
+
+    propertyFieldMapping: {
+      name: 'Name',
+      directory: 'Directory',
+      date: 'Date',
+      content: 'Content',
+    },
+  };
 }
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
index d1cc5b26..00c0b09c 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -33,4 +33,18 @@ export class StaticPage extends Thing {
     stylesheet: simpleString(),
     script: simpleString(),
   });
+
+  static [Thing.yamlDocumentSpec] = {
+    propertyFieldMapping: {
+      name: 'Name',
+      nameShort: 'Short Name',
+      directory: 'Directory',
+
+      stylesheet: 'Style',
+      script: 'Script',
+      content: 'Content',
+    },
+
+    ignoredFields: ['Review Points'],
+  };
 }
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index def7e914..42971c04 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -14,6 +14,8 @@ export default class Thing extends CacheableObject {
   static getPropertyDescriptors = Symbol('Thing.getPropertyDescriptors');
   static getSerializeDescriptors = Symbol('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
@@ -38,4 +40,44 @@ export default class Thing extends CacheableObject {
 
     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 375dd81d..3621510b 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -55,6 +55,13 @@ import {
   withPropertyFromAlbum,
 } from '#composite/things/track';
 
+import {
+  parseAdditionalFiles,
+  parseAdditionalNames,
+  parseContributors,
+  parseDuration,
+} from '#yaml';
+
 import CacheableObject from './cacheable-object.js';
 import Thing from './thing.js';
 
@@ -332,6 +339,94 @@ 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,
+    },
+
+    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',
+    },
+
+    ignoredFields: ['Review Points'],
+
+    invalidFieldCombinations: [
+      {message: `Re-releases inherit references from the original`, fields: [
+        'Originally Released As',
+        'Referenced Tracks',
+      ]},
+
+      {message: `Re-releases inherit samples from the original`, fields: [
+        'Originally Released As',
+        'Sampled Tracks',
+      ]},
+
+      {message: `Re-releases inherit artists from the original`, fields: [
+        'Originally Released As',
+        'Artists',
+      ]},
+
+      {message: `Re-releases inherit contributors from the original`, fields: [
+        'Originally Released As',
+        'Contributors',
+      ]},
+
+      {
+        message: ({'Has Cover Art': hasCoverArt}) =>
+          (hasCoverArt
+            ? `"Has Cover Art: true" is inferred from cover artist credits`
+            : `Tracks without cover art must not have cover artist credits`),
+
+        fields: [
+          'Has Cover Art',
+          'Cover Artists',
+        ],
+      },
+    ],
+  };
+
   [inspect.custom](depth) {
     const parts = [];
 
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 112d454f..80793550 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -74,4 +74,22 @@ export class WikiInfo extends Thing {
       class: input.value(Group),
     }),
   });
+
+  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',
+    },
+  };
 }
diff --git a/src/data/yaml.js b/src/data/yaml.js
index dd6da697..a232970b 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -26,6 +26,7 @@ import {
   filterProperties,
   openAggregate,
   showAggregate,
+  typeAppearance,
   withAggregate,
 } from '#sugar';
 
@@ -149,7 +150,7 @@ function makeProcessDocument(
     };
   };
 
-  const fn = decorateErrorWithName((document) => {
+  return decorateErrorWithName((document) => {
     const nameField = propertyFieldMapping['name'];
     const namePart =
       (nameField
@@ -284,13 +285,6 @@ function makeProcessDocument(
 
     return {thing, aggregate};
   });
-
-  Object.assign(fn, {
-    propertyFieldMapping,
-    fieldPropertyMapping,
-  });
-
-  return fn;
 }
 
 export class UnknownFieldsError extends Error {
@@ -386,344 +380,6 @@ export class SkippedFieldsSummaryError extends Error {
   }
 }
 
-export const processAlbumDocument = makeProcessDocument(T.Album, {
-  fieldTransformations: {
-    'Artists': parseContributors,
-    'Cover Artists': parseContributors,
-    'Default Track Cover Artists': parseContributors,
-    'Wallpaper Artists': parseContributors,
-    'Banner Artists': parseContributors,
-
-    'Date': (value) => new Date(value),
-    'Date Added': (value) => new Date(value),
-    'Cover Art Date': (value) => new Date(value),
-    'Default Track Cover Art Date': (value) => new Date(value),
-
-    'Banner Dimensions': parseDimensions,
-
-    'Additional Files': parseAdditionalFiles,
-  },
-
-  propertyFieldMapping: {
-    name: 'Album',
-    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',
-  },
-
-  ignoredFields: ['Review Points'],
-});
-
-export const processTrackSectionDocument = makeProcessDocument(T.TrackSectionHelper, {
-  fieldTransformations: {
-    'Date Originally Released': (value) => new Date(value),
-  },
-
-  propertyFieldMapping: {
-    name: 'Section',
-    color: 'Color',
-    dateOriginallyReleased: 'Date Originally Released',
-  },
-});
-
-export const processTrackDocument = makeProcessDocument(T.Track, {
-  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,
-  },
-
-  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',
-  },
-
-  ignoredFields: ['Review Points'],
-
-  invalidFieldCombinations: [
-    {message: `Re-releases inherit references from the original`, fields: [
-      'Originally Released As',
-      'Referenced Tracks',
-    ]},
-
-    {message: `Re-releases inherit samples from the original`, fields: [
-      'Originally Released As',
-      'Sampled Tracks',
-    ]},
-
-    {message: `Re-releases inherit artists from the original`, fields: [
-      'Originally Released As',
-      'Artists',
-    ]},
-
-    {message: `Re-releases inherit contributors from the original`, fields: [
-      'Originally Released As',
-      'Contributors',
-    ]},
-
-    {
-      message: ({'Has Cover Art': hasCoverArt}) =>
-        (hasCoverArt
-          ? `"Has Cover Art: true" is inferred from cover artist credits`
-          : `Tracks without cover art must not have cover artist credits`),
-
-      fields: [
-        'Has Cover Art',
-        'Cover Artists',
-      ],
-    },
-  ],
-});
-
-export const processArtistDocument = makeProcessDocument(T.Artist, {
-  propertyFieldMapping: {
-    name: 'Artist',
-    directory: 'Directory',
-    urls: 'URLs',
-    contextNotes: 'Context Notes',
-
-    hasAvatar: 'Has Avatar',
-    avatarFileExtension: 'Avatar File Extension',
-
-    aliasNames: 'Aliases',
-  },
-
-  ignoredFields: ['Dead URLs', 'Review Points'],
-});
-
-export const processFlashDocument = makeProcessDocument(T.Flash, {
-  fieldTransformations: {
-    'Date': (value) => new Date(value),
-
-    'Contributors': parseContributors,
-  },
-
-  propertyFieldMapping: {
-    name: 'Flash',
-    directory: 'Directory',
-    page: 'Page',
-    color: 'Color',
-    urls: 'URLs',
-
-    date: 'Date',
-    coverArtFileExtension: 'Cover Art File Extension',
-
-    featuredTracks: 'Featured Tracks',
-    contributorContribs: 'Contributors',
-  },
-
-  ignoredFields: ['Review Points'],
-});
-
-export const processFlashActDocument = makeProcessDocument(T.FlashAct, {
-  propertyFieldMapping: {
-    name: 'Act',
-    directory: 'Directory',
-
-    color: 'Color',
-    listTerminology: 'List Terminology',
-
-    jump: 'Jump',
-    jumpColor: 'Jump Color',
-  },
-
-  ignoredFields: ['Review Points'],
-});
-
-export const processNewsEntryDocument = makeProcessDocument(T.NewsEntry, {
-  fieldTransformations: {
-    'Date': (value) => new Date(value),
-  },
-
-  propertyFieldMapping: {
-    name: 'Name',
-    directory: 'Directory',
-    date: 'Date',
-    content: 'Content',
-  },
-});
-
-export const processArtTagDocument = makeProcessDocument(T.ArtTag, {
-  propertyFieldMapping: {
-    name: 'Tag',
-    nameShort: 'Short Name',
-    directory: 'Directory',
-
-    color: 'Color',
-    isContentWarning: 'Is CW',
-  },
-
-  ignoredFields: ['Review Points'],
-});
-
-export const processGroupDocument = makeProcessDocument(T.Group, {
-  propertyFieldMapping: {
-    name: 'Group',
-    directory: 'Directory',
-    description: 'Description',
-    urls: 'URLs',
-
-    featuredAlbums: 'Featured Albums',
-  },
-
-  ignoredFields: ['Review Points'],
-});
-
-export const processGroupCategoryDocument = makeProcessDocument(T.GroupCategory, {
-  propertyFieldMapping: {
-    name: 'Category',
-    color: 'Color',
-  },
-});
-
-export const processStaticPageDocument = makeProcessDocument(T.StaticPage, {
-  propertyFieldMapping: {
-    name: 'Name',
-    nameShort: 'Short Name',
-    directory: 'Directory',
-
-    stylesheet: 'Style',
-    script: 'Script',
-    content: 'Content',
-  },
-
-  ignoredFields: ['Review Points'],
-});
-
-export const processWikiInfoDocument = makeProcessDocument(T.WikiInfo, {
-  propertyFieldMapping: {
-    name: 'Name',
-    nameShort: 'Short Name',
-    color: 'Color',
-    description: 'Description',
-    footerContent: 'Footer Content',
-    defaultLanguage: 'Default Language',
-    canonicalBase: 'Canonical Base',
-    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',
-  },
-});
-
-export const processHomepageLayoutDocument = makeProcessDocument(T.HomepageLayout, {
-  propertyFieldMapping: {
-    sidebarContent: 'Sidebar Content',
-    navbarLinks: 'Navbar Links',
-  },
-
-  ignoredFields: ['Homepage'],
-});
-
-export function makeProcessHomepageLayoutRowDocument(rowClass, spec) {
-  return makeProcessDocument(rowClass, {
-    ...spec,
-
-    propertyFieldMapping: {
-      name: 'Row',
-      color: 'Color',
-      type: 'Type',
-      ...spec.propertyFieldMapping,
-    },
-  });
-}
-
-export const homepageLayoutRowTypeProcessMapping = {
-  albums: makeProcessHomepageLayoutRowDocument(T.HomepageLayoutAlbumsRow, {
-    propertyFieldMapping: {
-      displayStyle: 'Display Style',
-      sourceGroup: 'Group',
-      countAlbumsFromGroup: 'Count',
-      sourceAlbums: 'Albums',
-      actionLinks: 'Actions',
-    },
-  }),
-};
-
-export function processHomepageLayoutRowDocument(document) {
-  const type = document['Type'];
-
-  const match = Object.entries(homepageLayoutRowTypeProcessMapping)
-    .find(([key]) => key === type);
-
-  if (!match) {
-    throw new TypeError(`No processDocument function for row type ${type}!`);
-  }
-
-  return match[1](document);
-}
-
 // --> Utilities shared across document parsing functions
 
 export function parseDuration(string) {
@@ -754,9 +410,12 @@ export function parseAdditionalFiles(array) {
   }));
 }
 
-const extractAccentRegex =
+export const extractAccentRegex =
   /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
 
+export const extractPrefixAccentRegex =
+  /^(?:\((?<accent>.*)\) )?(?<main>.*?)$/;
+
 export function parseContributors(contributionStrings) {
   // If this isn't something we can parse, just return it as-is.
   // The Thing object's validators will handle the data error better
@@ -802,7 +461,7 @@ export function parseAdditionalNames(additionalNameStrings) {
   });
 }
 
-function parseDimensions(string) {
+export function parseDimensions(string) {
   // It's technically possible to pass an array like [30, 40] through here.
   // That's not really an issue because if it isn't of the appropriate shape,
   // the Thing object's validators will handle the error.
@@ -899,13 +558,13 @@ export const documentModes = {
 //   them to each other, setting additional properties, etc). Input argument
 //   format depends on documentMode.
 //
-export const dataSteps = [
+export const getDataSteps = () => [
   {
     title: `Process wiki info file`,
     file: WIKI_INFO_FILE,
 
     documentMode: documentModes.oneDocumentTotal,
-    processDocument: processWikiInfoDocument,
+    documentThing: T.WikiInfo,
 
     save(wikiInfo) {
       if (!wikiInfo) {
@@ -926,12 +585,11 @@ export const dataSteps = [
       }),
 
     documentMode: documentModes.headerAndEntries,
-    processHeaderDocument: processAlbumDocument,
-    processEntryDocument(document) {
-      return 'Section' in document
-        ? processTrackSectionDocument(document)
-        : processTrackDocument(document);
-    },
+    headerDocumentThing: T.Album,
+    entryDocumentThing: document =>
+      ('Section' in document
+        ? T.TrackSectionHelper
+        : T.Track),
 
     save(results) {
       const albumData = [];
@@ -1000,7 +658,7 @@ export const dataSteps = [
     file: ARTIST_DATA_FILE,
 
     documentMode: documentModes.allInOne,
-    processDocument: processArtistDocument,
+    documentThing: T.Artist,
 
     save(results) {
       const artistData = results;
@@ -1027,11 +685,10 @@ export const dataSteps = [
     file: FLASH_DATA_FILE,
 
     documentMode: documentModes.allInOne,
-    processDocument(document) {
-      return 'Act' in document
-        ? processFlashActDocument(document)
-        : processFlashDocument(document);
-    },
+    documentThing: document =>
+      ('Act' in document
+        ? T.FlashAct
+        : T.Flash),
 
     save(results) {
       let flashAct;
@@ -1070,11 +727,10 @@ export const dataSteps = [
     file: GROUP_DATA_FILE,
 
     documentMode: documentModes.allInOne,
-    processDocument(document) {
-      return 'Category' in document
-        ? processGroupCategoryDocument(document)
-        : processGroupDocument(document);
-    },
+    documentThing: document =>
+      ('Category' in document
+        ? T.GroupCategory
+        : T.Group),
 
     save(results) {
       let groupCategory;
@@ -1117,8 +773,15 @@ export const dataSteps = [
     files: [HOMEPAGE_LAYOUT_DATA_FILE],
 
     documentMode: documentModes.headerAndEntries,
-    processHeaderDocument: processHomepageLayoutDocument,
-    processEntryDocument: processHomepageLayoutRowDocument,
+    headerDocumentThing: T.HomepageLayout,
+    entryDocumentThing: document => {
+      switch (document['Type']) {
+        case 'albums':
+          return T.HomepageLayoutAlbumsRow;
+        default:
+          throw new TypeError(`No processDocument function for row type ${type}!`);
+      }
+    },
 
     save(results) {
       if (!results[0]) {
@@ -1137,7 +800,7 @@ export const dataSteps = [
     file: NEWS_DATA_FILE,
 
     documentMode: documentModes.allInOne,
-    processDocument: processNewsEntryDocument,
+    documentThing: T.NewsEntry,
 
     save(newsData) {
       sortChronologically(newsData);
@@ -1152,7 +815,7 @@ export const dataSteps = [
     file: ART_TAG_DATA_FILE,
 
     documentMode: documentModes.allInOne,
-    processDocument: processArtTagDocument,
+    documentThing: T.ArtTag,
 
     save(artTagData) {
       sortAlphabetically(artTagData);
@@ -1171,7 +834,7 @@ export const dataSteps = [
       }),
 
     documentMode: documentModes.onePerFile,
-    processDocument: processStaticPageDocument,
+    documentThing: T.StaticPage,
 
     save(staticPageData) {
       sortAlphabetically(staticPageData);
@@ -1203,7 +866,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
     return decorateErrorWithFile(fn).async;
   }
 
-  for (const dataStep of dataSteps) {
+  for (const dataStep of getDataSteps()) {
     await processDataAggregate.nestAsync(
       {
         message: `Errors during data step: ${colors.bright(dataStep.title)}`,
@@ -1284,6 +947,32 @@ export async function loadAndProcessDataDocuments({dataPath}) {
           return {documents: filteredDocuments, aggregate};
         };
 
+        const processDocument = (document, thingClassOrFn) => {
+          const thingClass =
+            (thingClassOrFn.prototype instanceof Thing
+              ? thingClassOrFn
+              : thingClassOrFn(document));
+
+          if (typeof thingClass !== 'function') {
+            throw new Error(`Expected a thing class, got ${typeAppearance(thingClass)}`);
+          }
+
+          if (!(thingClass.prototype instanceof Thing)) {
+            throw new Error(`Expected a thing class, got ${thingClass.name}`);
+          }
+
+          const spec = thingClass[Thing.yamlDocumentSpec];
+
+          if (!spec) {
+            throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
+          }
+
+          // TODO: Making a function to only call it just like that is
+          // obviously pretty jank! It should be created once per data step.
+          const fn = makeProcessDocument(thingClass, spec);
+          return fn(document);
+        };
+
         if (
           documentMode === documentModes.allInOne ||
           documentMode === documentModes.oneDocumentTotal
@@ -1340,7 +1029,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
               }
 
               const {thing, aggregate} =
-                dataStep.processDocument(yamlResult);
+                processDocument(yamlResult, dataStep.documentThing);
 
               processResults = thing;
 
@@ -1366,7 +1055,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
 
               map(documents, decorateErrorWithIndex(document => {
                 const {thing, aggregate} =
-                  dataStep.processDocument(document);
+                  processDocument(document, dataStep.documentThing);
 
                 processResults.push(thing);
                 aggregate.close();
@@ -1457,7 +1146,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
 
                 withAggregate({message: `Errors processing documents`}, ({push}) => {
                   const {thing: headerObject, aggregate: headerAggregate} =
-                    dataStep.processHeaderDocument(headerDocument);
+                    processDocument(headerDocument, dataStep.headerDocumentThing);
 
                   try {
                     headerAggregate.close();
@@ -1472,7 +1161,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
                     const entryDocument = entryDocuments[index];
 
                     const {thing: entryObject, aggregate: entryAggregate} =
-                      dataStep.processEntryDocument(entryDocument);
+                      processDocument(entryDocument, dataStep.entryDocumentThing);
 
                     entryObjects.push(entryObject);
 
@@ -1502,7 +1191,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
                   throw new Error(`Expected a document, this file is empty`);
 
                 const {thing, aggregate} =
-                  dataStep.processDocument(documents[0]);
+                  processDocument(documents[0], dataStep.documentThing);
 
                 processResults.push(thing);
                 aggregate.close();
@@ -1698,7 +1387,7 @@ export function filterDuplicateDirectories(wikiData) {
 // data array.
 export function filterReferenceErrors(wikiData) {
   const referenceSpec = [
-    ['albumData', processAlbumDocument, {
+    ['albumData', {
       artistContribs: '_contrib',
       coverArtistContribs: '_contrib',
       trackCoverArtistContribs: '_contrib',
@@ -1709,25 +1398,25 @@ export function filterReferenceErrors(wikiData) {
       commentary: '_commentary',
     }],
 
-    ['groupCategoryData', processGroupCategoryDocument, {
+    ['groupCategoryData', {
       groups: 'group',
     }],
 
-    ['homepageLayout.rows', undefined, {
+    ['homepageLayout.rows', {
       sourceGroup: '_homepageSourceGroup',
       sourceAlbums: 'album',
     }],
 
-    ['flashData', processFlashDocument, {
+    ['flashData', {
       contributorContribs: '_contrib',
       featuredTracks: 'track',
     }],
 
-    ['flashActData', processFlashActDocument, {
+    ['flashActData', {
       flashes: 'flash',
     }],
 
-    ['trackData', processTrackDocument, {
+    ['trackData', {
       artistContribs: '_contrib',
       contributorContribs: '_contrib',
       coverArtistContribs: '_contrib',
@@ -1738,7 +1427,7 @@ export function filterReferenceErrors(wikiData) {
       commentary: '_commentary',
     }],
 
-    ['wikiInfo', processWikiInfoDocument, {
+    ['wikiInfo', {
       divideTrackListsByGroups: 'group',
     }],
   ];
@@ -1752,23 +1441,13 @@ export function filterReferenceErrors(wikiData) {
 
   const aggregate = openAggregate({message: `Errors validating between-thing references in data`});
   const boundFind = bindFind(wikiData, {mode: 'error'});
-  for (const [thingDataProp, providedProcessDocumentFn, propSpec] of referenceSpec) {
+  for (const [thingDataProp, propSpec] of referenceSpec) {
     const thingData = getNestedProp(wikiData, thingDataProp);
 
     aggregate.nest({message: `Reference errors in ${colors.green('wikiData.' + thingDataProp)}`}, ({nest}) => {
       const things = Array.isArray(thingData) ? thingData : [thingData];
 
       for (const thing of things) {
-        let processDocumentFn = providedProcessDocumentFn;
-
-        if (processDocumentFn === undefined) {
-          switch (thingDataProp) {
-            case 'homepageLayout.rows':
-              processDocumentFn = homepageLayoutRowTypeProcessMapping[thing.type]
-              break;
-          }
-        }
-
         nest({message: `Reference errors in ${inspect(thing)}`}, ({nest, push, filter}) => {
           for (const [property, findFnKey] of Object.entries(propSpec)) {
             let value = CacheableObject.getUpdateValue(thing, property);
@@ -1895,9 +1574,13 @@ export function filterReferenceErrors(wikiData) {
               return false;
             }, fn);
 
+            const field =
+              thing.constructor[Thing.yamlDocumentSpec]
+                .propertyFieldMapping[property];
+
             const fieldPropertyMessage =
-              (processDocumentFn?.propertyFieldMapping?.[property]
-                ? ` in field ${colors.green(processDocumentFn.propertyFieldMapping[property])}`
+              (field
+                ? ` in field ${colors.green(field)}`
                 : ` in property ${colors.green(property)}`);
 
             const findFnMessage =
@@ -1923,7 +1606,7 @@ export function filterReferenceErrors(wikiData) {
                 let hasCoverArtwork =
                   !empty(CacheableObject.getUpdateValue(thing, 'coverArtistContribs'));
 
-                if (processDocumentFn === processTrackDocument) {
+                if (thing.constructor === T.Track) {
                   if (thing.album) {
                     hasCoverArtwork ||=
                       !empty(CacheableObject.getUpdateValue(thing.album, 'trackCoverArtistContribs'));