« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data
diff options
context:
space:
mode:
Diffstat (limited to 'src/data')
-rw-r--r--src/data/things/album.js108
-rw-r--r--src/data/things/art-tag.js20
-rw-r--r--src/data/things/artist.js35
-rw-r--r--src/data/things/cacheable-object.js4
-rw-r--r--src/data/things/composite.js22
-rw-r--r--src/data/things/flash.js50
-rw-r--r--src/data/things/group.js37
-rw-r--r--src/data/things/homepage-layout.js29
-rw-r--r--src/data/things/language.js12
-rw-r--r--src/data/things/news-entry.js15
-rw-r--r--src/data/things/static-page.js14
-rw-r--r--src/data/things/thing.js554
-rw-r--r--src/data/things/track.js83
-rw-r--r--src/data/things/validators.js6
-rw-r--r--src/data/things/wiki-info.js34
-rw-r--r--src/data/yaml.js42
16 files changed, 599 insertions, 466 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js
index da018856..9cf58641 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -2,7 +2,25 @@ import find from '#find';
 import {empty} from '#sugar';
 import {isDate, isDimensions, isTrackSectionList} from '#validators';
 
-import Thing from './thing.js';
+import Thing, {
+  additionalFiles,
+  commentary,
+  color,
+  commentatorArtists,
+  contribsByRef,
+  contribsPresent,
+  directory,
+  dynamicContribs,
+  fileExtension,
+  flag,
+  name,
+  resolvedReferenceList,
+  referenceList,
+  simpleDate,
+  simpleString,
+  urls,
+  wikiData,
+} from './thing.js';
 
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
@@ -10,14 +28,14 @@ export class Album extends Thing {
   static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Album'),
-    color: Thing.common.color(),
-    directory: Thing.common.directory(),
-    urls: Thing.common.urls(),
+    name: name('Unnamed Album'),
+    color: color(),
+    directory: directory(),
+    urls: urls(),
 
-    date: Thing.common.simpleDate(),
-    trackArtDate: Thing.common.simpleDate(),
-    dateAddedToWiki: Thing.common.simpleDate(),
+    date: simpleDate(),
+    trackArtDate: simpleDate(),
+    dateAddedToWiki: simpleDate(),
 
     coverArtDate: {
       flags: {update: true, expose: true},
@@ -36,14 +54,14 @@ export class Album extends Thing {
       },
     },
 
-    artistContribsByRef: Thing.common.contribsByRef(),
-    coverArtistContribsByRef: Thing.common.contribsByRef(),
-    trackCoverArtistContribsByRef: Thing.common.contribsByRef(),
-    wallpaperArtistContribsByRef: Thing.common.contribsByRef(),
-    bannerArtistContribsByRef: Thing.common.contribsByRef(),
+    artistContribsByRef: contribsByRef(),
+    coverArtistContribsByRef: contribsByRef(),
+    trackCoverArtistContribsByRef: contribsByRef(),
+    wallpaperArtistContribsByRef: contribsByRef(),
+    bannerArtistContribsByRef: contribsByRef(),
 
-    groupsByRef: Thing.common.referenceList(Group),
-    artTagsByRef: Thing.common.referenceList(ArtTag),
+    groupsByRef: referenceList(Group),
+    artTagsByRef: referenceList(ArtTag),
 
     trackSections: {
       flags: {update: true, expose: true},
@@ -81,58 +99,58 @@ export class Album extends Thing {
       },
     },
 
-    coverArtFileExtension: Thing.common.fileExtension('jpg'),
-    trackCoverArtFileExtension: Thing.common.fileExtension('jpg'),
+    coverArtFileExtension: fileExtension('jpg'),
+    trackCoverArtFileExtension: fileExtension('jpg'),
 
-    wallpaperStyle: Thing.common.simpleString(),
-    wallpaperFileExtension: Thing.common.fileExtension('jpg'),
+    wallpaperStyle: simpleString(),
+    wallpaperFileExtension: fileExtension('jpg'),
 
-    bannerStyle: Thing.common.simpleString(),
-    bannerFileExtension: Thing.common.fileExtension('jpg'),
+    bannerStyle: simpleString(),
+    bannerFileExtension: fileExtension('jpg'),
     bannerDimensions: {
       flags: {update: true, expose: true},
       update: {validate: isDimensions},
     },
 
-    hasTrackNumbers: Thing.common.flag(true),
-    isListedOnHomepage: Thing.common.flag(true),
-    isListedInGalleries: Thing.common.flag(true),
+    hasTrackNumbers: flag(true),
+    isListedOnHomepage: flag(true),
+    isListedInGalleries: flag(true),
 
-    commentary: Thing.common.commentary(),
-    additionalFiles: Thing.common.additionalFiles(),
+    commentary: commentary(),
+    additionalFiles: additionalFiles(),
 
     // Update only
 
-    artistData: Thing.common.wikiData(Artist),
-    artTagData: Thing.common.wikiData(ArtTag),
-    groupData: Thing.common.wikiData(Group),
-    trackData: Thing.common.wikiData(Track),
+    artistData: wikiData(Artist),
+    artTagData: wikiData(ArtTag),
+    groupData: wikiData(Group),
+    trackData: wikiData(Track),
 
     // Expose only
 
-    artistContribs: Thing.common.dynamicContribs('artistContribsByRef'),
-    coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'),
-    trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'),
-    wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'),
-    bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'),
+    artistContribs: dynamicContribs('artistContribsByRef'),
+    coverArtistContribs: dynamicContribs('coverArtistContribsByRef'),
+    trackCoverArtistContribs: dynamicContribs('trackCoverArtistContribsByRef'),
+    wallpaperArtistContribs: dynamicContribs('wallpaperArtistContribsByRef'),
+    bannerArtistContribs: dynamicContribs('bannerArtistContribsByRef'),
 
-    commentatorArtists: Thing.common.commentatorArtists(),
+    commentatorArtists: commentatorArtists(),
 
-    groups: Thing.common.resolvedReferenceList({
+    groups: resolvedReferenceList({
       list: 'groupsByRef',
       data: 'groupData',
       find: find.group,
     }),
 
-    artTags: Thing.common.resolvedReferenceList({
+    artTags: resolvedReferenceList({
       list: 'artTagsByRef',
       data: 'artTagData',
       find: find.artTag,
     }),
 
-    hasCoverArt: Thing.common.contribsPresent('coverArtistContribsByRef'),
-    hasWallpaperArt: Thing.common.contribsPresent('wallpaperArtistContribsByRef'),
-    hasBannerArt: Thing.common.contribsPresent('bannerArtistContribsByRef'),
+    hasCoverArt: contribsPresent('coverArtistContribsByRef'),
+    hasWallpaperArt: contribsPresent('wallpaperArtistContribsByRef'),
+    hasBannerArt: contribsPresent('bannerArtistContribsByRef'),
 
     tracks: {
       flags: {expose: true},
@@ -192,9 +210,9 @@ export class Album extends Thing {
 
 export class TrackSectionHelper extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
-    name: Thing.common.name('Unnamed Track Group'),
-    color: Thing.common.color(),
-    dateOriginallyReleased: Thing.common.simpleDate(),
-    isDefaultTrackGroup: Thing.common.flag(false),
+    name: name('Unnamed Track Group'),
+    color: color(),
+    dateOriginallyReleased: simpleDate(),
+    isDefaultTrackGroup: flag(false),
   })
 }
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index 5d7d0cbf..3d65b578 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,6 +1,12 @@
 import {sortAlbumsTracksChronologically} from '#wiki-data';
 
-import Thing from './thing.js';
+import Thing, {
+  color,
+  directory,
+  flag,
+  name,
+  wikiData,
+} from './thing.js';
 
 export class ArtTag extends Thing {
   static [Thing.referenceType] = 'tag';
@@ -8,10 +14,10 @@ export class ArtTag extends Thing {
   static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Art Tag'),
-    directory: Thing.common.directory(),
-    color: Thing.common.color(),
-    isContentWarning: Thing.common.flag(false),
+    name: name('Unnamed Art Tag'),
+    directory: directory(),
+    color: color(),
+    isContentWarning: flag(false),
 
     nameShort: {
       flags: {update: true, expose: true},
@@ -25,8 +31,8 @@ export class ArtTag extends Thing {
 
     // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    trackData: Thing.common.wikiData(Track),
+    albumData: wikiData(Album),
+    trackData: wikiData(Track),
 
     // Expose only
 
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 93a1b51b..2676591a 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -1,7 +1,16 @@
 import find from '#find';
 import {isName, validateArrayItems} from '#validators';
 
-import Thing from './thing.js';
+import Thing, {
+  directory,
+  fileExtension,
+  flag,
+  name,
+  simpleString,
+  singleReference,
+  urls,
+  wikiData,
+} from './thing.js';
 
 export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
@@ -9,13 +18,13 @@ export class Artist extends Thing {
   static [Thing.getPropertyDescriptors] = ({Album, Flash, Track}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Artist'),
-    directory: Thing.common.directory(),
-    urls: Thing.common.urls(),
-    contextNotes: Thing.common.simpleString(),
+    name: name('Unnamed Artist'),
+    directory: directory(),
+    urls: urls(),
+    contextNotes: simpleString(),
 
-    hasAvatar: Thing.common.flag(false),
-    avatarFileExtension: Thing.common.fileExtension('jpg'),
+    hasAvatar: flag(false),
+    avatarFileExtension: fileExtension('jpg'),
 
     aliasNames: {
       flags: {update: true, expose: true},
@@ -23,15 +32,15 @@ export class Artist extends Thing {
       expose: {transform: (names) => names ?? []},
     },
 
-    isAlias: Thing.common.flag(),
-    aliasedArtistRef: Thing.common.singleReference(Artist),
+    isAlias: flag(),
+    aliasedArtistRef: singleReference(Artist),
 
     // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    artistData: Thing.common.wikiData(Artist),
-    flashData: Thing.common.wikiData(Flash),
-    trackData: Thing.common.wikiData(Track),
+    albumData: wikiData(Album),
+    artistData: wikiData(Artist),
+    flashData: wikiData(Flash),
+    trackData: wikiData(Track),
 
     // Expose only
 
diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js
index 62c23d13..92a46d66 100644
--- a/src/data/things/cacheable-object.js
+++ b/src/data/things/cacheable-object.js
@@ -76,7 +76,7 @@
 
 import {inspect as nodeInspect} from 'node:util';
 
-import {color, ENABLE_COLOR} from '#cli';
+import {colors, ENABLE_COLOR} from '#cli';
 
 function inspect(value) {
   return nodeInspect(value, {colors: ENABLE_COLOR});
@@ -183,7 +183,7 @@ export default class CacheableObject {
           }
         } catch (error) {
           error.message = [
-            `Property ${color.green(property)}`,
+            `Property ${colors.green(property)}`,
             `(${inspect(this[property])} -> ${inspect(newValue)}):`,
             error.message
           ].join(' ');
diff --git a/src/data/things/composite.js b/src/data/things/composite.js
index 5b6de901..fd52aa0f 100644
--- a/src/data/things/composite.js
+++ b/src/data/things/composite.js
@@ -1,6 +1,6 @@
 import {inspect} from 'node:util';
 
-import {color} from '#cli';
+import {colors} from '#cli';
 
 import {
   empty,
@@ -346,8 +346,8 @@ export function compositeFrom(firstArg, secondArg) {
     if (compositeFrom.debug === true) {
       const label =
         (annotation
-          ? color.dim(`[composite: ${annotation}]`)
-          : color.dim(`[composite]`));
+          ? colors.dim(`[composite: ${annotation}]`)
+          : colors.dim(`[composite]`));
       const result = fn();
       if (Array.isArray(result)) {
         console.log(label, ...result.map(value =>
@@ -594,9 +594,9 @@ export function compositeFrom(firstArg, secondArg) {
     const availableDependencies = {...initialDependencies};
 
     if (expectingTransform) {
-      debug(() => [color.bright(`begin composition - transforming from:`), initialValue]);
+      debug(() => [colors.bright(`begin composition - transforming from:`), initialValue]);
     } else {
-      debug(() => color.bright(`begin composition - not transforming`));
+      debug(() => colors.bright(`begin composition - not transforming`));
     }
 
     for (let i = 0; i < steps.length; i++) {
@@ -641,7 +641,7 @@ export function compositeFrom(firstArg, secondArg) {
           throw new TypeError(`Inferred early-exit is disallowed in nested compositions`);
         }
 
-        debug(() => color.bright(`end composition - exit (inferred)`));
+        debug(() => colors.bright(`end composition - exit (inferred)`));
 
         return result;
       }
@@ -652,7 +652,7 @@ export function compositeFrom(firstArg, secondArg) {
         const {providedValue} = continuationStorage;
 
         debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]);
-        debug(() => color.bright(`end composition - exit (explicit)`));
+        debug(() => colors.bright(`end composition - exit (explicit)`));
 
         if (baseComposes) {
           return continuationIfApplicable.exit(providedValue);
@@ -708,17 +708,17 @@ export function compositeFrom(firstArg, secondArg) {
         case 'raise':
           debug(() =>
             (isBase
-              ? color.bright(`end composition - raise (base: explicit)`)
-              : color.bright(`end composition - raise`)));
+              ? colors.bright(`end composition - raise (base: explicit)`)
+              : colors.bright(`end composition - raise`)));
           return continuationIfApplicable(...continuationArgs);
 
         case 'raiseAbove':
-          debug(() => color.bright(`end composition - raiseAbove`));
+          debug(() => colors.bright(`end composition - raiseAbove`));
           return continuationIfApplicable.raise(...continuationArgs);
 
         case 'continuation':
           if (isBase) {
-            debug(() => color.bright(`end composition - raise (inferred)`));
+            debug(() => colors.bright(`end composition - raise (inferred)`));
             return continuationIfApplicable(...continuationArgs);
           } else {
             Object.assign(availableDependencies, continuingWithDependencies);
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index ce2e7fac..4e640dac 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -8,7 +8,19 @@ import {
   oneOf,
 } from '#validators';
 
-import Thing from './thing.js';
+import Thing, {
+  dynamicContribs,
+  color,
+  contribsByRef,
+  fileExtension,
+  name,
+  referenceList,
+  resolvedReferenceList,
+  simpleDate,
+  simpleString,
+  urls,
+  wikiData,
+} from './thing.js';
 
 export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
@@ -16,7 +28,7 @@ export class Flash extends Thing {
   static [Thing.getPropertyDescriptors] = ({Artist, Track, FlashAct}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Flash'),
+    name: name('Unnamed Flash'),
 
     directory: {
       flags: {update: true, expose: true},
@@ -44,27 +56,27 @@ export class Flash extends Thing {
       },
     },
 
-    date: Thing.common.simpleDate(),
+    date: simpleDate(),
 
-    coverArtFileExtension: Thing.common.fileExtension('jpg'),
+    coverArtFileExtension: fileExtension('jpg'),
 
-    contributorContribsByRef: Thing.common.contribsByRef(),
+    contributorContribsByRef: contribsByRef(),
 
-    featuredTracksByRef: Thing.common.referenceList(Track),
+    featuredTracksByRef: referenceList(Track),
 
-    urls: Thing.common.urls(),
+    urls: urls(),
 
     // Update only
 
-    artistData: Thing.common.wikiData(Artist),
-    trackData: Thing.common.wikiData(Track),
-    flashActData: Thing.common.wikiData(FlashAct),
+    artistData: wikiData(Artist),
+    trackData: wikiData(Track),
+    flashActData: wikiData(FlashAct),
 
     // Expose only
 
-    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
+    contributorContribs: dynamicContribs('contributorContribsByRef'),
 
-    featuredTracks: Thing.common.resolvedReferenceList({
+    featuredTracks: resolvedReferenceList({
       list: 'featuredTracksByRef',
       data: 'trackData',
       find: find.track,
@@ -111,10 +123,10 @@ export class FlashAct extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Flash Act'),
-    color: Thing.common.color(),
-    anchor: Thing.common.simpleString(),
-    jump: Thing.common.simpleString(),
+    name: name('Unnamed Flash Act'),
+    color: color(),
+    anchor: simpleString(),
+    jump: simpleString(),
 
     jumpColor: {
       flags: {update: true, expose: true},
@@ -126,15 +138,15 @@ export class FlashAct extends Thing {
       }
     },
 
-    flashesByRef: Thing.common.referenceList(Flash),
+    flashesByRef: referenceList(Flash),
 
     // Update only
 
-    flashData: Thing.common.wikiData(Flash),
+    flashData: wikiData(Flash),
 
     // Expose only
 
-    flashes: Thing.common.resolvedReferenceList({
+    flashes: resolvedReferenceList({
       list: 'flashesByRef',
       data: 'flashData',
       find: find.flash,
diff --git a/src/data/things/group.js b/src/data/things/group.js
index 6c712847..873c6d88 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -1,6 +1,15 @@
 import find from '#find';
 
-import Thing from './thing.js';
+import Thing, {
+  color,
+  directory,
+  name,
+  referenceList,
+  resolvedReferenceList,
+  simpleString,
+  urls,
+  wikiData,
+} from './thing.js';
 
 export class Group extends Thing {
   static [Thing.referenceType] = 'group';
@@ -8,23 +17,23 @@ export class Group extends Thing {
   static [Thing.getPropertyDescriptors] = ({Album}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Group'),
-    directory: Thing.common.directory(),
+    name: name('Unnamed Group'),
+    directory: directory(),
 
-    description: Thing.common.simpleString(),
+    description: simpleString(),
 
-    urls: Thing.common.urls(),
+    urls: urls(),
 
-    featuredAlbumsByRef: Thing.common.referenceList(Album),
+    featuredAlbumsByRef: referenceList(Album),
 
     // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    groupCategoryData: Thing.common.wikiData(GroupCategory),
+    albumData: wikiData(Album),
+    groupCategoryData: wikiData(GroupCategory),
 
     // Expose only
 
-    featuredAlbums: Thing.common.resolvedReferenceList({
+    featuredAlbums: resolvedReferenceList({
       list: 'featuredAlbumsByRef',
       data: 'albumData',
       find: find.album,
@@ -77,18 +86,18 @@ export class GroupCategory extends Thing {
   static [Thing.getPropertyDescriptors] = ({Group}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Group Category'),
-    color: Thing.common.color(),
+    name: name('Unnamed Group Category'),
+    color: color(),
 
-    groupsByRef: Thing.common.referenceList(Group),
+    groupsByRef: referenceList(Group),
 
     // Update only
 
-    groupData: Thing.common.wikiData(Group),
+    groupData: wikiData(Group),
 
     // Expose only
 
-    groups: Thing.common.resolvedReferenceList({
+    groups: resolvedReferenceList({
       list: 'groupsByRef',
       data: 'groupData',
       find: find.group,
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index 59656b41..ab6f4cff 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -9,13 +9,22 @@ import {
   validateInstanceOf,
 } from '#validators';
 
-import Thing from './thing.js';
+import Thing, {
+  color,
+  name,
+  referenceList,
+  resolvedReference,
+  resolvedReferenceList,
+  simpleString,
+  singleReference,
+  wikiData,
+} from './thing.js';
 
 export class HomepageLayout extends Thing {
   static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({
     // Update & expose
 
-    sidebarContent: Thing.common.simpleString(),
+    sidebarContent: simpleString(),
 
     navbarLinks: {
       flags: {update: true, expose: true},
@@ -36,7 +45,7 @@ export class HomepageLayoutRow extends Thing {
   static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Homepage Row'),
+    name: name('Unnamed Homepage Row'),
 
     type: {
       flags: {update: true, expose: true},
@@ -48,15 +57,15 @@ export class HomepageLayoutRow extends Thing {
       },
     },
 
-    color: Thing.common.color(),
+    color: color(),
 
     // Update only
 
     // These aren't necessarily used by every HomepageLayoutRow subclass, but
     // for convenience of providing this data, every row accepts all wiki data
     // arrays depended upon by any subclass's behavior.
-    albumData: Thing.common.wikiData(Album),
-    groupData: Thing.common.wikiData(Group),
+    albumData: wikiData(Album),
+    groupData: wikiData(Group),
   });
 }
 
@@ -92,8 +101,8 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
       },
     },
 
-    sourceGroupByRef: Thing.common.singleReference(Group),
-    sourceAlbumsByRef: Thing.common.referenceList(Album),
+    sourceGroupByRef: singleReference(Group),
+    sourceAlbumsByRef: referenceList(Album),
 
     countAlbumsFromGroup: {
       flags: {update: true, expose: true},
@@ -107,13 +116,13 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
 
     // Expose only
 
-    sourceGroup: Thing.common.resolvedReference({
+    sourceGroup: resolvedReference({
       ref: 'sourceGroupByRef',
       data: 'groupData',
       find: find.group,
     }),
 
-    sourceAlbums: Thing.common.resolvedReferenceList({
+    sourceAlbums: resolvedReferenceList({
       list: 'sourceAlbumsByRef',
       data: 'albumData',
       find: find.album,
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 0638afa2..c98495dc 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,6 +1,10 @@
 import {isLanguageCode} from '#validators';
 
-import Thing from './thing.js';
+import Thing, {
+  externalFunction,
+  flag,
+  simpleString,
+} from './thing.js';
 
 export class Language extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
@@ -16,7 +20,7 @@ export class Language extends Thing {
 
     // Human-readable name. This should be the language's own native name, not
     // localized to any other language.
-    name: Thing.common.simpleString(),
+    name: simpleString(),
 
     // Language code specific to JavaScript's Internationalization (Intl) API.
     // Usually this will be the same as the language's general code, but it
@@ -38,7 +42,7 @@ export class Language extends Thing {
     // with languages that are currently in development and not ready for
     // formal release, or which are just kept hidden as "experimental zones"
     // for wiki development or content testing.
-    hidden: Thing.common.flag(false),
+    hidden: flag(false),
 
     // Mapping of translation keys to values (strings). Generally, don't
     // access this object directly - use methods instead.
@@ -66,7 +70,7 @@ export class Language extends Thing {
 
     // Update only
 
-    escapeHTML: Thing.common.externalFunction(),
+    escapeHTML: externalFunction(),
 
     // Expose only
 
diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js
index 43911410..6984874e 100644
--- a/src/data/things/news-entry.js
+++ b/src/data/things/news-entry.js
@@ -1,4 +1,9 @@
-import Thing from './thing.js';
+import Thing, {
+  directory,
+  name,
+  simpleDate,
+  simpleString,
+} from './thing.js';
 
 export class NewsEntry extends Thing {
   static [Thing.referenceType] = 'news-entry';
@@ -6,11 +11,11 @@ export class NewsEntry extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed News Entry'),
-    directory: Thing.common.directory(),
-    date: Thing.common.simpleDate(),
+    name: name('Unnamed News Entry'),
+    directory: directory(),
+    date: simpleDate(),
 
-    content: Thing.common.simpleString(),
+    content: simpleString(),
 
     // Expose only
 
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
index ae0ca420..0133e0b6 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -1,6 +1,10 @@
 import {isName} from '#validators';
 
-import Thing from './thing.js';
+import Thing, {
+  directory,
+  name,
+  simpleString,
+} from './thing.js';
 
 export class StaticPage extends Thing {
   static [Thing.referenceType] = 'static';
@@ -8,7 +12,7 @@ export class StaticPage extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Static Page'),
+    name: name('Unnamed Static Page'),
 
     nameShort: {
       flags: {update: true, expose: true},
@@ -20,8 +24,8 @@ export class StaticPage extends Thing {
       },
     },
 
-    directory: Thing.common.directory(),
-    content: Thing.common.simpleString(),
-    stylesheet: Thing.common.simpleString(),
+    directory: directory(),
+    content: simpleString(),
+    stylesheet: simpleString(),
   });
 }
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index 98dec3c3..19f00b3e 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -3,7 +3,7 @@
 
 import {inspect} from 'node:util';
 
-import {color} from '#cli';
+import {colors} from '#cli';
 import find from '#find';
 import {empty, stitchArrays} from '#sugar';
 import {filterMultipleArrays, getKebabCase} from '#wiki-data';
@@ -41,297 +41,329 @@ export default class Thing extends CacheableObject {
   static getPropertyDescriptors = Symbol('Thing.getPropertyDescriptors');
   static getSerializeDescriptors = Symbol('Thing.getSerializeDescriptors');
 
-  // Regularly reused property descriptors, for ease of access and generally
-  // duplicating less code across wiki data types. These are specialized utility
-  // functions, so check each for how its own arguments behave!
-  static common = {
-    name: (defaultName) => ({
-      flags: {update: true, expose: true},
-      update: {validate: isName, default: defaultName},
-    }),
+  // 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;
 
-    color: () => ({
-      flags: {update: true, expose: true},
-      update: {validate: isColor},
-    }),
+    return (
+      (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) +
+      (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '')
+    );
+  }
 
-    directory: () => ({
-      flags: {update: true, expose: true},
-      update: {validate: isDirectory},
-      expose: {
-        dependencies: ['name'],
-        transform(directory, {name}) {
-          if (directory === null && name === null) return null;
-          else if (directory === null) return getKebabCase(name);
-          else return directory;
-        },
-      },
-    }),
+  static getReference(thing) {
+    if (!thing.constructor[Thing.referenceType]) {
+      throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`);
+    }
 
-    urls: () => ({
-      flags: {update: true, expose: true},
-      update: {validate: validateArrayItems(isURL)},
-      expose: {transform: (value) => value ?? []},
-    }),
+    if (!thing.directory) {
+      throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
+    }
 
-    // A file extension! Or the default, if provided when calling this.
-    fileExtension: (defaultFileExtension = null) => ({
-      flags: {update: true, expose: true},
-      update: {validate: isFileExtension},
-      expose: {transform: (value) => value ?? defaultFileExtension},
-    }),
+    return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
+  }
+}
+
+// Property descriptor templates
+//
+// Regularly reused property descriptors, for ease of access and generally
+// duplicating less code across wiki data types. These are specialized utility
+// functions, so check each for how its own arguments behave!
+
+export function name(defaultName) {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isName, default: defaultName},
+  };
+}
+
+export function color() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isColor},
+  };
+}
 
-    // Straightforward flag descriptor for a variety of property purposes.
-    // Provide a default value, true or false!
-    flag: (defaultValue = false) => {
-      if (typeof defaultValue !== 'boolean') {
-        throw new TypeError(`Always set explicit defaults for flags!`);
-      }
-
-      return {
-        flags: {update: true, expose: true},
-        update: {validate: isBoolean, default: defaultValue},
-      };
+export function directory() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDirectory},
+    expose: {
+      dependencies: ['name'],
+      transform(directory, {name}) {
+        if (directory === null && name === null) return null;
+        else if (directory === null) return getKebabCase(name);
+        else return directory;
+      },
     },
+  };
+}
 
-    // General date type, used as the descriptor for a bunch of properties.
-    // This isn't dynamic though - it won't inherit from a date stored on
-    // another object, for example.
-    simpleDate: () => ({
-      flags: {update: true, expose: true},
-      update: {validate: isDate},
-    }),
+export function urls() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: validateArrayItems(isURL)},
+    expose: {transform: (value) => value ?? []},
+  };
+}
 
-    // General string type. This should probably generally be avoided in favor
-    // of more specific validation, but using it makes it easy to find where we
-    // might want to improve later, and it's a useful shorthand meanwhile.
-    simpleString: () => ({
-      flags: {update: true, expose: true},
-      update: {validate: isString},
-    }),
+// A file extension! Or the default, if provided when calling this.
+export function fileExtension(defaultFileExtension = null) {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isFileExtension},
+    expose: {transform: (value) => value ?? defaultFileExtension},
+  };
+}
 
-    // External function. These should only be used as dependencies for other
-    // properties, so they're left unexposed.
-    externalFunction: () => ({
-      flags: {update: true},
-      update: {validate: (t) => typeof t === 'function'},
-    }),
+// Straightforward flag descriptor for a variety of property purposes.
+// Provide a default value, true or false!
+export function flag(defaultValue = false) {
+  // TODO:                        ^ Are you actually kidding me
+  if (typeof defaultValue !== 'boolean') {
+    throw new TypeError(`Always set explicit defaults for flags!`);
+  }
 
-    // Super simple "contributions by reference" list, used for a variety of
-    // properties (Artists, Cover Artists, etc). This is the property which is
-    // externally provided, in the form:
-    //
-    //     [
-    //         {who: 'Artist Name', what: 'Viola'},
-    //         {who: 'artist:john-cena', what: null},
-    //         ...
-    //     ]
-    //
-    // ...processed from YAML, spreadsheet, or any other kind of input.
-    contribsByRef: () => ({
-      flags: {update: true, expose: true},
-      update: {validate: isContributionList},
-    }),
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isBoolean, default: defaultValue},
+  };
+}
 
-    // Artist commentary! Generally present on tracks and albums.
-    commentary: () => ({
-      flags: {update: true, expose: true},
-      update: {validate: isCommentary},
-    }),
+// General date type, used as the descriptor for a bunch of properties.
+// This isn't dynamic though - it won't inherit from a date stored on
+// another object, for example.
+export function simpleDate() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDate},
+  };
+}
 
-    // This is a somewhat more involved data structure - it's for additional
-    // or "bonus" files associated with albums or tracks (or anything else).
-    // It's got this form:
-    //
-    //     [
-    //         {title: 'Booklet', files: ['Booklet.pdf']},
-    //         {
-    //             title: 'Wallpaper',
-    //             description: 'Cool Wallpaper!',
-    //             files: ['1440x900.png', '1920x1080.png']
-    //         },
-    //         {title: 'Alternate Covers', description: null, files: [...]},
-    //         ...
-    //     ]
-    //
-    additionalFiles: () => ({
-      flags: {update: true, expose: true},
-      update: {validate: isAdditionalFileList},
-      expose: {
-        transform: (additionalFiles) =>
-          additionalFiles ?? [],
-      },
-    }),
+// General string type. This should probably generally be avoided in favor
+// of more specific validation, but using it makes it easy to find where we
+// might want to improve later, and it's a useful shorthand meanwhile.
+export function simpleString() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isString},
+  };
+}
 
-    // A reference list! Keep in mind this is for general references to wiki
-    // objects of (usually) other Thing subclasses, not specifically leitmotif
-    // references in tracks (although that property uses referenceList too!).
-    //
-    // The underlying function validateReferenceList expects a string like
-    // 'artist' or 'track', but this utility keeps from having to hard-code the
-    // string in multiple places by referencing the value saved on the class
-    // instead.
-    referenceList: (thingClass) => {
-      const {[Thing.referenceType]: referenceType} = thingClass;
-      if (!referenceType) {
-        throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
-      }
-
-      return {
-        flags: {update: true, expose: true},
-        update: {validate: validateReferenceList(referenceType)},
-      };
-    },
+// External function. These should only be used as dependencies for other
+// properties, so they're left unexposed.
+export function externalFunction() {
+  return {
+    flags: {update: true},
+    update: {validate: (t) => typeof t === 'function'},
+  };
+}
 
-    // Corresponding function for a single reference.
-    singleReference: (thingClass) => {
-      const {[Thing.referenceType]: referenceType} = thingClass;
-      if (!referenceType) {
-        throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
-      }
-
-      return {
-        flags: {update: true, expose: true},
-        update: {validate: validateReference(referenceType)},
-      };
-    },
+// Super simple "contributions by reference" list, used for a variety of
+// properties (Artists, Cover Artists, etc). This is the property which is
+// externally provided, in the form:
+//
+//     [
+//         {who: 'Artist Name', what: 'Viola'},
+//         {who: 'artist:john-cena', what: null},
+//         ...
+//     ]
+//
+// ...processed from YAML, spreadsheet, or any other kind of input.
+export function contribsByRef() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isContributionList},
+  };
+}
 
-    // Corresponding dynamic property to referenceList, which takes the values
-    // in the provided property and searches the specified wiki data for
-    // matching actual Thing-subclass objects.
-    resolvedReferenceList({list, data, find}) {
-      return compositeFrom(`Thing.common.resolvedReferenceList`, [
-        withResolvedReferenceList({
-          list, data, find,
-          notFoundMode: 'filter',
-        }),
+// Artist commentary! Generally present on tracks and albums.
+export function commentary() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isCommentary},
+  };
+}
 
-        exposeDependency({dependency: '#resolvedReferenceList'}),
-      ]);
+// This is a somewhat more involved data structure - it's for additional
+// or "bonus" files associated with albums or tracks (or anything else).
+// It's got this form:
+//
+//     [
+//         {title: 'Booklet', files: ['Booklet.pdf']},
+//         {
+//             title: 'Wallpaper',
+//             description: 'Cool Wallpaper!',
+//             files: ['1440x900.png', '1920x1080.png']
+//         },
+//         {title: 'Alternate Covers', description: null, files: [...]},
+//         ...
+//     ]
+//
+export function additionalFiles() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isAdditionalFileList},
+    expose: {
+      transform: (additionalFiles) =>
+        additionalFiles ?? [],
     },
+  };
+}
 
-    // Corresponding function for a single reference.
-    resolvedReference({ref, data, find}) {
-      return compositeFrom(`Thing.common.resolvedReference`, [
-        withResolvedReference({ref, data, find}),
-        exposeDependency({dependency: '#resolvedReference'}),
-      ]);
-    },
+// A reference list! Keep in mind this is for general references to wiki
+// objects of (usually) other Thing subclasses, not specifically leitmotif
+// references in tracks (although that property uses referenceList too!).
+//
+// The underlying function validateReferenceList expects a string like
+// 'artist' or 'track', but this utility keeps from having to hard-code the
+// string in multiple places by referencing the value saved on the class
+// instead.
+export function referenceList(thingClass) {
+  const {[Thing.referenceType]: referenceType} = thingClass;
+  if (!referenceType) {
+    throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
+  }
 
-    // Corresponding dynamic property to contribsByRef, which takes the values
-    // in the provided property and searches the object's artistData for
-    // matching actual Artist objects. The computed structure has the same form
-    // as contribsByRef, but with Artist objects instead of string references:
-    //
-    //     [
-    //         {who: (an Artist), what: 'Viola'},
-    //         {who: (an Artist), what: null},
-    //         ...
-    //     ]
-    //
-    // Contributions whose "who" values don't match anything in artistData are
-    // filtered out. (So if the list is all empty, chances are that either the
-    // reference list is somehow messed up, or artistData isn't being provided
-    // properly.)
-    dynamicContribs(contribsByRefProperty) {
-      return compositeFrom(`Thing.common.dynamicContribs`, [
-        withResolvedContribs({
-          from: contribsByRefProperty,
-          into: '#contribs',
-        }),
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: validateReferenceList(referenceType)},
+  };
+}
 
-        exposeDependency({dependency: '#contribs'}),
-      ]);
-    },
+// Corresponding function for a single reference.
+export function singleReference(thingClass) {
+  const {[Thing.referenceType]: referenceType} = thingClass;
+  if (!referenceType) {
+    throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
+  }
 
-    // Nice 'n simple shorthand for an exposed-only flag which is true when any
-    // contributions are present in the specified property.
-    contribsPresent: (contribsByRefProperty) => ({
-      flags: {expose: true},
-      expose: {
-        dependencies: [contribsByRefProperty],
-        compute({
-          [contribsByRefProperty]: contribsByRef,
-        }) {
-          return !empty(contribsByRef);
-        },
-      }
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: validateReference(referenceType)},
+  };
+}
+
+// Corresponding dynamic property to referenceList, which takes the values
+// in the provided property and searches the specified wiki data for
+// matching actual Thing-subclass objects.
+export function resolvedReferenceList({list, data, find}) {
+  return compositeFrom(`resolvedReferenceList`, [
+    withResolvedReferenceList({
+      list, data, find,
+      notFoundMode: 'filter',
     }),
 
-    // Neat little shortcut for "reversing" the reference lists stored on other
-    // things - for example, tracks specify a "referenced tracks" property, and
-    // you would use this to compute a corresponding "referenced *by* tracks"
-    // property. Naturally, the passed ref list property is of the things in the
-    // wiki data provided, not the requesting Thing itself.
-    reverseReferenceList({data, list}) {
-      return compositeFrom(`Thing.common.reverseReferenceList`, [
-        withReverseReferenceList({data, list}),
-        exposeDependency({dependency: '#reverseReferenceList'}),
-      ]);
-    },
+    exposeDependency({dependency: '#resolvedReferenceList'}),
+  ]);
+}
 
-    // General purpose wiki data constructor, for properties like artistData,
-    // trackData, etc.
-    wikiData: (thingClass) => ({
-      flags: {update: true},
-      update: {
-        validate: validateArrayItems(validateInstanceOf(thingClass)),
-      },
-    }),
+// Corresponding function for a single reference.
+export function resolvedReference({ref, data, find}) {
+  return compositeFrom(`resolvedReference`, [
+    withResolvedReference({ref, data, find}),
+    exposeDependency({dependency: '#resolvedReference'}),
+  ]);
+}
 
-    // This one's kinda tricky: it parses artist "references" from the
-    // commentary content, and finds the matching artist for each reference.
-    // This is mostly useful for credits and listings on artist pages.
-    commentatorArtists: () => ({
-      flags: {expose: true},
-
-      expose: {
-        dependencies: ['artistData', 'commentary'],
-
-        compute: ({artistData, commentary}) =>
-          artistData && commentary
-            ? Array.from(
-                new Set(
-                  Array.from(
-                    commentary
-                      .replace(/<\/?b>/g, '')
-                      .matchAll(/<i>(?<who>.*?):<\/i>/g)
-                  ).map(({groups: {who}}) =>
-                    find.artist(who, artistData, {mode: 'quiet'})
-                  )
-                )
-              )
-            : [],
-      },
+// Corresponding dynamic property to contribsByRef, which takes the values
+// in the provided property and searches the object's artistData for
+// matching actual Artist objects. The computed structure has the same form
+// as contribsByRef, but with Artist objects instead of string references:
+//
+//     [
+//         {who: (an Artist), what: 'Viola'},
+//         {who: (an Artist), what: null},
+//         ...
+//     ]
+//
+// Contributions whose "who" values don't match anything in artistData are
+// filtered out. (So if the list is all empty, chances are that either the
+// reference list is somehow messed up, or artistData isn't being provided
+// properly.)
+export function dynamicContribs(contribsByRefProperty) {
+  return compositeFrom(`dynamicContribs`, [
+    withResolvedContribs({
+      from: contribsByRefProperty,
+      into: '#contribs',
     }),
-  };
-
-  // Default custom inspect function, which may be overridden by Thing
-  // subclasses. This will be used when displaying aggregate errors and other
-  // command-line logging - it's the place to provide information useful in
-  // identifying the Thing being presented.
-  [inspect.custom]() {
-    const cname = this.constructor.name;
 
-    return (
-      (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) +
-      (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '')
-    );
-  }
+    exposeDependency({dependency: '#contribs'}),
+  ]);
+}
 
-  static getReference(thing) {
-    if (!thing.constructor[Thing.referenceType]) {
-      throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`);
+// Nice 'n simple shorthand for an exposed-only flag which is true when any
+// contributions are present in the specified property.
+export function contribsPresent(contribsByRefProperty) {
+  return {
+    flags: {expose: true},
+    expose: {
+      dependencies: [contribsByRefProperty],
+      compute({
+        [contribsByRefProperty]: contribsByRef,
+      }) {
+        return !empty(contribsByRef);
+      },
     }
+  };
+}
 
-    if (!thing.directory) {
-      throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
-    }
+// Neat little shortcut for "reversing" the reference lists stored on other
+// things - for example, tracks specify a "referenced tracks" property, and
+// you would use this to compute a corresponding "referenced *by* tracks"
+// property. Naturally, the passed ref list property is of the things in the
+// wiki data provided, not the requesting Thing itself.
+export function reverseReferenceList({data, list}) {
+  return compositeFrom(`reverseReferenceList`, [
+    withReverseReferenceList({data, list}),
+    exposeDependency({dependency: '#reverseReferenceList'}),
+  ]);
+}
 
-    return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
-  }
+// General purpose wiki data constructor, for properties like artistData,
+// trackData, etc.
+export function wikiData(thingClass) {
+  return {
+    flags: {update: true},
+    update: {
+      validate: validateArrayItems(validateInstanceOf(thingClass)),
+    },
+  };
 }
 
+// This one's kinda tricky: it parses artist "references" from the
+// commentary content, and finds the matching artist for each reference.
+// This is mostly useful for credits and listings on artist pages.
+export function commentatorArtists(){
+  return {
+    flags: {expose: true},
+
+    expose: {
+      dependencies: ['artistData', 'commentary'],
+
+      compute: ({artistData, commentary}) =>
+        artistData && commentary
+          ? Array.from(
+              new Set(
+                Array.from(
+                  commentary
+                    .replace(/<\/?b>/g, '')
+                    .matchAll(/<i>(?<who>.*?):<\/i>/g)
+                ).map(({groups: {who}}) =>
+                  find.artist(who, artistData, {mode: 'quiet'})
+                )
+              )
+            )
+          : [],
+    },
+  };
+}
+
+// Compositional utilities
+
 // Resolves the contribsByRef contained in the provided dependency,
 // providing (named by the second argument) the result. "Resolving"
 // means mapping the "who" reference of each contribution to an artist
@@ -479,14 +511,14 @@ export function withResolvedReferenceList({
   ]);
 }
 
-// Check out the info on Thing.common.reverseReferenceList!
+// Check out the info on reverseReferenceList!
 // This is its composable form.
 export function withReverseReferenceList({
   data,
   list: refListProperty,
   into = '#reverseReferenceList',
 }) {
-  return compositeFrom(`Thing.common.reverseReferenceList`, [
+  return compositeFrom(`withReverseReferenceList`, [
     exitWithoutDependency({
       dependency: data,
       value: [],
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 10b966a7..41c92092 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -1,6 +1,6 @@
 import {inspect} from 'node:util';
 
-import {color} from '#cli';
+import {colors} from '#cli';
 import find from '#find';
 import {empty} from '#sugar';
 import {isColor, isDate, isDuration, isFileExtension} from '#validators';
@@ -16,6 +16,23 @@ import {
 } from '#composite';
 
 import Thing, {
+  additionalFiles,
+  commentary,
+  commentatorArtists,
+  contribsByRef,
+  directory,
+  dynamicContribs,
+  flag,
+  name,
+  referenceList,
+  resolvedReference,
+  resolvedReferenceList,
+  reverseReferenceList,
+  simpleDate,
+  singleReference,
+  simpleString,
+  urls,
+  wikiData,
   withResolvedContribs,
   withResolvedReference,
   withReverseReferenceList,
@@ -27,24 +44,24 @@ export class Track extends Thing {
   static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Track'),
-    directory: Thing.common.directory(),
+    name: name('Unnamed Track'),
+    directory: directory(),
 
     duration: {
       flags: {update: true, expose: true},
       update: {validate: isDuration},
     },
 
-    urls: Thing.common.urls(),
-    dateFirstReleased: Thing.common.simpleDate(),
+    urls: urls(),
+    dateFirstReleased: simpleDate(),
 
-    artistContribsByRef: Thing.common.contribsByRef(),
-    contributorContribsByRef: Thing.common.contribsByRef(),
-    coverArtistContribsByRef: Thing.common.contribsByRef(),
+    artistContribsByRef: contribsByRef(),
+    contributorContribsByRef: contribsByRef(),
+    coverArtistContribsByRef: contribsByRef(),
 
-    referencedTracksByRef: Thing.common.referenceList(Track),
-    sampledTracksByRef: Thing.common.referenceList(Track),
-    artTagsByRef: Thing.common.referenceList(ArtTag),
+    referencedTracksByRef: referenceList(Track),
+    sampledTracksByRef: referenceList(Track),
+    artTagsByRef: referenceList(ArtTag),
 
     color: compositeFrom(`Track.color`, [
       exposeUpdateValueOrContinue(),
@@ -74,7 +91,7 @@ export class Track extends Thing {
     // This flag should only be used in select circumstances, i.e. to override
     // an album's trackCoverArtists. This flag supercedes that property, as well
     // as the track's own coverArtists.
-    disableUniqueCoverArt: Thing.common.flag(),
+    disableUniqueCoverArt: flag(),
 
     // File extension for track's corresponding media file. This represents the
     // track's unique cover artwork, if any, and does not inherit the extension
@@ -117,27 +134,27 @@ export class Track extends Thing {
       }),
     ]),
 
-    originalReleaseTrackByRef: Thing.common.singleReference(Track),
+    originalReleaseTrackByRef: singleReference(Track),
 
-    dataSourceAlbumByRef: Thing.common.singleReference(Album),
+    dataSourceAlbumByRef: singleReference(Album),
 
-    commentary: Thing.common.commentary(),
-    lyrics: Thing.common.simpleString(),
-    additionalFiles: Thing.common.additionalFiles(),
-    sheetMusicFiles: Thing.common.additionalFiles(),
-    midiProjectFiles: Thing.common.additionalFiles(),
+    commentary: commentary(),
+    lyrics: simpleString(),
+    additionalFiles: additionalFiles(),
+    sheetMusicFiles: additionalFiles(),
+    midiProjectFiles: additionalFiles(),
 
     // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    artistData: Thing.common.wikiData(Artist),
-    artTagData: Thing.common.wikiData(ArtTag),
-    flashData: Thing.common.wikiData(Flash),
-    trackData: Thing.common.wikiData(Track),
+    albumData: wikiData(Album),
+    artistData: wikiData(Artist),
+    artTagData: wikiData(ArtTag),
+    flashData: wikiData(Flash),
+    trackData: wikiData(Track),
 
     // Expose only
 
-    commentatorArtists: Thing.common.commentatorArtists(),
+    commentatorArtists: commentatorArtists(),
 
     album: compositeFrom(`Track.album`, [
       withAlbum(),
@@ -151,7 +168,7 @@ export class Track extends Thing {
     // not generally relevant information). It's also not guaranteed that
     // dataSourceAlbum is available (depending on the Track creator to optionally
     // provide dataSourceAlbumByRef).
-    dataSourceAlbum: Thing.common.resolvedReference({
+    dataSourceAlbum: resolvedReference({
       ref: 'dataSourceAlbumByRef',
       data: 'albumData',
       find: find.album,
@@ -226,7 +243,7 @@ export class Track extends Thing {
 
     contributorContribs: compositeFrom(`Track.contributorContribs`, [
       inheritFromOriginalRelease({property: 'contributorContribs'}),
-      Thing.common.dynamicContribs('contributorContribsByRef'),
+      dynamicContribs('contributorContribsByRef'),
     ]),
 
     // Cover artists aren't inherited from the original release, since it
@@ -260,7 +277,7 @@ export class Track extends Thing {
 
     referencedTracks: compositeFrom(`Track.referencedTracks`, [
       inheritFromOriginalRelease({property: 'referencedTracks'}),
-      Thing.common.resolvedReferenceList({
+      resolvedReferenceList({
         list: 'referencedTracksByRef',
         data: 'trackData',
         find: find.track,
@@ -269,14 +286,14 @@ export class Track extends Thing {
 
     sampledTracks: compositeFrom(`Track.sampledTracks`, [
       inheritFromOriginalRelease({property: 'sampledTracks'}),
-      Thing.common.resolvedReferenceList({
+      resolvedReferenceList({
         list: 'sampledTracksByRef',
         data: 'trackData',
         find: find.track,
       }),
     ]),
 
-    artTags: Thing.common.resolvedReferenceList({
+    artTags: resolvedReferenceList({
       list: 'artTagsByRef',
       data: 'artTagData',
       find: find.artTag,
@@ -299,7 +316,7 @@ export class Track extends Thing {
       property: 'sampledTracks',
     }),
 
-    featuredInFlashes: Thing.common.reverseReferenceList({
+    featuredInFlashes: reverseReferenceList({
       data: 'flashData',
       list: 'featuredTracks',
     }),
@@ -311,7 +328,7 @@ export class Track extends Thing {
     parts.push(Thing.prototype[inspect.custom].apply(this));
 
     if (this.originalReleaseTrackByRef) {
-      parts.unshift(`${color.yellow('[rerelease]')} `);
+      parts.unshift(`${colors.yellow('[rerelease]')} `);
     }
 
     let album;
@@ -322,7 +339,7 @@ export class Track extends Thing {
         (albumIndex === -1
           ? '#?'
           : `#${albumIndex + 1}`);
-      parts.push(` (${color.yellow(trackNum)} in ${color.green(albumName)})`);
+      parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`);
     }
 
     return parts.join('');
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
index 5748eacf..4c8f683b 100644
--- a/src/data/things/validators.js
+++ b/src/data/things/validators.js
@@ -1,6 +1,6 @@
 import {inspect as nodeInspect} from 'node:util';
 
-import {color, ENABLE_COLOR} from '#cli';
+import {colors, ENABLE_COLOR} from '#cli';
 import {withAggregate} from '#sugar';
 
 function inspect(value) {
@@ -174,7 +174,7 @@ function validateArrayItemsHelper(itemValidator) {
         throw new Error(`Expected validator to return true`);
       }
     } catch (error) {
-      error.message = `(index: ${color.yellow(`#${index}`)}, item: ${inspect(item)}) ${error.message}`;
+      error.message = `(index: ${colors.yellow(`#${index}`)}, item: ${inspect(item)}) ${error.message}`;
       throw error;
     }
   };
@@ -264,7 +264,7 @@ export function validateProperties(spec) {
           try {
             specValidator(value);
           } catch (error) {
-            error.message = `(key: ${color.green(specKey)}, value: ${inspect(value)}) ${error.message}`;
+            error.message = `(key: ${colors.green(specKey)}, value: ${inspect(value)}) ${error.message}`;
             throw error;
           }
         });
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 0ccef5ed..416b6c4e 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -1,13 +1,21 @@
 import find from '#find';
 import {isLanguageCode, isName, isURL} from '#validators';
 
-import Thing from './thing.js';
+import Thing, {
+  color,
+  flag,
+  name,
+  referenceList,
+  resolvedReferenceList,
+  simpleString,
+  wikiData,
+} from './thing.js';
 
 export class WikiInfo extends Thing {
   static [Thing.getPropertyDescriptors] = ({Group}) => ({
     // Update & expose
 
-    name: Thing.common.name('Unnamed Wiki'),
+    name: name('Unnamed Wiki'),
 
     // Displayed in nav bar.
     nameShort: {
@@ -20,12 +28,12 @@ export class WikiInfo extends Thing {
       },
     },
 
-    color: Thing.common.color(),
+    color: color(),
 
     // One-line description used for <meta rel="description"> tag.
-    description: Thing.common.simpleString(),
+    description: simpleString(),
 
-    footerContent: Thing.common.simpleString(),
+    footerContent: simpleString(),
 
     defaultLanguage: {
       flags: {update: true, expose: true},
@@ -37,22 +45,22 @@ export class WikiInfo extends Thing {
       update: {validate: isURL},
     },
 
-    divideTrackListsByGroupsByRef: Thing.common.referenceList(Group),
+    divideTrackListsByGroupsByRef: referenceList(Group),
 
     // Feature toggles
-    enableFlashesAndGames: Thing.common.flag(false),
-    enableListings: Thing.common.flag(false),
-    enableNews: Thing.common.flag(false),
-    enableArtTagUI: Thing.common.flag(false),
-    enableGroupUI: Thing.common.flag(false),
+    enableFlashesAndGames: flag(false),
+    enableListings: flag(false),
+    enableNews: flag(false),
+    enableArtTagUI: flag(false),
+    enableGroupUI: flag(false),
 
     // Update only
 
-    groupData: Thing.common.wikiData(Group),
+    groupData: wikiData(Group),
 
     // Expose only
 
-    divideTrackListsByGroups: Thing.common.resolvedReferenceList({
+    divideTrackListsByGroups: resolvedReferenceList({
       list: 'divideTrackListsByGroupsByRef',
       data: 'groupData',
       find: find.group,
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 2ad2d41d..c0aad943 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -7,7 +7,7 @@ import {inspect as nodeInspect} from 'node:util';
 
 import yaml from 'js-yaml';
 
-import {color, ENABLE_COLOR, logInfo, logWarn} from '#cli';
+import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
 import find, {bindFind} from '#find';
 import {traverse} from '#node-utils';
 import T from '#things';
@@ -137,7 +137,7 @@ function makeProcessDocument(
         const name = document[nameField];
         error.message = name
           ? `(name: ${inspect(name)}) ${error.message}`
-          : `(${color.dim(`no name found`)}) ${error.message}`;
+          : `(${colors.dim(`no name found`)}) ${error.message}`;
         throw error;
       }
     };
@@ -195,7 +195,7 @@ function makeProcessDocument(
 
     const thing = Reflect.construct(thingClass, []);
 
-    withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({call}) => {
+    withAggregate({message: `Errors applying ${colors.green(thingClass.name)} properties`}, ({call}) => {
       for (const [property, value] of Object.entries(sourceProperties)) {
         call(() => (thing[property] = value));
       }
@@ -228,7 +228,7 @@ makeProcessDocument.FieldCombinationsError = class FieldCombinationsError extend
 makeProcessDocument.FieldCombinationError = class FieldCombinationError extends Error {
   constructor(fields, message) {
     const fieldNames = Object.keys(fields);
-    const combinePart = `Don't combine ${fieldNames.map(field => color.red(field)).join(', ')}`;
+    const combinePart = `Don't combine ${fieldNames.map(field => colors.red(field)).join(', ')}`;
 
     const messagePart =
       (typeof message === 'function'
@@ -1009,7 +1009,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
       } catch (error) {
         error.message +=
           (error.message.includes('\n') ? '\n' : ' ') +
-          `(file: ${color.bright(color.blue(path.relative(dataPath, x.file)))})`;
+          `(file: ${colors.bright(colors.blue(path.relative(dataPath, x.file)))})`;
         throw error;
       }
     };
@@ -1032,7 +1032,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
         // just without the callbacks. Thank you.
         const filterBlankDocuments = documents => {
           const aggregate = openAggregate({
-            message: `Found blank documents - check for extra '${color.cyan(`---`)}'`,
+            message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`,
           });
 
           const filteredDocuments =
@@ -1076,10 +1076,10 @@ export async function loadAndProcessDataDocuments({dataPath}) {
 
               if (count === 1) {
                 const range = `#${start + 1}`;
-                parts.push(`${count} document (${color.yellow(range)}), `);
+                parts.push(`${count} document (${colors.yellow(range)}), `);
               } else {
                 const range = `#${start + 1}-${end + 1}`;
-                parts.push(`${count} documents (${color.yellow(range)}), `);
+                parts.push(`${count} documents (${colors.yellow(range)}), `);
               }
 
               if (previous === null) {
@@ -1089,7 +1089,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
               } else {
                 const previousDescription = Object.entries(previous).at(0).join(': ');
                 const nextDescription = Object.entries(next).at(0).join(': ');
-                parts.push(`between "${color.cyan(previousDescription)}" and "${color.cyan(nextDescription)}"`);
+                parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`);
               }
 
               aggregate.push(new Error(parts.join('')));
@@ -1395,7 +1395,7 @@ export function filterDuplicateDirectories(wikiData) {
   const aggregate = openAggregate({message: `Duplicate directories found`});
   for (const thingDataProp of deduplicateSpec) {
     const thingData = wikiData[thingDataProp];
-    aggregate.nest({message: `Duplicate directories found in ${color.green('wikiData.' + thingDataProp)}`}, ({call}) => {
+    aggregate.nest({message: `Duplicate directories found in ${colors.green('wikiData.' + thingDataProp)}`}, ({call}) => {
       const directoryPlaces = Object.create(null);
       const duplicateDirectories = [];
 
@@ -1421,7 +1421,7 @@ export function filterDuplicateDirectories(wikiData) {
         const places = directoryPlaces[directory];
         call(() => {
           throw new Error(
-            `Duplicate directory ${color.green(directory)}:\n` +
+            `Duplicate directory ${colors.green(directory)}:\n` +
               places.map((thing) => ` - ` + inspect(thing)).join('\n')
           );
         });
@@ -1516,7 +1516,7 @@ export function filterReferenceErrors(wikiData) {
   for (const [thingDataProp, providedProcessDocumentFn, propSpec] of referenceSpec) {
     const thingData = getNestedProp(wikiData, thingDataProp);
 
-    aggregate.nest({message: `Reference errors in ${color.green('wikiData.' + thingDataProp)}`}, ({nest}) => {
+    aggregate.nest({message: `Reference errors in ${colors.green('wikiData.' + thingDataProp)}`}, ({nest}) => {
       const things = Array.isArray(thingData) ? thingData : [thingData];
 
       for (const thing of things) {
@@ -1535,7 +1535,7 @@ export function filterReferenceErrors(wikiData) {
             const value = thing[property];
 
             if (value === undefined) {
-              push(new TypeError(`Property ${color.red(property)} isn't valid for ${color.green(thing.constructor.name)}`));
+              push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`));
               continue;
             }
 
@@ -1553,7 +1553,7 @@ export function filterReferenceErrors(wikiData) {
                     // No need to check if the original exists here. Aliases are automatically
                     // created from a field on the original, so the original certainly exists.
                     const original = find.artist(alias.aliasedArtistRef, wikiData.artistData, {mode: 'quiet'});
-                    throw new Error(`Reference ${color.red(contribRef.who)} is to an alias, should be ${color.green(original.name)}`);
+                    throw new Error(`Reference ${colors.red(contribRef.who)} is to an alias, should be ${colors.green(original.name)}`);
                   }
 
                   return boundFind.artist(contribRef.who);
@@ -1578,12 +1578,12 @@ export function filterReferenceErrors(wikiData) {
 
                     const shouldBeMessage =
                       (originalByName
-                        ? color.green(original.name)
+                        ? colors.green(original.name)
                      : original
-                        ? color.green('track:' + original.directory)
-                        : color.green(track.originalReleaseTrackByRef));
+                        ? colors.green('track:' + original.directory)
+                        : colors.green(track.originalReleaseTrackByRef));
 
-                    throw new Error(`Reference ${color.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`);
+                    throw new Error(`Reference ${colors.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`);
                   }
 
                   return track;
@@ -1614,13 +1614,13 @@ export function filterReferenceErrors(wikiData) {
 
             const fieldPropertyMessage =
               (processDocumentFn?.propertyFieldMapping?.[property]
-                ? ` in field ${color.green(processDocumentFn.propertyFieldMapping[property])}`
-                : ` in property ${color.green(property)}`);
+                ? ` in field ${colors.green(processDocumentFn.propertyFieldMapping[property])}`
+                : ` in property ${colors.green(property)}`);
 
             const findFnMessage =
               (findFnKey.startsWith('_')
                 ? ``
-                : ` (${color.green('find.' + findFnKey)})`);
+                : ` (${colors.green('find.' + findFnKey)})`);
 
             const errorMessage =
               (Array.isArray(value)