« get me outta code hell

generalized reference errors - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2022-02-27 12:13:53 -0400
committer(quasar) nebula <qznebula@protonmail.com>2022-02-27 12:13:53 -0400
commitcd3a59d5024984a2d3be5164f6b2ce9ee05e9f62 (patch)
tree515c7457fcf752aee238af89d3c7888956e71e7a
parent7ffc79f9c891becdcf778ab6e5faf4c8ca3b14da (diff)
generalized reference errors
-rw-r--r--src/data/things.js76
-rw-r--r--src/data/validators.js4
-rw-r--r--src/page/homepage.js2
-rwxr-xr-xsrc/upd8.js349
-rw-r--r--src/util/find.js75
-rw-r--r--src/util/replacer.js12
6 files changed, 271 insertions, 247 deletions
diff --git a/src/data/things.js b/src/data/things.js
index c09f740c..475c4e1d 100644
--- a/src/data/things.js
+++ b/src/data/things.js
@@ -34,6 +34,9 @@ import {
 
 import find from '../util/find.js';
 
+import { inspect } from 'util';
+import { color } from '../util/cli.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
@@ -216,17 +219,17 @@ Thing.common = {
     // matching actual Thing-subclass objects.
     dynamicThingsFromReferenceList: (
         referenceListProperty,
-        wikiDataProperty,
+        thingDataProperty,
         findFn
     ) => ({
         flags: {expose: true},
 
         expose: {
-            dependencies: [referenceListProperty, wikiDataProperty],
-            compute: ({ [referenceListProperty]: refs, [wikiDataProperty]: wikiData }) => (
-                (refs && wikiData
+            dependencies: [referenceListProperty, thingDataProperty],
+            compute: ({ [referenceListProperty]: refs, [thingDataProperty]: thingData }) => (
+                (refs && thingData
                     ? (refs
-                        .map(ref => findFn(ref, {wikiData: {[wikiDataProperty]: wikiData}}))
+                        .map(ref => findFn(ref, thingData, {mode: 'quiet'}))
                         .filter(Boolean))
                     : [])
             )
@@ -236,17 +239,15 @@ Thing.common = {
     // Corresponding function for a single reference.
     dynamicThingFromSingleReference: (
         singleReferenceProperty,
-        wikiDataProperty,
+        thingDataProperty,
         findFn
     ) => ({
         flags: {expose: true},
 
         expose: {
-            dependencies: [singleReferenceProperty, wikiDataProperty],
-            compute: ({ [singleReferenceProperty]: ref, [wikiDataProperty]: wikiData }) => (
-                (ref && wikiData
-                    ? findFn(ref, {wikiData: {[wikiDataProperty]: wikiData}})
-                    : [])
+            dependencies: [singleReferenceProperty, thingDataProperty],
+            compute: ({ [singleReferenceProperty]: ref, [thingDataProperty]: thingData }) => (
+                (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : [])
             )
         }
     }),
@@ -274,7 +275,7 @@ Thing.common = {
                 ((contribsByRef && artistData)
                     ? (contribsByRef
                         .map(({ who: ref, what }) => ({
-                            who: find.artist(ref, {wikiData: {artistData}}),
+                            who: find.artist(ref, artistData),
                             what
                         }))
                         .filter(({ who }) => who))
@@ -293,24 +294,24 @@ Thing.common = {
     dynamicInheritContribs: (
         contribsByRefProperty,
         parentContribsByRefProperty,
-        wikiDataProperty,
+        thingDataProperty,
         findFn
     ) => ({
         flags: {expose: true},
         expose: {
-            dependencies: [contribsByRefProperty, wikiDataProperty, 'artistData'],
+            dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'],
             compute({
                 [Thing.instance]: thing,
                 [contribsByRefProperty]: contribsByRef,
-                [wikiDataProperty]: wikiData,
+                [thingDataProperty]: thingData,
                 artistData
             }) {
                 if (!artistData) return [];
-                const refs = (contribsByRef ?? findFn(thing, wikiData)?.[parentContribsByRefProperty]);
+                const refs = (contribsByRef ?? findFn(thing, thingData, {mode: 'quiet'})?.[parentContribsByRefProperty]);
                 if (!refs) return [];
                 return (refs
                     .map(({ who: ref, what }) => ({
-                        who: find.artist(ref, {wikiData: {artistData}}),
+                        who: find.artist(ref, artistData),
                         what
                     }))
                     .filter(({ who }) => who));
@@ -375,7 +376,7 @@ Thing.common = {
                         .from(commentary
                             .replace(/<\/?b>/g, '')
                             .matchAll(/<i>(?<who>.*?):<\/i>/g))
-                        .map(({ groups: {who} }) => find.artist(who, {wikiData: {artistData}, quiet: true})))))
+                        .map(({ groups: {who} }) => find.artist(who, artistData, {mode: 'quiet'})))))
                     : []))
         }
     }),
@@ -394,6 +395,18 @@ Thing.getReference = function(thing) {
     return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
 };
 
+// Default custom inspect function, which may be overridden by Thing subclasses.
+// This will be used when displaying aggregate errors and other in command-line
+// logging - it's the place to provide information useful in identifying the
+// Thing being presented.
+Thing.prototype[inspect.custom] = function() {
+    const cname = this.constructor.name;
+
+    return (this.name
+        ? `${cname} ${color.green(`"${this.name}"`)}`
+        : `${cname}`);
+};
+
 // -> Album
 
 Album.propertyDescriptors = {
@@ -476,7 +489,7 @@ Album.propertyDescriptors = {
                 (trackGroups && trackData
                     ? (trackGroups
                         .flatMap(group => group.tracksByRef ?? [])
-                        .map(ref => find.track(ref, {wikiData: {trackData}}))
+                        .map(ref => find.track(ref, trackData))
                         .filter(Boolean))
                     : [])
             )
@@ -537,7 +550,7 @@ TrackGroup.propertyDescriptors = {
             compute: ({ tracksByRef, trackData }) => (
                 (tracksByRef && trackData
                     ? (tracksByRef
-                        .map(ref => find.track(ref, {wikiData: {trackData}}))
+                        .map(ref => find.track(ref, trackData))
                         .filter(Boolean))
                     : [])
             )
@@ -570,13 +583,13 @@ Track.propertyDescriptors = {
 
     hasURLs: Thing.common.flag(true),
 
-    referencedTracksByRef: Thing.common.referenceList(Track),
-    artTagsByRef: Thing.common.referenceList(ArtTag),
-
     artistContribsByRef: Thing.common.contribsByRef(),
     contributorContribsByRef: Thing.common.contribsByRef(),
     coverArtistContribsByRef: Thing.common.contribsByRef(),
 
+    referencedTracksByRef: Thing.common.referenceList(Track),
+    artTagsByRef: Thing.common.referenceList(ArtTag),
+
     hasCoverArt: {
         flags: {update: true, expose: true},
 
@@ -676,7 +689,7 @@ Track.propertyDescriptors = {
                     return [];
                 }
 
-                const tOrig = find.track(ref1, {wikiData: {trackData}});
+                const tOrig = find.track(ref1, trackData);
                 if (!tOrig) {
                     return [];
                 }
@@ -688,7 +701,7 @@ Track.propertyDescriptors = {
                         return (
                             t2 !== t1 &&
                             ref2 &&
-                            find.track(ref2, {wikiData: {trackData}}) === tOrig
+                            find.track(ref2, trackData) === tOrig
                         );
                     })
                 ];
@@ -717,6 +730,13 @@ Track.propertyDescriptors = {
     artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag),
 };
 
+Track.prototype[inspect.custom] = function() {
+    const base = Thing.prototype[inspect.custom].apply(this);
+    return (this.album?.name
+        ? base + ` (from ${color.green(this.album.name)})`
+        : base);
+}
+
 // -> Artist
 
 Artist.filterByContrib = (thingDataProperty, contribsProperty) => ({
@@ -765,7 +785,7 @@ Artist.propertyDescriptors = {
             dependencies: ['artistData', 'aliasedArtistRef'],
             compute: ({ artistData, aliasedArtistRef }) => (
                 (aliasedArtistRef && artistData
-                    ? find.artist(aliasedArtistRef, {wikiData: {artistData}}, {quiet: true})
+                    ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'})
                     : null)
             )
         }
@@ -1095,10 +1115,10 @@ Flash.propertyDescriptors = {
         update: {validate: isFileExtension}
     },
 
-    featuredTracksByRef: Thing.common.referenceList(Track),
-
     contributorContribsByRef: Thing.common.contribsByRef(),
 
+    featuredTracksByRef: Thing.common.referenceList(Track),
+
     urls: Thing.common.urls(),
 
     // Update only
diff --git a/src/data/validators.js b/src/data/validators.js
index 8f4d06d7..2d90987f 100644
--- a/src/data/validators.js
+++ b/src/data/validators.js
@@ -155,7 +155,7 @@ function validateArrayItemsHelper(itemValidator) {
 export function validateArrayItems(itemValidator) {
     const fn = validateArrayItemsHelper(itemValidator);
 
-    return decorateTime('validateArrayItems -> work', array => {
+    return array => {
         isArray(array);
 
         withAggregate({message: 'Errors validating array items'}, ({ wrap }) => {
@@ -163,7 +163,7 @@ export function validateArrayItems(itemValidator) {
         });
 
         return true;
-    });
+    };
 }
 
 export function validateInstanceOf(constructor) {
diff --git a/src/page/homepage.js b/src/page/homepage.js
index 379e5ded..aa99527c 100644
--- a/src/page/homepage.js
+++ b/src/page/homepage.js
@@ -4,8 +4,6 @@
 
 import fixWS from 'fix-whitespace';
 
-import find from '../util/find.js';
-
 import * as html from '../util/html.js';
 
 import {
diff --git a/src/upd8.js b/src/upd8.js
index 45538260..e903d6af 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -424,7 +424,7 @@ const replacerSpec = {
     }
 };
 
-if (!validateReplacerSpec(replacerSpec, unbound_link)) {
+if (!validateReplacerSpec(replacerSpec, {find, link: unbound_link})) {
     process.exit();
 }
 
@@ -2049,6 +2049,35 @@ async function wrapLanguages(fn, {writeOneLanguage = null}) {
     }
 }
 
+// Handy utility function for binding the find.thing() functions to a complete
+// wikiData object, optionally taking default options to provide to the find
+// function. Note that this caches the arrays read from wikiData right when it's
+// called, so if their values change, you'll have to continue with a fresh call
+// to indFind.
+function bindFind(wikiData, opts1) {
+    return Object.fromEntries(Object.entries({
+        album: 'albumData',
+        artist: 'artistData',
+        artTag: 'artTagData',
+        flash: 'flashData',
+        group: 'groupData',
+        listing: 'listingSpec',
+        newsEntry: 'newsData',
+        staticPage: 'staticPageData',
+        track: 'trackData',
+    }).map(([ key, value ]) => {
+        const findFn = find[key];
+        const thingData = wikiData[value];
+        return [key, (opts1
+            ? (ref, opts2) => (opts2
+                ? findFn(ref, thingData, {...opts1, ...opts2})
+                : findFn(ref, thingData, opts1))
+            : (ref, opts2) => (opts2
+                ? findFn(ref, thingData, opts2)
+                : findFn(ref, thingData)))];
+    }));
+}
+
 async function main() {
     Error.stackTraceLimit = Infinity;
 
@@ -2109,6 +2138,13 @@ async function main() {
             type: 'flag'
         },
 
+        // Just working on data entries and not interested in actually
+        // generating site HTML yet? This flag will cut execution off right
+        // 8efore any site 8uilding actually happens.
+        'no-build': {
+            type: 'flag'
+        },
+
         // Only want to 8uild one language during testing? This can chop down
         // 8uild times a pretty 8ig chunk! Just pass a single language code.
         'lang': {
@@ -2182,6 +2218,7 @@ async function main() {
 
     const skipThumbs = miscOptions['skip-thumbs'] ?? false;
     const thumbsOnly = miscOptions['thumbs-only'] ?? false;
+    const noBuild = miscOptions['no-build'] ?? false;
     const showAggregateTraces = miscOptions['show-traces'] ?? false;
 
     const niceShowAggregate = (error, ...opts) => {
@@ -2244,7 +2281,9 @@ async function main() {
 
     logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
 
-    if (writeOneLanguage && !(writeOneLanguage in languages)) {
+    if (noBuild) {
+        logInfo`Not generating any site or page files this run (--no-build passed).`;
+    } else if (writeOneLanguage && !(writeOneLanguage in languages)) {
         logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`;
         return;
     } else if (writeOneLanguage) {
@@ -2765,26 +2804,138 @@ async function main() {
             albumData: sortByDate(WD.albumData.slice()),
             trackData: sortByDate(WD.trackData.slice())
         });
-    }
 
-    // Now post-process data in three steps...
+        // Re-link data arrays, so that every object has the new, sorted
+        // versions. Note that the sorting step deliberately creates new arrays
+        // (mutating slices instead of the original arrays) - this is so that
+        // the object caching system understands that it's working with a new
+        // ordering.  We still need to actually provide those updated arrays
+        // over again!
+        linkDataArrays();
+    }
+
+    // Warn about references across data which don't match anything.
+    // This involves using the find() functions on all references, setting it to
+    // 'error' mode, and collecting everything in a structured logged (which
+    // gets logged if there are any errors). At the same time, we remove errored
+    // references from the thing's data array.
+
+    const referenceSpec = [
+        ['albumData', {
+            artistContribsByRef: '_contrib',
+            coverArtistContribsByRef: '_contrib',
+            trackCoverArtistContribsByRef: '_contrib',
+            wallpaperArtistContribsByRef: '_contrib',
+            bannerArtistContribsByRef: '_contrib',
+            groupsByRef: 'group',
+            artTagsByRef: 'artTag',
+        }],
+
+        ['trackData', {
+            artistContribsByRef: '_contrib',
+            contributorContribsByRef: '_contrib',
+            coverArtistContribsByRef: '_contrib',
+            referencedTracksByRef: 'track',
+            artTagsByRef: 'artTag',
+            originalReleaseTrackByRef: 'track',
+        }],
+
+        ['groupCategoryData', {
+            groupsByRef: 'group',
+        }],
+
+        ['homepageLayout.rows', {
+            sourceGroupsByRef: 'group',
+            sourceAlbumsByRef: 'album',
+        }],
+
+        ['flashData', {
+            contributorContribsByRef: '_contrib',
+            featuredTracksByRef: 'track',
+        }],
+
+        ['flashActData', {
+            flashesByRef: 'flash',
+        }],
+    ];
+
+    function getNestedProp(obj, key) {
+        const recursive = (o, k) => (k.length === 1
+            ? o[k[0]]
+            : recursive(o[k[0]], k.slice(1)));
+        const keys = key.split(/(?<=(?<!\\)(?:\\\\)*)\./);
+        return recursive(obj, keys);
+    }
+
+    function filterAndShowReferenceErrors() {
+        const aggregate = openAggregate({message: `Errors validating between-thing references in data`});
+        const boundFind = bindFind(wikiData, {mode: 'error'});
+        for (const [ thingDataProp, propSpec ] of referenceSpec) {
+            const thingData = getNestedProp(wikiData, thingDataProp);
+            aggregate.nest({message: `Reference errors in ${color.green('wikiData.' + thingDataProp)}`}, ({ nest }) => {
+                for (const thing of thingData) {
+                    nest({message: `Reference errors in ${inspect(thing)}`}, ({ filter }) => {
+                        for (const [ property, findFnKey ] of Object.entries(propSpec)) {
+                            if (!thing[property]) continue;
+                            if (findFnKey === '_contrib') {
+                                thing[property] = filter(thing[property],
+                                    decorateErrorWithIndex(({ who }) => boundFind.artist(who)),
+                                    {message: `Reference errors in contributions ${color.green(property)}`});
+                                continue;
+                            }
+                            const findFn = boundFind[findFnKey];
+                            const value = thing[property];
+                            if (Array.isArray(value)) {
+                                thing[property] = filter(value, decorateErrorWithIndex(findFn),
+                                    {message: `Reference errors in property ${color.green(property)}`});
+                            } else {
+                                nest({message: `Reference error in property ${color.green(property)}`}, ({ call }) => {
+                                    try {
+                                        call(findFn, value);
+                                    } catch (error) {
+                                        thing[property] = null;
+                                        throw error;
+                                    }
+                                });
+                            }
+                        }
+                    });
+                }
+            });
+        }
 
-    // 1. Link data arrays so that all essential references between objects are
-    // are complete, so properties (like dates!) are inherited where that's
+        let errorless = true;
+        try {
+            aggregate.close();
+        } catch (error) {
+            niceShowAggregate(error);
+            logWarn`The above errors were detected while validating references in data files.`;
+            logWarn`If the remaining valid data is complete enough, the wiki will still build -`;
+            logWarn`but all errored references will be skipped.`;
+            logWarn`(Resolve errors for more complete output!)`;
+            errorless = false;
+        }
+
+        if (errorless) {
+            logInfo`All references validated without any errors - nice!`;
+            logInfo`(This means all references between things, such as leitmotif references`
+            logInfo` and artist credits, will be fully accounted for during page generation.)`;
+        }
+    }
+
+    // Link data arrays so that all essential references between objects are
+    // complete, so properties (like dates!) are inherited where that's
     // appropriate.
     linkDataArrays();
 
-    // 2. Sort data arrays so that they're all in order! This may use properties
+    // Filter out any reference errors throughout the data, warning about them
+    // too.
+    filterAndShowReferenceErrors();
+
+    // Sort data arrays so that they're all in order! This may use properties
     // which are only available after the initial linking.
     sortDataArrays();
 
-    // 3. Re-link data arrays, so that every object has the new, sorted
-    // versions. Note that the sorting step deliberately creates new arrays
-    // (mutating slices instead of the original arrays) - this is so that the
-    // object caching system understands that it's working with a new ordering.
-    // We still need to actually provide those updated arrays over again!
-    linkDataArrays();
-
     // const track = WD.trackData.find(t => t.name === 'Under the Sun');
     // console.log(track.album.trackGroups.find(tg => tg.tracks.includes(track)).color, track.color);
     // console.log(WD.homepageLayout.rows[0].countAlbumsFromGroup);
@@ -2816,7 +2967,7 @@ async function main() {
         const tagRefs = new Set([...WD.trackData, ...WD.albumData].flatMap(thing => thing.artTagsByRef ?? []));
 
         for (const ref of tagRefs) {
-            if (find.artTag(ref, {wikiData})) {
+            if (find.artTag(ref, WD.artTagData)) {
                 tagRefs.delete(ref);
             }
         }
@@ -2845,75 +2996,6 @@ async function main() {
         }) ?? []);
     });
 
-    // TODO: this should probably be some kinda generalized function lol
-    {
-        const aggregate = openAggregate({message: `Errors validating artist references in data`});
-
-        const sources = [
-            [WD.albumData, [
-                'artistContribsByRef',
-                'coverArtistContribsByRef',
-                'trackCoverArtistContribsByRef',
-                'wallpaperArtistContribsByRef',
-                'bannerArtistContribsByRef'
-            ]],
-            [WD.trackData, [
-                'artistContribsByRef',
-                'contributorContribsByRef',
-                'coverArtistContribsByRef'
-            ]],
-            [WD.flashData, [
-                'contributorContribsByRef'
-            ]]
-        ]
-
-        for (const [ things, properties ] of sources) {
-            if (!things) {
-                continue;
-            }
-
-            for (const thing of things) {
-                aggregate.nest({message: `Errors for ${thing.constructor.name} ${color.green(thing.name)} (${color.green(Thing.getReference(thing))})`}, thingAgg => {
-                    for (const prop of properties) {
-                        const contribs = thing[prop];
-                        if (!contribs) {
-                            continue;
-                        }
-                        thingAgg.nest({message: `Errors for property ${color.green(prop)}`}, propAgg => {
-                            for (const { who: ref } of contribs) {
-                                propAgg.call(() => {
-                                    const entryAlias = find.artist(ref, {wikiData: {artistData: wikiData.artistAliasData}, quiet: true});
-                                    if (entryAlias) {
-                                        const orig = find.artist(entryAlias.aliasedArtistRef, {wikiData: {artistData: wikiData.artistData}, quiet: true});
-                                        throw new Error(`Reference ${color.red(ref)} is to an alias, reference ${color.green(orig.name)} instead`);
-                                    }
-                                    const entry = find.artist(ref, {wikiData: {artistData: wikiData.artistData}, quiet: true});
-                                    if (!entry) {
-                                        throw new Error(`No entry found for reference ${color.red(ref)}`);
-                                    }
-                                    if (
-                                        ref.toLowerCase() === entry.name.toLowerCase() &&
-                                        ref !== entry.name
-                                    ) {
-                                        throw new Error(`Miscapitalized name ${color.red(ref)}, reference ${color.green(entry.name)} instead`);
-                                    }
-                                });
-                            }
-                        });
-                    }
-                });
-            }
-        }
-
-        try {
-            aggregate.close();
-        } catch (error) {
-            niceShowAggregate(error);
-            // TODO: more graceful auto-resolve, filter out invalid references
-            return;
-        }
-    }
-
     {
         const directories = [];
         for (const { directory, name } of WD.albumData) {
@@ -2941,103 +3023,11 @@ async function main() {
         }
     }
 
-    /*
-    const bound = {
-        findGroup: x => find.group(x, {wikiData}),
-        findTrack: x => find.track(x, {wikiData}),
-        findTag: x => find.tag(x, {wikiData})
-    };
-
-    for (const track of WD.trackData) {
-        const context = () => track.album.name;
-        track.aka = find.track(track.aka, {wikiData});
-        mapAndFilter(track, 'references', {map: bound.findTrack, context});
-        mapAndFilter(track, 'artTags', {map: bound.findTag, context});
-    }
-
-    for (const track1 of WD.trackData) {
-        track1.referencedBy = WD.trackData.filter(track2 => track2.references.includes(track1));
-        track1.otherReleases = [
-            track1.aka,
-            ...WD.trackData.filter(track2 =>
-                track2.aka === track1 ||
-                (track1.aka && track2.aka === track1.aka))
-        ].filter(x => x && x !== track1);
-    }
-
-    for (const album of WD.albumData) {
-        mapAndFilter(album, 'groups', {map: bound.findGroup});
-        mapAndFilter(album, 'artTags', {map: bound.findTag});
-    }
-
-    mapAndFilter(WD, 'artistAliasData', {
-        map: artist => {
-            artist.alias = find.artist(artist.alias, {wikiData});
-            return artist;
-        },
-        filter: artist => artist.alias
-    });
-
-    for (const group of WD.groupData) {
-        group.albums = WD.albumData.filter(album => album.groups.includes(group));
-        group.category = WD.groupCategoryData.find(x => x.name === group.category);
-    }
-
-    for (const category of WD.groupCategoryData) {
-        category.groups = WD.groupData.filter(x => x.category === category);
-    }
-
-    const albumAndTrackDataSortedByArtDateMan = sortByArtDate([...WD.albumData, ...WD.trackData]);
-
-    for (const tag of WD.artTagData) {
-        tag.things = albumAndTrackDataSortedByArtDateMan.filter(thing => thing.artTags.includes(tag));
-    }
-
-    if (WD.wikiInfo.enableFlashesAndGames) {
-        for (const flash of WD.flashData) {
-            flash.act = WD.flashActData.find(act => act.name === flash.act);
-            mapAndFilter(flash, 'tracks', {map: bound.findTrack});
-        }
-
-        for (const act of WD.flashActData) {
-            act.flashes = WD.flashData.filter(flash => flash.act === act);
-        }
-
-        for (const track of WD.trackData) {
-            track.flashes = WD.flashData.filter(flash => flash.tracks.includes(track));
-        }
-    }
-
-    for (const artist of WD.artistData) {
-        const filterProp = (array, prop) => array.filter(thing => thing[prop]?.some(({ who }) => who === artist));
-        const filterCommentary = array => array.filter(thing => thing.commentary && thing.commentary.replace(/<\/?b>/g, '').includes('<i>' + artist.name + ':</i>'));
-        artist.tracks = {
-            asArtist: filterProp(WD.trackData, 'artists'),
-            asCommentator: filterCommentary(WD.trackData),
-            asContributor: filterProp(WD.trackData, 'contributors'),
-            asCoverArtist: filterProp(WD.trackData, 'coverArtists'),
-            asAny: WD.trackData.filter(track => (
-                [...track.artists, ...track.contributors, ...track.coverArtists || []].some(({ who }) => who === artist)
-            ))
-        };
-        artist.albums = {
-            asArtist: filterProp(WD.albumData, 'artists'),
-            asCommentator: filterCommentary(WD.albumData),
-            asCoverArtist: filterProp(WD.albumData, 'coverArtists'),
-            asWallpaperArtist: filterProp(WD.albumData, 'wallpaperArtists'),
-            asBannerArtist: filterProp(WD.albumData, 'bannerArtists')
-        };
-        if (WD.wikiInfo.enableFlashesAndGames) {
-            artist.flashes = {
-                asContributor: filterProp(WD.flashData, 'contributors')
-            };
-        }
-    }
-    */
-
     WD.officialAlbumData = WD.albumData.filter(album => album.groups.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY));
     WD.fandomAlbumData = WD.albumData.filter(album => album.groups.every(group => group.directory !== OFFICIAL_GROUP_DIRECTORY));
 
+    if (noBuild) return;
+
     // Makes writing a little nicer on CPU theoretically, 8ut also costs in
     // performance right now 'cuz it'll w8 for file writes to 8e completed
     // 8efore moving on to more data processing. So, defaults to zero, which
@@ -3248,7 +3238,10 @@ async function main() {
                     to
                 });
 
+                bound.find = bindFind(wikiData, {mode: 'warn'});
+
                 bound.transformInline = bindOpts(transformInline, {
+                    find: bound.find,
                     link: bound.link,
                     replacerSpec,
                     strings,
diff --git a/src/util/find.js b/src/util/find.js
index e8e04a5b..fc82ba9e 100644
--- a/src/util/find.js
+++ b/src/util/find.js
@@ -1,9 +1,21 @@
 import {
+    color,
     logError,
     logWarn
 } from './cli.js';
 
-function findHelper(keys, dataProp, findFns = {}) {
+function warnOrThrow(mode, message) {
+    switch (mode) {
+        case 'error':
+            throw new Error(message);
+        case 'warn':
+            logWarn(message);
+        default:
+            return null;
+    }
+}
+
+function findHelper(keys, findFns = {}) {
     // Note: This cache explicitly *doesn't* support mutable data arrays. If the
     // data array is modified, make sure it's actually a new array object, not
     // the original, or the cache here will break and act as though the data
@@ -15,18 +27,24 @@ function findHelper(keys, dataProp, findFns = {}) {
 
     const keyRefRegex = new RegExp(String.raw`^(?:(${keys.join('|')}):(?=\S))?(.*)$`);
 
-    return (fullRef, {wikiData, quiet = false}) => {
+    // The mode argument here may be 'warn', 'error', or 'quiet'. 'error' throws
+    // errors for null matches (with details about the error), while 'warn' and
+    // 'quiet' both return null, with 'warn' logging details directly to the
+    // console.
+    return (fullRef, data, {mode = 'warn'} = {}) => {
         if (!fullRef) return null;
         if (typeof fullRef !== 'string') {
             throw new Error(`Got a reference that is ${typeof fullRef}, not string: ${fullRef}`);
         }
 
-        const data = wikiData[dataProp];
-
         if (!data) {
             throw new Error(`Expected data to be present`);
         }
 
+        if (!Array.isArray(data) && data.wikiData) {
+            throw new Error(`Old {wikiData: {...}} format provided`);
+        }
+
         let cacheForThisData = cache.get(data);
         const cachedValue = cacheForThisData?.[fullRef];
         if (cachedValue) {
@@ -40,18 +58,18 @@ function findHelper(keys, dataProp, findFns = {}) {
 
         const match = fullRef.match(keyRefRegex);
         if (!match) {
-            throw new Error(`Malformed link reference: "${fullRef}"`);
+            return warnOrThrow(mode, `Malformed link reference: "${fullRef}"`);
         }
 
         const key = match[1];
         const ref = match[2];
 
         const found = (key
-            ? byDirectory(ref, data, quiet)
-            : byName(ref, data, quiet));
+            ? byDirectory(ref, data, mode)
+            : byName(ref, data, mode));
 
-        if (!found && !quiet) {
-            logWarn`Didn't match anything for ${fullRef}!`;
+        if (!found) {
+            warnOrThrow(mode, `Didn't match anything for ${color.bright(fullRef)}`);
         }
 
         cacheForThisData[fullRef] = found;
@@ -60,23 +78,18 @@ function findHelper(keys, dataProp, findFns = {}) {
     };
 }
 
-function matchDirectory(ref, data, quiet) {
+function matchDirectory(ref, data, mode) {
     return data.find(({ directory }) => directory === ref);
 }
 
-function matchName(ref, data, quiet) {
+function matchName(ref, data, mode) {
     const matches = data.filter(({ name }) => name.toLowerCase() === ref.toLowerCase());
 
     if (matches.length > 1) {
-        // TODO: This should definitely be a thrown error.
-        if (!quiet) {
-            logError`Multiple matches for reference "${ref}". Please resolve:`;
-            for (const match of matches) {
-                logError`- ${match.name} (${match.directory})`;
-            }
-            logError`Returning null for this reference.`;
-        }
-        return null;
+        return warnOrThrow(mode,
+            `Multiple matches for reference "${ref}". Please resolve:\n` +
+            matches.map(match => `- ${match.name} (${match.directory})\n`).join('') +
+            `Returning null for this reference.`);
     }
 
     if (matches.length === 0) {
@@ -85,8 +98,8 @@ function matchName(ref, data, quiet) {
 
     const thing = matches[0];
 
-    if (ref !== thing.name && !quiet) {
-        logWarn`Bad capitalization: ${'\x1b[31m' + ref} -> ${'\x1b[32m' + thing.name}`;
+    if (ref !== thing.name) {
+        warnOrThrow(mode, `Bad capitalization: ${color.red(ref)} -> ${color.green(thing.name)}`);
     }
 
     return thing;
@@ -97,15 +110,15 @@ function matchTagName(ref, data, quiet) {
 }
 
 const find = {
-    album: findHelper(['album', 'album-commentary'], 'albumData'),
-    artist: findHelper(['artist', 'artist-gallery'], 'artistData'),
-    artTag: findHelper(['tag'], 'artTagData', {byName: matchTagName}),
-    flash: findHelper(['flash'], 'flashData'),
-    group: findHelper(['group', 'group-gallery'], 'groupData'),
-    listing: findHelper(['listing'], 'listingSpec'),
-    newsEntry: findHelper(['news-entry'], 'newsData'),
-    staticPage: findHelper(['static'], 'staticPageData'),
-    track: findHelper(['track'], 'trackData')
+    album: findHelper(['album', 'album-commentary']),
+    artist: findHelper(['artist', 'artist-gallery']),
+    artTag: findHelper(['tag'], {byName: matchTagName}),
+    flash: findHelper(['flash']),
+    group: findHelper(['group', 'group-gallery']),
+    listing: findHelper(['listing']),
+    newsEntry: findHelper(['news-entry']),
+    staticPage: findHelper(['static']),
+    track: findHelper(['track'])
 };
 
 export default find;
diff --git a/src/util/replacer.js b/src/util/replacer.js
index 6c524778..0066d218 100644
--- a/src/util/replacer.js
+++ b/src/util/replacer.js
@@ -1,8 +1,7 @@
-import find from './find.js';
 import {logError, logWarn} from './cli.js';
 import {escapeRegex} from './sugar.js';
 
-export function validateReplacerSpec(replacerSpec, link) {
+export function validateReplacerSpec(replacerSpec, {find, link}) {
     let success = true;
 
     for (const [key, {link: linkKey, find: findKey, value, html}] of Object.entries(replacerSpec)) {
@@ -320,7 +319,7 @@ export function parseInput(input) {
 }
 
 function evaluateTag(node, opts) {
-    const { input, link, replacerSpec, strings, to, wikiData } = opts;
+    const { find, input, link, replacerSpec, strings, to, wikiData } = opts;
 
     const source = input.slice(node.i, node.iEnd);
 
@@ -348,7 +347,7 @@ function evaluateTag(node, opts) {
         valueFn ? valueFn(replacerValue) :
         findKey ? find[findKey]((replacerKeyImplied
             ? replacerValue
-            : replacerKey + `:` + replacerValue), {wikiData}) :
+            : replacerKey + `:` + replacerValue)) :
         {
             directory: replacerValue,
             name: null
@@ -417,13 +416,14 @@ function transformNodes(nodes, opts) {
     return nodes.map(node => transformNode(node, opts)).join('');
 }
 
-export function transformInline(input, {replacerSpec, link, strings, to, wikiData}) {
+export function transformInline(input, {replacerSpec, find, link, strings, to, wikiData}) {
     if (!replacerSpec) throw new Error('Expected replacerSpec');
+    if (!find) throw new Error('Expected find');
     if (!link) throw new Error('Expected link');
     if (!strings) throw new Error('Expected strings');
     if (!to) throw new Error('Expected to');
     if (!wikiData) throw new Error('Expected wikiData');
 
     const nodes = parseInput(input);
-    return transformNodes(nodes, {input, link, replacerSpec, strings, to, wikiData});
+    return transformNodes(nodes, {input, find, link, replacerSpec, strings, to, wikiData});
 }