« get me outta code hell

bam (Thing subclasses: several steps, one file) - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2022-02-12 17:38:27 -0400
committer(quasar) nebula <qznebula@protonmail.com>2022-02-12 17:40:01 -0400
commit95bd943d62473e53de11cd6368e540cd48e4231a (patch)
treea6e1f0a21222eaeb01a270d8202a31616903649b
parentcfa02cb03a363c46408db7f0ec54bd3a7e4ad018 (diff)
bam (Thing subclasses: several steps, one file)
-rw-r--r--src/data/cacheable-object.js (renamed from src/thing/cacheable-object.js)4
-rw-r--r--src/data/things.js783
-rw-r--r--src/data/validators.js (renamed from src/thing/validators.js)0
-rw-r--r--src/thing/album.js270
-rw-r--r--src/thing/art-tag.js37
-rw-r--r--src/thing/artist.js48
-rw-r--r--src/thing/flash.js129
-rw-r--r--src/thing/group.js73
-rw-r--r--src/thing/homepage-layout.js99
-rw-r--r--src/thing/news-entry.js49
-rw-r--r--src/thing/static-page.js52
-rw-r--r--src/thing/structures.js1
-rw-r--r--src/thing/thing.js62
-rw-r--r--src/thing/track.js173
-rw-r--r--src/thing/wiki-info.js90
-rwxr-xr-xsrc/upd8.js36
-rw-r--r--src/util/sugar.js3
-rw-r--r--test/cacheable-object.js2
-rw-r--r--test/data-validators.js2
19 files changed, 812 insertions, 1101 deletions
diff --git a/src/thing/cacheable-object.js b/src/data/cacheable-object.js
index 9af4160..9928095 100644
--- a/src/thing/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -134,6 +134,10 @@ export default class CacheableObject {
     }
 
     #defineProperties() {
