« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/util')
-rw-r--r--src/util/cli.js210
-rw-r--r--src/util/colors.js21
-rw-r--r--src/util/find.js54
-rw-r--r--src/util/html.js94
-rw-r--r--src/util/link.js80
-rw-r--r--src/util/magic-constants.js11
-rw-r--r--src/util/node-utils.js27
-rw-r--r--src/util/replacer.js424
-rw-r--r--src/util/serialize.js71
-rw-r--r--src/util/strings.js287
-rw-r--r--src/util/sugar.js272
-rw-r--r--src/util/urls.js102
-rw-r--r--src/util/wiki-data.js283
13 files changed, 0 insertions, 1936 deletions
diff --git a/src/util/cli.js b/src/util/cli.js
deleted file mode 100644
index 7f84be7c..00000000
--- a/src/util/cli.js
+++ /dev/null
@@ -1,210 +0,0 @@
-// Utility functions for CLI- and de8ugging-rel8ted stuff.
-//
-// A 8unch of these depend on process.stdout 8eing availa8le, so they won't
-// work within the 8rowser.
-
-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`);
-};
-
-export const logInfo = logColor(2);
-export const logWarn = logColor(33);
-export const logError = logColor(31);
-
-// Stolen as #@CK from mtui!
-export async function parseOptions(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;
-}
-
-export const handleDashless = Symbol();
-export const handleUnknown = Symbol();
-
-export function decorateTime(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();
-        }
-    }
-};
-
-export function progressPromiseAll(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.resolve(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;
-    })));
-}
diff --git a/src/util/colors.js b/src/util/colors.js
deleted file mode 100644
index 3a7ce8f3..00000000
--- a/src/util/colors.js
+++ /dev/null
@@ -1,21 +0,0 @@
-// Color and theming utility functions! Handy.
-
-// Graciously stolen from https://stackoverflow.com/a/54071699! ::::)
-// in: r,g,b in [0,1], out: h in [0,360) and s,l in [0,1]
-export function rgb2hsl(r, g, b) {
-    let a=Math.max(r,g,b), n=a-Math.min(r,g,b), f=(1-Math.abs(a+a-n-1));
-    let h= n && ((a==r) ? (g-b)/n : ((a==g) ? 2+(b-r)/n : 4+(r-g)/n));
-    return [60*(h<0?h+6:h), f ? n/f : 0, (a+a-n)/2];
-}
-
-export function getColors(primary) {
-    const [ r, g, b ] = primary.slice(1)
-        .match(/[0-9a-fA-F]{2,2}/g)
-        .slice(0, 3)
-        .map(val => parseInt(val, 16) / 255);
-    const [ h, s, l ] = rgb2hsl(r, g, b);
-    const dim = `hsl(${Math.round(h)}deg, ${Math.round(s * 50)}%, ${Math.round(l * 80)}%)`;
-    const bg = `hsla(${Math.round(h)}deg, ${Math.round(s * 15)}%, 12%, 0.80)`;
-
-    return {primary, dim, bg};
-}
diff --git a/src/util/find.js b/src/util/find.js
deleted file mode 100644
index 1cbeb82c..00000000
--- a/src/util/find.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import {
-    logWarn
-} from './cli.js';
-
-function findHelper(keys, dataProp, findFn) {
-    return (ref, {wikiData}) => {
-        if (!ref) return null;
-        ref = ref.replace(new RegExp(`^(${keys.join('|')}):`), '');
-
-        const found = findFn(ref, wikiData[dataProp]);
-        if (!found) {
-            logWarn`Didn't match anything for ${ref}! (${keys.join(', ')})`;
-        }
-
-        return found;
-    };
-}
-
-function matchDirectory(ref, data) {
-    return data.find(({ directory }) => directory === ref);
-}
-
-function 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 find = {
-    album: findHelper(['album', 'album-commentary'], 'albumData', matchDirectoryOrName),
-    artist: findHelper(['artist', 'artist-gallery'], 'artistData', matchDirectoryOrName),
-    flash: findHelper(['flash'], 'flashData', matchDirectory),
-    group: findHelper(['group', 'group-gallery'], 'groupData', matchDirectoryOrName),
-    listing: findHelper(['listing'], 'listingSpec', matchDirectory),
-    newsEntry: findHelper(['news-entry'], 'newsData', matchDirectory),
-    staticPage: findHelper(['static'], 'staticPageData', matchDirectory),
-    tag: findHelper(['tag'], 'tagData', (ref, data) =>
-        matchDirectoryOrName(ref.startsWith('cw: ') ? ref.slice(4) : ref, data)),
-    track: findHelper(['track'], 'trackData', matchDirectoryOrName)
-};
-
-export default find;
diff --git a/src/util/html.js b/src/util/html.js
deleted file mode 100644
index 94756984..00000000
--- a/src/util/html.js
+++ /dev/null
@@ -1,94 +0,0 @@
-// Some really simple functions for formatting HTML content.
-
-// Non-comprehensive. ::::P
-export const 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.
-export const onlyIfContent = Symbol();
-
-export function tag(tagName, ...args) {
-    const selfClosing = 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?.[onlyIfContent] && !content) {
-        return '';
-    }
-
-    if (attrs) {
-        const attrString = 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 (
-                `<${openTag}>\n` +
-                content.split('\n').map(line => '    ' + line + '\n').join('') +
-                `</${tagName}>`
-            );
-        } else {
-            return `<${openTag}>${content}</${tagName}>`;
-        }
-    } else {
-        if (selfClosing) {
-            return `<${openTag}>`;
-        } else {
-            return `<${openTag}></${tagName}>`;
-        }
-    }
-}
-
-export function escapeAttributeValue(value) {
-    return value
-        .replaceAll('"', '&quot;')
-        .replaceAll("'", '&apos;');
-}
-
-export function attributes(attribs) {
-    return Object.entries(attribs)
-        .map(([ key, val ]) => {
-            if (typeof val === 'undefined' || val === null)
-                return [key, val, false];
-            else if (typeof val === 'string')
-                return [key, val, true];
-            else if (typeof val === 'boolean')
-                return [key, val, val];
-            else if (typeof val === 'number')
-                return [key, val.toString(), true];
-            else if (Array.isArray(val))
-                return [key, val.filter(Boolean).join(' '), val.length > 0];
-            else
-                throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
-        })
-        .filter(([ key, val, keep ]) => keep)
-        .map(([ key, val ]) => (typeof val === 'boolean'
-            ? `${key}`
-            : `${key}="${escapeAttributeValue(val)}"`))
-        .join(' ');
-}
diff --git a/src/util/link.js b/src/util/link.js
deleted file mode 100644
index 7ed5fd8e..00000000
--- a/src/util/link.js
+++ /dev/null
@@ -1,80 +0,0 @@
-// This file is essentially one level of a8straction a8ove urls.js (and the
-// urlSpec it gets its paths from). It's a 8unch of utility functions which
-// take certain types of wiki data o8jects (colloquially known as "things")
-// and return actual <a href> HTML link tags.
-//
-// The functions we're cre8ting here (all factory-style) take a "to" argument,
-// which is roughly a function which takes a urlSpec key and spits out a path
-// to 8e stuck in an href or src or suchever. There are also a few other
-// options availa8le in all the functions, making a common interface for
-// gener8ting just a8out any link on the site.
-
-import * as html from './html.js'
-import { getColors } from './colors.js'
-
-export function getLinkThemeString(color) {
-    if (!color) return '';
-
-    const { primary, dim } = getColors(color);
-    return `--primary-color: ${primary}; --dim-color: ${dim}`;
-}
-
-const linkHelper = (hrefFn, {color = true, attr = null} = {}) =>
-    (thing, {
-        to,
-        text = '',
-        attributes = null,
-        class: className = '',
-        color: color2 = true,
-        hash = ''
-    }) => (
-        html.tag('a', {
-            ...attr ? attr(thing) : {},
-            ...attributes ? attributes : {},
-            href: hrefFn(thing, {to}) + (hash ? (hash.startsWith('#') ? '' : '#') + hash : ''),
-            style: (
-                typeof color2 === 'string' ? getLinkThemeString(color2) :
-                color2 && color ? getLinkThemeString(thing.color) :
-                ''),
-            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})
-};
-
-export default link;
diff --git a/src/util/magic-constants.js b/src/util/magic-constants.js
deleted file mode 100644
index 3174daec..00000000
--- a/src/util/magic-constants.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// Magic constants only! These are hard-coded, and any use of them should be
-// considered a flaw in the codebase - areas where we use hard-coded behavior
-// to support one use of the wiki software (i.e. HSMusic, usually), rather than
-// implementing the feature more generally/customizably.
-//
-// All such uses should eventually be replaced with better code in due time
-// (TM).
-
-export const UNRELEASED_TRACKS_DIRECTORY = 'unreleased-tracks';
-export const OFFICIAL_GROUP_DIRECTORY = 'official';
-export const FANDOM_GROUP_DIRECTORY = 'fandom';
diff --git a/src/util/node-utils.js b/src/util/node-utils.js
deleted file mode 100644
index d660612e..00000000
--- a/src/util/node-utils.js
+++ /dev/null
@@ -1,27 +0,0 @@
-// Utility functions which are only relevant to particular Node.js constructs.
-
-// Very cool function origin8ting in... http-music pro8a8ly!
-// Sorry if we happen to 8e violating past-us's copyright, lmao.
-export function promisifyProcess(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);
-            }
-        })
-    })
-}
diff --git a/src/util/replacer.js b/src/util/replacer.js
deleted file mode 100644
index 0c16dc8b..00000000
--- a/src/util/replacer.js
+++ /dev/null
@@ -1,424 +0,0 @@
-import find from './find.js';
-import {logError, logWarn} from './cli.js';
-import {escapeRegex} from './sugar.js';
-
-export function validateReplacerSpec(replacerSpec, link) {
-    let success = true;
-
-    for (const [key, {link: linkKey, find: findKey, 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.`;
-            success = false;
-        }
-        if (findKey && !find[findKey]) {
-            logError`The replacer spec ${key} has invalid find key ${findKey}! Specify it in find specs or fix typo.`;
-            success = false;
-        }
-    }
-
-    return success;
-}
-
-// Syntax literals.
-const tagBeginning = '[[';
-const tagEnding = ']]';
-const tagReplacerValue = ':';
-const tagHash = '#';
-const tagArgument = '*';
-const tagArgumentValue = '=';
-const tagLabel = '|';
-
-const noPrecedingWhitespace = '(?<!\\s)';
-
-const R_tagBeginning =
-    escapeRegex(tagBeginning);
-
-const R_tagEnding =
-    escapeRegex(tagEnding);
-
-const R_tagReplacerValue =
-    noPrecedingWhitespace +
-    escapeRegex(tagReplacerValue);
-
-const R_tagHash =
-    noPrecedingWhitespace +
-    escapeRegex(tagHash);
-
-const R_tagArgument =
-    escapeRegex(tagArgument);
-
-const R_tagArgumentValue =
-    escapeRegex(tagArgumentValue);
-
-const R_tagLabel =
-    escapeRegex(tagLabel);
-
-const regexpCache = {};
-
-const makeError = (i, message) => ({i, type: 'error', data: {message}});
-const endOfInput = (i, comment) => makeError(i, `Unexpected end of input (${comment}).`);
-
-// These are 8asically stored on the glo8al scope, which might seem odd
-// for a recursive function, 8ut the values are only ever used immediately
-// after they're set.
-let stopped,
-    stop_iMatch,
-    stop_iParse,
-    stop_literal;
-
-function parseOneTextNode(input, i, stopAt) {
-    return parseNodes(input, i, stopAt, true)[0];
-}
-
-function parseNodes(input, i, stopAt, textOnly) {
-    let nodes = [];
-    let escapeNext = false;
-    let string = '';
-    let iString = 0;
-
-    stopped = false;
-
-    const pushTextNode = (isLast) => {
-        string = input.slice(iString, i);
-
-        // If this is the last text node 8efore stopping (at a stopAt match
-        // or the end of the input), trim off whitespace at the end.
-        if (isLast) {
-            string = string.trimEnd();
-        }
-
-        if (string.length) {
-            nodes.push({i: iString, iEnd: i, type: 'text', data: string});
-            string = '';
-        }
-    };
-
-    const literalsToMatch = stopAt ? stopAt.concat([R_tagBeginning]) : [R_tagBeginning];
-
-    // The 8ackslash stuff here is to only match an even (or zero) num8er
-    // of sequential 'slashes. Even amounts always cancel out! Odd amounts
-    // don't, which would mean the following literal is 8eing escaped and
-    // should 8e counted only as part of the current string/text.
-    //
-    // Inspired 8y this: https://stackoverflow.com/a/41470813
-    const regexpSource = `(?<!\\\\)(?:\\\\{2})*(${literalsToMatch.join('|')})`;
-
-    // There are 8asically only a few regular expressions we'll ever use,
-    // 8ut it's a pain to hard-code them all, so we dynamically gener8te
-    // and cache them for reuse instead.
-    let regexp;
-    if (regexpCache.hasOwnProperty(regexpSource)) {
-        regexp = regexpCache[regexpSource];
-    } else {
-        regexp = new RegExp(regexpSource);
-        regexpCache[regexpSource] = regexp;
-    }
-
-    // Skip whitespace at the start of parsing. This is run every time
-    // parseNodes is called (and thus parseOneTextNode too), so spaces
-    // at the start of syntax elements will always 8e skipped. We don't
-    // skip whitespace that shows up inside content (i.e. once we start
-    // parsing below), though!
-    const whitespaceOffset = input.slice(i).search(/[^\s]/);
-
-    // If the string is all whitespace, that's just zero content, so
-    // return the empty nodes array.
-    if (whitespaceOffset === -1) {
-        return nodes;
-    }
-
-    i += whitespaceOffset;
-
-    while (i < input.length) {
-        const match = input.slice(i).match(regexp);
-
-        if (!match) {
-            iString = i;
-            i = input.length;
-            pushTextNode(true);
-            break;
-        }
-
-        const closestMatch = match[0];
-        const closestMatchIndex = i + match.index;
-
-        if (textOnly && closestMatch === tagBeginning)
-            throw makeError(i, `Unexpected [[tag]] - expected only text here.`);
-
-        const stopHere = (closestMatch !== tagBeginning);
-
-        iString = i;
-        i = closestMatchIndex;
-        pushTextNode(stopHere);
-
-        i += closestMatch.length;
-
-        if (stopHere) {
-            stopped = true;
-            stop_iMatch = closestMatchIndex;
-            stop_iParse = i;
-            stop_literal = closestMatch;
-            break;
-        }
-
-        if (closestMatch === tagBeginning) {
-            const iTag = closestMatchIndex;
-
-            let N;
-
-            // Replacer key (or value)
-
-            N = parseOneTextNode(input, i, [R_tagReplacerValue, R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]);
-
-            if (!stopped) throw endOfInput(i, `reading replacer key`);
-
-            if (!N) {
-                switch (stop_literal) {
-                    case tagReplacerValue:
-                    case tagArgument:
-                        throw makeError(i, `Expected text (replacer key).`);
-                    case tagLabel:
-                    case tagHash:
-                    case tagEnding:
-                        throw makeError(i, `Expected text (replacer key/value).`);
-                }
-            }
-
-            const replacerFirst = N;
-            i = stop_iParse;
-
-            // Replacer value (if explicit)
-
-            let replacerSecond;
-
-            if (stop_literal === tagReplacerValue) {
-                N = parseNodes(input, i, [R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]);
-
-                if (!stopped) throw endOfInput(i, `reading replacer value`);
-                if (!N.length) throw makeError(i, `Expected content (replacer value).`);
-
-                replacerSecond = N;
-                i = stop_iParse
-            }
-
-            // Assign first & second to replacer key/value
-
-            let replacerKey,
-                replacerValue;
-
-            // Value is an array of nodes, 8ut key is just one (or null).
-            // So if we use replacerFirst as the value, we need to stick
-            // it in an array (on its own).
-            if (replacerSecond) {
-                replacerKey = replacerFirst;
-                replacerValue = replacerSecond;
-            } else {
-                replacerKey = null;
-                replacerValue = [replacerFirst];
-            }
-
-            // Hash
-
-            let hash;
-
-            if (stop_literal === tagHash) {
-                N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
-
-                if (!stopped) throw endOfInput(i, `reading hash`);
-
-                if (!N)
-                    throw makeError(i, `Expected content (hash).`);
-
-                hash = N;
-                i = stop_iParse;
-            }
-
-            // Arguments
-
-            const args = [];
-
-            while (stop_literal === tagArgument) {
-                N = parseOneTextNode(input, i, [R_tagArgumentValue, R_tagArgument, R_tagLabel, R_tagEnding]);
-
-                if (!stopped) throw endOfInput(i, `reading argument key`);
-
-                if (stop_literal !== tagArgumentValue)
-                    throw makeError(i, `Expected ${tagArgumentValue.literal} (tag argument).`);
-
-                if (!N)
-                    throw makeError(i, `Expected text (argument key).`);
-
-                const key = N;
-                i = stop_iParse;
-
-                N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
-
-                if (!stopped) throw endOfInput(i, `reading argument value`);
-                if (!N.length) throw makeError(i, `Expected content (argument value).`);
-
-                const value = N;
-                i = stop_iParse;
-
-                args.push({key, value});
-            }
-
-            let label;
-
-            if (stop_literal === tagLabel) {
-                N = parseOneTextNode(input, i, [R_tagEnding]);
-
-                if (!stopped) throw endOfInput(i, `reading label`);
-                if (!N) throw makeError(i, `Expected text (label).`);
-
-                label = N;
-                i = stop_iParse;
-            }
-
-            nodes.push({i: iTag, iEnd: i, type: 'tag', data: {replacerKey, replacerValue, hash, args, label}});
-
-            continue;
-        }
-    }
-
-    return nodes;
-};
-
-export function parseInput(input) {
-    try {
-        return parseNodes(input, 0);
-    } catch (errorNode) {
-        if (errorNode.type !== 'error') {
-            throw errorNode;
-        }
-
-        const { i, data: { message } } = errorNode;
-
-        let lineStart = input.slice(0, i).lastIndexOf('\n');
-        if (lineStart >= 0) {
-            lineStart += 1;
-        } else {
-            lineStart = 0;
-        }
-
-        let lineEnd = input.slice(i).indexOf('\n');
-        if (lineEnd >= 0) {
-            lineEnd += i;
-        } else {
-            lineEnd = input.length;
-        }
-
-        const line = input.slice(lineStart, lineEnd);
-
-        const cursor = i - lineStart;
-
-        throw new SyntaxError(fixWS`
-            Parse error (at pos ${i}): ${message}
-            ${line}
-            ${'-'.repeat(cursor) + '^'}
-        `);
-    }
-}
-
-function evaluateTag(node, opts) {
-    const { input, link, replacerSpec, strings, to, wikiData } = opts;
-
-    const source = input.slice(node.i, node.iEnd);
-
-    const replacerKey = node.data.replacerKey?.data || 'track';
-
-    if (!replacerSpec[replacerKey]) {
-        logWarn`The link ${source} has an invalid replacer key!`;
-        return source;
-    }
-
-    const {
-        find: findKey,
-        link: linkKey,
-        value: valueFn,
-        html: htmlFn,
-        transformName
-    } = replacerSpec[replacerKey];
-
-    const replacerValue = transformNodes(node.data.replacerValue, opts);
-
-    const value = (
-        valueFn ? valueFn(replacerValue) :
-        findKey ? find[findKey](replacerValue, {wikiData}) :
-        {
-            directory: replacerValue,
-            name: null
-        });
-
-    if (!value) {
-        logWarn`The link ${source} does not match anything!`;
-        return source;
-    }
-
-    const enteredLabel = node.data.label && transformNode(node.data.label, opts);
-
-    const label = (enteredLabel
-        || transformName && transformName(value.name, node, input)
-        || value.name);
-
-    if (!valueFn && !label) {
-        logWarn`The link ${source} requires a label be entered!`;
-        return source;
-    }
-
-    const hash = node.data.hash && transformNodes(node.data.hash, opts);
-
-    const args = node.data.args && Object.fromEntries(node.data.args.map(
-        ({ key, value }) => [
-            transformNode(key, opts),
-            transformNodes(value, opts)
-        ]));
-
-    const fn = (htmlFn
-        ? htmlFn
-        : link[linkKey]);
-
-    try {
-        return fn(value, {text: label, hash, args, strings, to});
-    } catch (error) {
-        logError`The link ${source} failed to be processed: ${error}`;
-        return source;
-    }
-}
-
-function transformNode(node, opts) {
-    if (!node) {
-        throw new Error('Expected a node!');
-    }
-
-    if (Array.isArray(node)) {
-        throw new Error('Got an array - use transformNodes here!');
-    }
-
-    switch (node.type) {
-        case 'text':
-            return node.data;
-        case 'tag':
-            return evaluateTag(node, opts);
-        default:
-            throw new Error(`Unknown node type ${node.type}`);
-    }
-}
-
-function transformNodes(nodes, opts) {
-    if (!nodes || !Array.isArray(nodes)) {
-        throw new Error(`Expected an array of nodes! Got: ${nodes}`);
-    }
-
-    return nodes.map(node => transformNode(node, opts)).join('');
-}
-
-export function transformInline(input, {replacerSpec, link, strings, to, wikiData}) {
-    if (!replacerSpec) throw new Error('Expected replacerSpec');
-    if (!link) throw new Error('Expected link');
-    if (!strings) throw new Error('Expected strings');
-    if (!to) throw new Error('Expected to');
-    if (!wikiData) throw new Error('Expected wikiData');
-
-    const nodes = parseInput(input);
-    return transformNodes(nodes, {input, link, replacerSpec, strings, to, wikiData});
-}
diff --git a/src/util/serialize.js b/src/util/serialize.js
deleted file mode 100644
index 7b0f890f..00000000
--- a/src/util/serialize.js
+++ /dev/null
@@ -1,71 +0,0 @@
-export function serializeLink(thing) {
-    const ret = {};
-    ret.name = thing.name;
-    ret.directory = thing.directory;
-    if (thing.color) ret.color = thing.color;
-    return ret;
-}
-
-export function serializeContribs(contribs) {
-    return contribs.map(({ who, what }) => {
-        const ret = {};
-        ret.artist = serializeLink(who);
-        if (what) ret.contribution = what;
-        return ret;
-    });
-}
-
-export function serializeImagePaths(original, {thumb}) {
-    return {
-        original,
-        medium: thumb.medium(original),
-        small: thumb.small(original)
-    };
-}
-
-export function serializeCover(thing, pathFunction, {
-    serializeImagePaths,
-    urls
-}) {
-    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)
-    };
-}
-
-export function serializeGroupsForAlbum(album, {
-    serializeLink
-}) {
-    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
-    }));
-}
-
-export function serializeGroupsForTrack(track, {
-    serializeLink
-}) {
-    return track.album.groups.map(group => ({
-        link: serializeLink(group),
-        urls: group.urls,
-    }));
-}
diff --git a/src/util/strings.js b/src/util/strings.js
deleted file mode 100644
index e749b945..00000000
--- a/src/util/strings.js
+++ /dev/null
@@ -1,287 +0,0 @@
-import { logError, logWarn } from './cli.js';
-import { bindOpts } from './sugar.js';
-
-// 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.
-//
-// This function also takes an optional "bindUtilities" argument; it should
-// look like a dictionary each value of which is itself a util dictionary,
-// each value of which is a function in the format (value, opts) => (...).
-// Each of those util dictionaries will 8e attached to the final returned
-// strings() function, containing functions which automatically have that
-// same strings() function provided as part of its opts argument (alongside
-// any additional arguments passed).
-//
-// Basically, it's so that instead of doing:
-//
-//     count.tracks(album.tracks.length, {strings})
-//
-// ...you can just do:
-//
-//     strings.count.tracks(album.tracks.length)
-//
-// Definitely note bindUtilities expects an OBJECT, not an array, otherwise
-// it won't 8e a8le to know what keys to attach the utilities 8y!
-//
-// Oh also it'll need access to the he.encode() function, and callers have to
-// provide that themselves, 'cuz otherwise we can't reference this file from
-// client-side code.
-export function genStrings(stringsJSON, {
-    he,
-    defaultJSON = null,
-    bindUtilities = []
-}) {
-    // 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'})
-        }
-    };
-
-    // And the provided utility dictionaries themselves, of course!
-    for (const [key, utilDict] of Object.entries(bindUtilities)) {
-        const boundUtilDict = {};
-        for (const [key, fn] of Object.entries(utilDict)) {
-            boundUtilDict[key] = bindOpts(fn, {strings});
-        }
-        strings[key] = boundUtilDict;
-    }
-
-    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)});
-
-export 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);
-
-export const list = {
-    unit: listHelper('unit'),
-    or: listHelper('disjunction'),
-    and: listHelper('conjunction')
-};
diff --git a/src/util/sugar.js b/src/util/sugar.js
deleted file mode 100644
index 38c8047f..00000000
--- a/src/util/sugar.js
+++ /dev/null
@@ -1,272 +0,0 @@
-// Syntactic sugar! (Mostly.)
-// Generic functions - these are useful just a8out everywhere.
-//
-// 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.
-
-// 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.
-export function* splitArray(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;
-    }
-};
-
-export const mapInPlace = (array, fn) => array.splice(0, array.length, ...array.map(fn));
-
-export const filterEmptyLines = string => string.split('\n').filter(line => line.trim()).join('\n');
-
-export const unique = arr => Array.from(new Set(arr));
-
-// Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
-export const 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.
-export const call = fn => fn();
-
-export function queue(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;
-}
-
-export function delay(ms) {
-    return new Promise(res => setTimeout(res, ms));
-}
-
-// Stolen from here: https://stackoverflow.com/a/3561711
-//
-// There's a proposal for a native JS function like this, 8ut it's not even
-// past stage 1 yet: https://github.com/tc39/proposal-regex-escaping
-export function escapeRegex(string) {
-    return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
-}
-
-export function bindOpts(fn, bind) {
-    const bindIndex = bind[bindOpts.bindIndex] ?? 1;
-
-    return (...args) => {
-        const opts = args[bindIndex] ?? {};
-        return fn(...args.slice(0, bindIndex), {...bind, ...opts});
-    };
-}
-
-bindOpts.bindIndex = Symbol();
-
-// Utility function for providing useful interfaces to the JS AggregateError
-// class.
-//
-// Generally, this works by returning a set of interfaces which operate on
-// functions: wrap() takes a function and returns a new function which passes
-// its arguments through and appends any resulting error to the internal error
-// list; call() simplifies this process by wrapping the provided function and
-// then calling it immediately. Once the process for which errors should be
-// aggregated is complete, close() constructs and throws an AggregateError
-// object containing all caught errors (or doesn't throw anything if there were
-// no errors).
-export function openAggregate({
-    // Constructor to use, defaulting to the builtin AggregateError class.
-    // Anything passed here should probably extend from that! May be used for
-    // letting callers programatically distinguish between multiple aggregate
-    // errors.
-    //
-    // This should be provided using the aggregateThrows utility function.
-    [openAggregate.errorClassSymbol]: errorClass = AggregateError,
-
-    // Optional human-readable message to describe the aggregate error, if
-    // constructed.
-    message = '',
-
-    // Value to return when a provided function throws an error. If this is a
-    // function, it will be called with the arguments given to the function.
-    // (This is primarily useful when wrapping a function and then providing it
-    // to another utility, e.g. array.map().)
-    returnOnFail = null
-} = {}) {
-    const errors = [];
-
-    const aggregate = {};
-
-    aggregate.wrap = fn => (...args) => {
-        try {
-            return fn(...args);
-        } catch (error) {
-            errors.push(error);
-            return (typeof returnOnFail === 'function'
-                ? returnOnFail(...args)
-                : returnOnFail);
-        }
-    };
-
-    aggregate.call = (fn, ...args) => {
-        return aggregate.wrap(fn)(...args);
-    };
-
-    aggregate.nest = (...args) => {
-        return aggregate.call(() => withAggregate(...args));
-    };
-
-    aggregate.map = (...args) => {
-        const parent = aggregate;
-        const { result, aggregate: child } = mapAggregate(...args);
-        parent.call(child.close);
-        return result;
-    };
-
-    aggregate.filter = (...args) => {
-        const parent = aggregate;
-        const { result, aggregate: child } = filterAggregate(...args);
-        parent.call(child.close);
-        return result;
-    };
-
-    aggregate.throws = aggregateThrows;
-
-    aggregate.close = () => {
-        if (errors.length) {
-            throw Reflect.construct(errorClass, [errors, message]);
-        }
-    };
-
-    return aggregate;
-}
-
-openAggregate.errorClassSymbol = Symbol('error class');
-
-// Utility function for providing {errorClass} parameter to aggregate functions.
-export function aggregateThrows(errorClass) {
-    return {[openAggregate.errorClassSymbol]: errorClass};
-}
-
-// Performs an ordinary array map with the given function, collating into a
-// results array (with errored inputs filtered out) and an error aggregate.
-//
-// Optionally, override returnOnFail to disable filtering and map errored inputs
-// to a particular output.
-//
-// Note the aggregate property is the result of openAggregate(), still unclosed;
-// use aggregate.close() to throw the error. (This aggregate may be passed to a
-// parent aggregate: `parent.call(aggregate.close)`!)
-export function mapAggregate(array, fn, aggregateOpts) {
-    const failureSymbol = Symbol();
-
-    const aggregate = openAggregate({
-        returnOnFail: failureSymbol,
-        ...aggregateOpts
-    });
-
-    const result = array.map(aggregate.wrap(fn))
-        .filter(value => value !== failureSymbol);
-
-    return {result, aggregate};
-}
-
-// Performs an ordinary array filter with the given function, collating into a
-// results array (with errored inputs filtered out) and an error aggregate.
-//
-// Optionally, override returnOnFail to disable filtering errors and map errored
-// inputs to a particular output.
-//
-// As with mapAggregate, the returned aggregate property is not yet closed.
-export function filterAggregate(array, fn, aggregateOpts) {
-    const failureSymbol = Symbol();
-
-    const aggregate = openAggregate({
-        returnOnFail: failureSymbol,
-        ...aggregateOpts
-    });
-
-    const result = array.map(aggregate.wrap((x, ...rest) => ({
-        input: x,
-        output: fn(x, ...rest)
-    })))
-        .filter(value => {
-            // Filter out results which match the failureSymbol, i.e. errored
-            // inputs.
-            if (value === failureSymbol) return false;
-
-            // Always keep results which match the overridden returnOnFail
-            // value, if provided.
-            if (value === aggregateOpts.returnOnFail) return true;
-
-            // Otherwise, filter according to the returned value of the wrapped
-            // function.
-            return value.output;
-        })
-        .map(value => {
-            // Then turn the results back into their corresponding input, or, if
-            // provided, the overridden returnOnFail value.
-            return (value === aggregateOpts.returnOnFail
-                ? value
-                : value.input);
-        });
-
-    return {result, aggregate};
-}
-
-// Totally sugar function for opening an aggregate, running the provided
-// function with it, then closing the function and returning the result (if
-// there's no throw).
-export function withAggregate(aggregateOpts, fn) {
-    if (typeof aggregateOpts === 'function') {
-        fn = aggregateOpts;
-        aggregateOpts = {};
-    }
-
-    const aggregate = openAggregate(aggregateOpts);
-    const result = fn(aggregate);
-    aggregate.close();
-    return result;
-}
-
-export function showAggregate(topError) {
-    const recursive = error => {
-        const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'}`;
-        if (error instanceof AggregateError) {
-            return header + '\n' + (error.errors
-                .map(recursive)
-                .flatMap(str => str.split('\n'))
-                .map(line => ` | ` + line)
-                .join('\n'));
-        } else {
-            return header;
-        }
-    };
-
-    console.log(recursive(topError));
-}
diff --git a/src/util/urls.js b/src/util/urls.js
deleted file mode 100644
index f0f9cdb1..00000000
--- a/src/util/urls.js
+++ /dev/null
@@ -1,102 +0,0 @@
-// Code that deals with URLs (really the pathnames that get referenced all
-// throughout the gener8ted HTML). Most nota8ly here is generateURLs, which
-// is in charge of pre-gener8ting a complete network of template strings
-// which can really quickly take su8stitute parameters to link from any one
-// place to another; 8ut there are also a few other utilities, too.
-//
-// Nota8ly, everything here is string-8ased, for gener8ting and transforming
-// actual path strings. More a8stract operations using wiki data o8jects is
-// the domain of link.js.
-
-import * as path from 'path';
-import { withEntries } from './sugar.js';
-
-export function generateURLs(urlSpec) {
-    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 thumbnailHelper = name => file =>
-    file.replace(/\.(jpg|png)$/, name + '.jpg');
-
-export const thumb = {
-    medium: thumbnailHelper('.medium'),
-    small: thumbnailHelper('.small')
-};
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
deleted file mode 100644
index 2f705f91..00000000
--- a/src/util/wiki-data.js
+++ /dev/null
@@ -1,283 +0,0 @@
-// Utility functions for interacting with wiki data.
-
-import {
-    UNRELEASED_TRACKS_DIRECTORY
-} from '../util/magic-constants.js';
-
-// Generic value operations
-
-export function getKebabCase(name) {
-    return name
-        .split(' ')
-        .join('-')
-        .replace(/&/g, 'and')
-        .replace(/[^a-zA-Z0-9\-]/g, '')
-        .replace(/-{2,}/g, '-')
-        .replace(/^-+|-+$/g, '')
-        .toLowerCase();
-}
-
-export function chunkByConditions(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;
-}
-
-export function chunkByProperties(array, properties) {
-    return 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
-        }));
-}
-
-// Sorting functions
-
-export function 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;
-}
-
-// This function was originally made to sort just al8um data, 8ut its exact
-// code works fine for sorting tracks too, so I made the varia8les and names
-// more general.
-export function sortByDate(data, dateKey = 'date') {
-    // Just to 8e clear: sort is a mutating function! I only return the array
-    // 8ecause then you don't have to define it as a separate varia8le 8efore
-    // passing it into this function.
-    return data.sort((a, b) => a[dateKey] - b[dateKey]);
-}
-
-// Same details as the sortByDate, 8ut for covers~
-export function sortByArtDate(data) {
-    return data.sort((a, b) => (a.coverArtDate || a.date) - (b.coverArtDate || b.date));
-}
-
-// Specific data utilities
-
-export function filterAlbumsByCommentary(albums) {
-    return albums.filter(album => [album, ...album.tracks].some(x => x.commentary));
-}
-
-export function getAlbumCover(album, {to}) {
-    return to('media.albumCover', album.directory);
-}
-
-export function getAlbumListTag(album) {
-    // TODO: This is hard-coded! No. 8ad.
-    return (album.directory === UNRELEASED_TRACKS_DIRECTORY ? 'ul' : 'ol');
-}
-
-// This gets all the track o8jects defined in every al8um, and sorts them 8y
-// date released. Generally, albumData will pro8a8ly already 8e sorted 8efore
-// you pass it to this function, 8ut individual tracks can have their own
-// original release d8, distinct from the al8um's d8. I allowed that 8ecause
-// in Homestuck, the first four Vol.'s were com8ined into one al8um really
-// early in the history of the 8andcamp, and I still want to use that as the
-// al8um listing (not the original four al8um listings), 8ut if I only did
-// that, all the tracks would 8e sorted as though they were released at the
-// same time as the compilation al8um - i.e, after some other al8ums (including
-// Vol.'s 5 and 6!) were released. That would mess with chronological listings
-// including tracks from multiple al8ums, like artist pages. So, to fix that,
-// I gave tracks an Original Date field, defaulting to the release date of the
-// al8um if not specified. Pretty reasona8le, I think! Oh, and this feature can
-// 8e used for other projects too, like if you wanted to have an al8um listing
-// compiling a 8unch of songs with radically different & interspersed release
-// d8s, 8ut still keep the al8um listing in a specific order, since that isn't
-// sorted 8y date.
-export function getAllTracks(albumData) {
-    return sortByDate(albumData.flatMap(album => album.tracks));
-}
-
-export function getArtistNumContributions(artist) {
-    return (
-        artist.tracks.asAny.length +
-        artist.albums.asCoverArtist.length +
-        (artist.flashes ? artist.flashes.asContributor.length : 0)
-    );
-}
-
-export function getArtistCommentary(artist, {justEverythingMan}) {
-    return justEverythingMan.filter(thing =>
-        (thing?.commentary
-            .replace(/<\/?b>/g, '')
-            .includes('<i>' + artist.name + ':</i>')));
-}
-
-export function getFlashCover(flash, {to}) {
-    return (flash.jiff
-        ? to('media.flashArtGif', flash.directory)
-        : to('media.flashArt', flash.directory));
-}
-
-export function getFlashLink(flash) {
-    return `https://homestuck.com/story/${flash.page}`;
-}
-
-export function getTotalDuration(tracks) {
-    return tracks.reduce((duration, track) => duration + track.duration, 0);
-}
-
-export 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);
-    }
-}
-
-// Big-ass homepage row functions
-
-export function getNewAdditions(numAlbums, {wikiData}) {
-    const { albumData } = wikiData;
-
-    // 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.filter(album => album.isListedOnHomepage).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}));
-}
-
-export function getNewReleases(numReleases, {wikiData}) {
-    const { albumData } = wikiData;
-
-    const latestFirst = albumData.filter(album => album.isListedOnHomepage).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}))
-    ];
-}