« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/upd8
diff options
context:
space:
mode:
Diffstat (limited to 'upd8')
-rw-r--r--upd8/gen-thumbs.js323
-rwxr-xr-xupd8/main.js6597
-rw-r--r--upd8/strings-default.json305
-rw-r--r--upd8/util.js423
4 files changed, 0 insertions, 7648 deletions
diff --git a/upd8/gen-thumbs.js b/upd8/gen-thumbs.js
deleted file mode 100644
index bec01d1..0000000
--- a/upd8/gen-thumbs.js
+++ /dev/null
@@ -1,323 +0,0 @@
-#!/usr/bin/env node
-
-// Ok, so the d8te is 3 March 2021, and the music wiki was initially released
-// on 15 November 2019. That is 474 days or 11376 hours. In my opinion, and
-// pro8a8ly the opinions of at least one other person, that is WAY TOO LONG
-// to go without media thum8nails!!!! So that's what this file is here to do.
-//
-// This program takes a path to the media folder (via --media or the environ.
-// varia8le HSMUSIC_MEDIA), traverses su8directories to locate image files,
-// and gener8tes lower-resolution/file-size versions of all that are new or
-// have 8een modified since the last run. We use a JSON-format cache of MD5s
-// for each file to perform this comparision; we gener8te files (using ffmpeg)
-// in "medium" and "small" sizes adjacent to the existing PNG for easy and
-// versatile access in site gener8tion code.
-//
-// So for example, on the very first run, you might have a media folder which
-// looks something like this:
-//
-//   media/
-//     album-art/
-//       one-year-older/
-//         cover.jpg
-//         firefly-cloud.jpg
-//         october.jpg
-//         ...
-//     flash-art/
-//       413.jpg
-//       ...
-//     bg.jpg
-//     ...
-//
-// After running gen-thumbs.js with the path to that folder passed, you'd end
-// up with something like this:
-//
-//   media/
-//     album-art/
-//       one-year-older/
-//         cover.jpg
-//         cover.medium.jpg
-//         cover.small.jpg
-//         firefly-cloud.jpg
-//         firefly-cloud.medium.jpg
-//         firefly-cloud.small.jpg
-//         october.jpg
-//         october.medium.jpg
-//         october.small.jpg
-//         ...
-//     flash-art/
-//       413.jpg
-//       413.medium.jpg
-//       413.small.jpg
-//       ...
-//     bg.jpg
-//     bg.medium.jpg
-//     bg.small.jpg
-//     thumbs-cache.json
-//     ...
-//
-// (Do note that while 8oth JPG and PNG are supported, gener8ted files will
-// always 8e in JPG format and file extension. GIFs are skipped since there
-// aren't any super gr8 ways to make those more efficient!)
-//
-// And then in gener8tion code, you'd reference the medium/small or original
-// version of each file, as decided is appropriate. Here are some guidelines:
-//
-// - Small: Grid tiles on the homepage and in galleries.
-// - Medium: Cover art on individual al8um and track pages, etc.
-// - Original: Only linked to, not embedded.
-//
-// The traversal code is indiscrimin8te: there are no special cases to, say,
-// not gener8te thum8nails for the bg.jpg file (since those would generally go
-// unused). This is just to make the code more porta8le and sta8le, long-term,
-// since it avoids a lot of otherwise implic8ted maintenance.
-
-'use strict';
-
-const CACHE_FILE = 'thumbnail-cache.json';
-const WARNING_DELAY_TIME = 10000;
-
-const { spawn } = require('child_process');
-const crypto = require('crypto');
-const fsp = require('fs/promises'); // Whatcha know! Nice.
-const fs = require('fs'); // Still gotta include 8oth tho, for createReadStream.
-const path = require('path');
-
-const {
-    delay,
-    logError,
-    logInfo,
-    logWarn,
-    parseOptions,
-    progressPromiseAll,
-    promisifyProcess,
-    queue,
-} = require('./util');
-
-function traverse(startDirPath, {
-    filterFile = () => true,
-    filterDir = () => true
-} = {}) {
-    const recursive = (names, subDirPath) => Promise
-        .all(names.map(name => fsp.readdir(path.join(startDirPath, subDirPath, name)).then(
-            names => filterDir(name) ? recursive(names, path.join(subDirPath, name)) : [],
-            err => filterFile(name) ? [path.join(subDirPath, name)] : [])))
-        .then(pathArrays => pathArrays.flatMap(x => x));
-
-    return fsp.readdir(startDirPath)
-        .then(names => recursive(names, ''));
-}
-
-function readFileMD5(filePath) {
-    return new Promise((resolve, reject) => {
-        const md5 = crypto.createHash('md5');
-        const stream = fs.createReadStream(filePath);
-        stream.on('data', data => md5.update(data));
-        stream.on('end', data => resolve(md5.digest('hex')));
-        stream.on('error', err => reject(err));
-    });
-}
-
-function generateImageThumbnails(filePath) {
-    const dirname = path.dirname(filePath);
-    const extname = path.extname(filePath);
-    const basename = path.basename(filePath, extname);
-    const output = name => path.join(dirname, basename + name + '.jpg');
-
-    const convert = (name, {size, quality}) => spawn('convert', [
-        '-strip',
-        '-resize', `${size}x${size}>`,
-        '-interlace', 'Plane',
-        '-quality', `${quality}%`,
-        filePath,
-        output(name)
-    ]);
-
-    return Promise.all([
-        promisifyProcess(convert('.medium', {size: 400, quality: 95}), false),
-        promisifyProcess(convert('.small', {size: 250, quality: 85}), false)
-    ]);
-
-    return new Promise((resolve, reject) => {
-        if (Math.random() < 0.2) {
-            reject(new Error(`Them's the 8r8ks, kiddo!`));
-        } else {
-            resolve();
-        }
-    });
-}
-
-async function genThumbs(mediaPath, {
-    queueSize = 0,
-    quiet = false
-} = {}) {
-    if (!mediaPath) {
-        throw new Error('Expected mediaPath to be passed');
-    }
-
-    const quietInfo = (quiet
-        ? () => null
-        : logInfo);
-
-    const filterFile = name => {
-        // TODO: Why is this not working????????
-        // thumbnail-cache.json is 8eing passed through, for some reason.
-
-        const ext = path.extname(name);
-        if (ext !== '.jpg' && ext !== '.png') return false;
-
-        const rest = path.basename(name, ext);
-        if (rest.endsWith('.medium') || rest.endsWith('.small')) return false;
-
-        return true;
-    };
-
-    const filterDir = name => {
-        if (name === '.git') return false;
-        return true;
-    };
-
-    let cache, firstRun = false, failedReadingCache = false;
-    try {
-        cache = JSON.parse(await fsp.readFile(path.join(mediaPath, CACHE_FILE)));
-        quietInfo`Cache file successfully read.`;
-    } catch (error) {
-        cache = {};
-        if (error.code === 'ENOENT') {
-            firstRun = true;
-        } else {
-            failedReadingCache = true;
-            logWarn`Malformed or unreadable cache file: ${error}`;
-            logWarn`You may want to cancel and investigate this!`;
-            logWarn`All-new thumbnails and cache will be generated for this run.`;
-            await delay(WARNING_DELAY_TIME);
-        }
-    }
-
-    try {
-        await fsp.writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache));
-        quietInfo`Writing to cache file appears to be working.`;
-    } catch (error) {
-        logWarn`Test of cache file writing failed: ${error}`;
-        if (cache) {
-            logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`;
-        } else if (firstRun) {
-            logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`;
-        } else {
-            logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`;
-        }
-        logWarn`You may want to cancel and investigate this!`;
-        await delay(WARNING_DELAY_TIME);
-    }
-
-    const imagePaths = await traverse(mediaPath, {filterFile, filterDir});
-
-    const imageToMD5Entries = await progressPromiseAll(`Generating MD5s of image files`, queue(
-        imagePaths.map(imagePath => () => readFileMD5(path.join(mediaPath, imagePath)).then(
-            md5 => [imagePath, md5],
-            error => [imagePath, {error}]
-        )),
-        queueSize
-    ));
-
-    {
-        let error = false;
-        for (const entry of imageToMD5Entries) {
-            if (entry[1].error) {
-                logError`Failed to read ${entry[0]}: ${entry[1].error}`;
-                error = true;
-            }
-        }
-        if (error) {
-            logError`Failed to read at least one image file!`;
-            logError`This implies a thumbnail probably won't be generatable.`;
-            logError`So, exiting early.`;
-            return false;
-        } else {
-            quietInfo`All image files successfully read.`;
-        }
-    }
-
-    // Technically we could pro8a8ly mut8te the cache varia8le in-place?
-    // 8ut that seems kinda iffy.
-    const updatedCache = Object.assign({}, cache);
-
-    const entriesToGenerate = imageToMD5Entries
-        .filter(([filePath, md5]) => md5 !== cache[filePath]);
-
-    if (entriesToGenerate.length === 0) {
-        logInfo`All image thumbnails are already up-to-date - nice!`;
-        return true;
-    }
-
-    const failed = [];
-    const succeeded = [];
-    const writeMessageFn = () => `Writing image thumbnails. [failed: ${failed.length}]`;
-
-    // This is actually sort of a lie, 8ecause we aren't doing synchronicity.
-    // (We pass queueSize = 1 to queue().) 8ut we still use progressPromiseAll,
-    // 'cuz the progress indic8tor is very cool and good.
-    await progressPromiseAll(writeMessageFn, queue(entriesToGenerate.map(([filePath, md5]) =>
-        () => generateImageThumbnails(path.join(mediaPath, filePath)).then(
-            () => {
-                updatedCache[filePath] = md5;
-                succeeded.push(filePath);
-            },
-            error => {
-                failed.push([filePath, error]);
-            }
-        )
-    )));
-
-    if (failed.length > 0) {
-        for (const [path, error] of failed) {
-            logError`Thumbnails failed to generate for ${path} - ${error}`;
-        }
-        logWarn`Result is incomplete - the above ${failed.length} thumbnails should be checked for errors.`;
-        logWarn`${succeeded.length} successfully generated images won't be regenerated next run, though!`;
-    } else {
-        logInfo`Generated all (updated) thumbnails successfully!`;
-    }
-
-    try {
-        await fsp.writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(updatedCache));
-        quietInfo`Updated cache file successfully written!`;
-    } catch (error) {
-        logWarn`Failed to write updated cache file: ${error}`;
-        logWarn`Any newly (re)generated thumbnails will be regenerated next run.`;
-        logWarn`Sorry about that!`;
-    }
-
-    return true;
-};
-
-module.exports = genThumbs;
-
-if (require.main === module) {
-    (async () => {
-        const miscOptions = await parseOptions(process.argv.slice(2), {
-            'media': {
-                type: 'value'
-            },
-
-            'queue-size': {
-                type: 'value',
-                validate(size) {
-                    if (parseInt(size) !== parseFloat(size)) return 'an integer';
-                    if (parseInt(size) < 0) return 'a counting number or zero';
-                    return true;
-                }
-            },
-            queue: {alias: 'queue-size'}
-        });
-
-        const mediaPath = miscOptions.media || process.env.HSMUSIC_MEDIA;
-        if (!mediaPath) {
-            logError`Expected --media option or HSMUSIC_MEDIA to be set`;
-        }
-
-        const queueSize = +(miscOptions['queue-size'] ?? 0);
-
-        await genThumbs(mediaPath, {queueSize});
-    })().catch(err => console.error(err));
-}
diff --git a/upd8/main.js b/upd8/main.js
deleted file mode 100755
index 84bab6c..0000000
--- a/upd8/main.js
+++ /dev/null
@@ -1,6597 +0,0 @@
-#!/usr/bin/env node
-
-// HEY N8RDS!
-//
-// This is one of the 8ACKEND FILES. It's not used anywhere on the actual site
-// you are pro8a8ly using right now.
-//
-// Specifically, this one does all the actual work of the music wiki. The
-// process looks something like this:
-//
-//   1. Crawl the music directories. Well, not so much "crawl" as "look inside
-//      the folders for each al8um, and read the metadata file descri8ing that
-//      al8um and the tracks within."
-//
-//   2. Read that metadata. I'm writing this 8efore actually doing any of the
-//      code, and I've gotta admit I have no idea what file format they're
-//      going to 8e in. May8e JSON, 8ut more likely some weird custom format
-//      which will 8e a lot easier to edit.
-//
-//   3. Generate the page files! They're just static index.html files, and are
-//      what gh-pages (or wherever this is hosted) will show to clients.
-//      Hopefully pretty minimalistic HTML, 8ut like, shrug. They'll reference
-//      CSS (and maaaaaaaay8e JS) files, hard-coded somewhere near the root.
-//
-//   4. Print an awesome message which says the process is done. This is the
-//      most important step.
-//
-// Oh yeah, like. Just run this through some relatively recent version of
-// node.js and you'll 8e fine. ...Within the project root. O8viously.
-
-// HEY FUTURE ME!!!!!!!! Don't forget to implement artist pages! Those are,
-// like, the coolest idea you've had yet, so DO NOT FORGET. (Remem8er, link
-// from track listings, etc!) --- Thanks, past me. To futurerer me: an al8um
-// listing page (a list of all the al8ums)! Make sure to sort these 8y date -
-// we'll need a new field for al8ums.
-
-// ^^^^^^^^ DID THAT! 8ut also, artist images. Pro8a8ly stolen from the fandom
-// wiki (I found half those images anywayz).
-
-// TRACK ART CREDITS. This is a must.
-
-// 2020-08-23
-// ATTENTION ALL 8*TCHES AND OTHER GENDER TRUCKERS: AS IT TURNS OUT, THIS CODE
-// ****SUCKS****. I DON'T THINK ANYTHING WILL EVER REDEEM IT, 8UT THAT DOESN'T
-// MEAN WE CAN'T TAKE SOME ACTION TO MAKE WRITING IT A LITTLE LESS TERRI8LE.
-// We're gonna start defining STRUCTURES to make things suck less!!!!!!!!
-// No classes 8ecause those are a huge pain and like, pro8a8ly 8ad performance
-// or whatever -- just some standard structures that should 8e followed
-// wherever reasona8le. Only one I need today is the contri8 one 8ut let's put
-// any new general-purpose structures here too, ok?
-//
-// Contri8ution: {who, what, date, thing}. D8 and thing are the new fields.
-//
-// Use these wisely, which is to say all the time and instead of whatever
-// terri8le new pseudo structure you're trying to invent!!!!!!!!
-//
-// Upd8 2021-01-03: Soooooooo we didn't actually really end up using these,
-// lol? Well there's still only one anyway. Kinda ended up doing a 8ig refactor
-// of all the o8ject structures today. It's not *especially* relevant 8ut feels
-// worth mentioning? I'd get rid of this comment 8lock 8ut I like it too much!
-// Even though I haven't actually reread it, lol. 8ut yeah, hopefully in the
-// spirit of this "make things more consistent" attitude I 8rought up 8ack in
-// August, stuff's lookin' 8etter than ever now. W00t!
-
-'use strict';
-
-const fs = require('fs');
-const path = require('path');
-const util = require('util');
-
-// I made this dependency myself! A long, long time ago. It is pro8a8ly my
-// most useful li8rary ever. I'm not sure 8esides me actually uses it, though.
-const fixWS = require('fix-whitespace');
-// Wait nevermind, I forgot a8out why-do-kids-love-the-taste-of-cinnamon-toast-
-// crunch. THAT is my 8est li8rary.
-
-// The require function just returns whatever the module exports, so there's
-// no reason you can't wrap it in some decorator right out of the 8ox. Which is
-// exactly what we do here.
-const mkdirp = util.promisify(require('mkdirp'));
-
-// It stands for "HTML Entities", apparently. Cursed.
-const he = require('he');
-
-// This is the dum8est name for a function possi8le. Like, SURE, fine, may8e
-// the UNIX people had some valid reason to go with the weird truncated
-// lowercased convention they did. 8ut Node didn't have to ALSO use that
-// convention! Would it have 8een so hard to just name the function something
-// like fs.readDirectory???????? No, it wouldn't have 8een.
-const readdir = util.promisify(fs.readdir);
-// 8ut okay, like, look at me. DOING THE SAME THING. See, *I* could have named
-// my promisified function differently, and yet I did not. I literally cannot
-// explain why. We are all used to following in the 8ad decisions of our
-// ancestors, and never never never never never never never consider that hey,
-// may8e we don't need to make the exact same decisions they did. Even when
-// we're perfectly aware th8t's exactly what we're doing! Programmers,
-// including me, are all pretty stupid.
-
-// 8ut I mean, come on. Look. Node decided to use readFile, instead of like,
-// what, cat? Why couldn't they rename readdir too???????? As Johannes Kepler
-// once so elegantly put it: "Shrug."
-const readFile = util.promisify(fs.readFile);
-const writeFile = util.promisify(fs.writeFile);
-const access = util.promisify(fs.access);
-const symlink = util.promisify(fs.symlink);
-const unlink = util.promisify(fs.unlink);
-
-const {
-    cacheOneArg,
-    call,
-    chunkByConditions,
-    chunkByProperties,
-    curry,
-    decorateTime,
-    filterEmptyLines,
-    joinNoOxford,
-    mapInPlace,
-    logWarn,
-    logInfo,
-    logError,
-    parseOptions,
-    progressPromiseAll,
-    queue,
-    s,
-    sortByName,
-    splitArray,
-    th,
-    unique,
-    withEntries
-} = require('./util');
-
-const genThumbs = require('./gen-thumbs');
-
-const C = require('../common/common');
-
-const CACHEBUST = 5;
-
-const WIKI_INFO_FILE = 'wiki-info.txt';
-const HOMEPAGE_INFO_FILE = 'homepage.txt';
-const ARTIST_DATA_FILE = 'artists.txt';
-const FLASH_DATA_FILE = 'flashes.txt';
-const NEWS_DATA_FILE = 'news.txt';
-const TAG_DATA_FILE = 'tags.txt';
-const GROUP_DATA_FILE = 'groups.txt';
-const STATIC_PAGE_DATA_FILE = 'static-pages.txt';
-const DEFAULT_STRINGS_FILE = 'strings-default.json';
-
-const CSS_FILE = 'site.css';
-
-// Shared varia8les! These are more efficient to access than a shared varia8le
-// (or at least I h8pe so), and are easier to pass across functions than a
-// 8unch of specific arguments.
-//
-// Upd8: Okay yeah these aren't actually any different. Still cleaner than
-// passing around a data object containing all this, though.
-let dataPath;
-let mediaPath;
-let langPath;
-let outputPath;
-
-let wikiInfo;
-let homepageInfo;
-let albumData;
-let trackData;
-let flashData;
-let flashActData;
-let newsData;
-let tagData;
-let groupData;
-let groupCategoryData;
-let staticPageData;
-
-let artistNames;
-let artistData;
-let artistAliasData;
-
-let officialAlbumData;
-let fandomAlbumData;
-let justEverythingMan; // tracks, albums, flashes -- don't forget to upd8 toAnythingMan!
-let justEverythingSortedByArtDateMan;
-let contributionData;
-
-let queueSize;
-
-let languages;
-
-const html = {
-    // Non-comprehensive. ::::P
-    selfClosingTags: ['br', 'img'],
-
-    // Pass to tag() as an attri8utes key to make tag() return a 8lank string
-    // if the provided content is empty. Useful for when you'll only 8e showing
-    // an element according to the presence of content that would 8elong there.
-    onlyIfContent: Symbol(),
-
-    tag(tagName, ...args) {
-        const selfClosing = html.selfClosingTags.includes(tagName);
-
-        let openTag;
-        let content;
-        let attrs;
-
-        if (typeof args[0] === 'object' && !Array.isArray(args[0])) {
-            attrs = args[0];
-            content = args[1];
-        } else {
-            content = args[0];
-        }
-
-        if (selfClosing && content) {
-            throw new Error(`Tag <${tagName}> is self-closing but got content!`);
-        }
-
-        if (attrs?.[html.onlyIfContent] && !content) {
-            return '';
-        }
-
-        if (attrs) {
-            const attrString = html.attributes(args[0]);
-            if (attrString) {
-                openTag = `${tagName} ${attrString}`;
-            }
-        }
-
-        if (!openTag) {
-            openTag = tagName;
-        }
-
-        if (Array.isArray(content)) {
-            content = content.filter(Boolean).join('\n');
-        }
-
-        if (content) {
-            if (content.includes('\n')) {
-                return fixWS`
-                    <${openTag}>
-                        ${content}
-                    </${tagName}>
-                `;
-            } else {
-                return `<${openTag}>${content}</${tagName}>`;
-            }
-        } else {
-            if (selfClosing) {
-                return `<${openTag}>`;
-            } else {
-                return `<${openTag}></${tagName}>`;
-            }
-        }
-    },
-
-    escapeAttributeValue(value) {
-        return value
-            .replaceAll('"', '&quot;')
-            .replaceAll("'", '&apos;');
-    },
-
-    attributes(attribs) {
-        return Object.entries(attribs)
-            .map(([ key, val ]) => {
-                if (!val)
-                    return [key, val];
-                else if (typeof val === 'string' || typeof val === 'boolean')
-                    return [key, val];
-                else if (typeof val === 'number')
-                    return [key, val.toString()];
-                else if (Array.isArray(val))
-                    return [key, val.join(' ')];
-                else
-                    throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
-            })
-            .filter(([ key, val ]) => val)
-            .map(([ key, val ]) => (typeof val === 'boolean'
-                ? `${key}`
-                : `${key}="${html.escapeAttributeValue(val)}"`))
-            .join(' ');
-    }
-};
-
-const urlSpec = {
-    data: {
-        prefix: 'data/',
-
-        paths: {
-            root: '',
-            path: '<>',
-
-            album: 'album/<>',
-            artist: 'artist/<>',
-            track: 'track/<>'
-        }
-    },
-
-    localized: {
-        // TODO: Implement this.
-        // prefix: '_languageCode',
-
-        paths: {
-            root: '',
-            path: '<>',
-
-            home: '',
-
-            album: 'album/<>/',
-            albumCommentary: 'commentary/album/<>/',
-
-            artist: 'artist/<>/',
-            artistGallery: 'artist/<>/gallery/',
-
-            commentaryIndex: 'commentary/',
-
-            flashIndex: 'flash/',
-            flash: 'flash/<>/',
-
-            groupInfo: 'group/<>/',
-            groupGallery: 'group/<>/gallery/',
-
-            listingIndex: 'list/',
-            listing: 'list/<>/',
-
-            newsIndex: 'news/',
-            newsEntry: 'news/<>/',
-
-            staticPage: '<>/',
-            tag: 'tag/<>/',
-            track: 'track/<>/'
-        }
-    },
-
-    shared: {
-        paths: {
-            root: '',
-            path: '<>',
-
-            commonFile: 'common/<>',
-            staticFile: 'static/<>'
-        }
-    },
-
-    media: {
-        prefix: 'media/',
-
-        paths: {
-            root: '',
-            path: '<>',
-
-            albumCover: 'album-art/<>/cover.jpg',
-            albumWallpaper: 'album-art/<>/bg.jpg',
-            albumBanner: 'album-art/<>/banner.jpg',
-            trackCover: 'album-art/<>/<>.jpg',
-            artistAvatar: 'artist-avatar/<>.jpg',
-            flashArt: 'flash-art/<>.jpg'
-        }
-    }
-};
-
-// This gets automatically switched in place when working from a baseDirectory,
-// so it should never be referenced manually.
-urlSpec.localizedWithBaseDirectory = {
-    paths: withEntries(
-        urlSpec.localized.paths,
-        entries => entries.map(([key, path]) => [key, '<>/' + path])
-    )
-};
-
-const linkHelper = (hrefFn, {color = true, attr = null} = {}) =>
-    (thing, {
-        strings, to,
-        text = '',
-        class: className = '',
-        hash = ''
-    }) => (
-        html.tag('a', {
-            ...attr ? attr(thing) : {},
-            href: hrefFn(thing, {to}) + (hash ? (hash.startsWith('#') ? '' : '#') + hash : ''),
-            style: color ? getLinkThemeString(thing) : '',
-            class: className
-        }, text || thing.name)
-    );
-
-const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) =>
-    linkHelper((thing, {to}) => to('localized.' + key, thing.directory), {
-        attr: thing => ({
-            ...attr ? attr(thing) : {},
-            ...expose ? {[expose]: thing.directory} : {}
-        }),
-        ...conf
-    });
-
-const linkPathname = (key, conf) => linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf);
-const linkIndex = (key, conf) => linkHelper((_, {to}) => to('localized.' + key), conf);
-
-const link = {
-    album: linkDirectory('album'),
-    albumCommentary: linkDirectory('albumCommentary'),
-    artist: linkDirectory('artist', {color: false}),
-    artistGallery: linkDirectory('artistGallery', {color: false}),
-    commentaryIndex: linkIndex('commentaryIndex', {color: false}),
-    flashIndex: linkIndex('flashIndex', {color: false}),
-    flash: linkDirectory('flash'),
-    groupInfo: linkDirectory('groupInfo'),
-    groupGallery: linkDirectory('groupGallery'),
-    home: linkIndex('home', {color: false}),
-    listingIndex: linkIndex('listingIndex'),
-    listing: linkDirectory('listing'),
-    newsIndex: linkIndex('newsIndex', {color: false}),
-    newsEntry: linkDirectory('newsEntry', {color: false}),
-    staticPage: linkDirectory('staticPage', {color: false}),
-    tag: linkDirectory('tag'),
-    track: linkDirectory('track', {expose: 'data-track'}),
-
-    media: linkPathname('media.path', {color: false}),
-    root: linkPathname('shared.path', {color: false}),
-    data: linkPathname('data.path', {color: false}),
-    site: linkPathname('localized.path', {color: false})
-};
-
-const thumbnailHelper = name => file =>
-    file.replace(/\.(jpg|png)$/, name + '.jpg');
-
-const thumb = {
-    medium: thumbnailHelper('.medium'),
-    small: thumbnailHelper('.small')
-};
-
-function generateURLs(fromPath) {
-    const getValueForFullKey = (obj, fullKey, prop = null) => {
-        const [ groupKey, subKey ] = fullKey.split('.');
-        if (!groupKey || !subKey) {
-            throw new Error(`Expected group key and subkey (got ${fullKey})`);
-        }
-
-        if (!obj.hasOwnProperty(groupKey)) {
-            throw new Error(`Expected valid group key (got ${groupKey})`);
-        }
-
-        const group = obj[groupKey];
-
-        if (!group.hasOwnProperty(subKey)) {
-            throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`);
-        }
-
-        return {
-            value: group[subKey],
-            group
-        };
-    };
-
-    const generateTo = (fromPath, fromGroup) => {
-        const rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length);
-
-        const pathHelper = (toPath, toGroup) => {
-            let target = toPath;
-
-            let argIndex = 0;
-            target = target.replaceAll('<>', () => `<${argIndex++}>`);
-
-            if (toGroup.prefix !== fromGroup.prefix) {
-                // TODO: Handle differing domains in prefixes.
-                target = rebasePrefix + (toGroup.prefix || '') + target;
-            }
-
-            return (path.relative(fromPath, target)
-                + (toPath.endsWith('/') ? '/' : ''));
-        };
-
-        const groupSymbol = Symbol();
-
-        const groupHelper = urlGroup => ({
-            [groupSymbol]: urlGroup,
-            ...withEntries(urlGroup.paths, entries => entries
-                .map(([key, path]) => [key, pathHelper(path, urlGroup)]))
-        });
-
-        const relative = withEntries(urlSpec, entries => entries
-            .map(([key, urlGroup]) => [key, groupHelper(urlGroup)]));
-
-        const to = (key, ...args) => {
-            const { value: template, group: {[groupSymbol]: toGroup} } = getValueForFullKey(relative, key)
-            let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => args[n]);
-
-            // Kinda hacky lol, 8ut it works.
-            const missing = result.match(/<([0-9]+)>/g);
-            if (missing) {
-                throw new Error(`Expected ${missing[missing.length - 1]} arguments, got ${args.length}`);
-            }
-
-            return result;
-        };
-
-        return {to, relative};
-    };
-
-    const generateFrom = () => {
-        const map = withEntries(urlSpec, entries => entries
-            .map(([key, group]) => [key, withEntries(group.paths, entries => entries
-                .map(([key, path]) => [key, generateTo(path, group)])
-            )]));
-
-        const from = key => getValueForFullKey(map, key).value;
-
-        return {from, map};
-    };
-
-    return generateFrom();
-}
-
-const urls = generateURLs();
-
-const searchHelper = (keys, dataFn, findFn) => ref => {
-    if (!ref) return null;
-    ref = ref.replace(new RegExp(`^(${keys.join('|')}):`), '');
-    const found = findFn(ref, dataFn());
-    if (!found) {
-        logWarn`Didn't match anything for ${ref}! (${keys.join(', ')})`;
-    }
-    return found;
-};
-
-const matchDirectory = (ref, data) => data.find(({ directory }) => directory === ref);
-
-const matchDirectoryOrName = (ref, data) => {
-    let thing;
-
-    thing = matchDirectory(ref, data);
-    if (thing) return thing;
-
-    thing = data.find(({ name }) => name === ref);
-    if (thing) return thing;
-
-    thing = data.find(({ name }) => name.toLowerCase() === ref.toLowerCase());
-    if (thing) {
-        logWarn`Bad capitalization: ${'\x1b[31m' + ref} -> ${'\x1b[32m' + thing.name}`;
-        return thing;
-    }
-
-    return null;
-};
-
-const search = {
-    album: searchHelper(['album', 'album-commentary'], () => albumData, matchDirectoryOrName),
-    artist: searchHelper(['artist', 'artist-gallery'], () => artistData, matchDirectoryOrName),
-    flash: searchHelper(['flash'], () => flashData, matchDirectory),
-    group: searchHelper(['group', 'group-gallery'], () => groupData, matchDirectoryOrName),
-    listing: searchHelper(['listing'], () => listingSpec, matchDirectory),
-    newsEntry: searchHelper(['news-entry'], () => newsData, matchDirectory),
-    staticPage: searchHelper(['static'], () => staticPageData, matchDirectory),
-    tag: searchHelper(['tag'], () => tagData, (ref, data) =>
-        matchDirectoryOrName(ref.startsWith('cw: ') ? ref.slice(4) : ref, data)),
-    track: searchHelper(['track'], () => trackData, matchDirectoryOrName)
-};
-
-// Localiz8tion time! Or l10n as the neeeeeeeerds call it. Which is a terri8le
-// name and not one I intend on using, thank you very much. (Don't even get me
-// started on """"a11y"""".)
-//
-// All the default strings are in strings-default.json, if you're curious what
-// those actually look like. Pretty much it's "I like {ANIMAL}" for example.
-// For each language, the o8ject gets turned into a single function of form
-// f(key, {args}). It searches for a key in the o8ject and uses the string it
-// finds (or the one in strings-default.json) as a templ8 evaluated with the
-// arguments passed. (This function gets treated as an o8ject too; it gets
-// the language code attached.)
-//
-// The function's also responsi8le for getting rid of dangerous characters
-// (quotes and angle tags), though only within the templ8te (not the args),
-// and it converts the keys of the arguments o8ject from camelCase to
-// CONSTANT_CASE too.
-function genStrings(stringsJSON, defaultJSON = null) {
-    // genStrings will only 8e called once for each language, and it happens
-    // right at the start of the program (or at least 8efore 8uilding pages).
-    // So, now's a good time to valid8te the strings and let any warnings be
-    // known.
-
-    // May8e contrary to the argument name, the arguments should 8e o8jects,
-    // not actual JSON-formatted strings!
-    if (typeof stringsJSON !== 'object' || stringsJSON.constructor !== Object) {
-        return {error: `Expected an object (parsed JSON) for stringsJSON.`};
-    }
-    if (typeof defaultJSON !== 'object') { // typeof null === object. I h8 JS.
-        return {error: `Expected an object (parsed JSON) or null for defaultJSON.`};
-    }
-
-    // All languages require a language code.
-    const code = stringsJSON['meta.languageCode'];
-    if (!code) {
-        return {error: `Missing language code.`};
-    }
-    if (typeof code !== 'string') {
-        return {error: `Expected language code to be a string.`};
-    }
-
-    // Every value on the provided o8ject should be a string.
-    // (This is lazy, but we only 8other checking this on stringsJSON, on the
-    // assumption that defaultJSON was passed through this function too, and so
-    // has already been valid8ted.)
-    {
-        let err = false;
-        for (const [ key, value ] of Object.entries(stringsJSON)) {
-            if (typeof value !== 'string') {
-                logError`(${code}) The value for ${key} should be a string.`;
-                err = true;
-            }
-        }
-        if (err) {
-            return {error: `Expected all values to be a string.`};
-        }
-    }
-
-    // Checking is generally done against the default JSON, so we'll skip out
-    // if that isn't provided (which should only 8e the case when it itself is
-    // 8eing processed as the first loaded language).
-    if (defaultJSON) {
-        // Warn for keys that are missing or unexpected.
-        const expectedKeys = Object.keys(defaultJSON);
-        const presentKeys = Object.keys(stringsJSON);
-        for (const key of presentKeys) {
-            if (!expectedKeys.includes(key)) {
-                logWarn`(${code}) Unexpected translation key: ${key} - this won't be used!`;
-            }
-        }
-        for (const key of expectedKeys) {
-            if (!presentKeys.includes(key)) {
-                logWarn`(${code}) Missing translation key: ${key} - this won't be localized!`;
-            }
-        }
-    }
-
-    // Valid8tion is complete, 8ut We can still do a little caching to make
-    // repeated actions faster.
-
-    // We're gonna 8e mut8ting the strings dictionary o8ject from here on out.
-    // We make a copy so we don't mess with the one which was given to us.
-    stringsJSON = Object.assign({}, stringsJSON);
-
-    // Preemptively pass everything through HTML encoding. This will prevent
-    // strings from embedding HTML tags or accidentally including characters
-    // that throw HTML parsers off.
-    for (const key of Object.keys(stringsJSON)) {
-        stringsJSON[key] = he.encode(stringsJSON[key], {useNamedReferences: true});
-    }
-
-    // It's time to cre8te the actual langauge function!
-
-    // In the function, we don't actually distinguish 8etween the primary and
-    // default (fall8ack) strings - any relevant warnings have already 8een
-    // presented a8ove, at the time the language JSON is processed. Now we'll
-    // only 8e using them for indexing strings to use as templ8tes, and we can
-    // com8ine them for that.
-    const stringIndex = Object.assign({}, defaultJSON, stringsJSON);
-
-    // We do still need the list of valid keys though. That's 8ased upon the
-    // default strings. (Or stringsJSON, 8ut only if the defaults aren't
-    // provided - which indic8tes that the single o8ject provided *is* the
-    // default.)
-    const validKeys = Object.keys(defaultJSON || stringsJSON);
-
-    const invalidKeysFound = [];
-
-    const strings = (key, args = {}) => {
-        // Ok, with the warning out of the way, it's time to get to work.
-        // First make sure we're even accessing a valid key. (If not, return
-        // an error string as su8stitute.)
-        if (!validKeys.includes(key)) {
-            // We only want to warn a8out a given key once. More than that is
-            // just redundant!
-            if (!invalidKeysFound.includes(key)) {
-                invalidKeysFound.push(key);
-                logError`(${code}) Accessing invalid key ${key}. Fix a typo or provide this in strings-default.json!`;
-            }
-            return `MISSING: ${key}`;
-        }
-
-        const template = stringIndex[key];
-
-        // Convert the keys on the args dict from camelCase to CONSTANT_CASE.
-        // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
-        // like, who cares, dude?) Also, this is an array, 8ecause it's handy
-        // for the iterating we're a8out to do.
-        const processedArgs = Object.entries(args)
-            .map(([ k, v ]) => [k.replace(/[A-Z]/g, '_$&').toUpperCase(), v]);
-
-        // Replacement time! Woot. Reduce comes in handy here!
-        const output = processedArgs.reduce(
-            (x, [ k, v ]) => x.replaceAll(`{${k}}`, v),
-            template);
-
-        // Post-processing: if any expected arguments *weren't* replaced, that
-        // is almost definitely an error.
-        if (output.match(/\{[A-Z_]+\}/)) {
-            logError`(${code}) Args in ${key} were missing - output: ${output}`;
-        }
-
-        return output;
-    };
-
-    // And lastly, we add some utility stuff to the strings function.
-
-    // Store the language code, for convenience of access.
-    strings.code = code;
-
-    // Store the strings dictionary itself, also for convenience.
-    strings.json = stringsJSON;
-
-    // Store Intl o8jects that can 8e reused for value formatting.
-    strings.intl = {
-        date: new Intl.DateTimeFormat(code, {full: true}),
-        number: new Intl.NumberFormat(code),
-        list: {
-            conjunction: new Intl.ListFormat(code, {type: 'conjunction'}),
-            disjunction: new Intl.ListFormat(code, {type: 'disjunction'}),
-            unit: new Intl.ListFormat(code, {type: 'unit'})
-        },
-        plural: {
-            cardinal: new Intl.PluralRules(code, {type: 'cardinal'}),
-            ordinal: new Intl.PluralRules(code, {type: 'ordinal'})
-        }
-    };
-
-    const bindUtilities = (obj, bind) => Object.fromEntries(Object.entries(obj).map(
-        ([ key, fn ]) => [key, (value, opts = {}) => fn(value, {...bind, ...opts})]
-    ));
-
-    // There are a 8unch of handy count functions which expect a strings value;
-    // for a more terse syntax, we'll stick 'em on the strings function itself,
-    // with automatic 8inding for the strings argument.
-    strings.count = bindUtilities(count, {strings});
-
-    // The link functions also expect the strings o8ject(*). May as well hand
-    // 'em over here too! Keep in mind they still expect {to} though, and that
-    // isn't something we have access to from this scope (so calls such as
-    // strings.link.album(...) still need to provide it themselves).
-    //
-    // (*) At time of writing, it isn't actually used for anything, 8ut future-
-    // proofing, ok????????
-    strings.link = bindUtilities(link, {strings});
-
-    // List functions, too!
-    strings.list = bindUtilities(list, {strings});
-
-    return strings;
-};
-
-const countHelper = (stringKey, argName = stringKey) => (value, {strings, unit = false}) => strings(
-    (unit
-        ? `count.${stringKey}.withUnit.` + strings.intl.plural.cardinal.select(value)
-        : `count.${stringKey}`),
-    {[argName]: strings.intl.number.format(value)});
-
-const count = {
-    date: (date, {strings}) => {
-        return strings.intl.date.format(date);
-    },
-
-    dateRange: ([startDate, endDate], {strings}) => {
-        return strings.intl.date.formatRange(startDate, endDate);
-    },
-
-    duration: (secTotal, {strings, approximate = false, unit = false}) => {
-        if (secTotal === 0) {
-            return strings('count.duration.missing');
-        }
-
-        const hour = Math.floor(secTotal / 3600);
-        const min = Math.floor((secTotal - hour * 3600) / 60);
-        const sec = Math.floor(secTotal - hour * 3600 - min * 60);
-
-        const pad = val => val.toString().padStart(2, '0');
-
-        const stringSubkey = unit ? '.withUnit' : '';
-
-        const duration = (hour > 0
-            ? strings('count.duration.hours' + stringSubkey, {
-                hours: hour,
-                minutes: pad(min),
-                seconds: pad(sec)
-            })
-            : strings('count.duration.minutes' + stringSubkey, {
-                minutes: min,
-                seconds: pad(sec)
-            }));
-
-        return (approximate
-            ? strings('count.duration.approximate', {duration})
-            : duration);
-    },
-
-    index: (value, {strings}) => {
-        return strings('count.index.' + strings.intl.plural.ordinal.select(value), {index: value});
-    },
-
-    number: value => strings.intl.number.format(value),
-
-    words: (value, {strings, unit = false}) => {
-        const num = strings.intl.number.format(value > 1000
-            ? Math.floor(value / 100) / 10
-            : value);
-
-        const words = (value > 1000
-            ? strings('count.words.thousand', {words: num})
-            : strings('count.words', {words: num}));
-
-        return strings('count.words.withUnit.' + strings.intl.plural.cardinal.select(value), {words});
-    },
-
-    albums: countHelper('albums'),
-    commentaryEntries: countHelper('commentaryEntries', 'entries'),
-    contributions: countHelper('contributions'),
-    coverArts: countHelper('coverArts'),
-    timesReferenced: countHelper('timesReferenced'),
-    timesUsed: countHelper('timesUsed'),
-    tracks: countHelper('tracks')
-};
-
-const listHelper = type => (list, {strings}) => strings.intl.list[type].format(list);
-
-const list = {
-    unit: listHelper('unit'),
-    or: listHelper('disjunction'),
-    and: listHelper('conjunction')
-};
-
-// Note there isn't a 'find track data files' function. I plan on including the
-// data for all tracks within an al8um collected in the single metadata file
-// for that al8um. Otherwise there'll just 8e way too many files, and I'd also
-// have to worry a8out linking track files to al8um files (which would contain
-// only the track listing, not track data itself), and dealing with errors of
-// missing track files (or track files which are not linked to al8ums). All a
-// 8unch of stuff that's a pain to deal with for no apparent 8enefit.
-async function findFiles(dataPath, filter = f => true) {
-    return (await readdir(dataPath))
-        .map(file => path.join(dataPath, file))
-        .filter(file => filter(file));
-}
-
-function* getSections(lines) {
-    // ::::)
-    const isSeparatorLine = line => /^-{8,}$/.test(line);
-    yield* splitArray(lines, isSeparatorLine);
-}
-
-function getBasicField(lines, name) {
-    const line = lines.find(line => line.startsWith(name + ':'));
-    return line && line.slice(name.length + 1).trim();
-}
-
-function getBooleanField(lines, name) {
-    // The ?? oper8tor (which is just, hilariously named, lol) can 8e used to
-    // specify a default!
-    const value = getBasicField(lines, name);
-    switch (value) {
-        case 'yes':
-        case 'true':
-            return true;
-        case 'no':
-        case 'false':
-            return false;
-        default:
-            return null;
-    }
-}
-
-function getListField(lines, name) {
-    let startIndex = lines.findIndex(line => line.startsWith(name + ':'));
-    // If callers want to default to an empty array, they should stick
-    // "|| []" after the call.
-    if (startIndex === -1) {
-        return null;
-    }
-    // We increment startIndex 8ecause we don't want to include the
-    // "heading" line (e.g. "URLs:") in the actual data.
-    startIndex++;
-    let endIndex = lines.findIndex((line, index) => index >= startIndex && !line.startsWith('- '));
-    if (endIndex === -1) {
-        endIndex = lines.length;
-    }
-    if (endIndex === startIndex) {
-        // If there is no list that comes after the heading line, treat the
-        // heading line itself as the comma-separ8ted array value, using
-        // the 8asic field function to do that. (It's l8 and my 8rain is
-        // sleepy. Please excuse any unhelpful comments I may write, or may
-        // have already written, in this st8. Thanks!)
-        const value = getBasicField(lines, name);
-        return value && value.split(',').map(val => val.trim());
-    }
-    const listLines = lines.slice(startIndex, endIndex);
-    return listLines.map(line => line.slice(2));
-};
-
-function getContributionField(section, name) {
-    let contributors = getListField(section, name);
-
-    if (!contributors) {
-        return null;
-    }
-
-    if (contributors.length === 1 && contributors[0].startsWith('<i>')) {
-        const arr = [];
-        arr.textContent = contributors[0];
-        return arr;
-    }
-
-    contributors = contributors.map(contrib => {
-        // 8asically, the format is "Who (What)", or just "Who". 8e sure to
-        // keep in mind that "what" doesn't necessarily have a value!
-        const match = contrib.match(/^(.*?)( \((.*)\))?$/);
-        if (!match) {
-            return contrib;
-        }
-        const who = match[1];
-        const what = match[3] || null;
-        return {who, what};
-    });
-
-    const badContributor = contributors.find(val => typeof val === 'string');
-    if (badContributor) {
-        return {error: `An entry has an incorrectly formatted contributor, "${badContributor}".`};
-    }
-
-    if (contributors.length === 1 && contributors[0].who === 'none') {
-        return null;
-    }
-
-    return contributors;
-};
-
-function getMultilineField(lines, name) {
-    // All this code is 8asically the same as the getListText - just with a
-    // different line prefix (four spaces instead of a dash and a space).
-    let startIndex = lines.findIndex(line => line.startsWith(name + ':'));
-    if (startIndex === -1) {
-        return null;
-    }
-    startIndex++;
-    let endIndex = lines.findIndex((line, index) => index >= startIndex && line.length && !line.startsWith('    '));
-    if (endIndex === -1) {
-        endIndex = lines.length;
-    }
-    // If there aren't any content lines, don't return anything!
-    if (endIndex === startIndex) {
-        return null;
-    }
-    // We also join the lines instead of returning an array.
-    const listLines = lines.slice(startIndex, endIndex);
-    return listLines.map(line => line.slice(4)).join('\n');
-};
-
-const replacerSpec = {
-    'album': {
-        search: 'album',
-        link: 'album'
-    },
-    'album-commentary': {
-        search: 'album',
-        link: 'albumCommentary'
-    },
-    'artist': {
-        search: 'artist',
-        link: 'artist'
-    },
-    'artist-gallery': {
-        search: 'artist',
-        link: 'artistGallery'
-    },
-    'commentary-index': {
-        search: null,
-        link: 'commentaryIndex'
-    },
-    'date': {
-        search: null,
-        value: ref => new Date(ref),
-        html: (date, {strings}) => `<time datetime="${date.toString()}">${strings.count.date(date)}</time>`
-    },
-    'flash': {
-        search: 'flash',
-        link: 'flash',
-        transformName(name, search, offset, text) {
-            const nextCharacter = text[offset + search.length];
-            const lastCharacter = name[name.length - 1];
-            if (
-                ![' ', '\n', '<'].includes(nextCharacter) &&
-                lastCharacter === '.'
-            ) {
-                return name.slice(0, -1);
-            } else {
-                return name;
-            }
-        }
-    },
-    'group': {
-        search: 'group',
-        link: 'groupInfo'
-    },
-    'group-gallery': {
-        search: 'group',
-        link: 'groupGallery'
-    },
-    'listing-index': {
-        search: null,
-        link: 'listingIndex'
-    },
-    'listing': {
-        search: 'listing',
-        link: 'listing'
-    },
-    'media': {
-        search: null,
-        link: 'media'
-    },
-    'news-index': {
-        search: null,
-        link: 'newsIndex'
-    },
-    'news-entry': {
-        search: 'newsEntry',
-        link: 'newsEntry'
-    },
-    'root': {
-        search: null,
-        link: 'root'
-    },
-    'site': {
-        search: null,
-        link: 'site'
-    },
-    'static': {
-        search: 'staticPage',
-        link: 'staticPage'
-    },
-    'tag': {
-        search: 'tag',
-        link: 'tag'
-    },
-    'track': {
-        search: 'track',
-        link: 'track'
-    }
-};
-
-{
-    let error = false;
-    for (const [key, {link: linkKey, search: searchKey, value, html}] of Object.entries(replacerSpec)) {
-        if (!html && !link[linkKey]) {
-            logError`The replacer spec ${key} has invalid link key ${linkKey}! Specify it in link specs or fix typo.`;
-            error = true;
-        }
-        if (searchKey && !search[searchKey]) {
-            logError`The replacer spec ${key} has invalid search key ${searchKey}! Specify it in search specs or fix typo.`;
-            error = true;
-        }
-    }
-    if (error) process.exit();
-
-    const categoryPart = Object.keys(replacerSpec).join('|');
-    transformInline.regexp = new RegExp(String.raw`(?<!\\)\[\[((${categoryPart}):)?(.+?)((?<! )#.+?)?(\|(.+?))?\]\]`, 'g');
-}
-
-function transformInline(text, {strings, to}) {
-    return text.replace(transformInline.regexp, (match, _1, category, ref, hash, _2, enteredName, offset) => {
-        if (!category) {
-            category = 'track';
-        }
-
-        const {
-            search: searchKey,
-            link: linkKey,
-            value: valueFn,
-            html: htmlFn,
-            transformName
-        } = replacerSpec[category];
-
-        const value = (
-            valueFn ? valueFn(ref) :
-            searchKey ? search[searchKey](ref) :
-            {
-                directory: ref.replace(category + ':', ''),
-                name: null
-            });
-
-        if (!value) {
-            logWarn`The link ${match} does not match anything!`;
-            return match;
-        }
-
-        const label = (enteredName
-            || transformName && transformName(value.name, match, offset, text)
-            || value.name);
-
-        if (!valueFn && !label) {
-            logWarn`The link ${match} requires a label be entered!`;
-            return match;
-        }
-
-        const fn = (htmlFn
-            ? htmlFn
-            : strings.link[linkKey]);
-
-        try {
-            return fn(value, {text: label, hash, strings, to});
-        } catch (error) {
-            logError`The link ${match} failed to be processed: ${error}`;
-            return match;
-        }
-    }).replaceAll(String.raw`\[[`, '[[');
-}
-
-function parseAttributes(string, {to}) {
-    const attributes = Object.create(null);
-    const skipWhitespace = i => {
-        const ws = /\s/;
-        if (ws.test(string[i])) {
-            const match = string.slice(i).match(/[^\s]/);
-            if (match) {
-                return i + match.index;
-            } else {
-                return string.length;
-            }
-        } else {
-            return i;
-        }
-    };
-
-    for (let i = 0; i < string.length;) {
-        i = skipWhitespace(i);
-        const aStart = i;
-        const aEnd = i + string.slice(i).match(/[\s=]|$/).index;
-        const attribute = string.slice(aStart, aEnd);
-        i = skipWhitespace(aEnd);
-        if (string[i] === '=') {
-            i = skipWhitespace(i + 1);
-            let end, endOffset;
-            if (string[i] === '"' || string[i] === "'") {
-                end = string[i];
-                endOffset = 1;
-                i++;
-            } else {
-                end = '\\s';
-                endOffset = 0;
-            }
-            const vStart = i;
-            const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index;
-            const value = string.slice(vStart, vEnd);
-            i = vEnd + endOffset;
-            if (attribute === 'src' && value.startsWith('media/')) {
-                attributes[attribute] = to('media.path', value.slice('media/'.length));
-            } else {
-                attributes[attribute] = value;
-            }
-        } else {
-            attributes[attribute] = attribute;
-        }
-    }
-    return Object.fromEntries(Object.entries(attributes).map(([ key, val ]) => [
-        key,
-        val === 'true' ? true :
-        val === 'false' ? false :
-        val === key ? true :
-        val
-    ]));
-}
-
-function transformMultiline(text, {strings, to}) {
-    // Heck yes, HTML magics.
-
-    text = transformInline(text.trim(), {strings, to});
-
-    const outLines = [];
-
-    const indentString = ' '.repeat(4);
-
-    let levelIndents = [];
-    const openLevel = indent => {
-        // opening a sublist is a pain: to be semantically *and* visually
-        // correct, we have to append the <ul> at the end of the existing
-        // previous <li>
-        const previousLine = outLines[outLines.length - 1];
-        if (previousLine?.endsWith('</li>')) {
-            // we will re-close the <li> later
-            outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>';
-        } else {
-            // if the previous line isn't a list item, this is the opening of
-            // the first list level, so no need for indent
-            outLines.push('<ul>');
-        }
-        levelIndents.push(indent);
-    };
-    const closeLevel = () => {
-        levelIndents.pop();
-        if (levelIndents.length) {
-            // closing a sublist, so close the list item containing it too
-            outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>');
-        } else {
-            // closing the final list level! no need for indent here
-            outLines.push('</ul>');
-        }
-    };
-
-    // okay yes we should support nested formatting, more than one blockquote
-    // layer, etc, but hear me out here: making all that work would basically
-    // be the same as implementing an entire markdown converter, which im not
-    // interested in doing lol. sorry!!!
-    let inBlockquote = false;
-
-    for (let line of text.split(/\r|\n|\r\n/)) {
-        const imageLine = line.startsWith('<img');
-        line = line.replace(/<img (.*?)>/g, (match, attributes) => img({
-            lazy: true,
-            link: true,
-            thumb: 'medium',
-            ...parseAttributes(attributes, {to})
-        }));
-
-        let indentThisLine = 0;
-        let lineContent = line;
-        let lineTag = 'p';
-
-        const listMatch = line.match(/^( *)- *(.*)$/);
-        if (listMatch) {
-            // is a list item!
-            if (!levelIndents.length) {
-                // first level is always indent = 0, regardless of actual line
-                // content (this is to avoid going to a lesser indent than the
-                // initial level)
-                openLevel(0);
-            } else {
-                // find level corresponding to indent
-                const indent = listMatch[1].length;
-                let i;
-                for (i = levelIndents.length - 1; i >= 0; i--) {
-                    if (levelIndents[i] <= indent) break;
-                }
-                // note: i cannot equal -1 because the first indentation level
-                // is always 0, and the minimum indentation is also 0
-                if (levelIndents[i] === indent) {
-                    // same indent! return to that level
-                    while (levelIndents.length - 1 > i) closeLevel();
-                    // (if this is already the current level, the above loop
-                    // will do nothing)
-                } else if (levelIndents[i] < indent) {
-                    // lesser indent! branch based on index
-                    if (i === levelIndents.length - 1) {
-                        // top level is lesser: add a new level
-                        openLevel(indent);
-                    } else {
-                        // lower level is lesser: return to that level
-                        while (levelIndents.length - 1 > i) closeLevel();
-                    }
-                }
-            }
-            // finally, set variables for appending content line
-            indentThisLine = levelIndents.length;
-            lineContent = listMatch[2];
-            lineTag = 'li';
-        } else {
-            // not a list item! close any existing list levels
-            while (levelIndents.length) closeLevel();
-
-            // like i said, no nested shenanigans - quotes only appear outside
-            // of lists. sorry!
-            const quoteMatch = line.match(/^> *(.*)$/);
-            if (quoteMatch) {
-                // is a quote! open a blockquote tag if it doesnt already exist
-                if (!inBlockquote) {
-                    inBlockquote = true;
-                    outLines.push('<blockquote>');
-                }
-                indentThisLine = 1;
-                lineContent = quoteMatch[1];
-            } else if (inBlockquote) {
-                // not a quote! close a blockquote tag if it exists
-                inBlockquote = false;
-                outLines.push('</blockquote>');
-            }
-        }
-
-        if (lineTag === 'p') {
-            // certain inline element tags should still be postioned within a
-            // paragraph; other elements (e.g. headings) should be added as-is
-            const elementMatch = line.match(/^<(.*?)[ >]/);
-            if (elementMatch && !imageLine && !['a', 'abbr', 'b', 'bdo', 'br', 'cite', 'code', 'data', 'datalist', 'del', 'dfn', 'em', 'i', 'img', 'ins', 'kbd', 'mark', 'output', 'picture', 'q', 'ruby', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'svg', 'time', 'var', 'wbr'].includes(elementMatch[1])) {
-                lineTag = '';
-            }
-        }
-
-        let pushString = indentString.repeat(indentThisLine);
-        if (lineTag) {
-            pushString += `<${lineTag}>${lineContent}</${lineTag}>`;
-        } else {
-            pushString += lineContent;
-        }
-        outLines.push(pushString);
-    }
-
-    // after processing all lines...
-
-    // if still in a list, close all levels
-    while (levelIndents.length) closeLevel();
-
-    // if still in a blockquote, close its tag
-    if (inBlockquote) {
-        inBlockquote = false;
-        outLines.push('</blockquote>');
-    }
-
-    return outLines.join('\n');
-}
-
-function transformLyrics(text, {strings, to}) {
-    // Different from transformMultiline 'cuz it joins multiple lines together
-    // with line 8reaks (<br>); transformMultiline treats each line as its own
-    // complete paragraph (or list, etc).
-
-    // If it looks like old data, then like, oh god.
-    // Use the normal transformMultiline tool.
-    if (text.includes('<br')) {
-        return transformMultiline(text, {strings, to});
-    }
-
-    text = transformInline(text.trim(), {strings, to});
-
-    let buildLine = '';
-    const addLine = () => outLines.push(`<p>${buildLine}</p>`);
-    const outLines = [];
-    for (const line of text.split('\n')) {
-        if (line.length) {
-            if (buildLine.length) {
-                buildLine += '<br>';
-            }
-            buildLine += line;
-        } else if (buildLine.length) {
-            addLine();
-            buildLine = '';
-        }
-    }
-    if (buildLine.length) {
-        addLine();
-    }
-    return outLines.join('\n');
-}
-
-function getCommentaryField(lines) {
-    const text = getMultilineField(lines, 'Commentary');
-    if (text) {
-        const lines = text.split('\n');
-        if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) {
-            return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`};
-        }
-        return text;
-    } else {
-        return null;
-    }
-};
-
-async function processAlbumDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        // This function can return "error o8jects," which are really just
-        // ordinary o8jects with an error message attached. I'm not 8othering
-        // with error codes here or anywhere in this function; while this would
-        // normally 8e 8ad coding practice, it doesn't really matter here,
-        // 8ecause this isn't an API getting consumed 8y other services (e.g.
-        // translaction functions). If we return an error, the caller will just
-        // print the attached message in the output summary.
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    // We're pro8a8ly supposed to, like, search for a header somewhere in the
-    // al8um contents, to make sure it's trying to 8e the intended structure
-    // and is a valid utf-8 (or at least ASCII) file. 8ut like, whatever.
-    // We'll just return more specific errors if it's missing necessary data
-    // fields.
-
-    const contentLines = contents.split('\n');
-
-    // In this line of code I defeat the purpose of using a generator in the
-    // first place. Sorry!!!!!!!!
-    const sections = Array.from(getSections(contentLines));
-
-    const albumSection = sections[0];
-    const album = {};
-
-    album.name = getBasicField(albumSection, 'Album');
-    album.artists = getContributionField(albumSection, 'Artists') || getContributionField(albumSection, 'Artist');
-    album.wallpaperArtists = getContributionField(albumSection, 'Wallpaper Art');
-    album.wallpaperStyle = getMultilineField(albumSection, 'Wallpaper Style');
-    album.bannerArtists = getContributionField(albumSection, 'Banner Art');
-    album.bannerStyle = getMultilineField(albumSection, 'Banner Style');
-    album.date = getBasicField(albumSection, 'Date');
-    album.trackArtDate = getBasicField(albumSection, 'Track Art Date') || album.date;
-    album.coverArtDate = getBasicField(albumSection, 'Cover Art Date') || album.date;
-    album.dateAdded = getBasicField(albumSection, 'Date Added');
-    album.coverArtists = getContributionField(albumSection, 'Cover Art');
-    album.hasTrackArt = getBooleanField(albumSection, 'Has Track Art') ?? true;
-    album.trackCoverArtists = getContributionField(albumSection, 'Track Art');
-    album.artTags = getListField(albumSection, 'Art Tags') || [];
-    album.commentary = getCommentaryField(albumSection);
-    album.urls = getListField(albumSection, 'URLs') || [];
-    album.groups = getListField(albumSection, 'Groups') || [];
-    album.directory = getBasicField(albumSection, 'Directory');
-    album.isMajorRelease = getBooleanField(albumSection, 'Major Release') ?? false;
-
-    if (album.artists && album.artists.error) {
-        return {error: `${album.artists.error} (in ${album.name})`};
-    }
-
-    if (album.coverArtists && album.coverArtists.error) {
-        return {error: `${album.coverArtists.error} (in ${album.name})`};
-    }
-
-    if (album.commentary && album.commentary.error) {
-        return {error: `${album.commentary.error} (in ${album.name})`};
-    }
-
-    if (album.trackCoverArtists && album.trackCoverArtists.error) {
-        return {error: `${album.trackCoverArtists.error} (in ${album.name})`};
-    }
-
-    if (!album.coverArtists) {
-        return {error: `The album "${album.name}" is missing the "Cover Art" field.`};
-    }
-
-    album.color = (
-        getBasicField(albumSection, 'Color') ||
-        getBasicField(albumSection, 'FG')
-    );
-
-    if (!album.name) {
-        return {error: `Expected "Album" (name) field!`};
-    }
-
-    if (!album.date) {
-        return {error: `Expected "Date" field! (in ${album.name})`};
-    }
-
-    if (!album.dateAdded) {
-        return {error: `Expected "Date Added" field! (in ${album.name})`};
-    }
-
-    if (isNaN(Date.parse(album.date))) {
-        return {error: `Invalid Date field: "${album.date}" (in ${album.name})`};
-    }
-
-    if (isNaN(Date.parse(album.trackArtDate))) {
-        return {error: `Invalid Track Art Date field: "${album.trackArtDate}" (in ${album.name})`};
-    }
-
-    if (isNaN(Date.parse(album.coverArtDate))) {
-        return {error: `Invalid Cover Art Date field: "${album.coverArtDate}" (in ${album.name})`};
-    }
-
-    if (isNaN(Date.parse(album.dateAdded))) {
-        return {error: `Invalid Date Added field: "${album.dateAdded}" (in ${album.name})`};
-    }
-
-    album.date = new Date(album.date);
-    album.trackArtDate = new Date(album.trackArtDate);
-    album.coverArtDate = new Date(album.coverArtDate);
-    album.dateAdded = new Date(album.dateAdded);
-
-    if (!album.directory) {
-        album.directory = C.getKebabCase(album.name);
-    }
-
-    album.tracks = [];
-
-    // will be overwritten if a group section is found!
-    album.trackGroups = null;
-
-    let group = null;
-    let trackIndex = 0;
-
-    for (const section of sections.slice(1)) {
-        // Just skip empty sections. Sometimes I paste a 8unch of dividers,
-        // and this lets the empty sections doing that creates (temporarily)
-        // exist without raising an error.
-        if (!section.filter(Boolean).length) {
-            continue;
-        }
-
-        const groupName = getBasicField(section, 'Group');
-        if (groupName) {
-            group = {
-                name: groupName,
-                color: (
-                    getBasicField(section, 'Color') ||
-                    getBasicField(section, 'FG') ||
-                    album.color
-                ),
-                startIndex: trackIndex,
-                tracks: []
-            };
-            if (album.trackGroups) {
-                album.trackGroups.push(group);
-            } else {
-                album.trackGroups = [group];
-            }
-            continue;
-        }
-
-        trackIndex++;
-
-        const track = {};
-
-        track.name = getBasicField(section, 'Track');
-        track.commentary = getCommentaryField(section);
-        track.lyrics = getMultilineField(section, 'Lyrics');
-        track.originalDate = getBasicField(section, 'Original Date');
-        track.coverArtDate = getBasicField(section, 'Cover Art Date') || track.originalDate || album.trackArtDate;
-        track.references = getListField(section, 'References') || [];
-        track.artists = getContributionField(section, 'Artists') || getContributionField(section, 'Artist');
-        track.coverArtists = getContributionField(section, 'Track Art');
-        track.artTags = getListField(section, 'Art Tags') || [];
-        track.contributors = getContributionField(section, 'Contributors') || [];
-        track.directory = getBasicField(section, 'Directory');
-        track.aka = getBasicField(section, 'AKA');
-
-        if (!track.name) {
-            return {error: `A track section is missing the "Track" (name) field (in ${album.name}, previous: ${album.tracks[album.tracks.length - 1]?.name}).`};
-        }
-
-        let durationString = getBasicField(section, 'Duration') || '0:00';
-        track.duration = getDurationInSeconds(durationString);
-
-        if (track.contributors.error) {
-            return {error: `${track.contributors.error} (in ${track.name}, ${album.name})`};
-        }
-
-        if (track.commentary && track.commentary.error) {
-            return {error: `${track.commentary.error} (in ${track.name}, ${album.name})`};
-        }
-
-        if (!track.artists) {
-            // If an al8um has an artist specified (usually 8ecause it's a solo
-            // al8um), let tracks inherit that artist. We won't display the
-            // "8y <artist>" string on the al8um listing.
-            if (album.artists) {
-                track.artists = album.artists;
-            } else {
-                return {error: `The track "${track.name}" is missing the "Artist" field (in ${album.name}).`};
-            }
-        }
-
-        if (!track.coverArtists) {
-            if (getBasicField(section, 'Track Art') !== 'none' && album.hasTrackArt) {
-                if (album.trackCoverArtists) {
-                    track.coverArtists = album.trackCoverArtists;
-                } else {
-                    return {error: `The track "${track.name}" is missing the "Track Art" field (in ${album.name}).`};
-                }
-            }
-        }
-
-        if (track.coverArtists && track.coverArtists.length && track.coverArtists[0] === 'none') {
-            track.coverArtists = null;
-        }
-
-        if (!track.directory) {
-            track.directory = C.getKebabCase(track.name);
-        }
-
-        if (track.originalDate) {
-            if (isNaN(Date.parse(track.originalDate))) {
-                return {error: `The track "${track.name}"'s has an invalid "Original Date" field: "${track.originalDate}"`};
-            }
-            track.date = new Date(track.originalDate);
-        } else {
-            track.date = album.date;
-        }
-
-        track.coverArtDate = new Date(track.coverArtDate);
-
-        const hasURLs = getBooleanField(section, 'Has URLs') ?? true;
-
-        track.urls = hasURLs && (getListField(section, 'URLs') || []).filter(Boolean);
-
-        if (hasURLs && !track.urls.length) {
-            return {error: `The track "${track.name}" should have at least one URL specified.`};
-        }
-
-        // 8ack-reference the al8um o8ject! This is very useful for when
-        // we're outputting the track pages.
-        track.album = album;
-
-        if (group) {
-            track.color = group.color;
-            group.tracks.push(track);
-        } else {
-            track.color = album.color;
-        }
-
-        album.tracks.push(track);
-    }
-
-    return album;
-}
-
-async function processArtistDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
-
-    return sections.filter(s => s.filter(Boolean).length).map(section => {
-        const name = getBasicField(section, 'Artist');
-        const urls = (getListField(section, 'URLs') || []).filter(Boolean);
-        const alias = getBasicField(section, 'Alias');
-        const hasAvatar = getBooleanField(section, 'Has Avatar') ?? false;
-        const note = getMultilineField(section, 'Note');
-        let directory = getBasicField(section, 'Directory');
-
-        if (!name) {
-            return {error: 'Expected "Artist" (name) field!'};
-        }
-
-        if (!directory) {
-            directory = C.getArtistDirectory(name);
-        }
-
-        if (alias) {
-            return {name, directory, alias};
-        } else {
-            return {name, directory, urls, note, hasAvatar};
-        }
-    });
-}
-
-async function processFlashDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
-
-    let act, color;
-    return sections.map(section => {
-        if (getBasicField(section, 'ACT')) {
-            act = getBasicField(section, 'ACT');
-            color = (
-                getBasicField(section, 'Color') ||
-                getBasicField(section, 'FG')
-            );
-            const anchor = getBasicField(section, 'Anchor');
-            const jump = getBasicField(section, 'Jump');
-            const jumpColor = getBasicField(section, 'Jump Color') || color;
-            return {act8r8k: true, name: act, color, anchor, jump, jumpColor};
-        }
-
-        const name = getBasicField(section, 'Flash');
-        let page = getBasicField(section, 'Page');
-        let directory = getBasicField(section, 'Directory');
-        let date = getBasicField(section, 'Date');
-        const jiff = getBasicField(section, 'Jiff');
-        const tracks = getListField(section, 'Tracks') || [];
-        const contributors = getContributionField(section, 'Contributors') || [];
-        const urls = (getListField(section, 'URLs') || []).filter(Boolean);
-
-        if (!name) {
-            return {error: 'Expected "Flash" (name) field!'};
-        }
-
-        if (!page && !directory) {
-            return {error: 'Expected "Page" or "Directory" field!'};
-        }
-
-        if (!directory) {
-            directory = page;
-        }
-
-        if (!date) {
-            return {error: 'Expected "Date" field!'};
-        }
-
-        if (isNaN(Date.parse(date))) {
-            return {error: `Invalid Date field: "${date}"`};
-        }
-
-        date = new Date(date);
-
-        return {name, page, directory, date, contributors, tracks, urls, act, color, jiff};
-    });
-}
-
-async function processNewsDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
-
-    return sections.map(section => {
-        const name = getBasicField(section, 'Name');
-        if (!name) {
-            return {error: 'Expected "Name" field!'};
-        }
-
-        const directory = getBasicField(section, 'Directory') || getBasicField(section, 'ID');
-        if (!directory) {
-            return {error: 'Expected "Directory" field!'};
-        }
-
-        let body = getMultilineField(section, 'Body');
-        if (!body) {
-            return {error: 'Expected "Body" field!'};
-        }
-
-        let date = getBasicField(section, 'Date');
-        if (!date) {
-            return {error: 'Expected "Date" field!'};
-        }
-
-        if (isNaN(Date.parse(date))) {
-            return {error: `Invalid date field: "${date}"`};
-        }
-
-        date = new Date(date);
-
-        let bodyShort = body.split('<hr class="split">')[0];
-
-        return {
-            name,
-            directory,
-            body,
-            bodyShort,
-            date
-        };
-    });
-}
-
-async function processTagDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        if (error.code === 'ENOENT') {
-            return [];
-        } else {
-            return {error: `Could not read ${file} (${error.code}).`};
-        }
-    }
-
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
-
-    return sections.map(section => {
-        let isCW = false;
-
-        let name = getBasicField(section, 'Tag');
-        if (!name) {
-            name = getBasicField(section, 'CW');
-            isCW = true;
-            if (!name) {
-                return {error: 'Expected "Tag" or "CW" field!'};
-            }
-        }
-
-        let color;
-        if (!isCW) {
-            color = getBasicField(section, 'Color');
-            if (!color) {
-                return {error: 'Expected "Color" field!'};
-            }
-        }
-
-        const directory = C.getKebabCase(name);
-
-        return {
-            name,
-            directory,
-            isCW,
-            color
-        };
-    });
-}
-
-async function processGroupDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        if (error.code === 'ENOENT') {
-            return [];
-        } else {
-            return {error: `Could not read ${file} (${error.code}).`};
-        }
-    }
-
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
-
-    let category, color;
-    return sections.map(section => {
-        if (getBasicField(section, 'Category')) {
-            category = getBasicField(section, 'Category');
-            color = getBasicField(section, 'Color');
-            return {isCategory: true, name: category, color};
-        }
-
-        const name = getBasicField(section, 'Group');
-        if (!name) {
-            return {error: 'Expected "Group" field!'};
-        }
-
-        let directory = getBasicField(section, 'Directory');
-        if (!directory) {
-            directory = C.getKebabCase(name);
-        }
-
-        let description = getMultilineField(section, 'Description');
-        if (!description) {
-            return {error: 'Expected "Description" field!'};
-        }
-
-        let descriptionShort = description.split('<hr class="split">')[0];
-
-        const urls = (getListField(section, 'URLs') || []).filter(Boolean);
-
-        return {
-            isGroup: true,
-            name,
-            directory,
-            description,
-            descriptionShort,
-            urls,
-            category,
-            color
-        };
-    });
-}
-
-async function processStaticPageDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        if (error.code === 'ENOENT') {
-            return [];
-        } else {
-            return {error: `Could not read ${file} (${error.code}).`};
-        }
-    }
-
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
-
-    return sections.map(section => {
-        const name = getBasicField(section, 'Name');
-        if (!name) {
-            return {error: 'Expected "Name" field!'};
-        }
-
-        const shortName = getBasicField(section, 'Short Name') || name;
-
-        let directory = getBasicField(section, 'Directory');
-        if (!directory) {
-            return {error: 'Expected "Directory" field!'};
-        }
-
-        let content = getMultilineField(section, 'Content');
-        if (!content) {
-            return {error: 'Expected "Content" field!'};
-        }
-
-        let stylesheet = getMultilineField(section, 'Style') || '';
-
-        let listed = getBooleanField(section, 'Listed') ?? true;
-
-        return {
-            name,
-            shortName,
-            directory,
-            content,
-            stylesheet,
-            listed
-        };
-    });
-}
-
-async function processWikiInfoFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    // Unlike other data files, the site info data file isn't 8roken up into
-    // more than one entry. So we operate on the plain old contentLines array,
-    // rather than dividing into sections like we usually do!
-    const contentLines = contents.split('\n');
-
-    const name = getBasicField(contentLines, 'Name');
-    if (!name) {
-        return {error: 'Expected "Name" field!'};
-    }
-
-    const shortName = getBasicField(contentLines, 'Short Name') || name;
-
-    const color = getBasicField(contentLines, 'Color') || '#0088ff';
-
-    // This is optional! Without it, <meta rel="canonical"> tags won't 8e
-    // gener8ted.
-    const canonicalBase = getBasicField(contentLines, 'Canonical Base');
-
-    // This is optional! Without it, the site will default to 8uilding in
-    // English. (This is only really relevant if you've provided string files
-    // for non-English languages.)
-    const defaultLanguage = getBasicField(contentLines, 'Default Language');
-
-    // Also optional! In charge of <meta rel="description">.
-    const description = getBasicField(contentLines, 'Description');
-
-    const footer = getMultilineField(contentLines, 'Footer') || '';
-
-    // We've had a comment lying around for ages, just reading:
-    // "Might ena8le this later... we'll see! Eventually. May8e."
-    // We still haven't! 8ut hey, the option's here.
-    const enableArtistAvatars = getBooleanField(contentLines, 'Enable Artist Avatars') ?? false;
-
-    const enableFlashesAndGames = getBooleanField(contentLines, 'Enable Flashes & Games') ?? false;
-    const enableListings = getBooleanField(contentLines, 'Enable Listings') ?? false;
-    const enableNews = getBooleanField(contentLines, 'Enable News') ?? false;
-    const enableArtTagUI = getBooleanField(contentLines, 'Enable Art Tag UI') ?? false;
-    const enableGroupUI = getBooleanField(contentLines, 'Enable Group UI') ?? false;
-
-    return {
-        name,
-        shortName,
-        color,
-        canonicalBase,
-        defaultLanguage,
-        description,
-        footer,
-        features: {
-            artistAvatars: enableArtistAvatars,
-            flashesAndGames: enableFlashesAndGames,
-            listings: enableListings,
-            news: enableNews,
-            artTagUI: enableArtTagUI,
-            groupUI: enableGroupUI
-        }
-    };
-}
-
-async function processHomepageInfoFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
-
-    const [ firstSection, ...rowSections ] = sections;
-
-    const sidebar = getMultilineField(firstSection, 'Sidebar');
-
-    const validRowTypes = ['albums'];
-
-    const rows = rowSections.map(section => {
-        const name = getBasicField(section, 'Row');
-        if (!name) {
-            return {error: 'Expected "Row" (name) field!'};
-        }
-
-        const color = getBasicField(section, 'Color');
-
-        const type = getBasicField(section, 'Type');
-        if (!type) {
-            return {error: 'Expected "Type" field!'};
-        }
-
-        if (!validRowTypes.includes(type)) {
-            return {error: `Expected "Type" field to be one of: ${validRowTypes.join(', ')}`};
-        }
-
-        const row = {name, color, type};
-
-        switch (type) {
-            case 'albums': {
-                const group = getBasicField(section, 'Group') || null;
-                const albums = getListField(section, 'Albums') || [];
-
-                if (!group && !albums) {
-                    return {error: 'Expected "Group" and/or "Albums" field!'};
-                }
-
-                let groupCount = getBasicField(section, 'Count');
-                if (group && !groupCount) {
-                    return {error: 'Expected "Count" field!'};
-                }
-
-                if (groupCount) {
-                    if (isNaN(parseInt(groupCount))) {
-                        return {error: `Invalid Count field: "${groupCount}"`};
-                    }
-
-                    groupCount = parseInt(groupCount);
-                }
-
-                const actions = getListField(section, 'Actions') || [];
-
-                return {...row, group, groupCount, albums, actions};
-            }
-        }
-    });
-
-    return {sidebar, rows};
-}
-
-function getDurationInSeconds(string) {
-    const parts = string.split(':').map(n => parseInt(n))
-    if (parts.length === 3) {
-        return parts[0] * 3600 + parts[1] * 60 + parts[2]
-    } else if (parts.length === 2) {
-        return parts[0] * 60 + parts[1]
-    } else {
-        return 0
-    }
-}
-
-function getTotalDuration(tracks) {
-    return tracks.reduce((duration, track) => duration + track.duration, 0);
-}
-
-const stringifyIndent = 0;
-
-const toRefs = (label, objectOrArray) => {
-    if (Array.isArray(objectOrArray)) {
-        return objectOrArray.filter(Boolean).map(x => `${label}:${x.directory}`);
-    } else if (objectOrArray.directory) {
-        throw new Error('toRefs should not be passed a single object with directory');
-    } else if (typeof objectOrArray === 'object') {
-        return Object.fromEntries(Object.entries(objectOrArray)
-            .map(([ key, value ]) => [key, toRefs(key, value)]));
-    } else {
-        throw new Error('toRefs should be passed an array or object of arrays');
-    }
-};
-
-function stringifyRefs(key, value) {
-    switch (key) {
-        case 'tracks':
-        case 'references':
-        case 'referencedBy':
-            return toRefs('track', value);
-        case 'artists':
-        case 'contributors':
-        case 'coverArtists':
-        case 'trackCoverArtists':
-            return value && value.map(({ who, what }) => ({who: `artist:${who.directory}`, what}));
-        case 'albums': return toRefs('album', value);
-        case 'flashes': return toRefs('flash', value);
-        case 'groups': return toRefs('group', value);
-        case 'artTags': return toRefs('tag', value);
-        case 'aka': return value && `track:${value.directory}`;
-        default:
-            return value;
-    }
-}
-
-function stringifyAlbumData() {
-    return JSON.stringify(albumData, (key, value) => {
-        switch (key) {
-            case 'commentary':
-                return '';
-            default:
-                return stringifyRefs(key, value);
-        }
-    }, stringifyIndent);
-}
-
-function stringifyTrackData() {
-    return JSON.stringify(trackData, (key, value) => {
-        switch (key) {
-            case 'album':
-            case 'commentary':
-            case 'otherReleases':
-                return undefined;
-            default:
-                return stringifyRefs(key, value);
-        }
-    }, stringifyIndent);
-}
-
-function stringifyFlashData() {
-    return JSON.stringify(flashData, (key, value) => {
-        switch (key) {
-            case 'act':
-            case 'commentary':
-                return undefined;
-            default:
-                return stringifyRefs(key, value);
-        }
-    }, stringifyIndent);
-}
-
-function stringifyArtistData() {
-    return JSON.stringify(artistData, (key, value) => {
-        switch (key) {
-            case 'asAny':
-                return;
-            case 'asArtist':
-            case 'asContributor':
-            case 'asCoverArtist':
-                return toRefs('track', value);
-            default:
-                return stringifyRefs(key, value);
-        }
-    }, stringifyIndent);
-}
-
-function img({
-    src = '',
-    alt = '',
-    thumb: thumbKey = '',
-    reveal = '',
-    id = '',
-    class: className = '',
-    width = '',
-    height = '',
-    link = false,
-    lazy = false,
-    square = false
-}) {
-    const willSquare = square;
-    const willLink = typeof link === 'string' || link;
-
-    const originalSrc = src;
-    const thumbSrc = thumbKey ? thumb[thumbKey](src) : src;
-
-    const imgAttributes = html.attributes({
-        id: link ? '' : id,
-        class: className,
-        alt,
-        width,
-        height
-    });
-
-    const nonlazyHTML = wrap(`<img src="${thumbSrc}" ${imgAttributes}>`);
-    const lazyHTML = lazy && wrap(`<img class="lazy" data-original="${thumbSrc}" ${imgAttributes}>`, true);
-
-    if (lazy) {
-        return fixWS`
-            <noscript>${nonlazyHTML}</noscript>
-            ${lazyHTML}
-        `;
-    } else {
-        return nonlazyHTML;
-    }
-
-    function wrap(input, hide = false) {
-        let wrapped = input;
-
-        wrapped = `<div class="image-inner-area">${wrapped}</div>`;
-        wrapped = `<div class="image-container">${wrapped}</div>`;
-
-        if (reveal) {
-            wrapped = fixWS`
-                <div class="reveal">
-                    ${wrapped}
-                    <span class="reveal-text">${reveal}</span>
-                </div>
-            `;
-        }
-
-        if (willSquare) {
-            wrapped = html.tag('div', {class: 'square-content'}, wrapped);
-            wrapped = html.tag('div', {class: ['square', hide && !willLink && 'js-hide']}, wrapped);
-        }
-
-        if (willLink) {
-            wrapped = html.tag('a', {
-                id,
-                class: ['box', hide && 'js-hide'],
-                href: typeof link === 'string' ? link : originalSrc
-            }, wrapped);
-        }
-
-        return wrapped;
-    }
-}
-
-function serializeImagePaths(original) {
-    return {
-        original,
-        medium: thumb.medium(original),
-        small: thumb.small(original)
-    };
-}
-
-function serializeLink(thing) {
-    const ret = {};
-    ret.name = thing.name;
-    ret.directory = thing.directory;
-    if (thing.color) ret.color = thing.color;
-    return ret;
-}
-
-function serializeContribs(contribs) {
-    return contribs.map(({ who, what }) => {
-        const ret = {};
-        ret.artist = serializeLink(who);
-        if (what) ret.contribution = what;
-        return ret;
-    });
-}
-
-function serializeCover(thing, pathFunction) {
-    const coverPath = pathFunction(thing, {
-        to: urls.from('media.root').to
-    });
-
-    const { artTags } = thing;
-
-    const cwTags = artTags.filter(tag => tag.isCW);
-    const linkTags = artTags.filter(tag => !tag.isCW);
-
-    return {
-        paths: serializeImagePaths(coverPath),
-        tags: linkTags.map(serializeLink),
-        warnings: cwTags.map(tag => tag.name)
-    };
-}
-
-function serializeGroupsForAlbum(album) {
-    return album.groups.map(group => {
-        const index = group.albums.indexOf(album);
-        const next = group.albums[index + 1] || null;
-        const previous = group.albums[index - 1] || null;
-        return {group, index, next, previous};
-    }).map(({group, index, next, previous}) => ({
-        link: serializeLink(group),
-        descriptionShort: group.descriptionShort,
-        albumIndex: index,
-        nextAlbum: next && serializeLink(next),
-        previousAlbum: previous && serializeLink(previous),
-        urls: group.urls
-    }));
-}
-
-function serializeGroupsForTrack(track) {
-    return track.album.groups.map(group => ({
-        link: serializeLink(group),
-        urls: group.urls,
-    }));
-}
-
-function validateWritePath(path, urlGroup) {
-    if (!Array.isArray(path)) {
-        return {error: `Expected array, got ${path}`};
-    }
-
-    const { paths } = urlGroup;
-
-    const definedKeys = Object.keys(paths);
-    const specifiedKey = path[0];
-
-    if (!definedKeys.includes(specifiedKey)) {
-        return {error: `Specified key ${specifiedKey} isn't defined`};
-    }
-
-    const expectedArgs = paths[specifiedKey].match(/<>/g).length;
-    const specifiedArgs = path.length - 1;
-
-    if (specifiedArgs !== expectedArgs) {
-        return {error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`};
-    }
-
-    return {success: true};
-}
-
-function validateWriteObject(obj) {
-    if (typeof obj !== 'object') {
-        return {error: `Expected object, got ${typeof obj}`};
-    }
-
-    if (typeof obj.type !== 'string') {
-        return {error: `Expected type to be string, got ${obj.type}`};
-    }
-
-    switch (obj.type) {
-        case 'legacy': {
-            if (typeof obj.write !== 'function') {
-                return {error: `Expected write to be string, got ${obj.write}`};
-            }
-
-            break;
-        }
-
-        case 'page': {
-            const path = validateWritePath(obj.path, urlSpec.localized);
-            if (path.error) {
-                return {error: `Path validation failed: ${path.error}`};
-            }
-
-            if (typeof obj.page !== 'function') {
-                return {error: `Expected page to be function, got ${obj.content}`};
-            }
-
-            break;
-        }
-
-        case 'data': {
-            const path = validateWritePath(obj.path, urlSpec.data);
-            if (path.error) {
-                return {error: `Path validation failed: ${path.error}`};
-            }
-
-            if (typeof obj.data !== 'function') {
-                return {error: `Expected data to be function, got ${obj.data}`};
-            }
-
-            break;
-        }
-
-        default: {
-            return {error: `Unknown type: ${obj.type}`};
-        }
-    }
-
-    return {success: true};
-}
-
-async function writeData(subKey, directory, data) {
-    const paths = writePage.paths('', 'data.' + subKey, directory, {file: 'data.json'});
-    await writePage.write(JSON.stringify(data), {paths});
-}
-
-async function writePage(strings, baseDirectory, pageSubKey, directory, pageFn) {
-    // Generally this function shouldn't 8e called directly - instead use the
-    // shadowed version provided 8y wrapLanguages, which automatically provides
-    // the appropriate baseDirectory and strings arguments. (The utility
-    // functions attached to this function are generally useful, though!)
-
-    const paths = writePage.paths(baseDirectory, 'localized.' + pageSubKey, directory);
-
-    const to = (targetFullKey, ...args) => {
-        const [ groupKey, subKey ] = targetFullKey.split('.')[0];
-        let path = paths.subdirectoryPrefix
-        // When linking to *outside* the localized area of the site, we need to
-        // make sure the result is correctly relative to the 8ase directory.
-        if (groupKey !== 'localized' && baseDirectory) {
-            path += urls.from('localizedWithBaseDirectory.' + pageSubKey).to(targetFullKey, ...args);
-        } else {
-            // If we're linking inside the localized area (or there just is no
-            // 8ase directory), the 8ase directory doesn't matter.
-            path += urls.from('localized.' + pageSubKey).to(targetFullKey, ...args);
-        }
-        // console.log(pageSubKey, '->', targetFullKey, '=', path);
-        return path;
-    };
-
-    const content = writePage.html(pageFn, {paths, strings, to});
-    await writePage.write(content, {paths});
-}
-
-writePage.html = (pageFn, {paths, strings, to}) => {
-    let {
-        title = '',
-        meta = {},
-        theme = '',
-        stylesheet = '',
-
-        // missing properties are auto-filled, see below!
-        body = {},
-        banner = {},
-        main = {},
-        sidebarLeft = {},
-        sidebarRight = {},
-        nav = {},
-        footer = {}
-    } = pageFn({to});
-
-    body.style ??= '';
-
-    theme = theme || getThemeString(wikiInfo);
-
-    banner ||= {};
-    banner.classes ??= [];
-    banner.src ??= '';
-    banner.position ??= '';
-
-    main.classes ??= [];
-    main.content ??= '';
-
-    sidebarLeft ??= {};
-    sidebarRight ??= {};
-
-    for (const sidebar of [sidebarLeft, sidebarRight]) {
-        sidebar.classes ??= [];
-        sidebar.content ??= '';
-        sidebar.collapse ??= true;
-    }
-
-    nav.classes ??= [];
-    nav.content ??= '';
-    nav.links ??= [];
-
-    footer.classes ??= [];
-    footer.content ??= (wikiInfo.footer ? transformMultiline(wikiInfo.footer, {strings, to}) : '');
-
-    const canonical = (wikiInfo.canonicalBase
-        ? wikiInfo.canonicalBase + paths.pathname
-        : '');
-
-    const collapseSidebars = (sidebarLeft.collapse !== false) && (sidebarRight.collapse !== false);
-
-    const mainHTML = main.content && fixWS`
-        <main id="content" ${classes(...main.classes || [])}>
-            ${main.content}
-        </main>
-    `;
-
-    const footerHTML = footer.content && fixWS`
-        <footer id="footer" ${classes(...footer.classes || [])}>
-            ${footer.content}
-        </footer>
-    `;
-
-    const generateSidebarHTML = (id, {
-        content,
-        multiple,
-        classes: sidebarClasses = [],
-        collapse = true,
-        wide = false
-    }) => (content ? fixWS`
-        <div id="${id}" ${classes(
-            'sidebar-column',
-            'sidebar',
-            wide && 'wide',
-            !collapse && 'no-hide',
-            ...sidebarClasses
-        )}>
-            ${content}
-        </div>
-    ` : multiple ? fixWS`
-        <div id="${id}" ${classes(
-            'sidebar-column',
-            'sidebar-multiple',
-            wide && 'wide',
-            !collapse && 'no-hide'
-        )}>
-            ${multiple.map(content => fixWS`
-                <div ${classes(
-                    'sidebar',
-                    ...sidebarClasses
-                )}>
-                    ${content}
-                </div>
-            `).join('\n')}
-        </div>
-    ` : '');
-
-    const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft);
-    const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight);
-
-    if (nav.simple) {
-        nav.links = [
-            {
-                href: to('localized.home'),
-                title: wikiInfo.shortName
-            },
-            {
-                href: '',
-                title
-            }
-        ];
-    }
-
-    const links = (nav.links || []).filter(Boolean);
-
-    const navLinkParts = [];
-    for (let i = 0; i < links.length; i++) {
-        const link = links[i];
-        const prev = links[i - 1];
-        const next = links[i + 1];
-        const { html, href, title, divider = true } = link;
-        let part = prev && divider ? '/ ' : '';
-        if (typeof href === 'string') {
-            part += `<a href="${href}" ${classes(i === links.length - 1 && 'current')}>${title}</a>`;
-        } else if (html) {
-            part += `<span>${html}</span>`;
-        }
-        navLinkParts.push(part);
-    }
-
-    const navHTML = html.tag('nav', {
-        [html.onlyIfContent]: true,
-        id: 'header',
-        class: nav.classes
-    }, [
-        links.length && html.tag('h2', {class: 'highlight-last-link'}, navLinkParts),
-        nav.content
-    ]);
-
-    const bannerHTML = banner.position && banner.src && html.tag('div',
-        {
-            id: 'banner',
-            class: banner.classes
-        },
-        html.tag('img', {
-            src: banner.src,
-            alt: banner.alt,
-            width: 1100,
-            height: 200
-        })
-    );
-
-    const layoutHTML = [
-        navHTML,
-        banner.position === 'top' && bannerHTML,
-        (sidebarLeftHTML || sidebarRightHTML) ? fixWS`
-            <div ${classes('layout-columns', !collapseSidebars && 'vertical-when-thin')}>
-                ${sidebarLeftHTML}
-                ${mainHTML}
-                ${sidebarRightHTML}
-            </div>
-        ` : mainHTML,
-        banner.position === 'bottom' && bannerHTML,
-        footerHTML
-    ].filter(Boolean).join('\n');
-
-    const infoCardHTML = fixWS`
-        <div id="info-card-container">
-            <div class="info-card-decor">
-                <div class="info-card">
-                    <div class="info-card-art-container no-reveal">
-                        ${img({
-                            class: 'info-card-art',
-                            src: '',
-                            link: true,
-                            square: true
-                        })}
-                    </div>
-                    <div class="info-card-art-container reveal">
-                        ${img({
-                            class: 'info-card-art',
-                            src: '',
-                            link: true,
-                            square: true,
-                            reveal: getRevealStringFromWarnings('<span class="info-card-art-warnings"></span>', {strings})
-                        })}
-                    </div>
-                    <h1 class="info-card-name"><a></a></h1>
-                    <p class="info-card-album">${strings('releaseInfo.from', {album: '<a></a>'})}</p>
-                    <p class="info-card-artists">${strings('releaseInfo.by', {artists: '<span></span>'})}</p>
-                    <p class="info-card-cover-artists">${strings('releaseInfo.coverArtBy', {artists: '<span></span>'})}</p>
-                </div>
-            </div>
-        </div>
-    `;
-
-    return filterEmptyLines(fixWS`
-        <!DOCTYPE html>
-        <html ${html.attributes({
-            lang: strings.code,
-            'data-rebase-localized': to('localized.root'),
-            'data-rebase-shared': to('shared.root'),
-            'data-rebase-media': to('media.root'),
-            'data-rebase-data': to('data.root')
-        })}>
-            <head>
-                <title>${title}</title>
-                <meta charset="utf-8">
-                <meta name="viewport" content="width=device-width, initial-scale=1">
-                ${Object.entries(meta).filter(([ key, value ]) => value).map(([ key, value ]) => `<meta ${key}="${html.escapeAttributeValue(value)}">`).join('\n')}
-                ${canonical && `<link rel="canonical" href="${canonical}">`}
-                <link rel="stylesheet" href="${to('shared.staticFile', `site.css?${CACHEBUST}`)}">
-                ${(theme || stylesheet) && fixWS`
-                    <style>
-                        ${theme}
-                        ${stylesheet}
-                    </style>
-                `}
-                <script src="${to('shared.staticFile', `lazy-loading.js?${CACHEBUST}`)}"></script>
-            </head>
-            <body ${html.attributes({style: body.style || ''})}>
-                <div id="page-container">
-                    ${mainHTML && fixWS`
-                        <div id="skippers">
-                            ${[
-                                ['#content', strings('misc.skippers.skipToContent')],
-                                sidebarLeftHTML && ['#sidebar-left', (sidebarRightHTML
-                                    ? strings('misc.skippers.skipToSidebar.left')
-                                    : strings('misc.skippers.skipToSidebar'))],
-                                sidebarRightHTML && ['#sidebar-right', (sidebarLeftHTML
-                                    ? strings('misc.skippers.skipToSidebar.right')
-                                    : strings('misc.skippers.skipToSidebar'))],
-                                footerHTML && ['#footer', strings('misc.skippers.skipToFooter')]
-                            ].filter(Boolean).map(([ href, title ]) => fixWS`
-                                <span class="skipper"><a href="${href}">${title}</a></span>
-                            `).join('\n')}
-                        </div>
-                    `}
-                    ${layoutHTML}
-                </div>
-                ${infoCardHTML}
-                <script src="${to('shared.commonFile', `common.js?${CACHEBUST}`)}"></script>
-                <script src="${to('shared.staticFile', `client.js?${CACHEBUST}`)}"></script>
-            </body>
-        </html>
-    `);
-};
-
-writePage.write = async (content, {paths}) => {
-    await mkdirp(paths.outputDirectory);
-    await writeFile(paths.outputFile, content);
-};
-
-// TODO: This only supports one <>-style argument.
-writePage.paths = (baseDirectory, fullKey, directory, {
-    file = 'index.html'
-} = {}) => {
-    const [ groupKey, subKey ] = fullKey.split('.');
-
-    const pathname = (groupKey === 'localized' && baseDirectory
-        ? urls.from('shared.root').to('localizedWithBaseDirectory.' + subKey, baseDirectory, directory)
-        : urls.from('shared.root').to(fullKey, directory));
-
-    // Needed for the rare directory which itself contains a slash, e.g. for
-    // listings, with directories like 'albums/by-name'.
-    const subdirectoryPrefix = '../'.repeat(directory.split('/').length - 1);
-
-    const outputDirectory = path.join(outputPath, pathname);
-    const outputFile = path.join(outputDirectory, file);
-
-    return {
-        pathname,
-        subdirectoryPrefix,
-        outputDirectory, outputFile
-    };
-};
-
-function getGridHTML({
-    strings,
-    entries,
-    srcFn,
-    hrefFn,
-    altFn = () => '',
-    detailsFn = null,
-    lazy = true
-}) {
-    return entries.map(({ large, item }, i) => fixWS`
-        <a ${classes('grid-item', 'box', large && 'large-grid-item')} href="${hrefFn(item)}" style="${getLinkThemeString(item)}">
-            ${img({
-                src: srcFn(item),
-                alt: altFn(item),
-                thumb: 'small',
-                lazy: (typeof lazy === 'number' ? i >= lazy : lazy),
-                square: true,
-                reveal: getRevealStringFromTags(item.artTags, {strings})
-            })}
-            <span>${item.name}</span>
-            ${detailsFn && `<span>${detailsFn(item)}</span>`}
-        </a>
-    `).join('\n');
-}
-
-function getAlbumGridHTML({strings, to, details = false, ...props}) {
-    return getGridHTML({
-        strings,
-        srcFn: album => getAlbumCover(album, {to}),
-        hrefFn: album => to('localized.album', album.directory),
-        detailsFn: details && (album => strings('misc.albumGridDetails', {
-            tracks: strings.count.tracks(album.tracks.length, {unit: true}),
-            time: strings.count.duration(getTotalDuration(album.tracks))
-        })),
-        ...props
-    });
-}
-
-function getFlashGridHTML({strings, to, ...props}) {
-    return getGridHTML({
-        strings,
-        srcFn: flash => to('media.flashArt', flash.directory),
-        hrefFn: flash => to('localized.flash', flash.directory),
-        ...props
-    });
-}
-
-function getNewReleases(numReleases) {
-    const latestFirst = albumData.slice().reverse();
-    const majorReleases = latestFirst.filter(album => album.isMajorRelease);
-    majorReleases.splice(1);
-
-    const otherReleases = latestFirst
-        .filter(album => !majorReleases.includes(album))
-        .slice(0, numReleases - majorReleases.length);
-
-    return [
-        ...majorReleases.map(album => ({large: true, item: album})),
-        ...otherReleases.map(album => ({large: false, item: album}))
-    ];
-}
-
-function getNewAdditions(numAlbums) {
-    // Sort al8ums, in descending order of priority, 8y...
-    //
-    // * D8te of addition to the wiki (descending).
-    // * Major releases first.
-    // * D8te of release (descending).
-    //
-    // Major releases go first to 8etter ensure they show up in the list (and
-    // are usually at the start of the final output for a given d8 of release
-    // too).
-    const sortedAlbums = albumData.slice().sort((a, b) => {
-        if (a.dateAdded > b.dateAdded) return -1;
-        if (a.dateAdded < b.dateAdded) return 1;
-        if (a.isMajorRelease && !b.isMajorRelease) return -1;
-        if (!a.isMajorRelease && b.isMajorRelease) return 1;
-        if (a.date > b.date) return -1;
-        if (a.date < b.date) return 1;
-    });
-
-    // When multiple al8ums are added to the wiki at a time, we want to show
-    // all of them 8efore pulling al8ums from the next (earlier) date. We also
-    // want to show a diverse selection of al8ums - with limited space, we'd
-    // rather not show only the latest al8ums, if those happen to all 8e
-    // closely rel8ted!
-    //
-    // Specifically, we're concerned with avoiding too much overlap amongst
-    // the primary (first/top-most) group. We do this 8y collecting every
-    // primary group present amongst the al8ums for a given d8 into one
-    // (ordered) array, initially sorted (inherently) 8y latest al8um from
-    // the group. Then we cycle over the array, adding one al8um from each
-    // group until all the al8ums from that release d8 have 8een added (or
-    // we've met the total target num8er of al8ums). Once we've added all the
-    // al8ums for a given group, it's struck from the array (so the groups
-    // with the most additions on one d8 will have their oldest releases
-    // collected more towards the end of the list).
-
-    const albums = [];
-
-    let i = 0;
-    outerLoop: while (i < sortedAlbums.length) {
-        // 8uild up a list of groups and their al8ums 8y order of decending
-        // release, iter8ting until we're on a different d8. (We use a map for
-        // indexing so we don't have to iter8te through the entire array each
-        // time we access one of its entries. This is 8asically unnecessary
-        // since this will never 8e an expensive enough task for that to
-        // matter.... 8ut it's nicer code. BBBB) )
-        const currentDate = sortedAlbums[i].dateAdded;
-        const groupMap = new Map();
-        const groupArray = [];
-        for (let album; (album = sortedAlbums[i]) && +album.dateAdded === +currentDate; i++) {
-            const primaryGroup = album.groups[0];
-            if (groupMap.has(primaryGroup)) {
-                groupMap.get(primaryGroup).push(album);
-            } else {
-                const entry = [album]
-                groupMap.set(primaryGroup, entry);
-                groupArray.push(entry);
-            }
-        }
-
-        // Then cycle over that sorted array, adding one al8um from each to
-        // the main array until we've run out or have met the target num8er
-        // of al8ums.
-        while (groupArray.length) {
-            let j = 0;
-            while (j < groupArray.length) {
-                const entry = groupArray[j];
-                const album = entry.shift();
-                albums.push(album);
-
-
-                // This is the only time we ever add anything to the main al8um
-                // list, so it's also the only place we need to check if we've
-                // met the target length.
-                if (albums.length === numAlbums) {
-                    // If we've met it, 8r8k out of the outer loop - we're done
-                    // here!
-                    break outerLoop;
-                }
-
-                if (entry.length) {
-                    j++;
-                } else {
-                    groupArray.splice(j, 1);
-                }
-            }
-        }
-    }
-
-    // Finally, do some quick mapping shenanigans to 8etter display the result
-    // in a grid. (This should pro8a8ly 8e a separ8te, shared function, 8ut
-    // whatevs.)
-    return albums.map(album => ({large: album.isMajorRelease, item: album}));
-}
-
-function writeSymlinks() {
-    return progressPromiseAll('Writing site symlinks.', [
-        link(path.join(__dirname, C.COMMON_DIRECTORY), C.COMMON_DIRECTORY),
-        link(path.join(__dirname, C.STATIC_DIRECTORY), C.STATIC_DIRECTORY),
-        link(mediaPath, C.MEDIA_DIRECTORY)
-    ]);
-
-    async function link(directory, target) {
-        const file = path.join(outputPath, target);
-        try {
-            await unlink(file);
-        } catch (error) {
-            if (error.code !== 'ENOENT') {
-                throw error;
-            }
-        }
-        await symlink(path.resolve(directory), file);
-    }
-}
-
-function writeSharedFilesAndPages({strings}) {
-    const redirect = async (title, from, urlKey, directory) => {
-        const target = path.relative(from, urls.from('shared.root').to(urlKey, directory));
-        const content = generateRedirectPage(title, target, {strings});
-        await mkdirp(path.join(outputPath, from));
-        await writeFile(path.join(outputPath, from, 'index.html'), content);
-    };
-
-    return progressPromiseAll(`Writing files & pages shared across languages.`, [
-        groupData?.some(group => group.directory === 'fandom') &&
-        redirect('Fandom - Gallery', 'albums/fandom', 'localized.groupGallery', 'fandom'),
-
-        groupData?.some(group => group.directory === 'official') &&
-        redirect('Official - Gallery', 'albums/official', 'localized.groupGallery', 'official'),
-
-        wikiInfo.features.listings &&
-        redirect('Album Commentary', 'list/all-commentary', 'localized.commentaryIndex', ''),
-
-        writeFile(path.join(outputPath, 'data.json'), fixWS`
-            {
-                "albumData": ${stringifyAlbumData()},
-                ${wikiInfo.features.flashesAndGames && `"flashData": ${stringifyFlashData()},`}
-                "artistData": ${stringifyArtistData()}
-            }
-        `)
-    ].filter(Boolean));
-}
-
-function writeHomepage() {
-    return ({strings, writePage}) => writePage('home', '', ({to}) => ({
-        title: wikiInfo.name,
-
-        meta: {
-            description: wikiInfo.description
-        },
-
-        main: {
-            classes: ['top-index'],
-            content: fixWS`
-                <h1>${wikiInfo.name}</h1>
-                ${homepageInfo.rows.map((row, i) => fixWS`
-                    <section class="row" style="${getLinkThemeString(row)}">
-                        <h2>${row.name}</h2>
-                        ${row.type === 'albums' && fixWS`
-                            <div class="grid-listing">
-                                ${getAlbumGridHTML({
-                                    strings, to,
-                                    entries: (
-                                        row.group === 'new-releases' ? getNewReleases(row.groupCount) :
-                                        row.group === 'new-additions' ? getNewAdditions(row.groupCount) :
-                                        ((search.group(row.group)?.albums || [])
-                                            .slice()
-                                            .reverse()
-                                            .slice(0, row.groupCount)
-                                            .map(album => ({item: album})))
-                                    ).concat(row.albums
-                                        .map(search.album)
-                                        .map(album => ({item: album}))
-                                    ),
-                                    lazy: i > 0
-                                })}
-                                ${row.actions.length && fixWS`
-                                    <div class="grid-actions">
-                                        ${row.actions.map(action => transformInline(action, {strings, to})
-                                            .replace('<a', '<a class="box grid-item"')).join('\n')}
-                                    </div>
-                                `}
-                            </div>
-                        `}
-                    </section>
-                `).join('\n')}
-            `
-        },
-
-        sidebarLeft: homepageInfo.sidebar && {
-            wide: true,
-            collapse: false,
-            // This is a pretty filthy hack! 8ut otherwise, the [[news]] part
-            // gets treated like it's a reference to the track named "news",
-            // which o8viously isn't what we're going for. Gotta catch that
-            // 8efore we pass it to transformMultiline, 'cuz otherwise it'll
-            // get repl8ced with just the word "news" (or anything else that
-            // transformMultiline does with references it can't match) -- and
-            // we can't match that for replacing it with the news column!
-            //
-            // And no, I will not make [[news]] into part of transformMultiline
-            // (even though that would 8e hilarious).
-            content: transformMultiline(homepageInfo.sidebar.replace('[[news]]', '__GENERATE_NEWS__'), {strings, to}).replace('<p>__GENERATE_NEWS__</p>', wikiInfo.features.news ? fixWS`
-                <h1>${strings('homepage.news.title')}</h1>
-                ${newsData.slice(0, 3).map((entry, i) => fixWS`
-                    <article ${classes('news-entry', i === 0 && 'first-news-entry')}>
-                        <h2><time>${strings.count.date(entry.date)}</time> ${strings.link.newsEntry(entry, {to})}</h2>
-                        ${transformMultiline(entry.bodyShort, {strings, to})}
-                        ${entry.bodyShort !== entry.body && strings.link.newsEntry(entry, {
-                            to,
-                            text: strings('homepage.news.entry.viewRest')
-                        })}
-                    </article>
-                `).join('\n')}
-            ` : `<p><i>News requested in content description but this feature isn't enabled</i></p>`)
-        },
-
-        nav: {
-            content: fixWS`
-                <h2 class="dot-between-spans">
-                    ${[
-                        strings.link.home('', {text: wikiInfo.shortName, class: 'current', to}),
-                        wikiInfo.features.listings &&
-                        strings.link.listingIndex('', {text: strings('listingIndex.title'), to}),
-                        wikiInfo.features.news &&
-                        strings.link.newsIndex('', {text: strings('newsIndex.title'), to}),
-                        wikiInfo.features.flashesAndGames &&
-                        strings.link.flashIndex('', {text: strings('flashIndex.title'), to}),
-                        ...staticPageData.filter(page => page.listed).map(page => strings.link.staticPage(page, {to}))
-                    ].filter(Boolean).map(link => `<span>${link}</span>`).join('\n')}
-                </h2>
-            `
-        }
-    }));
-}
-
-function writeMiscellaneousPages() {
-    return [
-        writeHomepage()
-    ];
-}
-
-function writeNewsPages() {
-    if (!wikiInfo.features.news) {
-        return;
-    }
-
-    return [
-        writeNewsIndex(),
-        ...newsData.map(writeNewsEntryPage)
-    ];
-}
-
-function writeNewsIndex() {
-    return ({strings, writePage}) => writePage('newsIndex', '', ({to}) => ({
-        title: strings('newsIndex.title'),
-
-        main: {
-            content: fixWS`
-                <div class="long-content news-index">
-                    <h1>${strings('newsIndex.title')}</h1>
-                    ${newsData.map(entry => fixWS`
-                        <article id="${entry.directory}">
-                            <h2><time>${strings.count.date(entry.date)}</time> ${strings.link.newsEntry(entry, {to})}</h2>
-                            ${transformMultiline(entry.bodyShort, {strings, to})}
-                            ${entry.bodyShort !== entry.body && `<p>${strings.link.newsEntry(entry, {
-                                to,
-                                text: strings('newsIndex.entry.viewRest')
-                            })}</p>`}
-                        </article>
-                    `).join('\n')}
-                </div>
-            `
-        },
-
-        nav: {simple: true}
-    }));
-}
-
-function writeNewsEntryPage(entry) {
-    return ({strings, writePage}) => writePage('newsEntry', entry.directory, ({to}) => ({
-        title: strings('newsEntryPage.title', {entry: entry.name}),
-
-        main: {
-            content: fixWS`
-                <div class="long-content">
-                    <h1>${strings('newsEntryPage.title', {entry: entry.name})}</h1>
-                    <p>${strings('newsEntryPage.published', {date: strings.count.date(entry.date)})}</p>
-                    ${transformMultiline(entry.body, {strings, to})}
-                </div>
-            `
-        },
-
-        nav: generateNewsEntryNav(entry, {strings, to})
-    }));
-}
-
-function generateNewsEntryNav(entry, {strings, to}) {
-    // The newsData list is sorted reverse chronologically (newest ones first),
-    // so the way we find next/previous entries is flipped from normal.
-    const previousNextLinks = generatePreviousNextLinks('localized.newsEntry', entry, newsData.slice().reverse(), {strings, to});
-
-    return {
-        links: [
-            {
-                href: to('localized.home'),
-                title: wikiInfo.shortName
-            },
-            {
-                href: to('localized.newsIndex'),
-                title: strings('newsEntryPage.nav.news')
-            },
-            {
-                html: strings('newsEntryPage.nav.entry', {
-                    date: strings.count.date(entry.date),
-                    entry: strings.link.newsEntry(entry, {class: 'current', to})
-                })
-            },
-            previousNextLinks &&
-            {
-                divider: false,
-                html: `(${previousNextLinks})`
-            }
-        ]
-    };
-}
-
-function writeStaticPages() {
-    return staticPageData.map(writeStaticPage);
-}
-
-function writeStaticPage(staticPage) {
-    return ({strings, writePage}) => writePage('staticPage', staticPage.directory, ({to}) => ({
-        title: staticPage.name,
-        stylesheet: staticPage.stylesheet,
-
-        main: {
-            content: fixWS`
-                <div class="long-content">
-                    <h1>${staticPage.name}</h1>
-                    ${transformMultiline(staticPage.content, {strings, to})}
-                </div>
-            `
-        },
-
-        nav: {simple: true}
-    }));
-}
-
-
-function getRevealStringFromWarnings(warnings, {strings}) {
-    return strings('misc.contentWarnings', {warnings}) + `<br><span class="reveal-interaction">${strings('misc.contentWarnings.reveal')}</span>`
-}
-
-function getRevealStringFromTags(tags, {strings}) {
-    return tags && tags.some(tag => tag.isCW) && (
-        getRevealStringFromWarnings(strings.list.unit(tags.filter(tag => tag.isCW).map(tag => tag.name)), {strings}));
-}
-
-function generateCoverLink({
-    strings, to,
-    src,
-    alt,
-    tags = []
-}) {
-    return fixWS`
-        <div id="cover-art-container">
-            ${img({
-                src,
-                alt,
-                thumb: 'medium',
-                id: 'cover-art',
-                link: true,
-                square: true,
-                reveal: getRevealStringFromTags(tags, {strings})
-            })}
-            ${wikiInfo.features.artTagUI && tags.filter(tag => !tag.isCW).length && fixWS`
-                <p class="tags">
-                    ${strings('releaseInfo.artTags')}
-                    ${(tags
-                        .filter(tag => !tag.isCW)
-                        .map(tag => strings.link.tag(tag, {to}))
-                        .join(',\n'))}
-                </p>
-            `}
-        </div>
-    `;
-}
-
-// This function title is my gr8test work of art.
-// (The 8ehavior... well, um. Don't tell anyone, 8ut it's even 8etter.)
-/* // RIP, 2k20-2k20.
-function writeIndexAndTrackPagesForAlbum(album) {
-    return [
-        () => writeAlbumPage(album),
-        ...album.tracks.map(track => () => writeTrackPage(track))
-    ];
-}
-*/
-
-function writeAlbumPages() {
-    return albumData.map(writeAlbumPage);
-}
-
-function writeAlbumPage(album) {
-    const trackToListItem = (track, {strings, to}) => {
-        const itemOpts = {
-            duration: strings.count.duration(track.duration),
-            track: strings.link.track(track, {to})
-        };
-        return `<li style="${getLinkThemeString(track)}">${
-            (track.artists === album.artists
-                ? strings('trackList.item.withDuration', itemOpts)
-                : strings('trackList.item.withDuration.withArtists', {
-                    ...itemOpts,
-                    by: `<span class="by">${
-                        strings('trackList.item.withArtists.by', {
-                            artists: getArtistString(track.artists, {strings, to})
-                        })
-                    }</span>`
-                }))
-        }</li>`;
-    };
-
-    const commentaryEntries = [album, ...album.tracks].filter(x => x.commentary).length;
-    const albumDuration = getTotalDuration(album.tracks);
-
-    const listTag = getAlbumListTag(album);
-
-    const data = {
-        type: 'data',
-        path: ['album', album.directory],
-        data: () => ({
-            name: album.name,
-            directory: album.directory,
-            dates: {
-                released: album.date,
-                trackArtAdded: album.trackArtDate,
-                coverArtAdded: album.coverArtDate,
-                addedToWiki: album.dateAdded
-            },
-            duration: albumDuration,
-            color: album.color,
-            cover: serializeCover(album, getAlbumCover),
-            artists: serializeContribs(album.artists || []),
-            coverArtists: serializeContribs(album.coverArtists || []),
-            wallpaperArtists: serializeContribs(album.wallpaperArtists || []),
-            bannerArtists: serializeContribs(album.bannerArtists || []),
-            groups: serializeGroupsForAlbum(album),
-            trackGroups: album.trackGroups?.map(trackGroup => ({
-                name: trackGroup.name,
-                color: trackGroup.color,
-                tracks: trackGroup.tracks.map(track => track.directory)
-            })),
-            tracks: album.tracks.map(track => ({
-                link: serializeLink(track),
-                duration: track.duration
-            }))
-        })
-    };
-
-    const page = {type: 'page', path: ['album', album.directory], page: ({strings, to}) => ({
-        title: strings('albumPage.title', {album: album.name}),
-        stylesheet: getAlbumStylesheet(album, {to}),
-        theme: getThemeString(album, [
-            `--album-directory: ${album.directory}`
-        ]),
-
-        banner: album.bannerArtists && {
-            src: to('media.albumBanner', album.directory),
-            alt: strings('misc.alt.albumBanner'),
-            position: 'top'
-        },
-
-        main: {
-            content: fixWS`
-                ${generateCoverLink({
-                    strings, to,
-                    src: to('media.albumCover', album.directory),
-                    alt: strings('misc.alt.albumCover'),
-                    tags: album.artTags
-                })}
-                <h1>${strings('albumPage.title', {album: album.name})}</h1>
-                <p>
-                    ${[
-                        album.artists && strings('releaseInfo.by', {
-                            artists: getArtistString(album.artists, {
-                                strings, to,
-                                showContrib: true,
-                                showIcons: true
-                            })
-                        }),
-                        album.coverArtists && strings('releaseInfo.coverArtBy', {
-                            artists: getArtistString(album.coverArtists, {
-                                strings, to,
-                                showContrib: true,
-                                showIcons: true
-                            })
-                        }),
-                        album.wallpaperArtists && strings('releaseInfo.wallpaperArtBy', {
-                            artists: getArtistString(album.wallpaperArtists, {
-                                strings, to,
-                                showContrib: true,
-                                showIcons: true
-                            })
-                        }),
-                        album.bannerArtists && strings('releaseInfo.bannerArtBy', {
-                            artists: getArtistString(album.bannerArtists, {
-                                strings, to,
-                                showContrib: true,
-                                showIcons: true
-                            })
-                        }),
-                        strings('releaseInfo.released', {
-                            date: strings.count.date(album.date)
-                        }),
-                        +album.coverArtDate !== +album.date && strings('releaseInfo.artReleased', {
-                            date: strings.count.date(album.coverArtDate)
-                        }),
-                        strings('releaseInfo.duration', {
-                            duration: strings.count.duration(albumDuration, {approximate: album.tracks.length > 1})
-                        })
-                    ].filter(Boolean).join('<br>\n')}
-                </p>
-                ${commentaryEntries && `<p>${
-                    strings('releaseInfo.viewCommentary', {
-                        link: `<a href="${to('localized.albumCommentary', album.directory)}">${
-                            strings('releaseInfo.viewCommentary.link')
-                        }</a>`
-                    })
-                }</p>`}
-                ${album.urls.length && `<p>${
-                    strings('releaseInfo.listenOn', {
-                        links: strings.list.or(album.urls.map(url => fancifyURL(url, {album: true, strings})))
-                    })
-                }</p>`}
-                ${album.trackGroups ? fixWS`
-                    <dl class="album-group-list">
-                        ${album.trackGroups.map(({ name, color, startIndex, tracks }) => fixWS`
-                            <dt>${
-                                strings('trackList.group', {
-                                    duration: strings.count.duration(getTotalDuration(tracks), {approximate: tracks.length > 1}),
-                                    group: name
-                                })
-                            }</dt>
-                            <dd><${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}>
-                                ${tracks.map(t => trackToListItem(t, {strings, to})).join('\n')}
-                            </${listTag}></dd>
-                        `).join('\n')}
-                    </dl>
-                ` : fixWS`
-                    <${listTag}>
-                        ${album.tracks.map(t => trackToListItem(t, {strings, to})).join('\n')}
-                    </${listTag}>
-                `}
-                <p>
-                    ${[
-                        strings('releaseInfo.addedToWiki', {
-                            date: strings.count.date(album.dateAdded)
-                        })
-                    ].filter(Boolean).join('<br>\n')}
-                </p>
-                ${album.commentary && fixWS`
-                    <p>${strings('releaseInfo.artistCommentary')}</p>
-                    <blockquote>
-                        ${transformMultiline(album.commentary, {strings, to})}
-                    </blockquote>
-                `}
-            `
-        },
-
-        sidebarLeft: generateSidebarForAlbum(album, null, {strings, to}),
-
-        nav: {
-            links: [
-                {
-                    href: to('localized.home'),
-                    title: wikiInfo.shortName
-                },
-                {
-                    html: strings('albumPage.nav.album', {
-                        album: strings.link.album(album, {class: 'current', to})
-                    })
-                },
-                {
-                    divider: false,
-                    html: generateAlbumNavLinks(album, null, {strings, to})
-                }
-            ],
-            content: fixWS`
-                <div>
-                    ${generateAlbumChronologyLinks(album, null, {strings, to})}
-                </div>
-            `
-        }
-    })};
-
-    return [page, data];
-}
-
-function getAlbumStylesheet(album, {to}) {
-    return [
-        album.wallpaperArtists && fixWS`
-            body::before {
-                background-image: url("${to('media.albumWallpaper', album.directory)}");
-                ${album.wallpaperStyle}
-            }
-        `,
-        album.bannerStyle && fixWS`
-            #banner img {
-                ${album.bannerStyle}
-            }
-        `
-    ].filter(Boolean).join('\n');
-}
-
-function writeTrackPages() {
-    return trackData.map(writeTrackPage);
-}
-
-function writeTrackPage(track) {
-    const { album } = track;
-
-    const tracksThatReference = track.referencedBy;
-    const useDividedReferences = groupData.some(group => group.directory === C.OFFICIAL_GROUP_DIRECTORY);
-    const ttrFanon = (useDividedReferences &&
-        tracksThatReference.filter(t => t.album.groups.every(group => group.directory !== C.OFFICIAL_GROUP_DIRECTORY)));
-    const ttrOfficial = (useDividedReferences &&
-        tracksThatReference.filter(t => t.album.groups.some(group => group.directory === C.OFFICIAL_GROUP_DIRECTORY)));
-
-    const tracksReferenced = track.references;
-    const otherReleases = track.otherReleases;
-    const listTag = getAlbumListTag(album);
-
-    let flashesThatFeature;
-    if (wikiInfo.features.flashesAndGames) {
-        flashesThatFeature = C.sortByDate([track, ...otherReleases]
-            .flatMap(track => track.flashes.map(flash => ({flash, as: track}))));
-    }
-
-    const generateTrackList = (tracks, {strings, to}) => html.tag('ul',
-        tracks.map(track => {
-            const line = strings('trackList.item.withArtists', {
-                track: strings.link.track(track, {to}),
-                by: `<span class="by">${strings('trackList.item.withArtists.by', {
-                    artists: getArtistString(track.artists, {strings, to})
-                })}</span>`
-            });
-            return (track.aka
-                ? `<li class="rerelease">${strings('trackList.item.rerelease', {track: line})}</li>`
-                : `<li>${line}</li>`);
-        })
-    );
-
-    const hasCommentary = track.commentary || otherReleases.some(t => t.commentary);
-    const generateCommentary = ({strings, to}) => transformMultiline(
-        [
-            track.commentary,
-            ...otherReleases.map(track =>
-                (track.commentary?.split('\n')
-                    .filter(line => line.replace(/<\/b>/g, '').includes(':</i>'))
-                    .map(line => fixWS`
-                        ${line}
-                        ${strings('releaseInfo.artistCommentary.seeOriginalRelease', {
-                            original: strings.link.track(track, {to})
-                        })}
-                    `)
-                    .join('\n')))
-        ].filter(Boolean).join('\n'),
-        {strings, to});
-
-    const data = {
-        type: 'data',
-        path: ['track', track.directory],
-        data: () => ({
-            name: track.name,
-            directory: track.directory,
-            dates: {
-                released: track.date,
-                originallyReleased: track.originalDate,
-                coverArtAdded: track.coverArtDate
-            },
-            duration: track.duration,
-            color: track.color,
-            cover: serializeCover(track, getTrackCover),
-            artists: serializeContribs(track.artists),
-            contributors: serializeContribs(track.contributors),
-            coverArtists: serializeContribs(track.coverArtists || []),
-            album: serializeLink(track.album),
-            groups: serializeGroupsForTrack(track),
-            references: track.references.map(serializeLink),
-            referencedBy: track.referencedBy.map(serializeLink),
-            alsoReleasedAs: otherReleases.map(track => ({
-                track: serializeLink(track),
-                album: serializeLink(track.album)
-            }))
-        })
-    };
-
-    const page = {type: 'page', path: ['track', track.directory], page: ({strings, to}) => ({
-        title: strings('trackPage.title', {track: track.name}),
-        stylesheet: getAlbumStylesheet(album, {to}),
-        theme: getThemeString(track, [
-            `--album-directory: ${album.directory}`,
-            `--track-directory: ${track.directory}`
-        ]),
-
-        // disabled for now! shifting banner position per height of page is disorienting
-        /*
-        banner: album.bannerArtists && {
-            classes: ['dim'],
-            src: to('media.albumBanner', album.directory),
-            alt: strings('misc.alt.albumBanner'),
-            position: 'bottom'
-        },
-        */
-
-        main: {
-            content: fixWS`
-                ${generateCoverLink({
-                    strings, to,
-                    src: getTrackCover(track, {to}),
-                    alt: strings('misc.alt.trackCover'),
-                    tags: track.artTags
-                })}
-                <h1>${strings('trackPage.title', {track: track.name})}</h1>
-                <p>
-                    ${[
-                        strings('releaseInfo.by', {
-                            artists: getArtistString(track.artists, {
-                                strings, to,
-                                showContrib: true,
-                                showIcons: true
-                            })
-                        }),
-                        track.coverArtists && strings('releaseInfo.coverArtBy', {
-                            artists: getArtistString(track.coverArtists, {
-                                strings, to,
-                                showContrib: true,
-                                showIcons: true
-                            })
-                        }),
-                        album.directory !== C.UNRELEASED_TRACKS_DIRECTORY && strings('releaseInfo.released', {
-                            date: strings.count.date(track.date)
-                        }),
-                        +track.coverArtDate !== +track.date && strings('releaseInfo.artReleased', {
-                            date: strings.count.date(track.coverArtDate)
-                        }),
-                        track.duration && strings('releaseInfo.duration', {
-                            duration: strings.count.duration(track.duration)
-                        })
-                    ].filter(Boolean).join('<br>\n')}
-                </p>
-                <p>${
-                    (track.urls.length
-                        ? strings('releaseInfo.listenOn', {
-                            links: strings.list.or(track.urls.map(url => fancifyURL(url, {strings})))
-                        })
-                        : strings('releaseInfo.listenOn.noLinks'))
-                }</p>
-                ${otherReleases.length && fixWS`
-                    <p>${strings('releaseInfo.alsoReleasedAs')}</p>
-                    <ul>
-                        ${otherReleases.map(track => fixWS`
-                            <li>${strings('releaseInfo.alsoReleasedAs.item', {
-                                track: strings.link.track(track, {to}),
-                                album: strings.link.album(track.album, {to})
-                            })}</li>
-                        `).join('\n')}
-                    </ul>
-                `}
-                ${track.contributors.textContent && fixWS`
-                    <p>
-                        ${strings('releaseInfo.contributors')}
-                        <br>
-                        ${transformInline(track.contributors.textContent, {strings, to})}
-                    </p>
-                `}
-                ${track.contributors.length && fixWS`
-                    <p>${strings('releaseInfo.contributors')}</p>
-                    <ul>
-                        ${(track.contributors
-                            .map(contrib => `<li>${getArtistString([contrib], {
-                                strings, to,
-                                showContrib: true,
-                                showIcons: true
-                            })}</li>`)
-                            .join('\n'))}
-                    </ul>
-                `}
-                ${tracksReferenced.length && fixWS`
-                    <p>${strings('releaseInfo.tracksReferenced', {track: `<i>${track.name}</i>`})}</p>
-                    ${generateTrackList(tracksReferenced, {strings, to})}
-                `}
-                ${tracksThatReference.length && fixWS`
-                    <p>${strings('releaseInfo.tracksThatReference', {track: `<i>${track.name}</i>`})}</p>
-                    ${useDividedReferences && fixWS`
-                        <dl>
-                            ${ttrOfficial.length && fixWS`
-                                <dt>${strings('trackPage.referenceList.official')}</dt>
-                                <dd>${generateTrackList(ttrOfficial, {strings, to})}</dd>
-                            `}
-                            ${ttrFanon.length && fixWS`
-                                <dt>${strings('trackPage.referenceList.fandom')}</dt>
-                                <dd>${generateTrackList(ttrFanon, {strings, to})}</dd>
-                            `}
-                        </dl>
-                    `}
-                    ${!useDividedReferences && generateTrackList(tracksThatReference, {strings, to})}
-                `}
-                ${wikiInfo.features.flashesAndGames && flashesThatFeature.length && fixWS`
-                    <p>${strings('releaseInfo.flashesThatFeature', {track: `<i>${track.name}</i>`})}</p>
-                    <ul>
-                        ${flashesThatFeature.map(({ flash, as }) => fixWS`
-                            <li ${classes(as !== track && 'rerelease')}>${
-                                (as === track
-                                    ? strings('releaseInfo.flashesThatFeature.item', {
-                                        flash: strings.link.flash(flash, {to})
-                                    })
-                                    : strings('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
-                                        flash: strings.link.flash(flash, {to}),
-                                        track: strings.link.track(as, {to})
-                                    }))
-                            }</li>
-                        `).join('\n')}
-                    </ul>
-                `}
-                ${track.lyrics && fixWS`
-                    <p>${strings('releaseInfo.lyrics')}</p>
-                    <blockquote>
-                        ${transformLyrics(track.lyrics, {strings, to})}
-                    </blockquote>
-                `}
-                ${hasCommentary && fixWS`
-                    <p>${strings('releaseInfo.artistCommentary')}</p>
-                    <blockquote>
-                        ${generateCommentary({strings, to})}
-                    </blockquote>
-                `}
-            `
-        },
-
-        sidebarLeft: generateSidebarForAlbum(album, track, {strings, to}),
-
-        nav: {
-            links: [
-                {
-                    href: to('localized.home'),
-                    title: wikiInfo.shortName
-                },
-                {
-                    href: to('localized.album', album.directory),
-                    title: album.name
-                },
-                listTag === 'ol' ? {
-                    html: strings('trackPage.nav.track.withNumber', {
-                        number: album.tracks.indexOf(track) + 1,
-                        track: strings.link.track(track, {class: 'current', to})
-                    })
-                } : {
-                    html: strings('trackPage.nav.track', {
-                        track: strings.link.track(track, {class: 'current', to})
-                    })
-                },
-                {
-                    divider: false,
-                    html: generateAlbumNavLinks(album, track, {strings, to})
-                }
-            ].filter(Boolean),
-            content: fixWS`
-                <div>
-                    ${generateAlbumChronologyLinks(album, track, {strings, to})}
-                </div>
-            `
-        }
-    })};
-
-    return [data, page];
-}
-
-function writeArtistPages() {
-    return [
-        ...artistData.map(writeArtistPage),
-        ...artistAliasData.map(writeArtistAliasPage)
-    ];
-}
-
-function writeArtistPage(artist) {
-    const {
-        name,
-        urls = [],
-        note = ''
-    } = artist;
-
-    const artThingsAll = C.sortByDate(unique([...artist.albums.asCoverArtist, ...artist.albums.asWallpaperArtist, ...artist.albums.asBannerArtist, ...artist.tracks.asCoverArtist]));
-    const artThingsGallery = C.sortByDate([...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist]);
-    const commentaryThings = C.sortByDate([...artist.albums.asCommentator, ...artist.tracks.asCommentator]);
-
-    const hasGallery = artThingsGallery.length > 0;
-
-    const getArtistsAndContrib = (thing, key) => ({
-        artists: thing[key]?.filter(({ who }) => who !== artist),
-        contrib: thing[key]?.find(({ who }) => who === artist),
-        thing,
-        key
-    });
-
-    const artListChunks = chunkByProperties(artThingsAll.flatMap(thing =>
-        (['coverArtists', 'wallpaperArtists', 'bannerArtists']
-            .map(key => getArtistsAndContrib(thing, key))
-            .filter(({ contrib }) => contrib)
-            .map(props => ({
-                album: thing.album || thing,
-                track: thing.album ? thing : null,
-                date: +(thing.coverArtDate || thing.date),
-                ...props
-            })))
-    ), ['date', 'album']);
-
-    const commentaryListChunks = chunkByProperties(commentaryThings.map(thing => ({
-        album: thing.album || thing,
-        track: thing.album ? thing : null
-    })), ['album']);
-
-    const allTracks = C.sortByDate(unique([...artist.tracks.asArtist, ...artist.tracks.asContributor]));
-    const unreleasedTracks = allTracks.filter(track => track.album.directory === C.UNRELEASED_TRACKS_DIRECTORY);
-    const releasedTracks = allTracks.filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY);
-
-    const chunkTracks = tracks => (
-        chunkByProperties(tracks.map(track => ({
-            track,
-            date: +track.date,
-            album: track.album,
-            duration: track.duration,
-            artists: (track.artists.some(({ who }) => who === artist)
-                ? track.artists.filter(({ who }) => who !== artist)
-                : track.contributors.filter(({ who }) => who !== artist)),
-            contrib: {
-                who: artist,
-                what: [
-                    track.artists.find(({ who }) => who === artist)?.what,
-                    track.contributors.find(({ who }) => who === artist)?.what
-                ].filter(Boolean).join(', ')
-            }
-        })), ['date', 'album'])
-        .map(({date, album, chunk}) => ({
-            date, album, chunk,
-            duration: getTotalDuration(chunk),
-        })));
-
-    const unreleasedTrackListChunks = chunkTracks(unreleasedTracks);
-    const releasedTrackListChunks = chunkTracks(releasedTracks);
-
-    const totalReleasedDuration = getTotalDuration(releasedTracks);
-
-    const countGroups = things => {
-        const usedGroups = things.flatMap(thing => thing.groups || thing.album?.groups || []);
-        return groupData
-            .map(group => ({
-                group,
-                contributions: usedGroups.filter(g => g === group).length
-            }))
-            .filter(({ contributions }) => contributions > 0)
-            .sort((a, b) => b.contributions - a.contributions);
-    };
-
-    const musicGroups = countGroups(releasedTracks);
-    const artGroups = countGroups(artThingsAll);
-
-    let flashes, flashListChunks;
-    if (wikiInfo.features.flashesAndGames) {
-        flashes = C.sortByDate(artist.flashes.asContributor.slice());
-        flashListChunks = (
-            chunkByProperties(flashes.map(flash => ({
-                act: flash.act,
-                flash,
-                date: flash.date,
-                // Manual artists/contrib properties here, 8ecause we don't
-                // want to show the full list of other contri8utors inline.
-                // (It can often 8e very, very large!)
-                artists: [],
-                contrib: flash.contributors.find(({ who }) => who === artist)
-            })), ['act'])
-            .map(({ act, chunk }) => ({
-                act, chunk,
-                dateFirst: chunk[0].date,
-                dateLast: chunk[chunk.length - 1].date
-            })));
-    }
-
-    const generateEntryAccents = ({ aka, entry, artists, contrib, strings, to }) =>
-        (aka
-            ? strings('artistPage.creditList.entry.rerelease', {entry})
-            : (artists.length
-                ? (contrib.what
-                    ? strings('artistPage.creditList.entry.withArtists.withContribution', {
-                        entry,
-                        artists: getArtistString(artists, {strings, to}),
-                        contribution: contrib.what
-                    })
-                    : strings('artistPage.creditList.entry.withArtists', {
-                        entry,
-                        artists: getArtistString(artists, {strings, to})
-                    }))
-                : (contrib.what
-                    ? strings('artistPage.creditList.entry.withContribution', {
-                        entry,
-                        contribution: contrib.what
-                    })
-                    : entry)));
-
-    const generateTrackList = (chunks, {strings, to}) => fixWS`
-        <dl>
-            ${chunks.map(({date, album, chunk, duration}) => fixWS`
-                <dt>${strings('artistPage.creditList.album.withDate.withDuration', {
-                    album: strings.link.album(album, {to}),
-                    date: strings.count.date(date),
-                    duration: strings.count.duration(duration, {approximate: true})
-                })}</dt>
-                <dd><ul>
-                    ${(chunk
-                        .map(({track, ...props}) => ({
-                            aka: track.aka,
-                            entry: strings('artistPage.creditList.entry.track.withDuration', {
-                                track: strings.link.track(track, {to}),
-                                duration: strings.count.duration(track.duration, {to})
-                            }),
-                            ...props
-                        }))
-                        .map(({aka, ...opts}) => `<li ${classes(aka && 'rerelease')}>${generateEntryAccents({strings, to, aka, ...opts})}</li>`)
-                        .join('\n'))}
-                </ul></dd>
-            `).join('\n')}
-        </dl>
-    `;
-
-    const serializeArtistsAndContrib = key => thing => {
-        const { artists, contrib } = getArtistsAndContrib(thing, key);
-        const ret = {};
-        ret.link = serializeLink(thing);
-        if (contrib.what) ret.contribution = contrib.what;
-        if (artists.length) ret.otherArtists = serializeContribs(artists);
-        return ret;
-    };
-
-    const serializeTrackListChunks = chunks =>
-        chunks.map(({date, album, chunk, duration}) => ({
-            album: serializeLink(album),
-            date,
-            duration,
-            tracks: chunk.map(({ track }) => ({
-                link: serializeLink(track),
-                duration: track.duration
-            }))
-        }));
-
-    const data = {
-        type: 'data',
-        path: ['artist', artist.directory],
-        data: () => ({
-            albums: {
-                asCoverArtist: artist.albums.asCoverArtist.map(serializeArtistsAndContrib('coverArtists')),
-                asWallpaperArtist: artist.albums.asWallpaperArtist.map(serializeArtistsAndContrib('wallpaperArtists')),
-                asBannerArtist: artist.albums.asBannerArtist.map(serializeArtistsAndContrib('bannerArtists'))
-            },
-            flashes: wikiInfo.features.flashesAndGames ? {
-                asContributor: artist.flashes.asContributor
-                    .map(flash => getArtistsAndContrib(flash, 'contributors'))
-                    .map(({ contrib, thing: flash }) => ({
-                        link: serializeLink(flash),
-                        contribution: contrib.what
-                    }))
-            } : null,
-            tracks: {
-                asArtist: artist.tracks.asArtist.map(serializeArtistsAndContrib('artists')),
-                asContributor: artist.tracks.asContributor.map(serializeArtistsAndContrib('contributors')),
-                chunked: {
-                    released: serializeTrackListChunks(releasedTrackListChunks),
-                    unreleased: serializeTrackListChunks(unreleasedTrackListChunks)
-                }
-            }
-        })
-    };
-
-    const infoPage = {
-        type: 'page',
-        path: ['artist', artist.directory],
-        page: ({strings, to}) => ({
-            title: strings('artistPage.title', {artist: name}),
-
-            main: {
-                content: fixWS`
-                    ${artist.hasAvatar && generateCoverLink({
-                        strings, to,
-                        src: to('localized.artistAvatar', artist.directory),
-                        alt: strings('misc.alt.artistAvatar')
-                    })}
-                    <h1>${strings('artistPage.title', {artist: name})}</h1>
-                    ${note && fixWS`
-                        <p>${strings('releaseInfo.note')}</p>
-                        <blockquote>
-                            ${transformMultiline(note, {strings, to})}
-                        </blockquote>
-                        <hr>
-                    `}
-                    ${urls.length && `<p>${strings('releaseInfo.visitOn', {
-                        links: strings.list.or(urls.map(url => fancifyURL(url, {strings})))
-                    })}</p>`}
-                    ${hasGallery && `<p>${strings('artistPage.viewArtGallery', {
-                        link: strings.link.artistGallery(artist, {
-                            to,
-                            text: strings('artistPage.viewArtGallery.link')
-                        })
-                    })}</p>`}
-                    <p>${strings('misc.jumpTo.withLinks', {
-                        links: strings.list.unit([
-                            [
-                                [...releasedTracks, ...unreleasedTracks].length && `<a href="#tracks">${strings('artistPage.trackList.title')}</a>`,
-                                unreleasedTracks.length && `(<a href="#unreleased-tracks">${strings('artistPage.unreleasedTrackList.title')}</a>)`
-                            ].filter(Boolean).join(' '),
-                            artThingsAll.length && `<a href="#art">${strings('artistPage.artList.title')}</a>`,
-                            wikiInfo.features.flashesAndGames && flashes.length && `<a href="#flashes">${strings('artistPage.flashList.title')}</a>`,
-                            commentaryThings.length && `<a href="#commentary">${strings('artistPage.commentaryList.title')}</a>`
-                        ].filter(Boolean))
-                    })}</p>
-                    ${(releasedTracks.length || unreleasedTracks.length) && fixWS`
-                        <h2 id="tracks">${strings('artistPage.trackList.title')}</h2>
-                    `}
-                    ${releasedTracks.length && fixWS`
-                        <p>${strings('artistPage.contributedDurationLine', {
-                            artist: artist.name,
-                            duration: strings.count.duration(totalReleasedDuration, {approximate: true, unit: true})
-                        })}</p>
-                        <p>${strings('artistPage.musicGroupsLine', {
-                            groups: strings.list.unit(musicGroups
-                                .map(({ group, contributions }) => strings('artistPage.groupsLine.item', {
-                                    group: strings.link.groupInfo(group, {to}),
-                                    contributions: strings.count.contributions(contributions)
-                                })))
-                        })}</p>
-                        ${generateTrackList(releasedTrackListChunks, {strings, to})}
-                    `}
-                    ${unreleasedTracks.length && fixWS`
-                        <h3 id="unreleased-tracks">${strings('artistPage.unreleasedTrackList.title')}</h3>
-                        ${generateTrackList(unreleasedTrackListChunks, {strings, to})}
-                    `}
-                    ${artThingsAll.length && fixWS`
-                        <h2 id="art">${strings('artistPage.artList.title')}</h2>
-                        ${hasGallery && `<p>${strings('artistPage.viewArtGallery.orBrowseList', {
-                            link: strings.link.artistGallery(artist, {
-                                to,
-                                text: strings('artistPage.viewArtGallery.link')
-                            })
-                        })}</p>`}
-                        <p>${strings('artistPage.artGroupsLine', {
-                            groups: strings.list.unit(artGroups
-                                .map(({ group, contributions }) => strings('artistPage.groupsLine.item', {
-                                    group: strings.link.groupInfo(group, {to}),
-                                    contributions: strings.count.contributions(contributions)
-                                })))
-                        })}</p>
-                        <dl>
-                            ${artListChunks.map(({date, album, chunk}) => fixWS`
-                                <dt>${strings('artistPage.creditList.album.withDate', {
-                                    album: strings.link.album(album, {to}),
-                                    date: strings.count.date(date)
-                                })}</dt>
-                                <dd><ul>
-                                    ${(chunk
-                                        .map(({album, track, key, ...props}) => ({
-                                            entry: (track
-                                                ? strings('artistPage.creditList.entry.track', {
-                                                    track: strings.link.track(track, {to})
-                                                })
-                                                : `<i>${strings('artistPage.creditList.entry.album.' + {
-                                                    wallpaperArtists: 'wallpaperArt',
-                                                    bannerArtists: 'bannerArt',
-                                                    coverArtists: 'coverArt'
-                                                }[key])}</i>`),
-                                            ...props
-                                        }))
-                                        .map(opts => generateEntryAccents({strings, to, ...opts}))
-                                        .map(row => `<li>${row}</li>`)
-                                        .join('\n'))}
-                                </ul></dd>
-                            `).join('\n')}
-                        </dl>
-                    `}
-                    ${wikiInfo.features.flashesAndGames && flashes.length && fixWS`
-                        <h2 id="flashes">${strings('artistPage.flashList.title')}</h2>
-                        <dl>
-                            ${flashListChunks.map(({act, chunk, dateFirst, dateLast}) => fixWS`
-                                <dt>${strings('artistPage.creditList.flashAct.withDateRange', {
-                                    act: strings.link.flash(chunk[0].flash, {to, text: act.name}),
-                                    dateRange: strings.count.dateRange([dateFirst, dateLast])
-                                })}</dt>
-                                <dd><ul>
-                                    ${(chunk
-                                        .map(({flash, ...props}) => ({
-                                            entry: strings('artistPage.creditList.entry.flash', {
-                                                flash: strings.link.flash(flash, {to})
-                                            }),
-                                            ...props
-                                        }))
-                                        .map(opts => generateEntryAccents({strings, to, ...opts}))
-                                        .map(row => `<li>${row}</li>`)
-                                        .join('\n'))}
-                                </ul></dd>
-                            `).join('\n')}
-                        </dl>
-                    `}
-                    ${commentaryThings.length && fixWS`
-                        <h2 id="commentary">${strings('artistPage.commentaryList.title')}</h2>
-                        <dl>
-                            ${commentaryListChunks.map(({album, chunk}) => fixWS`
-                                <dt>${strings('artistPage.creditList.album', {
-                                    album: strings.link.album(album, {to})
-                                })}</dt>
-                                <dd><ul>
-                                    ${(chunk
-                                        .map(({album, track, ...props}) => track
-                                            ? strings('artistPage.creditList.entry.track', {
-                                                track: strings.link.track(track, {to})
-                                            })
-                                            : `<i>${strings('artistPage.creditList.entry.album.commentary')}</i>`)
-                                        .map(row => `<li>${row}</li>`)
-                                        .join('\n'))}
-                                </ul></dd>
-                            `).join('\n')}
-                        </dl>
-                    `}
-                `
-            },
-
-            nav: generateNavForArtist(artist, {strings, to, isGallery: false, hasGallery})
-        })
-    };
-
-    const galleryPage = hasGallery && {
-        type: 'page',
-        path: ['artistGallery', artist.directory],
-        page: ({strings, to}) => ({
-            title: strings('artistGalleryPage.title', {artist: name}),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${strings('artistGalleryPage.title', {artist: name})}</h1>
-                    <p class="quick-info">${strings('artistGalleryPage.infoLine', {
-                        coverArts: strings.count.coverArts(artThingsGallery.length, {unit: true})
-                    })}</p>
-                    <div class="grid-listing">
-                        ${getGridHTML({
-                            strings, to,
-                            entries: artThingsGallery.map(item => ({item})),
-                            srcFn: thing => (thing.album
-                                ? getTrackCover(thing, {to})
-                                : getAlbumCover(thing, {to})),
-                            hrefFn: thing => (thing.album
-                                ? to('localized.track', thing.directory)
-                                : to('localized.album', thing.directory))
-                        })}
-                    </div>
-                `
-            },
-
-            nav: generateNavForArtist(artist, {strings, to, isGallery: true, hasGallery})
-        })
-    };
-
-    return [data, infoPage, galleryPage].filter(Boolean);
-}
-
-function generateNavForArtist(artist, {strings, to, isGallery, hasGallery}) {
-    const infoGalleryLinks = (hasGallery &&
-        generateInfoGalleryLinks('artist', 'artistGallery', artist, isGallery, {strings, to}))
-
-    return {
-        links: [
-            {
-                href: to('localized.home'),
-                title: wikiInfo.shortName
-            },
-            wikiInfo.features.listings &&
-            {
-                href: to('localized.listingIndex'),
-                title: strings('listingIndex.title')
-            },
-            {
-                html: strings('artistPage.nav.artist', {
-                    artist: strings.link.artist(artist, {class: 'current', to})
-                })
-            },
-            hasGallery &&
-            {
-                divider: false,
-                html: `(${infoGalleryLinks})`
-            }
-        ]
-    };
-}
-
-function writeArtistAliasPage(artist) {
-    const { alias } = artist;
-
-    return async ({baseDirectory, strings, writePage}) => {
-        const { code } = strings;
-        const paths = writePage.paths(baseDirectory, 'artist', alias.directory);
-        const content = generateRedirectPage(alias.name, paths.pathname, {strings});
-        await writePage.write(content, {paths});
-    };
-}
-
-function generateRedirectPage(title, target, {strings}) {
-    return fixWS`
-        <!DOCTYPE html>
-        <html>
-            <head>
-                <title>${strings('redirectPage.title', {title})}</title>
-                <meta charset="utf-8">
-                <meta http-equiv="refresh" content="0;url=${target}">
-                <link rel="canonical" href="${target}">
-                <link rel="stylesheet" href="static/site-basic.css">
-            </head>
-            <body>
-                <main>
-                    <h1>${strings('redirectPage.title', {title})}</h1>
-                    <p>${strings('redirectPage.infoLine', {
-                        target: `<a href="${target}">${target}</a>`
-                    })}</p>
-                </main>
-            </body>
-        </html>
-    `;
-}
-
-function writeFlashPages() {
-    if (!wikiInfo.features.flashesAndGames) {
-        return;
-    }
-
-    return [
-        writeFlashIndex(),
-        ...flashData.map(writeFlashPage)
-    ];
-}
-
-function writeFlashIndex() {
-    return ({strings, writePage}) => writePage('flashIndex', '', ({to}) => ({
-        title: strings('flashIndex.title'),
-
-        main: {
-            classes: ['flash-index'],
-            content: fixWS`
-                <h1>${strings('flashIndex.title')}</h1>
-                <div class="long-content">
-                    <p class="quick-info">${strings('misc.jumpTo')}</p>
-                    <ul class="quick-info">
-                        ${flashActData.filter(act => act.jump).map(({ anchor, jump, jumpColor }) => fixWS`
-                            <li><a href="#${anchor}" style="${getLinkThemeString({color: jumpColor})}">${jump}</a></li>
-                        `).join('\n')}
-                    </ul>
-                </div>
-                ${flashActData.map((act, i) => fixWS`
-                    <h2 id="${act.anchor}" style="${getLinkThemeString(act)}"><a href="${to('localized.flash', act.flashes[0].directory)}">${act.name}</a></h2>
-                    <div class="grid-listing">
-                        ${getFlashGridHTML({
-                            strings, to,
-                            entries: act.flashes.map(flash => ({item: flash})),
-                            lazy: i === 0 ? 4 : true
-                        })}
-                    </div>
-                `).join('\n')}
-            `
-        },
-
-        nav: {simple: true}
-    }));
-}
-
-function writeFlashPage(flash) {
-    return ({strings, writePage}) => writePage('flash', flash.directory, ({to}) => ({
-        title: strings('flashPage.title', {flash: flash.name}),
-        theme: getThemeString(flash, [
-            `--flash-directory: ${flash.directory}`
-        ]),
-
-        main: {
-            content: fixWS`
-                <h1>${strings('flashPage.title', {flash: flash.name})}</h1>
-                ${generateCoverLink({
-                    strings, to,
-                    src: to('media.flashArt', flash.directory),
-                    alt: strings('misc.alt.flashArt')
-                })}
-                <p>${strings('releaseInfo.released', {date: strings.count.date(flash.date)})}</p>
-                ${(flash.page || flash.urls.length) && `<p>${strings('releaseInfo.playOn', {
-                    links: strings.list.or([
-                        flash.page && getFlashLink(flash),
-                        ...flash.urls
-                    ].map(url => fancifyFlashURL(url, flash, {strings})))
-                })}</p>`}
-                ${flash.tracks.length && fixWS`
-                    <p>Tracks featured in <i>${flash.name.replace(/\.$/, '')}</i>:</p>
-                    <ul>
-                        ${(flash.tracks
-                            .map(track => strings('trackList.item.withArtists', {
-                                track: strings.link.track(track, {strings, to}),
-                                by: `<span class="by">${
-                                    strings('trackList.item.withArtists.by', {
-                                        artists: getArtistString(track.artists, {strings, to})
-                                    })
-                                }</span>`
-                            }))
-                            .map(row => `<li>${row}</li>`)
-                            .join('\n'))}
-                    </ul>
-                `}
-                ${flash.contributors.textContent && fixWS`
-                    <p>
-                        ${strings('releaseInfo.contributors')}
-                        <br>
-                        ${transformInline(flash.contributors.textContent, {strings, to})}
-                    </p>
-                `}
-                ${flash.contributors.length && fixWS`
-                    <p>${strings('releaseInfo.contributors')}</p>
-                    <ul>
-                        ${flash.contributors
-                            .map(contrib => `<li>${getArtistString([contrib], {
-                                strings, to,
-                                showContrib: true,
-                                showIcons: true
-                            })}</li>`)
-                            .join('\n')}
-                    </ul>
-                `}
-            `
-        },
-
-        sidebarLeft: generateSidebarForFlash(flash, {strings, to}),
-        nav: generateNavForFlash(flash, {strings, to})
-    }));
-}
-
-function generateNavForFlash(flash, {strings, to}) {
-    const previousNextLinks = generatePreviousNextLinks('localized.flash', flash, flashData, {strings, to});
-
-    return {
-        links: [
-            {
-                href: to('localized.home'),
-                title: wikiInfo.shortName
-            },
-            {
-                href: to('localized.flashIndex'),
-                title: strings('flashIndex.title')
-            },
-            {
-                html: strings('flashPage.nav.flash', {
-                    flash: strings.link.flash(flash, {class: 'current', to})
-                })
-            },
-            previousNextLinks &&
-            {
-                divider: false,
-                html: `(${previousNextLinks})`
-            }
-        ],
-
-        content: fixWS`
-            <div>
-                ${chronologyLinks(flash, {
-                    strings, to,
-                    headingString: 'misc.chronology.heading.flash',
-                    contribKey: 'contributors',
-                    getThings: artist => artist.flashes.asContributor
-                })}
-            </div>
-        `
-    };
-}
-
-function generateSidebarForFlash(flash, {strings, to}) {
-    // all hard-coded, sorry :(
-    // this doesnt have a super portable implementation/design...yet!!
-
-    const act6 = flashActData.findIndex(act => act.name.startsWith('Act 6'));
-    const postCanon = flashActData.findIndex(act => act.name.includes('Post Canon'));
-    const outsideCanon = postCanon + flashActData.slice(postCanon).findIndex(act => !act.name.includes('Post Canon'));
-    const actIndex = flashActData.indexOf(flash.act);
-    const side = (
-        (actIndex < 0) ? 0 :
-        (actIndex < act6) ? 1 :
-        (actIndex <= outsideCanon) ? 2 :
-        3
-    );
-    const currentAct = flash && flash.act;
-
-    return {
-        content: fixWS`
-            <h1>${strings.link.flashIndex('', {to, text: strings('flashIndex.title')})}</h1>
-            <dl>
-                ${flashActData.filter(act =>
-                    act.name.startsWith('Act 1') ||
-                    act.name.startsWith('Act 6 Act 1') ||
-                    act.name.startsWith('Hiveswap') ||
-                    // Sorry not sorry -Yiffy
-                    (({index = flashActData.indexOf(act)} = {}) => (
-                        index < act6 ? side === 1 :
-                        index < outsideCanon ? side === 2 :
-                        true
-                    ))()
-                ).flatMap(act => [
-                    act.name.startsWith('Act 1') && `<dt ${classes('side', side === 1 && 'current')}><a href="${to('localized.flash', act.flashes[0].directory)}" style="--primary-color: #4ac925">Side 1 (Acts 1-5)</a></dt>`
-                    || act.name.startsWith('Act 6 Act 1') && `<dt ${classes('side', side === 2 && 'current')}><a href="${to('localized.flash', act.flashes[0].directory)}" style="--primary-color: #1076a2">Side 2 (Acts 6-7)</a></dt>`
-                    || act.name.startsWith('Hiveswap Act 1') && `<dt ${classes('side', side === 3 && 'current')}><a href="${to('localized.flash', act.flashes[0].directory)}" style="--primary-color: #008282">Outside Canon (Misc. Games)</a></dt>`,
-                    (({index = flashActData.indexOf(act)} = {}) => (
-                        index < act6 ? side === 1 :
-                        index < outsideCanon ? side === 2 :
-                        true
-                    ))()
-                    && `<dt ${classes(act === currentAct && 'current')}><a href="${to('localized.flash', act.flashes[0].directory)}" style="${getLinkThemeString(act)}">${act.name}</a></dt>`,
-                    act === currentAct && fixWS`
-                        <dd><ul>
-                            ${act.flashes.map(f => fixWS`
-                                <li ${classes(f === flash && 'current')}>${strings.link.flash(f, {to})}</li>
-                            `).join('\n')}
-                        </ul></dd>
-                    `
-                ]).filter(Boolean).join('\n')}
-            </dl>
-        `
-    };
-}
-
-const listingSpec = [
-    {
-        directory: 'albums/by-name',
-        title: ({strings}) => strings('listingPage.listAlbums.byName.title'),
-
-        data() {
-            return albumData.slice()
-                .sort(sortByName);
-        },
-
-        row(album, {strings, to}) {
-            return strings('listingPage.listAlbums.byName.item', {
-                album: strings.link.album(album, {to}),
-                tracks: strings.count.tracks(album.tracks.length, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'albums/by-tracks',
-        title: ({strings}) => strings('listingPage.listAlbums.byTracks.title'),
-
-        data() {
-            return albumData.slice()
-                .sort((a, b) => b.tracks.length - a.tracks.length);
-        },
-
-        row(album, {strings, to}) {
-            return strings('listingPage.listAlbums.byTracks.item', {
-                album: strings.link.album(album, {to}),
-                tracks: strings.count.tracks(album.tracks.length, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'albums/by-duration',
-        title: ({strings}) => strings('listingPage.listAlbums.byDuration.title'),
-
-        data() {
-            return albumData
-                .map(album => ({album, duration: getTotalDuration(album.tracks)}))
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({album, duration}, {strings, to}) {
-            return strings('listingPage.listAlbums.byDuration.item', {
-                album: strings.link.album(album, {to}),
-                duration: strings.count.duration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'albums/by-date',
-        title: ({strings}) => strings('listingPage.listAlbums.byDate.title'),
-
-        data() {
-            return C.sortByDate(albumData
-                .filter(album => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY));
-        },
-
-        row(album, {strings, to}) {
-            return strings('listingPage.listAlbums.byDate.item', {
-                album: strings.link.album(album, {to}),
-                date: strings.count.date(album.date)
-            });
-        }
-    },
-
-    {
-        directory: 'albusm/by-date-added',
-        title: ({strings}) => strings('listingPage.listAlbums.byDateAdded.title'),
-
-        data() {
-            return chunkByProperties(albumData.slice().sort((a, b) => {
-                if (a.dateAdded < b.dateAdded) return -1;
-                if (a.dateAdded > b.dateAdded) return 1;
-            }), ['dateAdded']);
-        },
-
-        html(chunks, {strings, to}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({dateAdded, chunk: albums}) => fixWS`
-                        <dt>${strings('listingPage.listAlbums.byDateAdded.date', {
-                            date: strings.count.date(dateAdded)
-                        })}</dt>
-                        <dd><ul>
-                            ${(albums
-                                .map(album => strings('listingPage.listAlbums.byDateAdded.album', {
-                                    album: strings.link.album(album, {to})
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul></dd>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'artists/by-name',
-        title: ({strings}) => strings('listingPage.listArtists.byName.title'),
-
-        data() {
-            return artistData.slice()
-                .sort(sortByName)
-                .map(artist => ({artist, contributions: C.getArtistNumContributions(artist)}));
-        },
-
-        row({artist, contributions}, {strings, to}) {
-            return strings('listingPage.listArtists.byName.item', {
-                artist: strings.link.artist(artist, {to}),
-                contributions: strings.count.contributions(contributions, {to, unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-contribs',
-        title: ({strings}) => strings('listingPage.listArtists.byContribs.title'),
-
-        data() {
-            return {
-                toTracks: (artistData
-                    .map(artist => ({
-                        artist,
-                        contributions: (
-                            artist.tracks.asContributor.length +
-                            artist.tracks.asArtist.length
-                        )
-                    }))
-                    .sort((a, b) => b.contributions - a.contributions)
-                    .filter(({ contributions }) => contributions)),
-
-                toArtAndFlashes: (artistData
-                    .map(artist => ({
-                        artist,
-                        contributions: (
-                            artist.tracks.asCoverArtist.length +
-                            artist.albums.asCoverArtist.length +
-                            artist.albums.asWallpaperArtist.length +
-                            artist.albums.asBannerArtist.length +
-                            (wikiInfo.features.flashesAndGames
-                                ? artist.flashes.asContributor.length
-                                : 0)
-                        )
-                    }))
-                    .sort((a, b) => b.contributions - a.contributions)
-                    .filter(({ contributions }) => contributions))
-            };
-        },
-
-        html({toTracks, toArtAndFlashes}, {strings, to}) {
-            return fixWS`
-                <div class="content-columns">
-                    <div class="column">
-                        <h2>${strings('listingPage.misc.trackContributors')}</h2>
-                        <ul>
-                            ${(toTracks
-                                .map(({ artist, contributions }) => strings('listingPage.listArtists.byContribs.item', {
-                                    artist: strings.link.artist(artist, {to}),
-                                    contributions: strings.count.contributions(contributions, {unit: true})
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                         </ul>
-                    </div>
-                    <div class="column">
-                        <h2>${strings('listingPage.misc' +
-                            (wikiInfo.features.flashesAndGames
-                                ? '.artAndFlashContributors'
-                                : '.artContributors'))}</h2>
-                        <ul>
-                            ${(toArtAndFlashes
-                                .map(({ artist, contributions }) => strings('listingPage.listArtists.byContribs.item', {
-                                    artist: strings.link.artist(artist, {to}),
-                                    contributions: strings.count.contributions(contributions, {unit: true})
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul>
-                    </div>
-                </div>
-            `;
-        }
-    },
-
-    {
-        directory: 'artists/by-commentary',
-        title: ({strings}) => strings('listingPage.listArtists.byCommentary.title'),
-
-        data() {
-            return artistData
-                .map(artist => ({artist, entries: artist.tracks.asCommentator.length + artist.albums.asCommentator.length}))
-                .filter(({ entries }) => entries)
-                .sort((a, b) => b.entries - a.entries);
-        },
-
-        row({artist, entries}, {strings, to}) {
-            return strings('listingPage.listArtists.byCommentary.item', {
-                artist: strings.link.artist(artist, {to}),
-                entries: strings.count.commentaryEntries(entries, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-duration',
-        title: ({strings}) => strings('listingPage.listArtists.byDuration.title'),
-
-        data() {
-            return artistData
-                .map(artist => ({artist, duration: getTotalDuration(
-                    [...artist.tracks.asArtist, ...artist.tracks.asContributor].filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY))
-                }))
-                .filter(({ duration }) => duration > 0)
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({artist, duration}, {strings, to}) {
-            return strings('listingPage.listArtists.byDuration.item', {
-                artist: strings.link.artist(artist, {to}),
-                duration: strings.count.duration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-latest',
-        title: ({strings}) => strings('listingPage.listArtists.byLatest.title'),
-
-        data() {
-            const reversedTracks = trackData.slice().reverse();
-            const reversedArtThings = justEverythingSortedByArtDateMan.slice().reverse();
-
-            return {
-                toTracks: C.sortByDate(artistData
-                    .filter(artist => !artist.alias)
-                    .map(artist => ({
-                        artist,
-                        date: reversedTracks.find(({ album, artists, contributors }) => (
-                            album.directory !== C.UNRELEASED_TRACKS_DIRECTORY &&
-                            [...artists, ...contributors].some(({ who }) => who === artist)
-                        ))?.date
-                    }))
-                    .filter(({ date }) => date)
-                    .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0)).reverse(),
-
-                toArtAndFlashes: C.sortByDate(artistData
-                    .filter(artist => !artist.alias)
-                    .map(artist => {
-                        const thing = reversedArtThings.find(({ album, coverArtists, contributors }) => (
-                            album?.directory !== C.UNRELEASED_TRACKS_DIRECTORY &&
-                            [...coverArtists || [], ...!album && contributors || []].some(({ who }) => who === artist)
-                        ));
-                        return thing && {
-                            artist,
-                            date: (thing.coverArtists?.some(({ who }) => who === artist)
-                                ? thing.coverArtDate
-                                : thing.date)
-                        };
-                    })
-                    .filter(Boolean)
-                    .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0)
-                ).reverse()
-            };
-        },
-
-        html({toTracks, toArtAndFlashes}, {strings, to}) {
-            return fixWS`
-                <div class="content-columns">
-                    <div class="column">
-                        <h2>${strings('listingPage.misc.trackContributors')}</h2>
-                        <ul>
-                            ${(toTracks
-                                .map(({ artist, date }) => strings('listingPage.listArtists.byLatest.item', {
-                                    artist: strings.link.artist(artist, {to}),
-                                    date: strings.count.date(date)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul>
-                    </div>
-                    <div class="column">
-                        <h2>${strings('listingPage.misc' +
-                            (wikiInfo.features.flashesAndGames
-                                ? '.artAndFlashContributors'
-                                : '.artContributors'))}</h2>
-                        <ul>
-                            ${(toArtAndFlashes
-                                .map(({ artist, date }) => strings('listingPage.listArtists.byLatest.item', {
-                                    artist: strings.link.artist(artist, {to}),
-                                    date: strings.count.date(date)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul>
-                    </div>
-                </div>
-            `;
-        }
-    },
-
-    {
-        directory: 'groups/by-name',
-        title: ({strings}) => strings('listingPage.listGroups.byName.title'),
-        condition: () => wikiInfo.features.groupUI,
-
-        data() {
-            return groupData.slice().sort(sortByName);
-        },
-
-        row(group, {strings, to}) {
-            return strings('listingPage.listGroups.byCategory.group', {
-                group: strings.link.groupInfo(group, {to}),
-                gallery: strings.link.groupGallery(group, {
-                    to,
-                    text: strings('listingPage.listGroups.byCategory.group.gallery')
-                })
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-category',
-        title: ({strings}) => strings('listingPage.listGroups.byCategory.title'),
-        condition: () => wikiInfo.features.groupUI,
-
-        html({strings, to}) {
-            return fixWS`
-                <dl>
-                    ${groupCategoryData.map(category => fixWS`
-                        <dt>${strings('listingPage.listGroups.byCategory.category', {
-                            category: strings.link.groupInfo(category.groups[0], {to, text: category.name})
-                        })}</dt>
-                        <dd><ul>
-                            ${(category.groups
-                                .map(group => strings('listingPage.listGroups.byCategory.group', {
-                                    group: strings.link.groupInfo(group, {to}),
-                                    gallery: strings.link.groupGallery(group, {
-                                        to,
-                                        text: strings('listingPage.listGroups.byCategory.group.gallery')
-                                    })
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul></dd>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'groups/by-albums',
-        title: ({strings}) => strings('listingPage.listGroups.byAlbums.title'),
-        condition: () => wikiInfo.features.groupUI,
-
-        data() {
-            return groupData
-                .map(group => ({group, albums: group.albums.length}))
-                .sort((a, b) => b.albums - a.albums);
-        },
-
-        row({group, albums}, {strings, to}) {
-            return strings('listingPage.listGroups.byAlbums.item', {
-                group: strings.link.groupInfo(group, {to}),
-                albums: strings.count.albums(albums, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-tracks',
-        title: ({strings}) => strings('listingPage.listGroups.byTracks.title'),
-        condition: () => wikiInfo.features.groupUI,
-
-        data() {
-            return groupData
-                .map(group => ({group, tracks: group.albums.reduce((acc, album) => acc + album.tracks.length, 0)}))
-                .sort((a, b) => b.tracks - a.tracks);
-        },
-
-        row({group, tracks}, {strings, to}) {
-            return strings('listingPage.listGroups.byTracks.item', {
-                group: strings.link.groupInfo(group, {to}),
-                tracks: strings.count.tracks(tracks, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-duration',
-        title: ({strings}) => strings('listingPage.listGroups.byDuration.title'),
-        condition: () => wikiInfo.features.groupUI,
-
-        data() {
-            return groupData
-                .map(group => ({group, duration: getTotalDuration(group.albums.flatMap(album => album.tracks))}))
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({group, duration}, {strings, to}) {
-            return strings('listingPage.listGroups.byDuration.item', {
-                group: strings.link.groupInfo(group, {to}),
-                duration: strings.count.duration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-latest-album',
-        title: ({strings}) => strings('listingPage.listGroups.byLatest.title'),
-        condition: () => wikiInfo.features.groupUI,
-
-        data() {
-            return C.sortByDate(groupData
-                .map(group => ({group, date: group.albums[group.albums.length - 1].date}))
-                // So this is kinda tough to explain, 8ut 8asically, when we reverse the list after sorting it 8y d8te
-                // (so that the latest d8tes come first), it also flips the order of groups which share the same d8te.
-                // This happens mostly when a single al8um is the l8test in two groups. So, say one such al8um is in
-                // the groups "Fandom" and "UMSPAF". Per category order, Fandom is meant to show up 8efore UMSPAF, 8ut
-                // when we do the reverse l8ter, that flips them, and UMSPAF ends up displaying 8efore Fandom. So we do
-                // an extra reverse here, which will fix that and only affect groups that share the same d8te (8ecause
-                // groups that don't will 8e moved 8y the sortByDate call surrounding this).
-                .reverse()).reverse()
-        },
-
-        row({group, date}, {strings, to}) {
-            return strings('listingPage.listGroups.byLatest.item', {
-                group: strings.link.groupInfo(group, {to}),
-                date: strings.count.date(date)
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/by-name',
-        title: ({strings}) => strings('listingPage.listTracks.byName.title'),
-
-        data() {
-            return trackData.slice().sort(sortByName);
-        },
-
-        row(track, {strings, to}) {
-            return strings('listingPage.listTracks.byName.item', {
-                track: strings.link.track(track, {to})
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/by-album',
-        title: ({strings}) => strings('listingPage.listTracks.byAlbum.title'),
-
-        html({strings, to}) {
-            return fixWS`
-                <dl>
-                    ${albumData.map(album => fixWS`
-                        <dt>${strings('listingPage.listTracks.byAlbum.album', {
-                            album: strings.link.album(album, {to})
-                        })}</dt>
-                        <dd><ol>
-                            ${(album.tracks
-                                .map(track => strings('listingPage.listTracks.byAlbum.track', {
-                                    track: strings.link.track(track, {to})
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ol></dd>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tracks/by-date',
-        title: ({strings}) => strings('listingPage.listTracks.byDate.title'),
-
-        data() {
-            return chunkByProperties(
-                C.sortByDate(trackData.filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY)),
-                ['album', 'date']
-            );
-        },
-
-        html(chunks, {strings, to}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({album, date, chunk: tracks}) => fixWS`
-                        <dt>${strings('listingPage.listTracks.byDate.album', {
-                            album: strings.link.album(album, {to}),
-                            date: strings.count.date(date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => track.aka
-                                    ? `<li class="rerelease">${strings('listingPage.listTracks.byDate.track.rerelease', {
-                                        track: strings.link.track(track, {to})
-                                    })}</li>`
-                                    : `<li>${strings('listingPage.listTracks.byDate.track', {
-                                        track: strings.link.track(track, {to})
-                                    })}</li>`)
-                                .join('\n'))}
-                        </ul></dd>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tracks/by-duration',
-        title: ({strings}) => strings('listingPage.listTracks.byDuration.title'),
-
-        data() {
-            return trackData
-                .filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY)
-                .map(track => ({track, duration: track.duration}))
-                .filter(({ duration }) => duration > 0)
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({track, duration}, {strings, to}) {
-            return strings('listingPage.listTracks.byDuration.item', {
-                track: strings.link.track(track, {to}),
-                duration: strings.count.duration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/by-duration-in-album',
-        title: ({strings}) => strings('listingPage.listTracks.byDurationInAlbum.title'),
-
-        data() {
-            return albumData.map(album => ({
-                album,
-                tracks: album.tracks.slice().sort((a, b) => b.duration - a.duration)
-            }));
-        },
-
-        html(albums, {strings, to}) {
-            return fixWS`
-                <dl>
-                    ${albums.map(({album, tracks}) => fixWS`
-                        <dt>${strings('listingPage.listTracks.byDurationInAlbum.album', {
-                            album: strings.link.album(album, {to})
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => strings('listingPage.listTracks.byDurationInAlbum.track', {
-                                    track: strings.link.track(track, {to}),
-                                    duration: strings.count.duration(track.duration)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </dd></ul>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tracks/by-times-referenced',
-        title: ({strings}) => strings('listingPage.listTracks.byTimesReferenced.title'),
-
-        data() {
-            return trackData
-                .map(track => ({track, timesReferenced: track.referencedBy.length}))
-                .filter(({ timesReferenced }) => timesReferenced > 0)
-                .sort((a, b) => b.timesReferenced - a.timesReferenced);
-        },
-
-        row({track, timesReferenced}, {strings, to}) {
-            return strings('listingPage.listTracks.byTimesReferenced.item', {
-                track: strings.link.track(track, {to}),
-                timesReferenced: strings.count.timesReferenced(timesReferenced, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/in-flashes/by-album',
-        title: ({strings}) => strings('listingPage.listTracks.inFlashes.byAlbum.title'),
-        condition: () => wikiInfo.features.flashesAndGames,
-
-        data() {
-            return chunkByProperties(trackData.filter(t => t.flashes.length > 0), ['album'])
-                .filter(({ album }) => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY);
-        },
-
-        html(chunks, {strings, to}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({album, chunk: tracks}) => fixWS`
-                        <dt>${strings('listingPage.listTracks.inFlashes.byAlbum.album', {
-                            album: strings.link.album(album, {to}),
-                            date: strings.count.date(album.date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => strings('listingPage.listTracks.inFlashes.byAlbum.track', {
-                                    track: strings.link.track(track, {to}),
-                                    flashes: strings.list.and(track.flashes.map(flash => strings.link.flash(flash, {to})))
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </dd></ul>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tracks/in-flashes/by-flash',
-        title: ({strings}) => strings('listingPage.listTracks.inFlashes.byFlash.title'),
-        condition: () => wikiInfo.features.flashesAndGames,
-
-        html({strings, to}) {
-            return fixWS`
-                <dl>
-                    ${C.sortByDate(flashData.slice()).map(flash => fixWS`
-                        <dt>${strings('listingPage.listTracks.inFlashes.byFlash.flash', {
-                            flash: strings.link.flash(flash, {to}),
-                            date: strings.count.date(flash.date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(flash.tracks
-                                .map(track => strings('listingPage.listTracks.inFlashes.byFlash.track', {
-                                    track: strings.link.track(track, {to}),
-                                    album: strings.link.album(track.album, {to})
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul></dd>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tracks/with-lyrics',
-        title: ({strings}) => strings('listingPage.listTracks.withLyrics.title'),
-
-        data() {
-            return chunkByProperties(trackData.filter(t => t.lyrics), ['album']);
-        },
-
-        html(chunks, {strings, to}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({album, chunk: tracks}) => fixWS`
-                        <dt>${strings('listingPage.listTracks.withLyrics.album', {
-                            album: strings.link.album(album, {to}),
-                            date: strings.count.date(album.date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => strings('listingPage.listTracks.withLyrics.track', {
-                                    track: strings.link.track(track, {to}),
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </dd></ul>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tags/by-name',
-        title: ({strings}) => strings('listingPage.listTags.byName.title'),
-        condition: () => wikiInfo.features.artTagUI,
-
-        data() {
-            return tagData
-                .filter(tag => !tag.isCW)
-                .sort(sortByName)
-                .map(tag => ({tag, timesUsed: tag.things.length}));
-        },
-
-        row({tag, timesUsed}, {strings, to}) {
-            return strings('listingPage.listTags.byName.item', {
-                tag: strings.link.tag(tag, {to}),
-                timesUsed: strings.count.timesUsed(timesUsed, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'tags/by-uses',
-        title: ({strings}) => strings('listingPage.listTags.byUses.title'),
-        condition: () => wikiInfo.features.artTagUI,
-
-        data() {
-            return tagData
-                .filter(tag => !tag.isCW)
-                .map(tag => ({tag, timesUsed: tag.things.length}))
-                .sort((a, b) => b.timesUsed - a.timesUsed);
-        },
-
-        row({tag, timesUsed}, {strings, to}) {
-            return strings('listingPage.listTags.byUses.item', {
-                tag: strings.link.tag(tag, {to}),
-                timesUsed: strings.count.timesUsed(timesUsed, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'random',
-        title: ({strings}) => `Random Pages`,
-        html: ({strings, to}) => fixWS`
-            <p>Choose a link to go to a random page in that category or album! If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.</p>
-            <p class="js-hide-once-data">(Data files are downloading in the background! Please wait for data to load.)</p>
-            <p class="js-show-once-data">(Data files have finished being downloaded. The links should work!)</p>
-            <dl>
-                <dt>Miscellaneous:</dt>
-                <dd><ul>
-                    <li>
-                        <a href="#" data-random="artist">Random Artist</a>
-                        (<a href="#" data-random="artist-more-than-one-contrib">&gt;1 contribution</a>)
-                    </li>
-                    <li><a href="#" data-random="album">Random Album (whole site)</a></li>
-                    <li><a href="#" data-random="track">Random Track (whole site)</a></li>
-                </ul></dd>
-                ${[
-                    {name: 'Official', albumData: officialAlbumData, code: 'official'},
-                    {name: 'Fandom', albumData: fandomAlbumData, code: 'fandom'}
-                ].map(category => fixWS`
-                    <dt>${category.name}: (<a href="#" data-random="album-in-${category.code}">Random Album</a>, <a href="#" data-random="track-in-${category.code}">Random Track</a>)</dt>
-                    <dd><ul>${category.albumData.map(album => fixWS`
-                        <li><a style="${getLinkThemeString(album)}; --album-directory: ${album.directory}" href="#" data-random="track-in-album">${album.name}</a></li>
-                    `).join('\n')}</ul></dd>
-                `).join('\n')}
-            </dl>
-        `
-    }
-];
-
-function writeListingPages() {
-    if (!wikiInfo.features.listings) {
-        return;
-    }
-
-    return [
-        writeListingIndex(),
-        ...listingSpec.map(writeListingPage).filter(Boolean)
-    ];
-}
-
-function writeListingIndex() {
-    const releasedTracks = trackData.filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY);
-    const releasedAlbums = albumData.filter(album => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY);
-    const duration = getTotalDuration(releasedTracks);
-
-    return ({strings, writePage}) => writePage('listingIndex', '', ({to}) => ({
-        title: strings('listingIndex.title'),
-
-        main: {
-            content: fixWS`
-                <h1>${strings('listingIndex.title')}</h1>
-                <p>${strings('listingIndex.infoLine', {
-                    wiki: wikiInfo.name,
-                    tracks: `<b>${strings.count.tracks(releasedTracks.length, {unit: true})}</b>`,
-                    albums: `<b>${strings.count.albums(releasedAlbums.length, {unit: true})}</b>`,
-                    duration: `<b>${strings.count.duration(duration, {approximate: true, unit: true})}</b>`
-                })}</p>
-                <hr>
-                <p>${strings('listingIndex.exploreList')}</p>
-                ${generateLinkIndexForListings(null, {strings, to})}
-            `
-        },
-
-        sidebarLeft: {
-            content: generateSidebarForListings(null, {strings, to})
-        },
-
-        nav: {simple: true}
-    }))
-}
-
-function writeListingPage(listing) {
-    if (listing.condition && !listing.condition()) {
-        return null;
-    }
-
-    const data = (listing.data
-        ? listing.data()
-        : null);
-
-    return ({strings, writePage}) => writePage('listing', listing.directory, ({to}) => ({
-        title: listing.title({strings}),
-
-        main: {
-            content: fixWS`
-                <h1>${listing.title({strings})}</h1>
-                ${listing.html && (listing.data
-                    ? listing.html(data, {strings, to})
-                    : listing.html({strings, to}))}
-                ${listing.row && fixWS`
-                    <ul>
-                        ${(data
-                            .map(item => listing.row(item, {strings, to}))
-                            .map(row => `<li>${row}</li>`)
-                            .join('\n'))}
-                    </ul>
-                `}
-            `
-        },
-
-        sidebarLeft: {
-            content: generateSidebarForListings(listing, {strings, to})
-        },
-
-        nav: {
-            links: [
-                {
-                    href: to('localized.home'),
-                    title: wikiInfo.shortName
-                },
-                {
-                    href: to('localized.listingIndex'),
-                    title: strings('listingIndex.title')
-                },
-                {
-                    href: '',
-                    title: listing.title({strings})
-                }
-            ]
-        }
-    }));
-}
-
-function generateSidebarForListings(currentListing, {strings, to}) {
-    return fixWS`
-        <h1>${strings.link.listingIndex('', {text: strings('listingIndex.title'), to})}</h1>
-        ${generateLinkIndexForListings(currentListing, {strings, to})}
-    `;
-}
-
-function generateLinkIndexForListings(currentListing, {strings, to}) {
-    return fixWS`
-        <ul>
-            ${(listingSpec
-                .filter(({ condition }) => !condition || condition())
-                .map(listing => fixWS`
-                    <li ${classes(listing === currentListing && 'current')}>
-                        <a href="${to('localized.listing', listing.directory)}">${listing.title({strings})}</a>
-                    </li>
-                `)
-                .join('\n'))}
-        </ul>
-    `;
-}
-
-function filterAlbumsByCommentary() {
-    return albumData.filter(album => [album, ...album.tracks].some(x => x.commentary));
-}
-
-function writeCommentaryPages() {
-    if (!filterAlbumsByCommentary().length) {
-        return;
-    }
-
-    return [
-        writeCommentaryIndex(),
-        ...filterAlbumsByCommentary().map(writeAlbumCommentaryPage)
-    ];
-}
-
-function writeCommentaryIndex() {
-    const data = filterAlbumsByCommentary()
-        .map(album => ({
-            album,
-            entries: [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary)
-        }))
-        .map(({ album, entries }) => ({
-            album, entries,
-            words: entries.join(' ').split(' ').length
-        }));
-
-    const totalEntries = data.reduce((acc, {entries}) => acc + entries.length, 0);
-    const totalWords = data.reduce((acc, {words}) => acc + words, 0);
-
-    return ({strings, writePage}) => writePage('commentaryIndex', '', ({to}) => ({
-        title: strings('commentaryIndex.title'),
-
-        main: {
-            content: fixWS`
-                <div class="long-content">
-                    <h1>${strings('commentaryIndex.title')}</h1>
-                    <p>${strings('commentaryIndex.infoLine', {
-                        words: `<b>${strings.count.words(totalWords, {unit: true})}</b>`,
-                        entries: `<b>${strings.count.commentaryEntries(totalEntries, {unit: true})}</b>`
-                    })}</p>
-                    <p>${strings('commentaryIndex.albumList.title')}</p>
-                    <ul>
-                        ${data
-                            .map(({ album, entries, words }) => fixWS`
-                                <li>${strings('commentaryIndex.albumList.item', {
-                                    album: strings.link.albumCommentary(album, {to}),
-                                    words: strings.count.words(words, {unit: true}),
-                                    entries: strings.count.commentaryEntries(entries.length, {unit: true})
-                                })}</li>
-                            `)
-                            .join('\n')}
-                    </ul>
-                </div>
-            `
-        },
-
-        nav: {simple: true}
-    }));
-}
-
-function writeAlbumCommentaryPage(album) {
-    const entries = [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary);
-    const words = entries.join(' ').split(' ').length;
-
-    return ({strings, writePage}) => writePage('albumCommentary', album.directory, ({to}) => ({
-        title: strings('albumCommentaryPage.title', {album: album.name}),
-        stylesheet: getAlbumStylesheet(album, {to}),
-        theme: getThemeString(album),
-
-        main: {
-            content: fixWS`
-                <div class="long-content">
-                    <h1>${strings('albumCommentaryPage.title', {
-                        album: strings.link.album(album, {to})
-                    })}</h1>
-                    <p>${strings('albumCommentaryPage.infoLine', {
-                        words: `<b>${strings.count.words(words, {unit: true})}</b>`,
-                        entries: `<b>${strings.count.commentaryEntries(entries.length, {unit: true})}</b>`
-                    })}</p>
-                    ${album.commentary && fixWS`
-                        <h3>${strings('albumCommentaryPage.entry.title.albumCommentary')}</h3>
-                        <blockquote>
-                            ${transformMultiline(album.commentary, {strings, to})}
-                        </blockquote>
-                    `}
-                    ${album.tracks.filter(t => t.commentary).map(track => fixWS`
-                        <h3 id="${track.directory}">${strings('albumCommentaryPage.entry.title.trackCommentary', {
-                            track: strings.link.track(track, {to})
-                        })}</h3>
-                        <blockquote style="${getLinkThemeString(track)}">
-                            ${transformMultiline(track.commentary, {strings, to})}
-                        </blockquote>
-                    `).join('\n')}
-                </div>
-            `
-        },
-
-        nav: {
-            links: [
-                {
-                    href: to('localized.home'),
-                    title: wikiInfo.shortName
-                },
-                {
-                    href: to('localized.commentaryIndex'),
-                    title: strings('commentaryIndex.title')
-                },
-                {
-                    html: strings('albumCommentaryPage.nav.album', {
-                        album: strings.link.albumCommentary(album, {class: 'current', to})
-                    })
-                }
-            ]
-        }
-    }));
-}
-
-function writeTagPages() {
-    if (!wikiInfo.features.artTagUI) {
-        return;
-    }
-
-    return tagData.filter(tag => !tag.isCW).map(writeTagPage);
-}
-
-function writeTagPage(tag) {
-    const { things } = tag;
-
-    return ({strings, writePage}) => writePage('tag', tag.directory, ({to}) => ({
-        title: strings('tagPage.title', {tag: tag.name}),
-        theme: getThemeString(tag),
-
-        main: {
-            classes: ['top-index'],
-            content: fixWS`
-                <h1>${strings('tagPage.title', {tag: tag.name})}</h1>
-                <p class="quick-info">${strings('tagPage.infoLine', {
-                    coverArts: strings.count.coverArts(things.length, {unit: true})
-                })}</p>
-                <div class="grid-listing">
-                    ${getGridHTML({
-                        strings, to,
-                        entries: things.map(item => ({item})),
-                        srcFn: thing => (thing.album
-                            ? getTrackCover(thing, {to})
-                            : getAlbumCover(thing, {to})),
-                        hrefFn: thing => (thing.album
-                            ? to('localized.track', thing.directory)
-                            : to('localized.album', thing.directory))
-                    })}
-                </div>
-            `
-        },
-
-        nav: {
-            links: [
-                {
-                    href: to('localized.home'),
-                    title: wikiInfo.shortName
-                },
-                wikiInfo.features.listings &&
-                {
-                    href: to('localized.listingIndex'),
-                    title: strings('listingIndex.title')
-                },
-                {
-                    html: strings('tagPage.nav.tag', {
-                        tag: strings.link.tag(tag, {class: 'current', to})
-                    })
-                }
-            ]
-        }
-    }));
-}
-
-function getArtistString(artists, {strings, to, showIcons = false, showContrib = false}) {
-    return strings.list.and(artists.map(({ who, what }) => {
-        const { urls, directory, name } = who;
-        return [
-            strings.link.artist(who, {to}),
-            showContrib && what && `(${what})`,
-            showIcons && urls.length && `<span class="icons">(${
-                strings.list.unit(urls.map(url => iconifyURL(url, {strings, to})))
-            })</span>`
-        ].filter(Boolean).join(' ');
-    }));
-}
-
-function getLinkThemeString(thing) {
-    const { primary, dim } = C.getColors(thing.color || wikiInfo.color);
-    return `--primary-color: ${primary}; --dim-color: ${dim}`;
-}
-
-function getThemeString(thing, additionalVariables = []) {
-    const { primary, dim } = C.getColors(thing.color || wikiInfo.color);
-
-    const variables = [
-        `--primary-color: ${primary}`,
-        `--dim-color: ${dim}`,
-        ...additionalVariables
-    ].filter(Boolean);
-
-    return fixWS`
-        ${variables.length && fixWS`
-            :root {
-                ${variables.map(line => line + ';').join('\n')}
-            }
-        `}
-    `;
-}
-
-function getFlashDirectory(flash) {
-    // const kebab = getKebabCase(flash.name.replace('[S] ', ''));
-    // return flash.page + (kebab ? '-' + kebab : '');
-    // return '' + flash.page;
-    return '' + flash.directory;
-}
-
-function getTagDirectory({name}) {
-    return C.getKebabCase(name);
-}
-
-function getAlbumListTag(album) {
-    if (album.directory === C.UNRELEASED_TRACKS_DIRECTORY) {
-        return 'ul';
-    } else {
-        return 'ol';
-    }
-}
-
-function fancifyURL(url, {strings, album = false} = {}) {
-    const domain = new URL(url).hostname;
-    return fixWS`<a href="${url}" class="nowrap">${
-        domain.includes('bandcamp.com') ? strings('misc.external.bandcamp') :
-        [
-            'music.solatrux.com'
-        ].includes(domain) ? strings('misc.external.bandcamp.domain', {domain}) :
-        [
-            'types.pl'
-        ].includes(domain) ? strings('misc.external.mastodon.domain', {domain}) :
-        domain.includes('youtu') ? (album
-            ? (url.includes('list=')
-                ? strings('misc.external.youtube.playlist')
-                : strings('misc.external.youtube.fullAlbum'))
-            : strings('misc.external.youtube')) :
-        domain.includes('soundcloud') ? strings('misc.external.soundcloud') :
-        domain.includes('tumblr.com') ? strings('misc.external.tumblr') :
-        domain.includes('twitter.com') ? strings('misc.external.twitter') :
-        domain.includes('deviantart.com') ? strings('misc.external.deviantart') :
-        domain.includes('wikipedia.org') ? strings('misc.external.wikipedia') :
-        domain.includes('poetryfoundation.org') ? strings('misc.external.poetryFoundation') :
-        domain.includes('instagram.com') ? strings('misc.external.instagram') :
-        domain.includes('patreon.com') ? strings('misc.external.patreon') :
-        domain
-    }</a>`;
-}
-
-function fancifyFlashURL(url, flash, {strings}) {
-    const link = fancifyURL(url, {strings});
-    return `<span class="nowrap">${
-        url.includes('homestuck.com') ? (isNaN(Number(flash.page))
-            ? strings('misc.external.flash.homestuck.secret', {link})
-            : strings('misc.external.flash.homestuck.page', {link, page: flash.page})) :
-        url.includes('bgreco.net') ? strings('misc.external.flash.bgreco', {link}) :
-        url.includes('youtu') ? strings('misc.external.flash.youtube', {link}) :
-        link
-    }</span>`;
-}
-
-function iconifyURL(url, {strings, to}) {
-    const domain = new URL(url).hostname;
-    const [ id, msg ] = (
-        domain.includes('bandcamp.com') ? ['bandcamp', strings('misc.external.bandcamp')] :
-        (
-            domain.includes('music.solatrus.com')
-        ) ? ['bandcamp', strings('misc.external.bandcamp.domain', {domain})] :
-        (
-            domain.includes('types.pl')
-        ) ? ['mastodon', strings('misc.external.mastodon.domain', {domain})] :
-        domain.includes('youtu') ? ['youtube', strings('misc.external.youtube')] :
-        domain.includes('soundcloud') ? ['soundcloud', strings('misc.external.soundcloud')] :
-        domain.includes('tumblr.com') ? ['tumblr', strings('misc.external.tumblr')] :
-        domain.includes('twitter.com') ? ['twitter', strings('misc.external.twitter')] :
-        domain.includes('deviantart.com') ? ['deviantart', strings('misc.external.deviantart')] :
-        domain.includes('instagram.com') ? ['instagram', strings('misc.external.bandcamp')] :
-        ['globe', strings('misc.external.domain', {domain})]
-    );
-    return fixWS`<a href="${url}" class="icon"><svg><title>${msg}</title><use href="${to('shared.staticFile', `icons.svg#icon-${id}`)}"></use></svg></a>`;
-}
-
-function chronologyLinks(currentThing, {
-    strings, to,
-    headingString,
-    contribKey,
-    getThings
-}) {
-    const contributions = currentThing[contribKey];
-    if (!contributions) {
-        return '';
-    }
-
-    if (contributions.length > 8) {
-        return `<div class="chronology">${strings('misc.chronology.seeArtistPages')}</div>`;
-    }
-
-    return contributions.map(({ who: artist }) => {
-        const things = C.sortByDate(unique(getThings(artist)));
-        const releasedThings = things.filter(thing => {
-            const album = albumData.includes(thing) ? thing : thing.album;
-            return !(album && album.directory === C.UNRELEASED_TRACKS_DIRECTORY);
-        });
-        const index = releasedThings.indexOf(currentThing);
-
-        if (index === -1) return '';
-
-        // TODO: This can pro8a8ly 8e made to use generatePreviousNextLinks?
-        // We'd need to make generatePreviousNextLinks use toAnythingMan tho.
-        const previous = releasedThings[index - 1];
-        const next = releasedThings[index + 1];
-        const parts = [
-            previous && `<a href="${toAnythingMan(previous, to)}" title="${previous.name}">Previous</a>`,
-            next && `<a href="${toAnythingMan(next, to)}" title="${next.name}">Next</a>`
-        ].filter(Boolean);
-
-        const stringOpts = {
-            index: strings.count.index(index + 1, {strings}),
-            artist: strings.link.artist(artist, {to})
-        };
-
-        return fixWS`
-            <div class="chronology">
-                <span class="heading">${strings(headingString, stringOpts)}</span>
-                ${parts.length && `<span class="buttons">(${parts.join(', ')})</span>`}
-            </div>
-        `;
-    }).filter(Boolean).join('\n');
-}
-
-function generateAlbumNavLinks(album, currentTrack, {strings, to}) {
-    if (album.tracks.length <= 1) {
-        return '';
-    }
-
-    const previousNextLinks = currentTrack && generatePreviousNextLinks('localized.track', currentTrack, album.tracks, {strings, to})
-    const randomLink = `<a href="#" data-random="track-in-album" id="random-button">${
-        (currentTrack
-            ? strings('trackPage.nav.random')
-            : strings('albumPage.nav.randomTrack'))
-    }</a>`;
-
-    return (previousNextLinks
-        ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)`
-        : `<span class="js-hide-until-data">(${randomLink})</span>`);
-}
-
-function generateAlbumChronologyLinks(album, currentTrack, {strings, to}) {
-    return [
-        currentTrack && chronologyLinks(currentTrack, {
-            strings, to,
-            headingString: 'misc.chronology.heading.track',
-            contribKey: 'artists',
-            getThings: artist => [...artist.tracks.asArtist, ...artist.tracks.asContributor]
-        }),
-        chronologyLinks(currentTrack || album, {
-            strings, to,
-            headingString: 'misc.chronology.heading.coverArt',
-            contribKey: 'coverArtists',
-            getThings: artist => [...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist]
-        })
-    ].filter(Boolean).join('\n');
-}
-
-function generateSidebarForAlbum(album, currentTrack, {strings, to}) {
-    const listTag = getAlbumListTag(album);
-
-    const trackToListItem = track => `<li ${classes(track === currentTrack && 'current')}>${
-        strings('albumSidebar.trackList.item', {
-            track: strings.link.track(track, {to})
-        })
-    }</li>`;
-
-    const trackListPart = fixWS`
-        <h1><a href="${to('localized.album', album.directory)}">${album.name}</a></h1>
-        ${album.trackGroups ? fixWS`
-            <dl>
-                ${album.trackGroups.map(({ name, color, startIndex, tracks }) => fixWS`
-                    <dt ${classes(tracks.includes(currentTrack) && 'current')}>${
-                        (listTag === 'ol'
-                            ? strings('albumSidebar.trackList.group.withRange', {
-                                group: strings.link.track(tracks[0], {to, text: name}),
-                                range: `${startIndex + 1}&ndash;${startIndex + tracks.length}`
-                            })
-                            : strings('albumSidebar.trackList.group', {
-                                group: strings.link.track(tracks[0], {to, text: name})
-                            }))
-                    }</dt>
-                    ${(!currentTrack || tracks.includes(currentTrack)) && fixWS`
-                        <dd><${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}>
-                            ${tracks.map(trackToListItem).join('\n')}
-                        </${listTag}></dd>
-                    `}
-                `).join('\n')}
-            </dl>
-        ` : fixWS`
-            <${listTag}>
-                ${album.tracks.map(trackToListItem).join('\n')}
-            </${listTag}>
-        `}
-    `;
-
-    const { groups } = album;
-
-    const groupParts = groups.map(group => {
-        const index = group.albums.indexOf(album);
-        const next = group.albums[index + 1];
-        const previous = group.albums[index - 1];
-        return {group, next, previous};
-    }).map(({group, next, previous}) => fixWS`
-        <h1>${
-            strings('albumSidebar.groupBox.title', {
-                group: `<a href="${to('localized.groupInfo', group.directory)}">${group.name}</a>`
-            })
-        }</h1>
-        ${!currentTrack && transformMultiline(group.descriptionShort, {strings, to})}
-        ${group.urls.length && `<p>${
-            strings('releaseInfo.visitOn', {
-                links: strings.list.or(group.urls.map(url => fancifyURL(url, {strings})))
-            })
-        }</p>`}
-        ${!currentTrack && fixWS`
-            ${next && `<p class="group-chronology-link">${
-                strings('albumSidebar.groupBox.next', {
-                    album: `<a href="${to('localized.album', next.directory)}" style="${getLinkThemeString(next)}">${next.name}</a>`
-                })
-            }</p>`}
-            ${previous && `<p class="group-chronology-link">${
-                strings('albumSidebar.groupBox.previous', {
-                    album: `<a href="${to('localized.album', previous.directory)}" style="${getLinkThemeString(previous)}">${previous.name}</a>`
-                })
-            }</p>`}
-        `}
-    `);
-
-    if (groupParts.length) {
-        if (currentTrack) {
-            const combinedGroupPart = groupParts.join('\n<hr>\n');
-            return {
-                multiple: [
-                    trackListPart,
-                    combinedGroupPart
-                ]
-            };
-        } else {
-            return {
-                multiple: [
-                    ...groupParts,
-                    trackListPart
-                ]
-            };
-        }
-    } else {
-        return {
-            content: trackListPart
-        };
-    }
-}
-
-function generateSidebarForGroup(currentGroup, {strings, to, isGallery}) {
-    if (!wikiInfo.features.groupUI) {
-        return null;
-    }
-
-    const urlKey = isGallery ? 'localized.groupGallery' : 'localized.groupInfo';
-
-    return {
-        content: fixWS`
-            <h1>${strings('groupSidebar.title')}</h1>
-            <dl>
-                ${groupCategoryData.map(category => [
-                    fixWS`
-                        <dt ${classes(category === currentGroup.category && 'current')}>${
-                            strings('groupSidebar.groupList.category', {
-                                category: `<a href="${to(urlKey, category.groups[0].directory)}" style="${getLinkThemeString(category)}">${category.name}</a>`
-                            })
-                        }</dt>
-                        <dd><ul>
-                            ${category.groups.map(group => fixWS`
-                                <li ${classes(group === currentGroup && 'current')} style="${getLinkThemeString(group)}">${
-                                    strings('groupSidebar.groupList.item', {
-                                        group: `<a href="${to(urlKey, group.directory)}">${group.name}</a>`
-                                    })
-                                }</li>
-                            `).join('\n')}
-                        </ul></dd>
-                    `
-                ]).join('\n')}
-            </dl>
-        `
-    };
-}
-
-function generateInfoGalleryLinks(urlKeyInfo, urlKeyGallery, currentThing, isGallery, {strings, to}) {
-    return [
-        strings.link[urlKeyInfo](currentThing, {
-            to,
-            class: isGallery ? '' : 'current',
-            text: strings('misc.nav.info')
-        }),
-        strings.link[urlKeyGallery](currentThing, {
-            to,
-            class: isGallery ? 'current' : '',
-            text: strings('misc.nav.gallery')
-        })
-    ].join(', ');
-}
-
-function generatePreviousNextLinks(urlKey, currentThing, thingData, {strings, to}) {
-    const index = thingData.indexOf(currentThing);
-    const previous = thingData[index - 1];
-    const next = thingData[index + 1];
-
-    return [
-        previous && `<a href="${to(urlKey, previous.directory)}" id="previous-button" title="${previous.name}">${strings('misc.nav.previous')}</a>`,
-        next && `<a href="${to(urlKey, next.directory)}" id="next-button" title="${next.name}">${strings('misc.nav.next')}</a>`
-    ].filter(Boolean).join(', ');
-}
-
-function generateNavForGroup(currentGroup, {strings, to, isGallery}) {
-    if (!wikiInfo.features.groupUI) {
-        return {simple: true};
-    }
-
-    const urlKey = isGallery ? 'localized.groupGallery' : 'localized.groupInfo';
-    const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
-
-    const infoGalleryLinks = generateInfoGalleryLinks('groupInfo', 'groupGallery', currentGroup, isGallery, {strings, to});
-    const previousNextLinks = generatePreviousNextLinks(urlKey, currentGroup, groupData, {strings, to})
-
-    return {
-        links: [
-            {
-                href: to('localized.home'),
-                title: wikiInfo.shortName
-            },
-            wikiInfo.features.listings &&
-            {
-                href: to('localized.listingIndex'),
-                title: strings('listingIndex.title')
-            },
-            {
-                html: strings('groupPage.nav.group', {
-                    group: strings.link[linkKey](currentGroup, {class: 'current', to})
-                })
-            },
-            {
-                divider: false,
-                html: (previousNextLinks
-                    ? `(${infoGalleryLinks}; ${previousNextLinks})`
-                    : `(${previousNextLinks})`)
-            }
-        ]
-    };
-}
-
-function writeGroupPages() {
-    return groupData.map(writeGroupPage);
-}
-
-function writeGroupPage(group) {
-    const releasedAlbums = group.albums.filter(album => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY);
-    const releasedTracks = releasedAlbums.flatMap(album => album.tracks);
-    const totalDuration = getTotalDuration(releasedTracks);
-
-    return async ({strings, writePage}) => {
-        await writePage('groupInfo', group.directory, ({to}) => ({
-            title: strings('groupInfoPage.title', {group: group.name}),
-            theme: getThemeString(group),
-
-            main: {
-                content: fixWS`
-                    <h1>${strings('groupInfoPage.title', {group: group.name})}</h1>
-                    ${group.urls.length && `<p>${
-                        strings('releaseInfo.visitOn', {
-                            links: strings.list.or(group.urls.map(url => fancifyURL(url, {strings})))
-                        })
-                    }</p>`}
-                    <blockquote>
-                        ${transformMultiline(group.description, {strings, to})}
-                    </blockquote>
-                    <h2>${strings('groupInfoPage.albumList.title')}</h2>
-                    <p>${
-                        strings('groupInfoPage.viewAlbumGallery', {
-                            link: `<a href="${to('localized.groupGallery', group.directory)}">${
-                                strings('groupInfoPage.viewAlbumGallery.link')
-                            }</a>`
-                        })
-                    }</p>
-                    <ul>
-                        ${group.albums.map(album => fixWS`
-                            <li>${
-                                strings('groupInfoPage.albumList.item', {
-                                    year: album.date.getFullYear(),
-                                    album: `<a href="${to('localized.album', album.directory)}" style="${getLinkThemeString(album)}">${album.name}</a>`
-                                })
-                            }</li>
-                        `).join('\n')}
-                    </ul>
-                `
-            },
-
-            sidebarLeft: generateSidebarForGroup(group, {strings, to, isGallery: false}),
-            nav: generateNavForGroup(group, {strings, to, isGallery: false})
-        }));
-
-        await writePage('groupGallery', group.directory, ({to}) => ({
-            title: strings('groupGalleryPage.title', {group: group.name}),
-            theme: getThemeString(group),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${strings('groupGalleryPage.title', {group: group.name})}</h1>
-                    <p class="quick-info">${
-                        strings('groupGalleryPage.infoLine', {
-                            tracks: `<b>${strings.count.tracks(releasedTracks.length, {unit: true})}</b>`,
-                            albums: `<b>${strings.count.albums(releasedAlbums.length, {unit: true})}</b>`,
-                            time: `<b>${strings.count.duration(totalDuration, {unit: true})}</b>`
-                        })
-                    }</p>
-                    ${wikiInfo.features.groupUI && wikiInfo.features.listings && `<p class="quick-info">(<a href="${to('localized.listing', 'groups/by-category')}">Choose another group to filter by!</a>)</p>`}
-                    <div class="grid-listing">
-                        ${getAlbumGridHTML({
-                            strings, to,
-                            entries: C.sortByDate(group.albums.map(item => ({item}))).reverse(),
-                            details: true
-                        })}
-                    </div>
-                `
-            },
-
-            sidebarLeft: generateSidebarForGroup(group, {strings, to, isGallery: true}),
-            nav: generateNavForGroup(group, {strings, to, isGallery: true})
-        }));
-    };
-}
-
-function toAnythingMan(anythingMan, to) {
-    return (
-        albumData.includes(anythingMan) ? to('localized.album', anythingMan.directory) :
-        trackData.includes(anythingMan) ? to('localized.track', anythingMan.directory) :
-        flashData?.includes(anythingMan) ? to('localized.flash', anythingMan.directory) :
-        'idk-bud'
-    )
-}
-
-function getAlbumCover(album, {to}) {
-    return to('media.albumCover', album.directory);
-}
-
-function getTrackCover(track, {to}) {
-    // Some al8ums don't have any track art at all, and in those, every track
-    // just inherits the al8um's own cover art.
-    if (track.coverArtists === null) {
-        return getAlbumCover(track.album, {to});
-    } else {
-        return to('media.trackCover', track.album.directory, track.directory);
-    }
-}
-
-function getFlashLink(flash) {
-    return `https://homestuck.com/story/${flash.page}`;
-}
-
-function classes(...args) {
-    const values = args.filter(Boolean);
-    return `class="${values.join(' ')}"`;
-}
-
-async function processLanguageFile(file, defaultStrings = null) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    let json;
-    try {
-        json = JSON.parse(contents);
-    } catch (error) {
-        return {error: `Could not parse JSON from ${file} (${error}).`};
-    }
-
-    return genStrings(json, defaultStrings);
-}
-
-// Wrapper function for running a function once for all languages. It provides:
-// * the language strings
-// * a shadowing writePages function for outputing to the appropriate subdir
-// * a shadowing urls object for linking to the appropriate relative paths
-async function wrapLanguages(fn, writeOneLanguage = null) {
-    const k = writeOneLanguage
-    const languagesToRun = (k
-        ? {[k]: languages[k]}
-        : languages)
-
-    const entries = Object.entries(languagesToRun)
-        .filter(([ key ]) => key !== 'default');
-
-    for (let i = 0; i < entries.length; i++) {
-        const [ key, strings ] = entries[i];
-
-        const baseDirectory = (strings === languages.default ? '' : strings.code);
-
-        const shadow_writePage = (urlKey, directory, pageFn) => writePage(strings, baseDirectory, urlKey, directory, pageFn);
-
-        // 8ring the utility functions over too!
-        Object.assign(shadow_writePage, writePage);
-
-        await fn({
-            baseDirectory,
-            strings,
-            writePage: shadow_writePage
-        }, i, entries);
-    }
-}
-
-async function main() {
-    const miscOptions = await parseOptions(process.argv.slice(2), {
-        // Data files for the site, including flash, artist, and al8um data,
-        // and like a jillion other things too. Pretty much everything which
-        // makes an individual wiki what it is goes here!
-        'data-path': {
-            type: 'value'
-        },
-
-        // Static media will 8e referenced in the site here! The contents are
-        // categorized; check out MEDIA_DIRECTORY and rel8ted constants in
-        // common/common.js. (This gets symlinked into the --data directory.)
-        'media-path': {
-            type: 'value'
-        },
-
-        // String files! For the most part, this is used for translating the
-        // site to different languages, though you can also customize strings
-        // for your own 8uild of the site if you'd like. Files here should all
-        // match the format in strings-default.json in this repository. (If a
-        // language file is missing any strings, the site code will fall 8ack
-        // to what's specified in strings-default.json.)
-        //
-        // Unlike the other options here, this one's optional - the site will
-        // 8uild with the default (English) strings if this path is left
-        // unspecified.
-        'lang-path': {
-            type: 'value'
-        },
-
-        // This is the output directory. It's the one you'll upload online with
-        // rsync or whatever when you're pushing an upd8, and also the one
-        // you'd archive if you wanted to make a 8ackup of the whole dang
-        // site. Just keep in mind that the gener8ted result will contain a
-        // couple symlinked directories, so if you're uploading, you're pro8a8ly
-        // gonna want to resolve those yourself.
-        'out-path': {
-            type: 'value'
-        },
-
-        // Thum8nail gener8tion is *usually* something you want, 8ut it can 8e
-        // kinda a pain to run every time, since it does necessit8te reading
-        // every media file at run time. Pass this to skip it.
-        'skip-thumbs': {
-            type: 'flag'
-        },
-
-        // Only want 8uild one language during testing? This can chop down
-        // 8uild times a pretty 8ig chunk! Just pass a single language code.
-        'lang': {
-            type: 'value'
-        },
-
-        'queue-size': {
-            type: 'value',
-            validate(size) {
-                if (parseInt(size) !== parseFloat(size)) return 'an integer';
-                if (parseInt(size) < 0) return 'a counting number or zero';
-                return true;
-            }
-        },
-        queue: {alias: 'queue-size'},
-
-        [parseOptions.handleUnknown]: () => {}
-    });
-
-    dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA;
-    mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA;
-    langPath = miscOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
-    outputPath = miscOptions['out-path'] || process.env.HSMUSIC_OUT;
-
-    const writeOneLanguage = miscOptions['lang'];
-
-    {
-        let errored = false;
-        const error = (cond, msg) => {
-            if (cond) {
-                console.error(`\x1b[31;1m${msg}\x1b[0m`);
-                errored = true;
-            }
-        };
-        error(!dataPath,   `Expected --data option or HSMUSIC_DATA to be set`);
-        error(!mediaPath,  `Expected --media option or HSMUSIC_MEDIA to be set`);
-        error(!outputPath, `Expected --out option or HSMUSIC_OUT to be set`);
-        if (errored) {
-            return;
-        }
-    }
-
-    const skipThumbs = miscOptions['skip-thumbs'] ?? false;
-
-    if (skipThumbs) {
-        logInfo`Skipping thumbnail generation.`;
-    } else {
-        logInfo`Begin thumbnail generation... -----+`;
-        const result = await genThumbs(mediaPath, {queueSize, quiet: true});
-        logInfo`Done thumbnail generation! --------+`;
-        if (!result) {
-            return;
-        }
-    }
-
-    const defaultStrings = await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE));
-    if (defaultStrings.error) {
-        logError`Error loading default strings: ${defaultStrings.error}`;
-        return;
-    }
-
-    if (langPath) {
-        const languageDataFiles = await findFiles(langPath, f => path.extname(f) === '.json');
-        const results = await progressPromiseAll(`Reading & processing language files.`, languageDataFiles
-            .map(file => processLanguageFile(file, defaultStrings.json)));
-
-        let error = false;
-        for (const strings of results) {
-            if (strings.error) {
-                logError`Error loading provided strings: ${strings.error}`;
-                error = true;
-            }
-        }
-        if (error) return;
-
-        languages = Object.fromEntries(results.map(strings => [strings.code, strings]));
-    } else {
-        languages = {};
-    }
-
-    if (!languages[defaultStrings.code]) {
-        languages[defaultStrings.code] = defaultStrings;
-    }
-
-    logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
-
-    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) {
-        logInfo`Writing only language ${writeOneLanguage} this run.`;
-    } else {
-        logInfo`Writing all languages.`;
-    }
-
-    wikiInfo = await processWikiInfoFile(path.join(dataPath, WIKI_INFO_FILE));
-    if (wikiInfo.error) {
-        console.log(`\x1b[31;1m${wikiInfo.error}\x1b[0m`);
-        return;
-    }
-
-    // Update languages o8ject with the wiki-specified default language!
-    // This will make page files for that language 8e gener8ted at the root
-    // directory, instead of the language-specific su8directory.
-    if (wikiInfo.defaultLanguage) {
-        if (Object.keys(languages).includes(wikiInfo.defaultLanguage)) {
-            languages.default = languages[wikiInfo.defaultLanguage];
-        } else {
-            logError`Wiki info file specified default language is ${wikiInfo.defaultLanguage}, but no such language file exists!`;
-            if (langPath) {
-                logError`Check if an appropriate file exists in ${langPath}?`;
-            } else {
-                logError`Be sure to specify ${'--lang'} or ${'HSMUSIC_LANG'} with the path to language files.`;
-            }
-            return;
-        }
-    } else {
-        languages.default = defaultStrings;
-    }
-
-    homepageInfo = await processHomepageInfoFile(path.join(dataPath, HOMEPAGE_INFO_FILE));
-
-    if (homepageInfo.error) {
-        console.log(`\x1b[31;1m${homepageInfo.error}\x1b[0m`);
-        return;
-    }
-
-    {
-        const errors = homepageInfo.rows.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-    }
-
-    // 8ut wait, you might say, how do we know which al8um these data files
-    // correspond to???????? You wouldn't dare suggest we parse the actual
-    // paths returned 8y this function, which ought to 8e of effectively
-    // unknown format except for their purpose as reada8le data files!?
-    // To that, I would say, yeah, you're right. Thanks a 8unch, my projection
-    // of "you". We're going to read these files later, and contained within
-    // will 8e the actual directory names that the data correspond to. Yes,
-    // that's redundant in some ways - we COULD just return the directory name
-    // in addition to the data path, and duplicating that name within the file
-    // itself suggests we 8e careful to avoid mismatching it - 8ut doing it
-    // this way lets the data files themselves 8e more porta8le (meaning we
-    // could store them all in one folder, if we wanted, and this program would
-    // still output to the correct al8um directories), and also does make the
-    // function's signature simpler (an array of strings, rather than some kind
-    // of structure containing 8oth data file paths and output directories).
-    // This is o8jectively a good thing, 8ecause it means the function can stay
-    // truer to its name, and have a narrower purpose: it doesn't need to
-    // concern itself with where we *output* files, or whatever other reasons
-    // we might (hypothetically) have for knowing the containing directory.
-    // And, in the strange case where we DO really need to know that info, we
-    // callers CAN use path.dirname to find out that data. 8ut we'll 8e
-    // avoiding that in our code 8ecause, again, we want to avoid assuming the
-    // format of the returned paths here - they're only meant to 8e used for
-    // reading as-is.
-    const albumDataFiles = await findFiles(path.join(dataPath, C.DATA_ALBUM_DIRECTORY));
-
-    // Technically, we could do the data file reading and output writing at the
-    // same time, 8ut that kinda makes the code messy, so I'm not 8othering
-    // with it.
-    albumData = await progressPromiseAll(`Reading & processing album files.`, albumDataFiles.map(processAlbumDataFile));
-
-    {
-        const errors = albumData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-    }
-
-    C.sortByDate(albumData);
-
-    artistData = await processArtistDataFile(path.join(dataPath, ARTIST_DATA_FILE));
-    if (artistData.error) {
-        console.log(`\x1b[31;1m${artistData.error}\x1b[0m`);
-        return;
-    }
-
-    {
-        const errors = artistData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-    }
-
-    artistAliasData = artistData.filter(x => x.alias);
-    artistData = artistData.filter(x => !x.alias);
-
-    trackData = C.getAllTracks(albumData);
-
-    if (wikiInfo.features.flashesAndGames) {
-        flashData = await processFlashDataFile(path.join(dataPath, FLASH_DATA_FILE));
-        if (flashData.error) {
-            console.log(`\x1b[31;1m${flashData.error}\x1b[0m`);
-            return;
-        }
-
-        const errors = flashData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-    }
-
-    flashActData = flashData?.filter(x => x.act8r8k);
-    flashData = flashData?.filter(x => !x.act8r8k);
-
-    artistNames = Array.from(new Set([
-        ...artistData.filter(artist => !artist.alias).map(artist => artist.name),
-        ...[
-            ...albumData.flatMap(album => [
-                ...album.artists || [],
-                ...album.coverArtists || [],
-                ...album.wallpaperArtists || [],
-                ...album.tracks.flatMap(track => [
-                    ...track.artists,
-                    ...track.coverArtists || [],
-                    ...track.contributors || []
-                ])
-            ]),
-            ...(flashData?.flatMap(flash => [
-                ...flash.contributors || []
-            ]) || [])
-        ].map(contribution => contribution.who)
-    ]));
-
-    tagData = await processTagDataFile(path.join(dataPath, TAG_DATA_FILE));
-    if (tagData.error) {
-        console.log(`\x1b[31;1m${tagData.error}\x1b[0m`);
-        return;
-    }
-
-    {
-        const errors = tagData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-    }
-
-    groupData = await processGroupDataFile(path.join(dataPath, GROUP_DATA_FILE));
-    if (groupData.error) {
-        console.log(`\x1b[31;1m${groupData.error}\x1b[0m`);
-        return;
-    }
-
-    {
-        const errors = groupData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-    }
-
-    groupCategoryData = groupData.filter(x => x.isCategory);
-    groupData = groupData.filter(x => x.isGroup);
-
-    staticPageData = await processStaticPageDataFile(path.join(dataPath, STATIC_PAGE_DATA_FILE));
-    if (staticPageData.error) {
-        console.log(`\x1b[31;1m${staticPageData.error}\x1b[0m`);
-        return;
-    }
-
-    {
-        const errors = staticPageData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-    }
-
-    if (wikiInfo.features.news) {
-        newsData = await processNewsDataFile(path.join(dataPath, NEWS_DATA_FILE));
-        if (newsData.error) {
-            console.log(`\x1b[31;1m${newsData.error}\x1b[0m`);
-            return;
-        }
-
-        const errors = newsData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-
-        C.sortByDate(newsData);
-        newsData.reverse();
-    }
-
-    {
-        const tagNames = new Set([...trackData, ...albumData].flatMap(thing => thing.artTags));
-
-        for (let { name, isCW } of tagData) {
-            if (isCW) {
-                name = 'cw: ' + name;
-            }
-            tagNames.delete(name);
-        }
-
-        if (tagNames.size) {
-            for (const name of Array.from(tagNames).sort()) {
-                console.log(`\x1b[33;1m- Missing tag: "${name}"\x1b[0m`);
-            }
-            return;
-        }
-    }
-
-    artistNames.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0);
-
-    justEverythingMan = C.sortByDate([...albumData, ...trackData, ...(flashData || [])]);
-    justEverythingSortedByArtDateMan = C.sortByArtDate(justEverythingMan.slice());
-    // console.log(JSON.stringify(justEverythingSortedByArtDateMan.map(toAnythingMan), null, 2));
-
-    {
-        let buffer = [];
-        const clearBuffer = function() {
-            if (buffer.length) {
-                for (const entry of buffer.slice(0, -1)) {
-                    console.log(`\x1b[2m... ${entry.name} ...\x1b[0m`);
-                }
-                const lastEntry = buffer[buffer.length - 1];
-                console.log(`\x1b[2m... \x1b[0m${lastEntry.name}\x1b[0;2m ...\x1b[0m`);
-                buffer = [];
-            }
-        };
-        const showWhere = (name, color) => {
-            const where = justEverythingMan.filter(thing => [
-                ...thing.coverArtists || [],
-                ...thing.contributors || [],
-                ...thing.artists || []
-            ].some(({ who }) => who === name));
-            for (const thing of where) {
-                console.log(`\x1b[${color}m- ` + (thing.album ? `(\x1b[1m${thing.album.name}\x1b[0;${color}m)` : '') + ` \x1b[1m${thing.name}\x1b[0m`);
-            }
-        };
-        let CR4SH = false;
-        for (let name of artistNames) {
-            const entry = [...artistData, ...artistAliasData].find(entry => entry.name === name || entry.name.toLowerCase() === name.toLowerCase());
-            if (!entry) {
-                clearBuffer();
-                console.log(`\x1b[31mMissing entry for artist "\x1b[1m${name}\x1b[0;31m"\x1b[0m`);
-                showWhere(name, 31);
-                CR4SH = true;
-            } else if (entry.alias) {
-                console.log(`\x1b[33mArtist "\x1b[1m${name}\x1b[0;33m" should be named "\x1b[1m${entry.alias}\x1b[0;33m"\x1b[0m`);
-                showWhere(name, 33);
-                CR4SH = true;
-            } else if (entry.name !== name) {
-                console.log(`\x1b[33mArtist "\x1b[1m${name}\x1b[0;33m" should be named "\x1b[1m${entry.name}\x1b[0;33m"\x1b[0m`);
-                showWhere(name, 33);
-                CR4SH = true;
-            } else {
-                buffer.push(entry);
-                if (buffer.length > 3) {
-                    buffer.shift();
-                }
-            }
-        }
-        if (CR4SH) {
-            return;
-        }
-    }
-
-    {
-        const directories = [];
-        for (const { directory, name } of albumData) {
-            if (directories.includes(directory)) {
-                console.log(`\x1b[31;1mDuplicate album directory "${directory}" (${name})\x1b[0m`);
-                return;
-            }
-            directories.push(directory);
-        }
-    }
-
-    {
-        const directories = [];
-        const where = {};
-        for (const { directory, album } of trackData) {
-            if (directories.includes(directory)) {
-                console.log(`\x1b[31;1mDuplicate track directory "${directory}"\x1b[0m`);
-                console.log(`Shows up in:`);
-                console.log(`- ${album.name}`);
-                console.log(`- ${where[directory].name}`);
-                return;
-            }
-            directories.push(directory);
-            where[directory] = album;
-        }
-    }
-
-    {
-        const artists = [];
-        const artistsLC = [];
-        for (const name of artistNames) {
-            if (!artists.includes(name) && artistsLC.includes(name.toLowerCase())) {
-                const other = artists.find(oth => oth.toLowerCase() === name.toLowerCase());
-                console.log(`\x1b[31;1mMiscapitalized artist name: ${name}, ${other}\x1b[0m`);
-                return;
-            }
-            artists.push(name);
-            artistsLC.push(name.toLowerCase());
-        }
-    }
-
-    {
-        for (const { references, name, album } of trackData) {
-            for (const ref of references) {
-                if (!search.track(ref)) {
-                    logWarn`Track not found "${ref}" in ${name} (${album.name})`;
-                }
-            }
-        }
-    }
-
-    contributionData = Array.from(new Set([
-        ...trackData.flatMap(track => [...track.artists || [], ...track.contributors || [], ...track.coverArtists || []]),
-        ...albumData.flatMap(album => [...album.artists || [], ...album.coverArtists || [], ...album.wallpaperArtists || [], ...album.bannerArtists || []]),
-        ...(flashData?.flatMap(flash => [...flash.contributors || []]) || [])
-    ]));
-
-    // Now that we have all the data, resolve references all 8efore actually
-    // gener8ting any of the pages, 8ecause page gener8tion is going to involve
-    // accessing these references a lot, and there's no reason to resolve them
-    // more than once. (We 8uild a few additional links that can't 8e cre8ted
-    // at initial data processing time here too.)
-
-    const filterNullArray = (parent, key) => {
-        for (const obj of parent) {
-            const array = obj[key];
-            for (let i = 0; i < array.length; i++) {
-                if (!array[i]) {
-                    const prev = array[i - 1] && array[i - 1].name;
-                    const next = array[i + 1] && array[i + 1].name;
-                    logWarn`Unexpected null in ${obj.name} (${obj.what}) (array key ${key} - prev: ${prev}, next: ${next})`;
-                }
-            }
-            array.splice(0, array.length, ...array.filter(Boolean));
-        }
-    };
-
-    const filterNullValue = (parent, key) => {
-        parent.splice(0, parent.length, ...parent.filter(obj => {
-            if (!obj[key]) {
-                logWarn`Unexpected null in ${obj.name} (value key ${key})`;
-            }
-        }));
-    };
-
-    trackData.forEach(track => mapInPlace(track.references, search.track));
-    trackData.forEach(track => track.aka = search.track(track.aka));
-    trackData.forEach(track => mapInPlace(track.artTags, search.tag));
-    albumData.forEach(album => mapInPlace(album.groups, search.group));
-    albumData.forEach(album => mapInPlace(album.artTags, search.tag));
-    artistAliasData.forEach(artist => artist.alias = search.artist(artist.alias));
-    contributionData.forEach(contrib => contrib.who = search.artist(contrib.who));
-
-    filterNullArray(trackData, 'references');
-    filterNullArray(trackData, 'artTags');
-    filterNullArray(albumData, 'groups');
-    filterNullArray(albumData, 'artTags');
-    filterNullValue(artistAliasData, 'alias');
-    filterNullValue(contributionData, 'who');
-
-    trackData.forEach(track1 => track1.referencedBy = trackData.filter(track2 => track2.references.includes(track1)));
-    groupData.forEach(group => group.albums = albumData.filter(album => album.groups.includes(group)));
-    tagData.forEach(tag => tag.things = C.sortByArtDate([...albumData, ...trackData]).filter(thing => thing.artTags.includes(tag)));
-
-    groupData.forEach(group => group.category = groupCategoryData.find(x => x.name === group.category));
-    groupCategoryData.forEach(category => category.groups = groupData.filter(x => x.category === category));
-
-    trackData.forEach(track => track.otherReleases = [
-        track.aka,
-        ...trackData.filter(({ aka }) => aka === track || (track.aka && aka === track.aka)),
-    ].filter(x => x && x !== track));
-
-    if (wikiInfo.features.flashesAndGames) {
-        flashData.forEach(flash => mapInPlace(flash.tracks, search.track));
-        flashData.forEach(flash => flash.act = flashActData.find(act => act.name === flash.act));
-        flashActData.forEach(act => act.flashes = flashData.filter(flash => flash.act === act));
-
-        filterNullArray(flashData, 'tracks');
-
-        trackData.forEach(track => track.flashes = flashData.filter(flash => flash.tracks.includes(track)));
-    }
-
-    artistData.forEach(artist => {
-        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(trackData, 'artists'),
-            asCommentator: filterCommentary(trackData),
-            asContributor: filterProp(trackData, 'contributors'),
-            asCoverArtist: filterProp(trackData, 'coverArtists'),
-            asAny: trackData.filter(track => (
-                [...track.artists, ...track.contributors, ...track.coverArtists || []].some(({ who }) => who === artist)
-            ))
-        };
-        artist.albums = {
-            asArtist: filterProp(albumData, 'artists'),
-            asCommentator: filterCommentary(albumData),
-            asCoverArtist: filterProp(albumData, 'coverArtists'),
-            asWallpaperArtist: filterProp(albumData, 'wallpaperArtists'),
-            asBannerArtist: filterProp(albumData, 'bannerArtists')
-        };
-        if (wikiInfo.features.flashesAndGames) {
-            artist.flashes = {
-                asContributor: filterProp(flashData, 'contributors')
-            };
-        }
-    });
-
-    officialAlbumData = albumData.filter(album => album.groups.some(group => group.directory === C.OFFICIAL_GROUP_DIRECTORY));
-    fandomAlbumData = albumData.filter(album => album.groups.every(group => group.directory !== C.OFFICIAL_GROUP_DIRECTORY));
-
-    // 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
-    // disa8les the queue feature altogether.
-    queueSize = +(miscOptions['queue-size'] ?? 0);
-
-    // NOT for ena8ling or disa8ling specific features of the site!
-    // This is only in charge of what general groups of files to 8uild.
-    // They're here to make development quicker when you're only working
-    // on some particular area(s) of the site rather than making changes
-    // across all of them.
-    const writeFlags = await parseOptions(process.argv.slice(2), {
-        all: {type: 'flag'}, // Defaults to true if none 8elow specified.
-
-        album: {type: 'flag'},
-        artist: {type: 'flag'},
-        commentary: {type: 'flag'},
-        flash: {type: 'flag'},
-        group: {type: 'flag'},
-        list: {type: 'flag'},
-        misc: {type: 'flag'},
-        news: {type: 'flag'},
-        static: {type: 'flag'},
-        tag: {type: 'flag'},
-        track: {type: 'flag'},
-
-        [parseOptions.handleUnknown]: () => {}
-    });
-
-    const writeAll = !Object.keys(writeFlags).length || writeFlags.all;
-
-    logInfo`Writing site pages: ${writeAll ? 'all' : Object.keys(writeFlags).join(', ')}`;
-
-    await writeSymlinks();
-    await writeSharedFilesAndPages({strings: defaultStrings});
-
-    const buildDictionary = {
-        misc: writeMiscellaneousPages,
-        news: writeNewsPages,
-        list: writeListingPages,
-        tag: writeTagPages,
-        commentary: writeCommentaryPages,
-        static: writeStaticPages,
-        group: writeGroupPages,
-        album: writeAlbumPages,
-        track: writeTrackPages,
-        artist: writeArtistPages,
-        flash: writeFlashPages
-    };
-
-    const buildSteps = (writeAll
-        ? Object.values(buildDictionary)
-        : (Object.entries(buildDictionary)
-            .filter(([ flag ]) => writeFlags[flag])
-            .map(([ flag, fn ]) => fn)));
-
-    // *NB: While what's 8elow is 8asically still true in principle, the
-    //      format is QUITE DIFFERENT than what's descri8ed here! There
-    //      will 8e actual document8tion on like, what the return format
-    //      looks like soon, once we implement a 8unch of other pages and
-    //      are certain what they actually, uh, will look like, in the end.*
-    //
-    // The writeThingPages functions don't actually immediately do any file
-    // writing themselves; an initial call will only gather the relevant data
-    // which is *then* used for writing. So the return value is a function
-    // (or an array of functions) which expects {writePage, strings}, and
-    // *that's* what we call after -- multiple times, once for each language.
-    let writes;
-    {
-        let error = false;
-
-        writes = buildSteps.flatMap(fn => {
-            const fns = fn() || [];
-
-            // Do a quick valid8tion! If one of the writeThingPages functions go
-            // wrong, this will stall out early and tell us which did.
-            if (!Array.isArray(fns)) {
-                logError`${fn.name} didn't return an array!`;
-                error = true;
-            } else if (fns.every(entry => Array.isArray(entry))) {
-                if (!(
-                    fns.every(entry => entry.every(obj => typeof obj === 'object')) &&
-                    fns.every(entry => entry.every(obj => {
-                        const result = validateWriteObject(obj);
-                        if (result.error) {
-                            logError`Validating write object failed: ${result.error}`;
-                            return false;
-                        } else {
-                            return true;
-                        }
-                    }))
-                )) {
-                    logError`${fn.name} uses updated format, but entries are invalid!`;
-                    error = true;
-                }
-
-                return fns.flatMap(writes => writes);
-            } else if (fns.some(fn => typeof fn !== 'function')) {
-                logError`${fn.name} didn't return all functions or all arrays!`;
-                error = true;
-            }
-
-            return fns;
-        });
-
-        if (error) {
-            return;
-        }
-
-        // The modern(TM) return format for each writeThingPages function is an
-        // array of arrays, each of which's items are 8ig Complicated Objects
-        // that 8asically look like {type, path, content}. 8ut surprise, these
-        // aren't actually implemented in most places yet! So, we transform
-        // stuff in the old format here. 'Scept keep in mind, the OLD FORMAT
-        // doesn't really give us most of the info we want for Cool And Modern
-        // Reasons, so they're going into a fancy {type: 'legacy'} sort of
-        // o8ject, with a plain {write} property for, uh, the writing stuff,
-        // same as usual.
-        //
-        // I promise this document8tion will get 8etter when we make progress
-        // actually moving old pages over. Also it'll 8e hecks of less work
-        // than previous restructures, don't worry.
-        writes = writes.map(entry =>
-            typeof entry === 'object' ? entry :
-            typeof entry === 'function' ? {type: 'legacy', write: entry} :
-            {type: 'wut', entry});
-
-        const wut = writes.filter(({ type }) => type === 'wut');
-        if (wut.length) {
-            // Oh g*d oh h*ck.
-            logError`Uhhhhh writes contains something 8esides o8jects and functions?`;
-            logError`Definitely a 8ug!`;
-            console.log(wut);
-            return;
-        }
-    }
-
-    const localizedWrites = writes.filter(({ type }) => type === 'page' || type === 'legacy');
-    const dataWrites = writes.filter(({ type }) => type === 'data');
-
-    await progressPromiseAll(`Writing data files shared across languages.`, queue(
-        // TODO: This only supports one <>-style argument.
-        dataWrites.map(({path, data}) => () => writeData(path[0], path[1], data())),
-        queueSize
-    ));
-
-    await wrapLanguages(async ({strings, ...opts}, i, entries) => {
-        console.log(`\x1b[34;1m${
-            (`[${i + 1}/${entries.length}] ${strings.code} (-> /${opts.baseDirectory}) `
-                .padEnd(60, '-'))
-        }\x1b[0m`);
-        await progressPromiseAll(`Writing ${strings.code}`, queue(
-            localizedWrites.map(({type, ...props}) => () => {
-                switch (type) {
-                    case 'legacy': {
-                        const { write } = props;
-                        return write({strings, ...opts});
-                    }
-                    case 'page': {
-                        const { path, page } = props;
-                        // TODO: This only supports one <>-style argument.
-                        return opts.writePage(path[0], path[1], ({to}) => page({strings, to}));
-                    }
-                }
-            }),
-            queueSize
-        ));
-    }, writeOneLanguage);
-
-    decorateTime.displayTime();
-
-    // The single most important step.
-    logInfo`Written!`;
-}
-
-main().catch(error => console.error(error));
diff --git a/upd8/strings-default.json b/upd8/strings-default.json
deleted file mode 100644
index 7a948d6..0000000
--- a/upd8/strings-default.json
+++ /dev/null
@@ -1,305 +0,0 @@
-{
-    "meta.languageCode": "en",
-    "count.tracks": "{TRACKS}",
-    "count.tracks.withUnit.zero": "",
-    "count.tracks.withUnit.one": "{TRACKS} track",
-    "count.tracks.withUnit.two": "",
-    "count.tracks.withUnit.few": "",
-    "count.tracks.withUnit.many": "",
-    "count.tracks.withUnit.other": "{TRACKS} tracks",
-    "count.albums": "{ALBUMS}",
-    "count.albums.withUnit.zero": "",
-    "count.albums.withUnit.one": "{ALBUMS} album",
-    "count.albums.withUnit.two": "",
-    "count.albums.withUnit.two": "",
-    "count.albums.withUnit.few": "",
-    "count.albums.withUnit.many": "",
-    "count.albums.withUnit.other": "{ALBUMS} albums",
-    "count.commentaryEntries": "{ENTRIES}",
-    "count.commentaryEntries.withUnit.zero": "",
-    "count.commentaryEntries.withUnit.one": "{ENTRIES} entry",
-    "count.commentaryEntries.withUnit.two": "",
-    "count.commentaryEntries.withUnit.few": "",
-    "count.commentaryEntries.withUnit.many": "",
-    "count.commentaryEntries.withUnit.other": "{ENTRIES} entries",
-    "count.contributions": "{CONTRIBUTIONS}",
-    "count.contributions.withUnit.zero": "",
-    "count.contributions.withUnit.one": "{CONTRIBUTIONS} contribution",
-    "count.contributions.withUnit.two": "",
-    "count.contributions.withUnit.few": "",
-    "count.contributions.withUnit.many": "",
-    "count.contributions.withUnit.other": "{CONTRIBUTIONS} contributions",
-    "count.coverArts": "{COVER_ARTS}",
-    "count.coverArts.withUnit.zero": "",
-    "count.coverArts.withUnit.one": "{COVER_ARTS} cover art",
-    "count.coverArts.withUnit.two": "",
-    "count.coverArts.withUnit.few": "",
-    "count.coverArts.withUnit.many": "",
-    "count.coverArts.withUnit.other": "{COVER_ARTS} cover arts",
-    "count.timesReferenced": "{TIMES_REFERENCED}",
-    "count.timesReferenced.withUnit.zero": "",
-    "count.timesReferenced.withUnit.one": "{TIMES_REFERENCED} time referenced",
-    "count.timesReferenced.withUnit.two": "",
-    "count.timesReferenced.withUnit.few": "",
-    "count.timesReferenced.withUnit.many": "",
-    "count.timesReferenced.withUnit.other": "{TIMES_REFERENCED} times referenced",
-    "count.words": "{WORDS}",
-    "count.words.thousand": "{WORDS}k",
-    "count.words.withUnit.zero": "",
-    "count.words.withUnit.one": "{WORDS} word",
-    "count.words.withUnit.two": "",
-    "count.words.withUnit.few": "",
-    "count.words.withUnit.many": "",
-    "count.words.withUnit.other": "{WORDS} words",
-    "count.timesUsed": "{TIMES_USED}",
-    "count.timesUsed.withUnit.zero": "",
-    "count.timesUsed.withUnit.one": "used {TIMES_USED} time",
-    "count.timesUsed.withUnit.two": "",
-    "count.timesUsed.withUnit.few": "",
-    "count.timesUsed.withUnit.many": "",
-    "count.timesUsed.withUnit.other": "used {TIMES_USED} times",
-    "count.index.zero": "",
-    "count.index.one": "{INDEX}st",
-    "count.index.two": "{INDEX}nd",
-    "count.index.few": "{INDEX}rd",
-    "count.index.many": "",
-    "count.index.other": "{INDEX}th",
-    "count.duration.hours": "{HOURS}:{MINUTES}:{SECONDS}",
-    "count.duration.hours.withUnit": "{HOURS}:{MINUTES}:{SECONDS} hours",
-    "count.duration.minutes": "{MINUTES}:{SECONDS}",
-    "count.duration.minutes.withUnit": "{MINUTES}:{SECONDS} minutes",
-    "count.duration.approximate": "~{DURATION}",
-    "count.duration.missing": "_:__",
-    "releaseInfo.by": "By {ARTISTS}.",
-    "releaseInfo.from": "From {ALBUM}.",
-    "releaseInfo.coverArtBy": "Cover art by {ARTISTS}.",
-    "releaseInfo.wallpaperArtBy": "Wallpaper art by {ARTISTS}.",
-    "releaseInfo.bannerArtBy": "Banner art by {ARTISTS}.",
-    "releaseInfo.released": "Released {DATE}.",
-    "releaseInfo.artReleased": "Art released {DATE}.",
-    "releaseInfo.addedToWiki": "Added to wiki {DATE}.",
-    "releaseInfo.duration": "Duration: {DURATION}.",
-    "releaseInfo.viewCommentary": "View {LINK}!",
-    "releaseInfo.viewCommentary.link": "commentary page",
-    "releaseInfo.listenOn": "Listen on {LINKS}.",
-    "releaseInfo.listenOn.noLinks": "This track has no URLs at which it can be listened.",
-    "releaseInfo.visitOn": "Visit on {LINKS}.",
-    "releaseInfo.playOn": "Play on {LINKS}.",
-    "releaseInfo.alsoReleasedAs": "Also released as:",
-    "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})",
-    "releaseInfo.contributors": "Contributors:",
-    "releaseInfo.tracksReferenced": "Tracks that {TRACK} references:",
-    "releaseInfo.tracksThatReference": "Tracks that reference {TRACK}:",
-    "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:",
-    "releaseInfo.flashesThatFeature.item": "{FLASH}",
-    "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})",
-    "releaseInfo.lyrics": "Lyrics:",
-    "releaseInfo.artistCommentary": "Artist commentary:",
-    "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!",
-    "releaseInfo.artTags": "Tags:",
-    "releaseInfo.note": "Note:",
-    "trackList.group": "{GROUP} ({DURATION}):",
-    "trackList.item.withDuration": "({DURATION}) {TRACK}",
-    "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}",
-    "trackList.item.withArtists": "{TRACK} {BY}",
-    "trackList.item.withArtists.by": "by {ARTISTS}",
-    "trackList.item.rerelease": "{TRACK} (re-release)",
-    "misc.alt.albumCover": "album cover",
-    "misc.alt.albumBanner": "album banner",
-    "misc.alt.trackCover": "track cover",
-    "misc.alt.artistAvatar": "artist avatar",
-    "misc.alt.flashArt": "flash art",
-    "misc.chronology.seeArtistPages": "(See artist pages for chronology info!)",
-    "misc.chronology.heading.coverArt": "{INDEX} cover art by {ARTIST}",
-    "misc.chronology.heading.flash": "{INDEX} flash/game by {ARTIST}",
-    "misc.chronology.heading.track": "{INDEX} track by {ARTIST}",
-    "misc.external.domain": "External ({DOMAIN})",
-    "misc.external.bandcamp": "Bandcamp",
-    "misc.external.bandcamp.domain": "Bandcamp ({DOMAIN})",
-    "misc.external.deviantart": "DeviantArt",
-    "misc.external.instagram": "Instagram",
-    "misc.external.mastodon": "Mastodon",
-    "misc.external.mastodon.domain": "Mastodon ({DOMAIN})",
-    "misc.external.patreon": "Patreon",
-    "misc.external.poetryFoundation": "Poetry Foundation",
-    "misc.external.soundcloud": "SoundCloud",
-    "misc.external.tumblr": "Tumblr",
-    "misc.external.twitter": "Twitter",
-    "misc.external.wikipedia": "Wikipedia",
-    "misc.external.youtube": "YouTube",
-    "misc.external.youtube.playlist": "YouTube (playlist)",
-    "misc.external.youtube.fullAlbum": "YouTube (full album)",
-    "misc.external.flash.bgreco": "{LINK} (HQ Audio)",
-    "misc.external.flash.homestuck.page": "{LINK} (page {PAGE})",
-    "misc.external.flash.homestuck.secret": "{LINK} (secret page)",
-    "misc.external.flash.youtube": "{LINK} (on any device)",
-    "misc.nav.previous": "Previous",
-    "misc.nav.next": "Next",
-    "misc.nav.info": "Info",
-    "misc.nav.gallery": "Gallery",
-    "misc.skippers.skipToContent": "Skip to content",
-    "misc.skippers.skipToSidebar": "Skip to sidebar",
-    "misc.skippers.skipToSidebar.left": "Skip to sidebar (left)",
-    "misc.skippers.skipToSidebar.right": "Skip to sidebar (right)",
-    "misc.skippers.skipToFooter": "Skip to footer",
-    "misc.jumpTo": "Jump to:",
-    "misc.jumpTo.withLinks": "Jump to: {LINKS}.",
-    "misc.contentWarnings": "cw: {WARNINGS}",
-    "misc.contentWarnings.reveal": "click to show",
-    "misc.albumGridDetails": "({TRACKS}, {TIME})",
-    "homepage.title": "{TITLE}",
-    "homepage.news.title": "News",
-    "homepage.news.entry.viewRest": "(View rest of entry!)",
-    "albumSidebar.trackList.group": "{GROUP}",
-    "albumSidebar.trackList.group.withRange": "{GROUP} ({RANGE})",
-    "albumSidebar.trackList.item": "{TRACK}",
-    "albumSidebar.groupBox.title": "{GROUP}",
-    "albumSidebar.groupBox.next": "Next: {ALBUM}",
-    "albumSidebar.groupBox.previous": "Previous: {ALBUM}",
-    "albumPage.title": "{ALBUM}",
-    "albumPage.nav.album": "{ALBUM}",
-    "albumPage.nav.randomTrack": "Random Track",
-    "albumCommentaryPage.title": "{ALBUM} - Commentary",
-    "albumCommentaryPage.infoLine": "{WORDS} across {ENTRIES}.",
-    "albumCommentaryPage.nav.album": "Album: {ALBUM}",
-    "albumCommentaryPage.entry.title.albumCommentary": "Album commentary",
-    "albumCommentaryPage.entry.title.trackCommentary": "{TRACK}",
-    "artistPage.title": "{ARTIST}",
-    "artistPage.creditList.album": "{ALBUM}",
-    "artistPage.creditList.album.withDate": "{ALBUM} ({DATE})",
-    "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})",
-    "artistPage.creditList.flashAct": "{ACT}",
-    "artistPage.creditList.flashAct.withDateRange": "{ACT} ({DATE_RANGE})",
-    "artistPage.creditList.entry.track": "{TRACK}",
-    "artistPage.creditList.entry.track.withDuration": "({DURATION}) {TRACK}",
-    "artistPage.creditList.entry.album.coverArt": "(cover art)",
-    "artistPage.creditList.entry.album.wallpaperArt": "(wallpaper art)",
-    "artistPage.creditList.entry.album.bannerArt": "(banner art)",
-    "artistPage.creditList.entry.album.commentary": "(album commentary)",
-    "artistPage.creditList.entry.flash": "{FLASH}",
-    "artistPage.creditList.entry.rerelease": "{ENTRY} (re-release)",
-    "artistPage.creditList.entry.withContribution": "{ENTRY} ({CONTRIBUTION})",
-    "artistPage.creditList.entry.withArtists": "{ENTRY} (with {ARTISTS})",
-    "artistPage.creditList.entry.withArtists.withContribution": "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})",
-    "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.",
-    "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}",
-    "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}",
-    "artistPage.groupsLine.item": "{GROUP} ({CONTRIBUTIONS})",
-    "artistPage.trackList.title": "Tracks",
-    "artistPage.unreleasedTrackList.title": "Unreleased Tracks",
-    "artistPage.artList.title": "Art",
-    "artistPage.flashList.title": "Flashes & Games",
-    "artistPage.commentaryList.title": "Commentary",
-    "artistPage.viewArtGallery": "View {LINK}!",
-    "artistPage.viewArtGallery.orBrowseList": "View {LINK}! Or browse the list:",
-    "artistPage.viewArtGallery.link": "art gallery",
-    "artistPage.nav.artist": "Artist: {ARTIST}",
-    "artistGalleryPage.title": "{ARTIST} - Gallery",
-    "artistGalleryPage.infoLine": "Contributed to {COVER_ARTS}.",
-    "commentaryIndex.title": "Commentary",
-    "commentaryIndex.infoLine": "{WORDS} across {ENTRIES}, in all.",
-    "commentaryIndex.albumList.title": "Choose an album:",
-    "commentaryIndex.albumList.item": "{ALBUM} ({WORDS} across {ENTRIES})",
-    "flashIndex.title": "Flashes & Games",
-    "flashPage.title": "{FLASH}",
-    "flashPage.nav.flash": "{FLASH}",
-    "groupSidebar.title": "Groups",
-    "groupSidebar.groupList.category": "{CATEGORY}",
-    "groupSidebar.groupList.item": "{GROUP}",
-    "groupPage.nav.group": "Group: {GROUP}",
-    "groupInfoPage.title": "{GROUP}",
-    "groupInfoPage.viewAlbumGallery": "View {LINK}! Or browse the list:",
-    "groupInfoPage.viewAlbumGallery.link": "album gallery",
-    "groupInfoPage.albumList.title": "Albums",
-    "groupInfoPage.albumList.item": "({YEAR}) {ALBUM}",
-    "groupGalleryPage.title": "{GROUP} - Gallery",
-    "groupGalleryPage.infoLine": "{TRACKS} across {ALBUMS}, totaling {TIME}.",
-    "listingIndex.title": "Listings",
-    "listingIndex.infoLine": "{WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.",
-    "listingIndex.exploreList": "Feel free to explore any of the listings linked below and in the sidebar!",
-    "listingPage.listAlbums.byName.title": "Albums - by Name",
-    "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})",
-    "listingPage.listAlbums.byTracks.title": "Albums - by Tracks",
-    "listingPage.listAlbums.byTracks.item": "{ALBUM} ({TRACKS})",
-    "listingPage.listAlbums.byDuration.title": "Albums - by Duration",
-    "listingPage.listAlbums.byDuration.item": "{ALBUM} ({DURATION})",
-    "listingPage.listAlbums.byDate.title": "Albums - by Date",
-    "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})",
-    "listingPage.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki",
-    "listingPage.listAlbums.byDateAdded.date": "{DATE}",
-    "listingPage.listAlbums.byDateAdded.album": "{ALBUM}",
-    "listingPage.listArtists.byName.title": "Artists - by Name",
-    "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})",
-    "listingPage.listArtists.byContribs.title": "Artists - by Contributions",
-    "listingPage.listArtists.byContribs.item": "{ARTIST} ({CONTRIBUTIONS})",
-    "listingPage.listArtists.byCommentary.title": "Artists - by Commentary Entries",
-    "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})",
-    "listingPage.listArtists.byDuration.title": "Artists - by Duration",
-    "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})",
-    "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution",
-    "listingPage.listArtists.byLatest.item": "{ARTIST} ({DATE})",
-    "listingPage.listGroups.byName.title": "Groups - by Name",
-    "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})",
-    "listingPage.listGroups.byName.item.gallery": "Gallery",
-    "listingPage.listGroups.byCategory.title": "Groups - by Category",
-    "listingPage.listGroups.byCategory.category": "{CATEGORY}",
-    "listingPage.listGroups.byCategory.group": "{GROUP} ({GALLERY})",
-    "listingPage.listGroups.byCategory.group.gallery": "Gallery",
-    "listingPage.listGroups.byAlbums.title": "Groups - by Albums",
-    "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})",
-    "listingPage.listGroups.byTracks.title": "Groups - by Tracks",
-    "listingPage.listGroups.byTracks.item": "{GROUP} ({TRACKS})",
-    "listingPage.listGroups.byDuration.title": "Groups - by Duration",
-    "listingPage.listGroups.byDuration.item": "{GROUP} ({DURATION})",
-    "listingPage.listGroups.byLatest.title": "Groups - by Latest Album",
-    "listingPage.listGroups.byLatest.item": "{GROUP} ({DATE})",
-    "listingPage.listTracks.byName.title": "Tracks - by Name",
-    "listingPage.listTracks.byName.item": "{TRACK}",
-    "listingPage.listTracks.byAlbum.title": "Tracks - by Album",
-    "listingPage.listTracks.byAlbum.album": "{ALBUM}",
-    "listingPage.listTracks.byAlbum.track": "{TRACK}",
-    "listingPage.listTracks.byDate.title": "Tracks - by Date",
-    "listingPage.listTracks.byDate.album": "{ALBUM} ({DATE})",
-    "listingPage.listTracks.byDate.track": "{TRACK}",
-    "listingPage.listTracks.byDate.track.rerelease": "{TRACK} (re-release)",
-    "listingPage.listTracks.byDuration.title": "Tracks - by Duration",
-    "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})",
-    "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)",
-    "listingPage.listTracks.byDurationInAlbum.album": "{ALBUM}",
-    "listingPage.listTracks.byDurationInAlbum.track": "{TRACK} ({DURATION})",
-    "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced",
-    "listingPage.listTracks.byTimesReferenced.item": "{TRACK} ({TIMES_REFERENCED})",
-    "listingPage.listTracks.inFlashes.byAlbum.title": "Tracks - in Flashes & Games (by Album)",
-    "listingPage.listTracks.inFlashes.byAlbum.album": "{ALBUM} ({DATE})",
-    "listingPage.listTracks.inFlashes.byAlbum.track": "{TRACK} (in {FLASHES})",
-    "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)",
-    "listingPage.listTracks.inFlashes.byFlash.flash": "{FLASH} ({DATE})",
-    "listingPage.listTracks.inFlashes.byFlash.track": "{TRACK} (from {ALBUM})",
-    "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics",
-    "listingPage.listTracks.withLyrics.album": "{ALBUM} ({DATE})",
-    "listingPage.listTracks.withLyrics.track": "{TRACK}",
-    "listingPage.listTags.byName.title": "Tags - by Name",
-    "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})",
-    "listingPage.listTags.byUses.title": "Tags - by Uses",
-    "listingPage.listTags.byUses.item": "{TAG} ({TIMES_USED})",
-    "listingPage.misc.trackContributors": "Track Contributors",
-    "listingPage.misc.artContributors": "Art Contributors",
-    "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors",
-    "newsIndex.title": "News",
-    "newsIndex.entry.viewRest": "(View rest of entry!)",
-    "newsEntryPage.title": "{ENTRY}",
-    "newsEntryPage.published": "(Published {DATE}.)",
-    "newsEntryPage.nav.news": "News",
-    "newsEntryPage.nav.entry": "{DATE}: {ENTRY}",
-    "redirectPage.title": "Moved to {TITLE}",
-    "redirectPage.infoLine": "This page has been moved to {TARGET}.",
-    "tagPage.title": "{TAG}",
-    "tagPage.infoLine": "Appears in {COVER_ARTS}.",
-    "tagPage.nav.tag": "Tag: {TAG}",
-    "trackPage.title": "{TRACK}",
-    "trackPage.referenceList.fandom": "Fandom:",
-    "trackPage.referenceList.official": "Official:",
-    "trackPage.nav.track": "{TRACK}",
-    "trackPage.nav.track.withNumber": "{NUMBER}. {TRACK}",
-    "trackPage.nav.random": "Random"
-}
diff --git a/upd8/util.js b/upd8/util.js
deleted file mode 100644
index 4c4186f..0000000
--- a/upd8/util.js
+++ /dev/null
@@ -1,423 +0,0 @@
-// This is used by upd8.js! It's part of the 8ackend. Read the notes there if
-// you're curious.
-//
-// Friendly(!) disclaimer: these utility functions haven't 8een tested all that
-// much. Do not assume it will do exactly what you want it to do in all cases.
-// It will likely only do exactly what I want it to, and only in the cases I
-// decided were relevant enough to 8other handling.
-
-'use strict';
-
-// Apparently JavaScript doesn't come with a function to split an array into
-// chunks! Weird. Anyway, this is an awesome place to use a generator, even
-// though we don't really make use of the 8enefits of generators any time we
-// actually use this. 8ut it's still awesome, 8ecause I say so.
-module.exports.splitArray = function*(array, fn) {
-    let lastIndex = 0;
-    while (lastIndex < array.length) {
-        let nextIndex = array.findIndex((item, index) => index >= lastIndex && fn(item));
-        if (nextIndex === -1) {
-            nextIndex = array.length;
-        }
-        yield array.slice(lastIndex, nextIndex);
-        // Plus one because we don't want to include the dividing line in the
-        // next array we yield.
-        lastIndex = nextIndex + 1;
-    }
-};
-
-// This function's name is a joke. Jokes! Hahahahahahahaha. Funny.
-module.exports.joinNoOxford = function(array, plural = 'and') {
-    array = array.filter(Boolean);
-
-    if (array.length === 0) {
-        // ????????
-        return '';
-    }
-
-    if (array.length === 1) {
-        return array[0];
-    }
-
-    if (array.length === 2) {
-        return `${array[0]} ${plural} ${array[1]}`;
-    }
-
-    return `${array.slice(0, -1).join(', ')} ${plural} ${array[array.length - 1]}`;
-};
-
-module.exports.progressPromiseAll = function (msgOrMsgFn, array) {
-    if (!array.length) {
-        return Promise.resolve([]);
-    }
-
-    const msgFn = (typeof msgOrMsgFn === 'function'
-        ? msgOrMsgFn
-        : () => msgOrMsgFn);
-
-    let done = 0, total = array.length;
-    process.stdout.write(`\r${msgFn()} [0/${total}]`);
-    const start = Date.now();
-    return Promise.all(array.map(promise => promise.then(val => {
-        done++;
-        // const pc = `${done}/${total}`;
-        const pc = (Math.round(done / total * 1000) / 10 + '%').padEnd('99.9%'.length, ' ');
-        if (done === total) {
-            const time = Date.now() - start;
-            process.stdout.write(`\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`)
-        } else {
-            process.stdout.write(`\r${msgFn()} [${pc}] `);
-        }
-        return val;
-    })));
-};
-
-module.exports.queue = function (array, max = 50) {
-    if (max === 0) {
-        return array.map(fn => fn());
-    }
-
-    const begin = [];
-    let current = 0;
-    const ret = array.map(fn => new Promise((resolve, reject) => {
-        begin.push(() => {
-            current++;
-            Promise.resolve(fn()).then(value => {
-                current--;
-                if (current < max && begin.length) {
-                    begin.shift()();
-                }
-                resolve(value);
-            }, reject);
-        });
-    }));
-
-    for (let i = 0; i < max && begin.length; i++) {
-        begin.shift()();
-    }
-
-    return ret;
-};
-
-module.exports.delay = ms => new Promise(res => setTimeout(res, ms));
-
-module.exports.th = function (n) {
-    if (n % 10 === 1 && n !== 11) {
-        return n + 'st';
-    } else if (n % 10 === 2 && n !== 12) {
-        return n + 'nd';
-    } else if (n % 10 === 3 && n !== 13) {
-        return n + 'rd';
-    } else {
-        return n + 'th';
-    }
-};
-
-// My function names just keep getting 8etter.
-module.exports.s = function (n, word) {
-    return `${n} ${word}` + (n === 1 ? '' : 's');
-};
-
-// Hey, did you know I apparently put a space 8efore the parameters in function
-// names? 8ut only in function expressions, not declar8tions? I mean, I guess
-// you did. You're pro8a8ly more familiar with my code than I am 8y this
-// point. I haven't messed with any of this code in ages. Yay!!!!!!!!
-//
-// This function only does anything on o8jects you're going to 8e reusing.
-// Argua8ly I could use a WeakMap here, 8ut since the o8ject needs to 8e
-// reused to 8e useful anyway, I just store the result with a symbol.
-// Sorry if it's 8een frozen I guess??
-module.exports.cacheOneArg = function (fn) {
-    const symbol = Symbol('Cache');
-    return arg => {
-        if (!arg[symbol]) {
-            arg[symbol] = fn(arg);
-        }
-        return arg[symbol];
-    };
-};
-
-const decorateTime = function (functionToBeWrapped) {
-    const fn = function(...args) {
-        const start = Date.now();
-        const ret = functionToBeWrapped(...args);
-        const end = Date.now();
-        fn.timeSpent += end - start;
-        fn.timesCalled++;
-        return ret;
-    };
-
-    fn.wrappedName = functionToBeWrapped.name;
-    fn.timeSpent = 0;
-    fn.timesCalled = 0;
-    fn.displayTime = function() {
-        const averageTime = fn.timeSpent / fn.timesCalled;
-        console.log(`\x1b[1m${fn.wrappedName}(...):\x1b[0m ${fn.timeSpent} ms / ${fn.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`);
-    };
-
-    decorateTime.decoratedFunctions.push(fn);
-
-    return fn;
-};
-
-decorateTime.decoratedFunctions = [];
-decorateTime.displayTime = function() {
-    if (decorateTime.decoratedFunctions.length) {
-        console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m');
-        for (const fn of decorateTime.decoratedFunctions) {
-            fn.displayTime();
-        }
-    }
-};
-
-module.exports.decorateTime = decorateTime;
-
-// Stolen as #@CK from mtui!
-const parseOptions = async function(options, optionDescriptorMap) {
-    // This function is sorely lacking in comments, but the basic usage is
-    // as such:
-    //
-    // options is the array of options you want to process;
-    // optionDescriptorMap is a mapping of option names to objects that describe
-    // the expected value for their corresponding options.
-    // Returned is a mapping of any specified option names to their values, or
-    // a process.exit(1) and error message if there were any issues.
-    //
-    // Here are examples of optionDescriptorMap to cover all the things you can
-    // do with it:
-    //
-    // optionDescriptorMap: {
-    //   'telnet-server': {type: 'flag'},
-    //   't': {alias: 'telnet-server'}
-    // }
-    //
-    // options: ['t'] -> result: {'telnet-server': true}
-    //
-    // optionDescriptorMap: {
-    //   'directory': {
-    //     type: 'value',
-    //     validate(name) {
-    //       // const whitelistedDirectories = ['apple', 'banana']
-    //       if (whitelistedDirectories.includes(name)) {
-    //         return true
-    //       } else {
-    //         return 'a whitelisted directory'
-    //       }
-    //     }
-    //   },
-    //   'files': {type: 'series'}
-    // }
-    //
-    // ['--directory', 'apple'] -> {'directory': 'apple'}
-    // ['--directory', 'artichoke'] -> (error)
-    // ['--files', 'a', 'b', 'c', ';'] -> {'files': ['a', 'b', 'c']}
-    //
-    // TODO: Be able to validate the values in a series option.
-
-    const handleDashless = optionDescriptorMap[parseOptions.handleDashless];
-    const handleUnknown = optionDescriptorMap[parseOptions.handleUnknown];
-    const result = Object.create(null);
-    for (let i = 0; i < options.length; i++) {
-        const option = options[i];
-        if (option.startsWith('--')) {
-            // --x can be a flag or expect a value or series of values
-            let name = option.slice(2).split('=')[0]; // '--x'.split('=') = ['--x']
-            let descriptor = optionDescriptorMap[name];
-            if (!descriptor) {
-                if (handleUnknown) {
-                    handleUnknown(option);
-                } else {
-                    console.error(`Unknown option name: ${name}`);
-                    process.exit(1);
-                }
-                continue;
-            }
-            if (descriptor.alias) {
-                name = descriptor.alias;
-                descriptor = optionDescriptorMap[name];
-            }
-            if (descriptor.type === 'flag') {
-                result[name] = true;
-            } else if (descriptor.type === 'value') {
-                let value = option.slice(2).split('=')[1];
-                if (!value) {
-                    value = options[++i];
-                    if (!value || value.startsWith('-')) {
-                        value = null;
-                    }
-                }
-                if (!value) {
-                    console.error(`Expected a value for --${name}`);
-                    process.exit(1);
-                }
-                result[name] = value;
-            } else if (descriptor.type === 'series') {
-                if (!options.slice(i).includes(';')) {
-                    console.error(`Expected a series of values concluding with ; (\\;) for --${name}`);
-                    process.exit(1);
-                }
-                const endIndex = i + options.slice(i).indexOf(';');
-                result[name] = options.slice(i + 1, endIndex);
-                i = endIndex;
-            }
-            if (descriptor.validate) {
-                const validation = await descriptor.validate(result[name]);
-                if (validation !== true) {
-                    console.error(`Expected ${validation} for --${name}`);
-                    process.exit(1);
-                }
-            }
-        } else if (option.startsWith('-')) {
-            // mtui doesn't use any -x=y or -x y format optionuments
-            // -x will always just be a flag
-            let name = option.slice(1);
-            let descriptor = optionDescriptorMap[name];
-            if (!descriptor) {
-                if (handleUnknown) {
-                    handleUnknown(option);
-                } else {
-                    console.error(`Unknown option name: ${name}`);
-                    process.exit(1);
-                }
-                continue;
-            }
-            if (descriptor.alias) {
-                name = descriptor.alias;
-                descriptor = optionDescriptorMap[name];
-            }
-            if (descriptor.type === 'flag') {
-                result[name] = true;
-            } else {
-                console.error(`Use --${name} (value) to specify ${name}`);
-                process.exit(1);
-            }
-        } else if (handleDashless) {
-            handleDashless(option);
-        }
-    }
-    return result;
-}
-
-parseOptions.handleDashless = Symbol();
-parseOptions.handleUnknown = Symbol();
-
-module.exports.parseOptions = parseOptions;
-
-// Cheap FP for a cheap dyke!
-// I have no idea if this is what curry actually means.
-module.exports.curry = f => x => (...args) => f(x, ...args);
-
-module.exports.mapInPlace = (array, fn) => array.splice(0, array.length, ...array.map(fn));
-
-module.exports.filterEmptyLines = string => string.split('\n').filter(line => line.trim()).join('\n');
-
-module.exports.unique = arr => Array.from(new Set(arr));
-
-const logColor = color => (literals, ...values) => {
-    const w = s => process.stdout.write(s);
-    w(`\x1b[${color}m`);
-    for (let i = 0; i < literals.length; i++) {
-        w(literals[i]);
-        if (values[i] !== undefined) {
-            w(`\x1b[1m`);
-            w(String(values[i]));
-            w(`\x1b[0;${color}m`);
-        }
-    }
-    w(`\x1b[0m\n`);
-};
-
-module.exports.logInfo = logColor(2);
-module.exports.logWarn = logColor(33);
-module.exports.logError = logColor(31);
-
-module.exports.sortByName = (a, b) => {
-    let an = a.name.toLowerCase();
-    let bn = b.name.toLowerCase();
-    if (an.startsWith('the ')) an = an.slice(4);
-    if (bn.startsWith('the ')) bn = bn.slice(4);
-    return an < bn ? -1 : an > bn ? 1 : 0;
-};
-
-module.exports.chunkByConditions = function(array, conditions) {
-    if (array.length === 0) {
-        return [];
-    } else if (conditions.length === 0) {
-        return [array];
-    }
-
-    const out = [];
-    let cur = [array[0]];
-    for (let i = 1; i < array.length; i++) {
-        const item = array[i];
-        const prev = array[i - 1];
-        let chunk = false;
-        for (const condition of conditions) {
-            if (condition(item, prev)) {
-                chunk = true;
-                break;
-            }
-        }
-        if (chunk) {
-            out.push(cur);
-            cur = [item];
-        } else {
-            cur.push(item);
-        }
-    }
-    out.push(cur);
-    return out;
-};
-
-module.exports.chunkByProperties = function(array, properties) {
-    return module.exports.chunkByConditions(array, properties.map(p => (a, b) => {
-        if (a[p] instanceof Date && b[p] instanceof Date)
-            return +a[p] !== +b[p];
-
-        if (a[p] !== b[p]) return true;
-
-        // Not sure if this line is still necessary with the specific check for
-        // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....?
-        if (a[p] != b[p]) return true;
-
-        return false;
-    }))
-        .map(chunk => ({
-            ...Object.fromEntries(properties.map(p => [p, chunk[0][p]])),
-            chunk
-        }));
-};
-
-// Very cool function origin8ting in... http-music pro8a8ly!
-// Sorry if we happen to 8e violating past-us's copyright, lmao.
-module.exports.promisifyProcess = function(proc, showLogging = true) {
-    // Takes a process (from the child_process module) and returns a promise
-    // that resolves when the process exits (or rejects, if the exit code is
-    // non-zero).
-    //
-    // Ayy look, no alpha8etical second letter! Couldn't tell this was written
-    // like three years ago 8efore I was me. 8888)
-
-    return new Promise((resolve, reject) => {
-        if (showLogging) {
-            proc.stdout.pipe(process.stdout);
-            proc.stderr.pipe(process.stderr);
-        }
-
-        proc.on('exit', code => {
-            if (code === 0) {
-                resolve();
-            } else {
-                reject(code);
-            }
-        })
-    })
-};
-
-// Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
-module.exports.withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj)));
-
-// Nothin' more to it than what it says. Runs a function in-place. Provides an
-// altern8tive syntax to the usual IIFEs (e.g. (() => {})()) when you want to
-// open a scope and run some statements while inside an existing expression.
-module.exports.call = fn => fn();