« 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
path: root/src/thing
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 /src/thing
parentcfa02cb03a363c46408db7f0ec54bd3a7e4ad018 (diff)
bam (Thing subclasses: several steps, one file)
Diffstat (limited to 'src/thing')
-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/cacheable-object.js305
-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/validators.js327
-rw-r--r--src/thing/wiki-info.js90
14 files changed, 0 insertions, 1715 deletions
diff --git a/src/thing/album.js b/src/thing/album.js
deleted file mode 100644
index ba75352d..00000000
--- 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 4b09d885..00000000
--- 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 bbb2a935..00000000
--- 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/cacheable-object.js b/src/thing/cacheable-object.js
deleted file mode 100644
index 9af41603..00000000
--- a/src/thing/cacheable-object.js
+++ /dev/null
@@ -1,305 +0,0 @@
-// Generally extendable class for caching properties and handling dependencies,
-// with a few key properties:
-//
-// 1) The behavior of every property is defined by its descriptor, which is a
-//    static value stored on the subclass (all instances share the same property
-//    descriptors).
-//
-//  1a) Additional properties may not be added past the time of object
-//      construction, and attempts to do so (including externally setting a
-//      property name which has no corresponding descriptor) will throw a
-//      TypeError. (This is done via an Object.seal(this) call after a newly
-//      created instance defines its own properties according to the descriptor
-//      on its constructor class.)
-//
-// 2) Properties may have two flags set: update and expose. Properties which
-//    update are provided values from the external. Properties which expose
-//    provide values to the external, generally dependent on other update
-//    properties (within the same object).
-//
-//  2a) Properties may be flagged as both updating and exposing. This is so
-//      that the same name may be used for both "output" and "input".
-//
-// 3) Exposed properties have values which are computations dependent on other
-//    properties, as described by a `compute` function on the descriptor.
-//    Depended-upon properties are explicitly listed on the descriptor next to
-//    this function, and are only provided as arguments to the function once
-//    listed.
-//
-//  3a) An exposed property may depend only upon updating properties, not other
-//      exposed properties (within the same object). This is to force the
-//      general complexity of a single object to be fairly simple: inputs
-//      directly determine outputs, with the only in-between step being the
-//      `compute` function, no multiple-layer dependencies. Note that this is
-//      only true within a given object - externally, values provided to one
-//      object's `update` may be (and regularly are) the exposed values of
-//      another object.
-//
-//  3b) If a property both updates and exposes, it is automatically regarded as
-//      a dependancy. (That is, its exposed value will depend on the value it is
-//      updated with.) Rather than a required `compute` function, these have an
-//      optional `transform` function, which takes the update value as its first
-//      argument and then the usual key-value dependencies as its second. If no
-//      `transform` function is provided, the expose value is the same as the
-//      update value.
-//
-// 4) Exposed properties are cached; that is, if no depended-upon properties are
-//    updated, the value of an exposed property is not recomputed.
-//
-//  4a) The cache for an exposed property is invalidated as soon as any of its
-//      dependencies are updated, but the cache itself is lazy: the exposed
-//      value will not be recomputed until it is again accessed. (Likewise, an
-//      exposed value won't be computed for the first time until it is first
-//      accessed.)
-//
-// 5) Updating a property may optionally apply validation checks before passing,
-//    declared by a `validate` function on the `update` block. This function
-//    should either throw an error (e.g. TypeError) or return false if the value
-//    is invalid.
-//
-// 6) Objects do not expect all updating properties to be provided at once.
-//    Incomplete objects are deliberately supported and enabled.
-//
-//  6a) The default value for every updating property is null; undefined is not
-//      accepted as a property value under any circumstances (it always errors).
-//      However, this default may be overridden by specifying a `default` value
-//      on a property's `update` block. (This value will be checked against
-//      the property's validate function.) Note that a property may always be
-//      updated to null, even if the default is non-null. (Null always bypasses
-//      the validate check.)
-//
-//  6b) It's required by the external consumer of an object to determine whether
-//      or not the object is ready for use (within the larger program). This is
-//      convenienced by the static CacheableObject.listAccessibleProperties()
-//      function, which provides a mapping of exposed property names to whether
-//      or not their dependencies are yet met.
-
-import { color, ENABLE_COLOR } from '../util/cli.js';
-
-import { inspect as nodeInspect } from 'util';
-
-function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
-}
-
-export default class CacheableObject {
-    static instance = Symbol('CacheableObject `this` instance');
-
-    #propertyUpdateValues = Object.create(null);
-    #propertyUpdateCacheInvalidators = Object.create(null);
-
-    /*
-    // Note the constructor doesn't take an initial data source. Due to a quirk
-    // of JavaScript, private members can't be accessed before the superclass's
-    // constructor is finished processing - so if we call the overridden
-    // update() function from inside this constructor, it will error when
-    // writing to private members. Pretty bad!
-    //
-    // That means initial data must be provided by following up with update()
-    // after constructing the new instance of the Thing (sub)class.
-    */
-
-    constructor() {
-        this.#defineProperties();
-        this.#initializeUpdatingPropertyValues();
-
-        if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-            return new Proxy(this, {
-                get: (obj, key) => {
-                    if (!Object.hasOwn(obj, key)) {
-                        if (key !== 'constructor') {
-                            CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`);
-                        }
-                    }
-                    return obj[key];
-                }
-            });
-        }
-    }
-
-    #initializeUpdatingPropertyValues() {
-        for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) {
-            const { flags, update } = descriptor;
-
-            if (!flags.update) {
-                continue;
-            }
-
-            if (update?.default) {
-                this[property] = update?.default;
-            } else {
-                this[property] = null;
-            }
-        }
-    }
-
-    #defineProperties() {
-        for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) {
-            const { flags } = descriptor;
-
-            const definition = {
-                configurable: false,
-                enumerable: true
-            };
-
-            if (flags.update) {
-                definition.set = this.#getUpdateObjectDefinitionSetterFunction(property);
-            }
-
-            if (flags.expose) {
-                definition.get = this.#getExposeObjectDefinitionGetterFunction(property);
-            }
-
-            Object.defineProperty(this, property, definition);
-        }
-
-        Object.seal(this);
-    }
-
-    #getUpdateObjectDefinitionSetterFunction(property) {
-        const { update } = this.#getPropertyDescriptor(property);
-        const validate = update?.validate;
-        const allowNull = update?.allowNull;
-
-        return (newValue) => {
-            const oldValue = this.#propertyUpdateValues[property];
-
-            if (newValue === undefined) {
-                throw new ValueError(`Properties cannot be set to undefined`);
-            }
-
-            if (newValue === oldValue) {
-                return;
-            }
-
-            if (newValue !== null && validate) {
-                try {
-                    const result = validate(newValue);
-                    if (result === undefined) {
-                        throw new TypeError(`Validate function returned undefined`);
-                    } else if (result !== true) {
-                        throw new TypeError(`Validation failed for value ${newValue}`);
-                    }
-                } catch (error) {
-                    error.message = `Property ${color.green(property)} (${inspect(this[property])} -> ${inspect(newValue)}): ${error.message}`;
-                    throw error;
-                }
-            }
-
-            this.#propertyUpdateValues[property] = newValue;
-            this.#invalidateCachesDependentUpon(property);
-        };
-    }
-
-    #getUpdatePropertyValidateFunction(property) {
-        const descriptor = this.#getPropertyDescriptor(property);
-    }
-
-    #getPropertyDescriptor(property) {
-        return this.constructor.propertyDescriptors[property];
-    }
-
-    #invalidateCachesDependentUpon(property) {
-        for (const invalidate of this.#propertyUpdateCacheInvalidators[property] || []) {
-            invalidate();
-        }
-    }
-
-    #getExposeObjectDefinitionGetterFunction(property) {
-        const { flags } = this.#getPropertyDescriptor(property);
-        const compute = this.#getExposeComputeFunction(property);
-
-        if (compute) {
-            let cachedValue;
-            const checkCacheValid = this.#getExposeCheckCacheValidFunction(property);
-            return () => {
-                if (checkCacheValid()) {
-                    return cachedValue;
-                } else {
-                    return (cachedValue = compute());
-                }
-            };
-        } else if (!flags.update && !compute) {
-            throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-        } else {
-            return () => this.#propertyUpdateValues[property];
-        }
-    }
-
-    #getExposeComputeFunction(property) {
-        const { flags, expose } = this.#getPropertyDescriptor(property);
-
-        const compute = expose?.compute;
-        const transform = expose?.transform;
-
-        if (flags.update && !transform) {
-            return null;
-        } else if (flags.update && compute) {
-            throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
-        } else if (!flags.update && !compute) {
-            throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-        }
-
-        const dependencyKeys = expose.dependencies || [];
-        const dependencyGetters = dependencyKeys.map(key => () => [key, this.#propertyUpdateValues[key]]);
-        const getAllDependencies = () => Object.fromEntries(dependencyGetters.map(f => f())
-            .concat([[this.constructor.instance, this]]));
-
-        if (flags.update) {
-            return () => transform(this.#propertyUpdateValues[property], getAllDependencies());
-        } else {
-            return () => compute(getAllDependencies());
-        }
-    }
-
-    #getExposeCheckCacheValidFunction(property) {
-        const { flags, expose } = this.#getPropertyDescriptor(property);
-
-        let valid = false;
-
-        const invalidate = () => {
-            valid = false;
-        };
-
-        const dependencyKeys = new Set(expose?.dependencies);
-
-        if (flags.update) {
-            dependencyKeys.add(property);
-        }
-
-        for (const key of dependencyKeys) {
-            if (this.#propertyUpdateCacheInvalidators[key]) {
-                this.#propertyUpdateCacheInvalidators[key].push(invalidate);
-            } else {
-                this.#propertyUpdateCacheInvalidators[key] = [invalidate];
-            }
-        }
-
-        return () => {
-            if (!valid) {
-                valid = true;
-                return false;
-            } else {
-                return true;
-            }
-        };
-    }
-
-    static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false;
-    static _invalidAccesses = new Set();
-
-    static showInvalidAccesses() {
-        if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-            return;
-        }
-
-        if (!this._invalidAccesses.size) {
-            return;
-        }
-
-        console.log(`${this._invalidAccesses.size} unique invalid accesses:`);
-        for (const line of this._invalidAccesses) {
-            console.log(` - ${line}`);
-        }
-    }
-}
diff --git a/src/thing/flash.js b/src/thing/flash.js
deleted file mode 100644
index 4eac65ad..00000000
--- 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 3b92e957..00000000
--- 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 47173917..00000000
--- 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 2db2f37c..00000000
--- 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 e2b51507..00000000
--- 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 364ba149..00000000
--- 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 2d6def62..00000000
--- 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 3edabc92..00000000
--- 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/validators.js b/src/thing/validators.js
deleted file mode 100644
index 83922229..00000000
--- a/src/thing/validators.js
+++ /dev/null
@@ -1,327 +0,0 @@
-import { withAggregate } from '../util/sugar.js';
-
-import { color, ENABLE_COLOR } from '../util/cli.js';
-
-import { inspect as nodeInspect } from 'util';
-
-function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
-}
-
-// Basic types (primitives)
-
-function a(noun) {
-    return (/[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`);
-}
-
-function isType(value, type) {
-    if (typeof value !== type)
-        throw new TypeError(`Expected ${a(type)}, got ${typeof value}`);
-
-    return true;
-}
-
-export function isBoolean(value) {
-    return isType(value, 'boolean');
-}
-
-export function isNumber(value) {
-    return isType(value, 'number');
-}
-
-export function isPositive(number) {
-    isNumber(number);
-
-    if (number <= 0)
-        throw new TypeError(`Expected positive number`);
-
-    return true;
-}
-
-export function isNegative(number) {
-    isNumber(number);
-
-    if (number >= 0)
-        throw new TypeError(`Expected negative number`);
-
-    return true;
-}
-
-export function isPositiveOrZero(number) {
-    isNumber(number);
-
-    if (number < 0)
-        throw new TypeError(`Expected positive number or zero`);
-
-    return true;
-}
-
-export function isNegativeOrZero(number) {
-    isNumber(number);
-
-    if (number > 0)
-        throw new TypeError(`Expected negative number or zero`);
-
-    return true;
-}
-
-export function isInteger(number) {
-    isNumber(number);
-
-    if (number % 1 !== 0)
-        throw new TypeError(`Expected integer`);
-
-    return true;
-}
-
-export function isCountingNumber(number) {
-    isInteger(number);
-    isPositive(number);
-
-    return true;
-}
-
-export function isString(value) {
-    return isType(value, 'string');
-}
-
-export function isStringNonEmpty(value) {
-    isString(value);
-
-    if (value.trim().length === 0)
-        throw new TypeError(`Expected non-empty string`);
-
-    return true;
-}
-
-// Complex types (non-primitives)
-
-export function isInstance(value, constructor) {
-    isObject(value);
-
-    if (!(value instanceof constructor))
-        throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`);
-
-    return true;
-}
-
-export function isDate(value) {
-    return isInstance(value, Date);
-}
-
-export function isObject(value) {
-    isType(value, 'object');
-
-    // Note: Please remember that null is always a valid value for properties
-    // held by a CacheableObject. This assertion is exclusively for use in other
-    // contexts.
-    if (value === null)
-        throw new TypeError(`Expected an object, got null`);
-
-    return true;
-}
-
-export function isArray(value) {
-    isObject(value);
-
-    if (!Array.isArray(value))
-        throw new TypeError(`Expected an array, got ${value}`);
-
-    return true;
-}
-
-function validateArrayItemsHelper(itemValidator) {
-    return (item, index) => {
-        try {
-            const value = itemValidator(item);
-
-            if (value !== true) {
-                throw new Error(`Expected validator to return true`);
-            }
-        } catch (error) {
-            error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`;
-            throw error;
-        }
-    };
-}
-
-export function validateArrayItems(itemValidator) {
-    const fn = validateArrayItemsHelper(itemValidator);
-
-    return array => {
-        isArray(array);
-
-        withAggregate({message: 'Errors validating array items'}, ({ wrap }) => {
-            array.forEach(wrap(fn));
-        });
-
-        return true;
-    };
-}
-
-export function validateInstanceOf(constructor) {
-    return object => isInstance(object, constructor);
-}
-
-// Wiki data (primitives & non-primitives)
-
-export function isColor(color) {
-    isStringNonEmpty(color);
-
-    if (color.startsWith('#')) {
-        if (![1 + 3, 1 + 4, 1 + 6, 1 + 8].includes(color.length))
-            throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`);
-
-        if (/[^0-9a-fA-F]/.test(color.slice(1)))
-            throw new TypeError(`Expected hexadecimal digits`);
-
-        return true;
-    }
-
-    throw new TypeError(`Unknown color format`);
-}
-
-export function isCommentary(commentary) {
-    return isString(commentary);
-}
-
-const isArtistRef = validateReference('artist');
-
-export function isContribution(contrib) {
-    // TODO: Use better object validation for this (supporting aggregates etc)
-
-    isObject(contrib);
-
-    isArtistRef(contrib.who);
-
-    if (contrib.what !== null) {
-        isStringNonEmpty(contrib.what);
-    }
-
-    return true;
-}
-
-export const isContributionList = validateArrayItems(isContribution);
-
-export function isDimensions(dimensions) {
-    isArray(dimensions);
-
-    if (dimensions.length !== 2)
-        throw new TypeError(`Expected 2 item array`);
-
-    isPositive(dimensions[0]);
-    isInteger(dimensions[0]);
-    isPositive(dimensions[1]);
-    isInteger(dimensions[1]);
-
-    return true;
-}
-
-export function isDirectory(directory) {
-    isStringNonEmpty(directory);
-
-    if (directory.match(/[^a-zA-Z0-9_\-]/))
-        throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`);
-
-    return true;
-}
-
-export function isDuration(duration) {
-    isNumber(duration);
-    isPositiveOrZero(duration);
-
-    return true;
-}
-
-export function isFileExtension(string) {
-    isStringNonEmpty(string);
-
-    if (string[0] === '.')
-        throw new TypeError(`Expected no dot (.) at the start of file extension`);
-
-    if (string.match(/[^a-zA-Z0-9_]/))
-        throw new TypeError(`Expected only alphanumeric and underscore`);
-
-    return true;
-}
-
-export function isLanguageCode(string) {
-    // TODO: This is a stub function because really we don't need a detailed
-    // is-language-code parser right now.
-
-    isString(string);
-
-    return true;
-}
-
-export function isName(name) {
-    return isString(name);
-}
-
-export function isURL(string) {
-    isStringNonEmpty(string);
-
-    new URL(string);
-
-    return true;
-}
-
-export function validateReference(type = 'track') {
-    return ref => {
-        isStringNonEmpty(ref);
-
-        const match = ref.trim().match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
-
-        if (!match)
-            throw new TypeError(`Malformed reference`);
-
-        const { groups: { typePart, directoryPart } } = match;
-
-        if (typePart && typePart !== type)
-            throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`);
-
-        if (typePart)
-            isDirectory(directoryPart);
-
-        isName(ref);
-
-        return true;
-    };
-}
-
-export function validateReferenceList(type = '') {
-    return validateArrayItems(validateReference(type));
-}
-
-// Compositional utilities
-
-export function oneOf(...checks) {
-    return value => {
-        const errorMeta = [];
-
-        for (let i = 0, check; check = checks[i]; i++) {
-            try {
-                const result = check(value);
-
-                if (result !== true) {
-                    throw new Error(`Check returned false`);
-                }
-
-                return true;
-            } catch (error) {
-                errorMeta.push([check, i, error]);
-            }
-        }
-
-        // Don't process error messages until every check has failed.
-        const errors = [];
-        for (const [ check, i, error ] of errorMeta) {
-            error.message = (check.name
-                ? `(#${i} "${check.name}") ${error.message}`
-                : `(#${i}) ${error.message}`);
-            error.check = check;
-            errors.push(error);
-        }
-        throw new AggregateError(errors, `Expected one of ${checks.length} possible checks, but none were true`);
-    };
-}
diff --git a/src/thing/wiki-info.js b/src/thing/wiki-info.js
deleted file mode 100644
index b805bf76..00000000
--- 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}
-        },
-    };
-}