« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data
diff options
context:
space:
mode:
Diffstat (limited to 'src/data')
-rw-r--r--src/data/cacheable-object.js309
-rw-r--r--src/data/things.js783
-rw-r--r--src/data/validators.js327
3 files changed, 1419 insertions, 0 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
new file mode 100644
index 00000000..99280956
--- /dev/null
+++ b/src/data/cacheable-object.js
@@ -0,0 +1,309 @@
+// 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() {
+        if (!this.constructor.propertyDescriptors) {
+            throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`);
+        }
+
+        for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) {
+            const { flags } = descriptor;
+
+            const definition = {
+                configurable: false,
+                enumerable: 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/data/things.js b/src/data/things.js
new file mode 100644
index 00000000..66176013
--- /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/data/validators.js b/src/data/validators.js
new file mode 100644
index 00000000..83922229
--- /dev/null
+++ b/src/data/validators.js
@@ -0,0 +1,327 @@
+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`);
+    };
+}