+        if (!this.constructor.propertyDescriptors) {
+            throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`);
+        }
+
         for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) {
             const { flags } = descriptor;
 
diff --git a/src/data/things.js b/src/data/things.js
new file mode 100644
index 0000000..6617601
--- /dev/null
+++ b/src/data/things.js
@@ -0,0 +1,783 @@
+// things.js: class definitions for various object types used across the wiki,
+// most of which correspond to an output page, such as Track, Album, Artist
+
+import CacheableObject from './cacheable-object.js';
+
+import {
+    isBoolean,
+    isColor,
+    isCommentary,
+    isCountingNumber,
+    isContributionList,
+    isDate,
+    isDimensions,
+    isDirectory,
+    isDuration,
+    isInstance,
+    isFileExtension,
+    isLanguageCode,
+    isName,
+    isNumber,
+    isURL,
+    isString,
+    oneOf,
+    validateArrayItems,
+    validateInstanceOf,
+    validateReference,
+    validateReferenceList,
+} from './validators.js';
+
+import {
+    getKebabCase,
+} from '../util/wiki-data.js';
+
+import find from '../util/find.js';
+
+// Stub classes (and their exports) at the top of the file - these are
+// referenced later when we actually define static class fields. We deliberately
+// define the classes and set their static fields in two separate steps so that
+// every class coexists from the outset, and can be directly referenced in field
+// definitions later.
+
+// This list also acts as a quick table of contents for this JS file - use
+// ctrl+F or similar to skip to a section.
+
+// -> Thing
+export class Thing extends CacheableObject {}
+
+// -> Album
+export class Album extends Thing {}
+export class TrackGroup extends CacheableObject {}
+
+// -> Track
+export class Track extends Thing {}
+
+// -> Artist
+export class Artist extends Thing {}
+
+// -> Group
+export class Group extends Thing {}
+export class GroupCategory extends CacheableObject {}
+
+// -> ArtTag
+export class ArtTag extends Thing {}
+
+// -> NewsEntry
+export class NewsEntry extends Thing {}
+
+// -> StaticPage
+export class StaticPage extends Thing {}
+
+// -> HomepageLayout
+export class HomepageLayout extends CacheableObject {}
+export class HomepageLayoutRow extends CacheableObject {}
+export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {}
+
+// -> Flash
+export class Flash extends Thing {}
+export class FlashAct extends CacheableObject {}
+
+// -> WikiInfo
+export class WikiInfo extends CacheableObject {}
+
+// Before initializing property descriptors, set additional independent
+// constants on the classes (which are referenced later).
+
+Thing.referenceType = Symbol('Thing.referenceType');
+
+Album[Thing.referenceType] = 'album';
+Track[Thing.referenceType] = 'track';
+Artist[Thing.referenceType] = 'artist';
+Group[Thing.referenceType] = 'group';
+ArtTag[Thing.referenceType] = 'tag';
+NewsEntry[Thing.referenceType] = 'news-entry';
+StaticPage[Thing.referenceType] = 'static';
+Flash[Thing.referenceType] = 'flash';
+
+// -> Thing: base class for wiki data types, providing wiki-specific utility
+// functions on top of essential CacheableObject behavior.
+
+// 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!
+Thing.common = {
+    name: (defaultName) => ({
+        flags: {update: true, expose: true},
+        update: {validate: isName, default: defaultName}
+    }),
+
+    color: () => ({
+        flags: {update: true, expose: true},
+        update: {validate: isColor}
+    }),
+
+    directory: () => ({
+        flags: {update: true, expose: true},
+        update: {validate: isDirectory},
+        expose: {
+            dependencies: ['name'],
+            transform(directory, { name }) {
+                if (directory === null && name === null)
+                    return null;
+                else if (directory === null)
+                    return getKebabCase(name);
+                else
+                    return directory;
+            }
+        }
+    }),
+
+    urls: () => ({
+        flags: {update: true, expose: true},
+        update: {validate: validateArrayItems(isURL)}
+    }),
+
+    // 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}
+        };
+    },
+
+    // General date type, used as the descriptor for a bunch of properties.
+    // This isn't dynamic though - it won't inherit from a date stored on
+    // another object, for example.
+    simpleDate: () => ({
+        flags: {update: true, expose: true},
+        update: {validate: isDate}
+    }),
+
+    // General string type. This should probably generally be avoided in favor
+    // of more specific validation, but using it makes it easy to find where we
+    // might want to improve later, and it's a useful shorthand meanwhile.
+    simpleString: () => ({
+        flags: {update: true, expose: true},
+        update: {validate: isString}
+    }),
+
+    // 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}
+    }),
+
+    // A reference list! Keep in mind this is for general references to wiki
+    // objects of (usually) other Thing subclasses, not specifically leitmotif
+    // references in tracks (although that property uses referenceList too!).
+    //
+    // The underlying function validateReferenceList expects a string like
+    // 'artist' or 'track', but this utility keeps from having to hard-code the
+    // string in multiple places by referencing the value saved on the class
+    // instead.
+    referenceList: thingClass => {
+        const { [Thing.referenceType]: referenceType } = thingClass;
+        if (!referenceType) {
+            throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
+        }
+
+        return {
+            flags: {update: true, expose: true},
+            update: {validate: validateReferenceList(referenceType)}
+        };
+    },
+
+    // Corresponding dynamic property to contribsByRef, which takes the values
+    // in the provided property and searches the object's artistData for
+    // matching actual Artist objects. The computed structure has the same form
+    // as contribsByRef, but with Artist objects instead of string references:
+    //
+    //     [
+    //         {who: (an Artist), what: 'Viola'},
+    //         {who: (an Artist), what: null},
+    //         ...
+    //     ]
+    //
+    // Contributions whose "who" values don't match anything in artistData are
+    // filtered out. (So if the list is all empty, chances are that either the
+    // reference list is somehow messed up, or artistData isn't being provided
+    // properly.)
+    dynamicContribs: (contribsByRefProperty) => ({
+        flags: {expose: true},
+        expose: {
+            dependencies: ['artistData', contribsByRefProperty],
+            compute: ({ artistData, [contribsByRefProperty]: contribsByRef }) => (
+                (contribsByRef && artistData
+                    ? (contribsByRef
+                        .map(({ who: ref, what }) => ({
+                            who: find.artist(ref, {wikiData: {artistData}}),
+                            what
+                        }))
+                        .filter(({ who }) => who))
+                    : [])
+            )
+        }
+    }),
+
+    // General purpose wiki data constructor, for properties like artistData,
+    // trackData, etc.
+    wikiData: (thingClass) => ({
+        flags: {update: true},
+        update: {
+            validate: validateArrayItems(validateInstanceOf(thingClass))
+        }
+    })
+};
+
+Thing.getReference = function(thing) {
+    if (!thing.constructor[Thing.referenceType])
+        throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`);
+
+    if (!thing.directory)
+        throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
+
+    return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
+};
+
+// -> Album
+
+Album.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Album'),
+    color: Thing.common.color(),
+    directory: Thing.common.directory(),
+    urls: Thing.common.urls(),
+
+    date: Thing.common.simpleDate(),
+    coverArtDate: Thing.common.simpleDate(),
+    trackArtDate: Thing.common.simpleDate(),
+    dateAddedToWiki: Thing.common.simpleDate(),
+
+    artistContribsByRef: Thing.common.contribsByRef(),
+    coverArtistContribsByRef: Thing.common.contribsByRef(),
+    trackCoverArtistContribsByRef: Thing.common.contribsByRef(),
+    wallpaperArtistContribsByRef: Thing.common.contribsByRef(),
+    bannerArtistContribsByRef: Thing.common.contribsByRef(),
+
+    groupsByRef: {
+        flags: {update: true, expose: true},
+
+        update: {
+            validate: validateReferenceList('group')
+        }
+    },
+
+    artTagsByRef: {
+        flags: {update: true, expose: true},
+
+        update: {
+            validate: validateReferenceList('tag')
+        }
+    },
+
+    trackGroups: {
+        flags: {update: true, expose: true},
+
+        update: {
+            validate: validateArrayItems(validateInstanceOf(TrackGroup))
+        }
+    },
+
+    wallpaperStyle: Thing.common.simpleString(),
+
+    wallpaperFileExtension: {
+        flags: {update: true, expose: true},
+        update: {validate: isFileExtension}
+    },
+
+    bannerStyle: Thing.common.simpleString(),
+
+    bannerFileExtension: {
+        flags: {update: true, expose: true},
+        update: {validate: isFileExtension}
+    },
+
+    bannerDimensions: {
+        flags: {update: true, expose: true},
+        update: {validate: isDimensions}
+    },
+
+    hasTrackArt: Thing.common.flag(true),
+    isMajorRelease: Thing.common.flag(false),
+    isListedOnHomepage: Thing.common.flag(true),
+
+    commentary: {
+        flags: {update: true, expose: true},
+        update: {validate: isCommentary}
+    },
+
+    // Update only
+
+    artistData: Thing.common.wikiData(Artist),
+    trackData: Thing.common.wikiData(Track),
+
+    // Expose only
+
+    // Previously known as: (album).artists
+    artistContribs: Thing.common.dynamicContribs('artistContribsByRef'),
+
+    tracks: {
+        flags: {expose: true},
+
+        expose: {
+            dependencies: ['trackGroups', 'trackData'],
+            compute: ({ trackGroups, trackData }) => (
+                (trackGroups && trackData
+                    ? (trackGroups
+                        .flatMap(group => group.tracksByRef ?? [])
+                        .map(ref => find.track(ref, {wikiData: {trackData}}))
+                        .filter(Boolean))
+                    : [])
+            )
+        }
+    },
+};
+
+TrackGroup.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Track Group'),
+    color: Thing.common.color(),
+
+    dateOriginallyReleased: Thing.common.simpleDate(),
+
+    tracksByRef: {
+        flags: {update: true, expose: true},
+        update: {validate: validateReferenceList('track')}
+    },
+
+    isDefaultTrackGroup: Thing.common.flag(false),
+
+    // Update only
+
+    trackData: Thing.common.wikiData(Track),
+
+    // Expose only
+
+    tracks: {
+        flags: {expose: true},
+
+        expose: {
+            dependencies: ['tracksByRef', 'trackData'],
+            compute: ({ tracksByRef, trackData }) => (
+                (tracksByRef && trackData
+                    ? (tracksByRef
+                        .map(ref => find.track(ref, {wikiData: {trackData}}))
+                        .filter(Boolean))
+                    : [])
+            )
+        }
+    },
+};
+
+// -> Track
+
+Track.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Track'),
+    directory: Thing.common.directory(),
+
+    duration: {
+        flags: {update: true, expose: true},
+        update: {validate: isDuration}
+    },
+
+    urls: Thing.common.urls(),
+    dateFirstReleased: Thing.common.simpleDate(),
+    coverArtDate: Thing.common.simpleDate(),
+
+    hasCoverArt: Thing.common.flag(true),
+    hasURLs: Thing.common.flag(true),
+
+    referencedTracksByRef: {
+        flags: {update: true, expose: true},
+        update: {validate: validateReferenceList('track')}
+    },
+
+    artistContribsByRef: Thing.common.contribsByRef(),
+    contributorContribsByRef: Thing.common.contribsByRef(),
+    coverArtistContribsByRef: Thing.common.contribsByRef(),
+
+    artTagsByRef: {
+        flags: {update: true, expose: true},
+        update: {validate: validateReferenceList('tag')}
+    },
+
+    // Previously known as: (track).aka
+    originalReleaseTrackByRef: {
+        flags: {update: true, expose: true},
+        update: {validate: validateReference('track')}
+    },
+
+    commentary: {
+        flags: {update: true, expose: true},
+        update: {validate: isCommentary}
+    },
+
+    lyrics: Thing.common.simpleString(),
+
+    // Update only
+
+    albumData: Thing.common.wikiData(Album),
+    artistData: Thing.common.wikiData(Artist),
+    artTagData: Thing.common.wikiData(ArtTag),
+
+    // Expose only
+
+    album: {
+        flags: {expose: true},
+
+        expose: {
+            dependencies: ['albumData'],
+            compute: ({ [Track.instance]: track, albumData }) => (
+                albumData?.find(album => album.tracks.includes(track)) ?? null)
+        }
+    },
+
+    date: {
+        flags: {expose: true},
+
+        expose: {
+            dependencies: ['albumData', 'dateFirstReleased'],
+            compute: ({ albumData, dateFirstReleased, [Track.instance]: track }) => (
+                dateFirstReleased ??
+                albumData?.find(album => album.tracks.includes(track))?.date ??
+                null
+            )
+        }
+    },
+
+    // Previously known as: (track).artists
+    artistContribs: Thing.common.dynamicContribs('artistContribsByRef'),
+
+    artTags: {
+        flags: {expose: true},
+
+        expose: {
+            dependencies: ['artTagsByRef', 'artTagData'],
+
+            compute: ({ artTagsByRef, artTagData }) => (
+                (artTagsByRef && artTagData
+                    ? (artTagsByRef
+                        .map(ref => find.tag(ref, {wikiData: {tagData: artTagData}}))
+                        .filter(Boolean))
+                    : [])
+            )
+        }
+    }
+};
+
+// -> Artist
+
+Artist.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Artist'),
+    directory: Thing.common.directory(),
+    urls: Thing.common.urls(),
+
+    aliasRefs: {
+        flags: {update: true, expose: true},
+        update: {validate: validateReferenceList('artist')}
+    },
+
+    contextNotes: Thing.common.simpleString(),
+};
+
+// -> Group
+
+Group.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Group'),
+    directory: Thing.common.directory(),
+
+    description: Thing.common.simpleString(),
+
+    urls: Thing.common.urls(),
+
+    // Expose only
+
+    descriptionShort: {
+        flags: {expose: true},
+
+        expose: {
+            dependencies: ['description'],
+            compute: ({ description }) => description.split('<hr class="split">')[0]
+        }
+    }
+};
+
+GroupCategory.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Group Category'),
+    color: Thing.common.color(),
+
+    groupsByRef: {
+        flags: {update: true, expose: true},
+        update: {validate: validateReferenceList('group')}
+    },
+};
+
+// -> ArtTag
+
+ArtTag.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Art Tag'),
+    directory: Thing.common.directory(),
+    color: Thing.common.color(),
+    isContentWarning: Thing.common.flag(false),
+};
+
+// -> NewsEntry
+
+NewsEntry.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed News Entry'),
+    directory: Thing.common.directory(),
+    date: Thing.common.simpleDate(),
+
+    content: Thing.common.simpleString(),
+
+    // Expose only
+
+    contentShort: {
+        flags: {expose: true},
+
+        expose: {
+            dependencies: ['content'],
+
+            compute({ content }) {
+                return body.split('<hr class="split">')[0];
+            }
+        }
+    },
+};
+
+// -> StaticPage
+
+StaticPage.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Static Page'),
+
+    nameShort: {
+        flags: {update: true, expose: true},
+        update: {validate: isName},
+
+        expose: {
+            dependencies: ['name'],
+            transform: (value, { name }) => value ?? name
+        }
+    },
+
+    directory: Thing.common.directory(),
+    content: Thing.common.simpleString(),
+    stylesheet: Thing.common.simpleString(),
+    showInNavigationBar: Thing.common.flag(true),
+};
+
+// -> HomepageLayout
+
+HomepageLayout.propertyDescriptors = {
+    // Update & expose
+
+    sidebarContent: Thing.common.simpleString(),
+
+    rows: {
+        flags: {update: true, expose: true},
+
+        update: {
+            validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow))
+        }
+    },
+};
+
+HomepageLayoutRow.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Homepage Row'),
+
+    type: {
+        flags: {update: true, expose: true},
+
+        update: {
+            validate(value) {
+                throw new Error(`'type' property validator must be overridden`);
+            }
+        }
+    },
+
+    color: Thing.common.color(),
+};
+
+HomepageLayoutAlbumsRow.propertyDescriptors = {
+    ...HomepageLayoutRow.propertyDescriptors,
+
+    // Update & expose
+
+    type: {
+        flags: {update: true, expose: true},
+        update: {
+            validate(value) {
+                if (value !== 'albums') {
+                    throw new TypeError(`Expected 'albums'`);
+                }
+
+                return true;
+            }
+        }
+    },
+
+    sourceGroupByRef: {
+        flags: {update: true, expose: true},
+        update: {validate: validateReference('group')}
+    },
+
+    sourceAlbumsByRef: {
+        flags: {update: true, expose: true},
+        update: {validate: validateReferenceList('album')}
+    },
+
+    countAlbumsFromGroup: {
+        flags: {update: true, expose: true},
+        update: {validate: isCountingNumber}
+    },
+
+    actionLinks: {
+        flags: {update: true, expose: true},
+        update: {validate: validateArrayItems(isString)}
+    },
+};
+
+// -> Flash
+
+Flash.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Flash'),
+
+    directory: {
+        flags: {update: true, expose: true},
+        update: {validate: isDirectory},
+
+        // Flashes expose directory differently from other Things! Their
+        // default directory is dependent on the page number (or ID), not
+        // the name.
+        expose: {
+            dependencies: ['page'],
+            transform(directory, { page }) {
+                if (directory === null && page === null)
+                    return null;
+                else if (directory === null)
+                    return page;
+                else
+                    return directory;
+            }
+        }
+    },
+
+    page: {
+        flags: {update: true, expose: true},
+        update: {validate: oneOf(isString, isNumber)},
+
+        expose: {
+            transform: value => value.toString()
+        }
+    },
+
+    date: Thing.common.simpleDate(),
+
+    coverArtFileExtension: {
+        flags: {update: true, expose: true},
+        update: {validate: isFileExtension}
+    },
+
+    featuredTracksByRef: {
+        flags: {update: true, expose: true},
+        update: {validate: validateReferenceList('track')}
+    },
+
+    contributorContribsByRef: Thing.common.contribsByRef(),
+    urls: Thing.common.urls(),
+};
+
+FlashAct.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Flash Act'),
+    color: Thing.common.color(),
+    anchor: Thing.common.simpleString(),
+    jump: Thing.common.simpleString(),
+    jumpColor: Thing.common.color(),
+
+    flashesByRef: {
+        flags: {update: true, expose: true},
+        update: {validate: validateReferenceList('flash')}
+    },
+};
+
+// WikiInfo
+
+WikiInfo.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Wiki'),
+
+    // Displayed in nav bar.
+    shortName: {
+        flags: {update: true, expose: true},
+        update: {validate: isName},
+
+        expose: {
+            dependencies: ['name'],
+            transform: (value, { name }) => value ?? name
+        }
+    },
+
+    color: Thing.common.color(),
+
+    // One-line description used for <meta rel="description"> tag.
+    description: Thing.common.simpleString(),
+
+    footerContent: Thing.common.simpleString(),
+
+    defaultLanguage: {
+        flags: {update: true, expose: true},
+        update: {validate: isLanguageCode}
+    },
+
+    canonicalBase: {
+        flags: {update: true, expose: true},
+        update: {validate: isURL}
+    },
+
+    // Feature toggles
+
+    enableArtistAvatars: Thing.common.flag(false),
+    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),
+};
diff --git a/src/thing/validators.js b/src/data/validators.js
index 8392222..8392222 100644
--- a/src/thing/validators.js
+++ b/src/data/validators.js
diff --git a/src/thing/album.js b/src/thing/album.js
deleted file mode 100644
index ba75352..0000000
--- a/src/thing/album.js
+++ /dev/null
@@ -1,270 +0,0 @@
-import CacheableObject from './cacheable-object.js';
-import Thing from './thing.js';
-
-import {
-    isBoolean,
-    isColor,
-    isCommentary,
-    isContributionList,
-    isDate,
-    isDimensions,
-    isDirectory,
-    isInstance,
-    isFileExtension,
-    isName,
-    isURL,
-    isString,
-    validateArrayItems,
-    validateInstanceOf,
-    validateReference,
-    validateReferenceList,
-} from './validators.js';
-
-import Artist from './artist.js';
-import ArtTag from './art-tag.js';
-import Track from './track.js';
-
-import find from '../util/find.js';
-
-export class TrackGroup extends CacheableObject {
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-            update: {default: 'Unnamed Track Group', validate: isName}
-        },
-
-        color: {
-            flags: {update: true, expose: true},
-            update: {validate: isColor}
-        },
-
-        dateOriginallyReleased: {
-            flags: {update: true, expose: true},
-            update: {validate: isDate}
-        },
-
-        tracksByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: validateReferenceList('track')}
-        },
-
-        isDefaultTrackGroup: {
-            flags: {update: true, expose: true},
-            update: {validate: isBoolean}
-        },
-
-        // Update only
-
-        trackData: {
-            flags: {update: true},
-            update: {validate: validateArrayItems(item => isInstance(item, Track))}
-        },
-
-        // Expose only
-
-        tracks: {
-            flags: {expose: true},
-
-            expose: {
-                dependencies: ['tracksByRef', 'trackData'],
-                compute: ({ tracksByRef, trackData }) => (
-                    (tracksByRef && trackData
-                        ? (tracksByRef
-                            .map(ref => find.track(ref, {wikiData: {trackData}}))
-                            .filter(Boolean))
-                        : [])
-                )
-            }
-        },
-    };
-}
-
-export default class Album extends Thing {
-    static [Thing.referenceType] = 'album';
-
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-            update: {default: 'Unnamed Album', validate: isName}
-        },
-
-        color: {
-            flags: {update: true, expose: true},
-            update: {validate: isColor}
-        },
-
-        directory: {
-            flags: {update: true, expose: true},
-            update: {validate: isDirectory},
-            expose: Thing.directoryExpose
-        },
-
-        urls: {
-            flags: {update: true, expose: true},
-
-            update: {
-                validate: validateArrayItems(isURL)
-            }
-        },
-
-        date: {
-            flags: {update: true, expose: true},
-            update: {validate: isDate}
-        },
-
-        coverArtDate: {
-            flags: {update: true, expose: true},
-            update: {validate: isDate}
-        },
-
-        trackArtDate: {
-            flags: {update: true, expose: true},
-            update: {validate: isDate}
-        },
-
-        dateAddedToWiki: {
-            flags: {update: true, expose: true},
-
-            update: {validate: isDate}
-        },
-
-        artistContribsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: isContributionList}
-        },
-
-        coverArtistContribsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: isContributionList}
-        },
-
-        trackCoverArtistContribsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: isContributionList}
-        },
-
-        wallpaperArtistContribsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: isContributionList}
-        },
-
-        bannerArtistContribsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: isContributionList}
-        },
-
-        groupsByRef: {
-            flags: {update: true, expose: true},
-
-            update: {
-                validate: validateReferenceList('group')
-            }
-        },
-
-        artTagsByRef: {
-            flags: {update: true, expose: true},
-
-            update: {
-                validate: validateReferenceList('tag')
-            }
-        },
-
-        trackGroups: {
-            flags: {update: true, expose: true},
-
-            update: {
-                validate: validateArrayItems(validateInstanceOf(TrackGroup))
-            }
-        },
-
-        wallpaperStyle: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-
-        wallpaperFileExtension: {
-            flags: {update: true, expose: true},
-            update: {validate: isFileExtension}
-        },
-
-        bannerStyle: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-
-        bannerFileExtension: {
-            flags: {update: true, expose: true},
-            update: {validate: isFileExtension}
-        },
-
-        bannerDimensions: {
-            flags: {update: true, expose: true},
-            update: {validate: isDimensions}
-        },
-
-        hasTrackArt: {
-            flags: {update: true, expose: true},
-
-            update: {
-                default: true,
-                validate: isBoolean
-            }
-        },
-
-        isMajorRelease: {
-            flags: {update: true, expose: true},
-
-            update: {
-                default: false,
-                validate: isBoolean
-            }
-        },
-
-        isListedOnHomepage: {
-            flags: {update: true, expose: true},
-
-            update: {
-                default: true,
-                validate: isBoolean
-            }
-        },
-
-        commentary: {
-            flags: {update: true, expose: true},
-            update: {validate: isCommentary}
-        },
-
-        // Update only
-
-        artistData: Thing.genWikiDataProperty(Artist),
-        trackData: Thing.genWikiDataProperty(Track),
-
-        // Expose only
-
-        // Previously known as: (album).artists
-        artistContribs: {
-            flags: {expose: true},
-            expose: Thing.genContribsExpose('artistContribsByRef')
-        },
-
-        tracks: {
-            flags: {expose: true},
-
-            expose: {
-                dependencies: ['trackGroups', 'trackData'],
-                compute: ({ trackGroups, trackData }) => (
-                    (trackGroups && trackData
-                        ? (trackGroups
-                            .flatMap(group => group.tracksByRef ?? [])
-                            .map(ref => find.track(ref, {wikiData: {trackData}}))
-                            .filter(Boolean))
-                        : [])
-                )
-            }
-        },
-    };
-}
diff --git a/src/thing/art-tag.js b/src/thing/art-tag.js
deleted file mode 100644
index 4b09d88..0000000
--- a/src/thing/art-tag.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import Thing from './thing.js';
-
-import {
-    isBoolean,
-    isColor,
-    isDirectory,
-    isName,
-} from './validators.js';
-
-export default class ArtTag extends Thing {
-    static [Thing.referenceType] = 'tag';
-
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-            update: {validate: isName}
-        },
-
-        directory: {
-            flags: {update: true, expose: true},
-            update: {validate: isDirectory},
-            expose: Thing.directoryExpose
-        },
-
-        color: {
-            flags: {update: true, expose: true},
-            update: {validate: isColor}
-        },
-
-        isContentWarning: {
-            flags: {update: true, expose: true},
-            update: {validate: isBoolean, default: false}
-        },
-    };
-}
diff --git a/src/thing/artist.js b/src/thing/artist.js
deleted file mode 100644
index bbb2a93..0000000
--- a/src/thing/artist.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import Thing from './thing.js';
-
-import {
-    isDirectory,
-    isName,
-    isString,
-    isURL,
-    validateArrayItems,
-    validateReferenceList,
-} from './validators.js';
-
-export default class Artist extends Thing {
-    static [Thing.referenceType] = 'artist';
-
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-
-            update: {
-                default: 'Unnamed Artist',
-                validate: isName
-            }
-        },
-
-        directory: {
-            flags: {update: true, expose: true},
-            update: {validate: isDirectory},
-            expose: Thing.directoryExpose
-        },
-
-        urls: {
-            flags: {update: true, expose: true},
-            update: {validate: validateArrayItems(isURL)}
-        },
-
-        aliasRefs: {
-            flags: {update: true, expose: true},
-            update: {validate: validateReferenceList('artist')}
-        },
-
-        contextNotes: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-    };
-}
diff --git a/src/thing/flash.js b/src/thing/flash.js
deleted file mode 100644
index 4eac65a..0000000
--- a/src/thing/flash.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import Thing from './thing.js';
-
-import {
-    isColor,
-    isContributionList,
-    isDate,
-    isDirectory,
-    isFileExtension,
-    isName,
-    isNumber,
-    isString,
-    isURL,
-    oneOf,
-    validateArrayItems,
-    validateReferenceList,
-} from './validators.js';
-
-export default class Flash extends Thing {
-    static [Thing.referenceType] = 'flash';
-
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-
-            update: {
-                default: 'Unnamed Flash',
-                validate: isName
-            }
-        },
-
-        directory: {
-            flags: {update: true, expose: true},
-            update: {validate: isDirectory},
-
-            // Flashes expose directory differently from other Things! Their
-            // default directory is dependent on the page number (or ID), not
-            // the name.
-            expose: {
-                dependencies: ['page'],
-                transform(directory, { page }) {
-                    if (directory === null && page === null)
-                        return null;
-                    else if (directory === null)
-                        return page;
-                    else
-                        return directory;
-                }
-            }
-        },
-
-        page: {
-            flags: {update: true, expose: true},
-            update: {validate: oneOf(isString, isNumber)},
-
-            expose: {
-                transform: value => value.toString()
-            }
-        },
-
-        date: {
-            flags: {update: true, expose: true},
-            update: {validate: isDate}
-        },
-
-        coverArtFileExtension: {
-            flags: {update: true, expose: true},
-            update: {validate: isFileExtension}
-        },
-
-        featuredTracksByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: validateReferenceList('track')}
-        },
-
-        contributorContribsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: isContributionList}
-        },
-
-        urls: {
-            flags: {update: true, expose: true},
-            update: {validate: validateArrayItems(isURL)}
-        },
-    };
-}
-
-export class FlashAct extends Thing {
-    static [Thing.referenceType] = 'flash-act';
-
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-
-            update: {
-                default: 'Unnamed Flash Act',
-                validate: isName
-            }
-        },
-
-        color: {
-            flags: {update: true, expose: true},
-            update: {validate: isColor}
-        },
-
-        anchor: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-
-        jump: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-
-        jumpColor: {
-            flags: {update: true, expose: true},
-            update: {validate: isColor}
-        },
-
-        flashesByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: validateReferenceList('flash')}
-        },
-    };
-}
diff --git a/src/thing/group.js b/src/thing/group.js
deleted file mode 100644
index 3b92e95..0000000
--- a/src/thing/group.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import CacheableObject from './cacheable-object.js';
-import Thing from './thing.js';
-
-import {
-    isColor,
-    isDirectory,
-    isName,
-    isString,
-    isURL,
-    validateArrayItems,
-    validateReferenceList,
-} from './validators.js';
-
-export class GroupCategory extends CacheableObject {
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-            update: {default: 'Unnamed Group Category', validate: isName}
-        },
-
-        color: {
-            flags: {update: true, expose: true},
-            update: {validate: isColor}
-        },
-
-        groupsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: validateReferenceList('group')}
-        },
-    };
-}
-
-export default class Group extends Thing {
-    static [Thing.referenceType] = 'group';
-
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-            update: {default: 'Unnamed Group', validate: isName}
-        },
-
-        directory: {
-            flags: {update: true, expose: true},
-            update: {validate: isDirectory},
-            expose: Thing.directoryExpose
-        },
-
-        description: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-
-        urls: {
-            flags: {update: true, expose: true},
-            update: {validate: validateArrayItems(isURL)}
-        },
-
-        // Expose only
-
-        descriptionShort: {
-            flags: {expose: true},
-
-            expose: {
-                dependencies: ['description'],
-                compute: ({ description }) => description.split('<hr class="split">')[0]
-            }
-        }
-    };
-}
diff --git a/src/thing/homepage-layout.js b/src/thing/homepage-layout.js
deleted file mode 100644
index 4717391..0000000
--- a/src/thing/homepage-layout.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import CacheableObject from './cacheable-object.js';
-
-import {
-    isColor,
-    isCountingNumber,
-    isName,
-    isString,
-    oneOf,
-    validateArrayItems,
-    validateInstanceOf,
-    validateReference,
-    validateReferenceList,
-} from './validators.js';
-
-export class HomepageLayoutRow extends CacheableObject {
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-            update: {validate: isName}
-        },
-
-        type: {
-            flags: {update: true, expose: true},
-
-            update: {
-                validate(value) {
-                    throw new Error(`'type' property validator must be overridden`);
-                }
-            }
-        },
-
-        color: {
-            flags: {update: true, expose: true},
-            update: {validate: isColor}
-        },
-    };
-}
-
-export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
-    static propertyDescriptors = {
-        ...HomepageLayoutRow.propertyDescriptors,
-
-        // Update & expose
-
-        type: {
-            flags: {update: true, expose: true},
-            update: {
-                validate(value) {
-                    if (value !== 'albums') {
-                        throw new TypeError(`Expected 'albums'`);
-                    }
-
-                    return true;
-                }
-            }
-        },
-
-        sourceGroupByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: validateReference('group')}
-        },
-
-        sourceAlbumsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: validateReferenceList('album')}
-        },
-
-        countAlbumsFromGroup: {
-            flags: {update: true, expose: true},
-            update: {validate: isCountingNumber}
-        },
-
-        actionLinks: {
-            flags: {update: true, expose: true},
-            update: {validate: validateArrayItems(isString)}
-        },
-    }
-}
-
-export default class HomepageLayout extends CacheableObject {
-    static propertyDescriptors = {
-        // Update & expose
-
-        sidebarContent: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-
-        rows: {
-            flags: {update: true, expose: true},
-
-            update: {
-                validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow))
-            }
-        },
-    };
-}
diff --git a/src/thing/news-entry.js b/src/thing/news-entry.js
deleted file mode 100644
index 2db2f37..0000000
--- a/src/thing/news-entry.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import Thing from './thing.js';
-
-import {
-    isDate,
-    isDirectory,
-    isName,
-} from './validators.js';
-
-export default class NewsEntry extends Thing {
-    static [Thing.referenceType] = 'news-entry';
-
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-            update: {validate: isName}
-        },
-
-        directory: {
-            flags: {update: true, expose: true},
-            update: {validate: isDirectory},
-            expose: Thing.directoryExpose
-        },
-
-        date: {
-            flags: {update: true, expose: true},
-            update: {validate: isDate}
-        },
-
-        content: {
-            flags: {update: true, expose: true},
-        },
-
-        // Expose only
-
-        contentShort: {
-            flags: {expose: true},
-
-            expose: {
-                dependencies: ['content'],
-
-                compute({ content }) {
-                    return body.split('<hr class="split">')[0];
-                }
-            }
-        },
-    };
-}
diff --git a/src/thing/static-page.js b/src/thing/static-page.js
deleted file mode 100644
index e2b5150..0000000
--- a/src/thing/static-page.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import Thing from './thing.js';
-
-import {
-    isBoolean,
-    isDirectory,
-    isName,
-    isString,
-} from './validators.js';
-
-export default class StaticPage extends Thing {
-    static [Thing.referenceType] = 'static';
-
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-            update: {validate: isName, default: 'Unnamed Static Page'}
-        },
-
-        nameShort: {
-            flags: {update: true, expose: true},
-            update: {validate: isName},
-
-            expose: {
-                dependencies: ['name'],
-                transform: (value, { name }) => value ?? name
-            }
-        },
-
-        directory: {
-            flags: {update: true, expose: true},
-            update: {validate: isDirectory},
-            expose: Thing.directoryExpose
-        },
-
-        content: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-
-        stylesheet: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-
-        showInNavigationBar: {
-            flags: {update: true, expose: true},
-            update: {validate: isBoolean, default: true}
-        },
-    };
-}
diff --git a/src/thing/structures.js b/src/thing/structures.js
deleted file mode 100644
index 364ba14..0000000
--- a/src/thing/structures.js
+++ /dev/null
@@ -1 +0,0 @@
-// Generic structure utilities common across various Thing types.
diff --git a/src/thing/thing.js b/src/thing/thing.js
deleted file mode 100644
index 2d6def6..0000000
--- a/src/thing/thing.js
+++ /dev/null
@@ -1,62 +0,0 @@
-// Base class for Things. No, we will not come up with a better name.
-// Sorry not sorry! :)
-
-import CacheableObject from './cacheable-object.js';
-
-import {
-    validateArrayItems,
-} from './validators.js';
-
-import { getKebabCase } from '../util/wiki-data.js';
-import find from '../util/find.js';
-
-export default class Thing extends CacheableObject {
-    static referenceType = Symbol('Thing.referenceType');
-
-    static directoryExpose = {
-        dependencies: ['name'],
-        transform(directory, { name }) {
-            if (directory === null && name === null)
-                return null;
-            else if (directory === null)
-                return getKebabCase(name);
-            else
-                return directory;
-        }
-    };
-
-    static genContribsExpose(contribsByRefProperty) {
-        return {
-            dependencies: ['artistData', contribsByRefProperty],
-            compute: ({ artistData, [contribsByRefProperty]: contribsByRef }) => (
-                (contribsByRef && artistData
-                    ? (contribsByRef
-                        .map(({ who: ref, what }) => ({
-                            who: find.artist(ref, {wikiData: {artistData}}),
-                            what
-                        }))
-                        .filter(({ who }) => who))
-                    : [])
-            )
-        };
-    }
-
-    static genWikiDataProperty(thingClass) {
-        return {
-            flags: {update: true},
-            update: {
-                validate: validateArrayItems(x => x instanceof thingClass)
-            }
-        };
-    }
-
-    static getReference(thing) {
-        if (!thing.constructor[Thing.referenceType])
-            throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`);
-
-        if (!thing.directory)
-            throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
-
-        return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
-    }
-}
diff --git a/src/thing/track.js b/src/thing/track.js
deleted file mode 100644
index 3edabc9..0000000
--- a/src/thing/track.js
+++ /dev/null
@@ -1,173 +0,0 @@
-import Thing from './thing.js';
-
-import {
-    isBoolean,
-    isColor,
-    isCommentary,
-    isContributionList,
-    isDate,
-    isDirectory,
-    isDuration,
-    isName,
-    isURL,
-    isString,
-    validateArrayItems,
-    validateReference,
-    validateReferenceList,
-} from './validators.js';
-
-import Album from './album.js';
-import Artist from './artist.js';
-import ArtTag from './art-tag.js';
-
-import find from '../util/find.js';
-
-export default class Track extends Thing {
-    static [Thing.referenceType] = 'track';
-
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-
-            update: {
-                default: 'Unnamed Track',
-                validate: isName
-            }
-        },
-
-        directory: {
-            flags: {update: true, expose: true},
-            update: {validate: isDirectory},
-            expose: Thing.directoryExpose
-        },
-
-        duration: {
-            flags: {update: true, expose: true},
-            update: {validate: isDuration}
-        },
-
-        urls: {
-            flags: {update: true, expose: true},
-
-            update: {
-                validate: validateArrayItems(isURL)
-            }
-        },
-
-        dateFirstReleased: {
-            flags: {update: true, expose: true},
-            update: {validate: isDate}
-        },
-
-        coverArtDate: {
-            flags: {update: true, expose: true},
-            update: {validate: isDate}
-        },
-
-        hasCoverArt: {
-            flags: {update: true, expose: true},
-            update: {default: true, validate: isBoolean}
-        },
-
-        hasURLs: {
-            flags: {update: true, expose: true},
-            update: {default: true, validate: isBoolean}
-        },
-
-        referencedTracksByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: validateReferenceList('track')}
-        },
-
-        artistContribsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: isContributionList}
-        },
-
-        contributorContribsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: isContributionList}
-        },
-
-        coverArtistContribsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: isContributionList}
-        },
-
-        artTagsByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: validateReferenceList('tag')}
-        },
-
-        // Previously known as: (track).aka
-        originalReleaseTrackByRef: {
-            flags: {update: true, expose: true},
-            update: {validate: validateReference('track')}
-        },
-
-        commentary: {
-            flags: {update: true, expose: true},
-            update: {validate: isCommentary}
-        },
-
-        lyrics: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-
-        // Update only
-
-        albumData: Thing.genWikiDataProperty(Album),
-        artistData: Thing.genWikiDataProperty(Artist),
-        artTagData: Thing.genWikiDataProperty(ArtTag),
-
-        // Expose only
-
-        album: {
-            flags: {expose: true},
-
-            expose: {
-                dependencies: ['albumData'],
-                compute: ({ [this.instance]: track, albumData }) => (
-                    albumData?.find(album => album.tracks.includes(track)) ?? null)
-            }
-        },
-
-        date: {
-            flags: {expose: true},
-
-            expose: {
-                dependencies: ['albumData', 'dateFirstReleased'],
-                compute: ({ albumData, dateFirstReleased, [this.instance]: track }) => (
-                    dateFirstReleased ??
-                    albumData?.find(album => album.tracks.includes(track))?.date ??
-                    null
-                )
-            }
-        },
-
-        // Previously known as: (track).artists
-        artistContribs: {
-            flags: {expose: true},
-            expose: Thing.genContribsExpose('artistContribsByRef')
-        },
-
-        artTags: {
-            flags: {expose: true},
-
-            expose: {
-                dependencies: ['artTagsByRef', 'artTagData'],
-
-                compute: ({ artTagsByRef, artTagData }) => (
-                    (artTagsByRef && artTagData
-                        ? (artTagsByRef
-                            .map(ref => find.tag(ref, {wikiData: {tagData: artTagData}}))
-                            .filter(Boolean))
-                        : [])
-                )
-            }
-        }
-    };
-}
diff --git a/src/thing/wiki-info.js b/src/thing/wiki-info.js
deleted file mode 100644
index b805bf7..0000000
--- a/src/thing/wiki-info.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import CacheableObject from './cacheable-object.js';
-
-import {
-    isBoolean,
-    isColor,
-    isLanguageCode,
-    isName,
-    isString,
-    isURL,
-} from './validators.js';
-
-export default class WikiInfo extends CacheableObject {
-    static propertyDescriptors = {
-        // Update & expose
-
-        name: {
-            flags: {update: true, expose: true},
-            update: {validate: isName, default: 'Unnamed Wiki'}
-        },
-
-        // Displayed in nav bar.
-        shortName: {
-            flags: {update: true, expose: true},
-            update: {validate: isName},
-
-            expose: {
-                dependencies: ['name'],
-                transform: (value, { name }) => value ?? name
-            }
-        },
-
-        color: {
-            flags: {update: true, expose: true},
-            update: {validate: isColor}
-        },
-
-        // One-line description used for <meta rel="description"> tag.
-        description: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-
-        footerContent: {
-            flags: {update: true, expose: true},
-            update: {validate: isString}
-        },
-
-        defaultLanguage: {
-            flags: {update: true, expose: true},
-            update: {validate: isLanguageCode}
-        },
-
-        canonicalBase: {
-            flags: {update: true, expose: true},
-            update: {validate: isURL}
-        },
-
-        // Feature toggles
-
-        enableArtistAvatars: {
-            flags: {update: true, expose: true},
-            update: {validate: isBoolean, default: false}
-        },
-
-        enableFlashesAndGames: {
-            flags: {update: true, expose: true},
-            update: {validate: isBoolean, default: false}
-        },
-
-        enableListings: {
-            flags: {update: true, expose: true},
-            update: {validate: isBoolean, default: false}
-        },
-
-        enableNews: {
-            flags: {update: true, expose: true},
-            update: {validate: isBoolean, default: false}
-        },
-
-        enableArtTagUI: {
-            flags: {update: true, expose: true},
-            update: {validate: isBoolean, default: false}
-        },
-
-        enableGroupUI: {
-            flags: {update: true, expose: true},
-            update: {validate: isBoolean, default: false}
-        },
-    };
-}
diff --git a/src/upd8.js b/src/upd8.js
index 2aa4eb2..de79a0f 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -91,20 +91,26 @@ import find from './util/find.js';
 import * as html from './util/html.js';
 import unbound_link, {getLinkThemeString} from './util/link.js';
 
-import Album, { TrackGroup } from './thing/album.js';
-import Artist from './thing/artist.js';
-import ArtTag from './thing/art-tag.js';
-import CacheableObject from './thing/cacheable-object.js';
-import Flash, { FlashAct } from './thing/flash.js';
-import Group, { GroupCategory } from './thing/group.js';
-import HomepageLayout, {
+import CacheableObject from './data/cacheable-object.js';
+
+import {
+    Album,
+    Artist,
+    ArtTag,
+    Flash,
+    FlashAct,
+    Group,
+    GroupCategory,
+    HomepageLayout,
     HomepageLayoutAlbumsRow,
-} from './thing/homepage-layout.js';
-import NewsEntry from './thing/news-entry.js';
-import StaticPage from './thing/static-page.js';
-import Thing from './thing/thing.js';
-import Track from './thing/track.js';
-import WikiInfo from './thing/wiki-info.js';
+    HomepageLayoutRow,
+    NewsEntry,
+    StaticPage,
+    Thing,
+    Track,
+    TrackGroup,
+    WikiInfo,
+} from './data/things.js';
 
 import {
     fancifyFlashURL,
@@ -2612,7 +2618,7 @@ async function main() {
 
                     call(processAggregate.close);
 
-                    dataStep.save(processResults);
+                    call(dataStep.save, processResults);
 
                     return;
                 }
@@ -2696,7 +2702,7 @@ async function main() {
                     });
                 }
 
-                dataStep.save(processResults);
+                call(dataStep.save, processResults);
             });
     }
 
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 219c3ee..d6bc3df 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -356,7 +356,8 @@ export function showAggregate(topError, {pathToFile = p => p} = {}) {
         const stackLine = stackLines?.find(line =>
             line.trim().startsWith('at')
             && !line.includes('sugar')
-            && !line.includes('node:internal'));
+            && !line.includes('node:internal')
+            && !line.includes('<anonymous>'));
         const tracePart = (stackLine
             ? '- ' + stackLine.trim().replace(/file:\/\/(.*\.js)/, (match, pathname) => pathToFile(pathname))
             : '(no stack trace)');
diff --git a/test/cacheable-object.js b/test/cacheable-object.js
index 203d2af..dd93343 100644
--- a/test/cacheable-object.js
+++ b/test/cacheable-object.js
@@ -1,6 +1,6 @@
 import test from 'tape';
 
-import CacheableObject from '../src/thing/cacheable-object.js';
+import CacheableObject from '../src/data/cacheable-object.js';
 
 // Utility
 
diff --git a/test/data-validators.js b/test/data-validators.js
index 739333a..a7b9b48 100644
--- a/test/data-validators.js
+++ b/test/data-validators.js
@@ -24,7 +24,7 @@ import {
 
     // Compositional utilities
     oneOf,
-} from '../src/thing/validators.js';
+} from '../src/data/validators.js';
 
 function test(msg, fn) {
     _test(msg, t => {