« get me outta code hell

break up utility file, get build for sure working - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
author(quasar) nebula <towerofnix@gmail.com>2021-05-06 14:56:18 -0300
committer(quasar) nebula <towerofnix@gmail.com>2021-05-06 14:56:18 -0300
commitead9bdc9fc1e9cc62a26e59f6880a13aa864f931 (patch)
treed459b47dbb17ad99615ca595bbe1e92d651eab15 /src
parent2260541dc69c19e7444348ac3243f96e4321b781 (diff)
break up utility file, get build for sure working
still Much Work Yet Ahead but this is good progress!! also the site is
in a working state afaict and thats a kinda nice milestone lmbo
Diffstat (limited to 'src')
-rw-r--r--src/gen-thumbs.js306
-rw-r--r--src/static/client.js415
-rw-r--r--src/static/icons.svg11
-rw-r--r--src/static/lazy-loading.js51
-rw-r--r--src/static/site-basic.css19
-rw-r--r--src/static/site.css872
-rw-r--r--src/strings-default.json305
-rwxr-xr-xsrc/upd8.js6395
-rw-r--r--src/util/cli.js210
-rw-r--r--src/util/colors.js47
-rw-r--r--src/util/html.js92
-rw-r--r--src/util/link.js67
-rw-r--r--src/util/node-utils.js27
-rw-r--r--src/util/sugar.js70
-rw-r--r--src/util/urls.js102
-rw-r--r--src/util/wiki-data.js126
16 files changed, 9115 insertions, 0 deletions
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
new file mode 100644
index 0000000..d636d2f
--- /dev/null
+++ b/src/gen-thumbs.js
@@ -0,0 +1,306 @@
+#!/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;
+
+import { spawn } from 'child_process';
+import { createHash } from 'crypto';
+import * as path from 'path';
+
+import {
+    readdir,
+    readFile,
+    writeFile
+} from 'fs/promises'; // Whatcha know! Nice.
+
+import {
+    createReadStream
+} from 'fs'; // Still gotta import from 8oth tho, for createReadStream.
+
+import {
+    logError,
+    logInfo,
+    logWarn,
+    parseOptions,
+    progressPromiseAll
+} from './util/cli.js';
+
+import {
+    promisifyProcess,
+} from './util/node-utils.js';
+
+import {
+    delay,
+    queue,
+} from './util/sugar.js';
+
+function traverse(startDirPath, {
+    filterFile = () => true,
+    filterDir = () => true
+} = {}) {
+    const recursive = (names, subDirPath) => Promise
+        .all(names.map(name => 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 readdir(startDirPath)
+        .then(names => recursive(names, ''));
+}
+
+function readFileMD5(filePath) {
+    return new Promise((resolve, reject) => {
+        const md5 = createHash('md5');
+        const stream = 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();
+        }
+    });
+}
+
+export default 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 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 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 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;
+}
diff --git a/src/static/client.js b/src/static/client.js
new file mode 100644
index 0000000..c12ff35
--- /dev/null
+++ b/src/static/client.js
@@ -0,0 +1,415 @@
+// This is the JS file that gets loaded on the client! It's only really used for
+// the random track feature right now - the idea is we only use it for stuff
+// that cannot 8e done at static-site compile time, 8y its fundamentally
+// ephemeral nature.
+//
+// Upd8: As of 04/02/2021, it's now used for info cards too! Nice.
+
+import {
+    getColors
+} from '../util/colors.js';
+
+let albumData, artistData, flashData;
+let officialAlbumData, fandomAlbumData, artistNames;
+
+let ready = false;
+
+// Localiz8tion nonsense ----------------------------------
+
+const language = document.documentElement.getAttribute('lang');
+
+let list;
+if (
+    typeof Intl === 'object' &&
+    typeof Intl.ListFormat === 'function'
+) {
+    const getFormat = type => {
+        const formatter = new Intl.ListFormat(language, {type});
+        return formatter.format.bind(formatter);
+    };
+
+    list = {
+        conjunction: getFormat('conjunction'),
+        disjunction: getFormat('disjunction'),
+        unit: getFormat('unit')
+    };
+} else {
+    // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free.
+    // We use the same mock for every list 'cuz we don't have any of the
+    // necessary CLDR info to appropri8tely distinguish 8etween them.
+    const arbitraryMock = array => array.join(', ');
+
+    list = {
+        conjunction: arbitraryMock,
+        disjunction: arbitraryMock,
+        unit: arbitraryMock
+    };
+}
+
+// Miscellaneous helpers ----------------------------------
+
+function rebase(href, rebaseKey = 'rebaseLocalized') {
+    const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/';
+    if (relative) {
+        return relative + href;
+    } else {
+        return href;
+    }
+}
+
+function pick(array) {
+    return array[Math.floor(Math.random() * array.length)];
+}
+
+function cssProp(el, key) {
+    return getComputedStyle(el).getPropertyValue(key).trim();
+}
+
+function getRefDirectory(ref) {
+    return ref.split(':')[1];
+}
+
+function getAlbum(el) {
+    const directory = cssProp(el, '--album-directory');
+    return albumData.find(album => album.directory === directory);
+}
+
+function getFlash(el) {
+    const directory = cssProp(el, '--flash-directory');
+    return flashData.find(flash => flash.directory === directory);
+}
+
+// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to
+// separ8te the tooling around that into common-shared code too.
+const getLinkHref = (type, directory) => rebase(`${type}/${directory}`);
+const openAlbum = d => rebase(`album/${d}`);
+const openTrack = d => rebase(`track/${d}`);
+const openArtist = d => rebase(`artist/${d}`);
+const openFlash = d => rebase(`flash/${d}`);
+
+function getTrackListAndIndex() {
+    const album = getAlbum(document.body);
+    const directory = cssProp(document.body, '--track-directory');
+    if (!directory && !album) return {};
+    if (!directory) return {list: album.tracks};
+    const trackIndex = album.tracks.findIndex(track => track.directory === directory);
+    return {list: album.tracks, index: trackIndex};
+}
+
+function openRandomTrack() {
+    const { list } = getTrackListAndIndex();
+    if (!list) return;
+    return openTrack(pick(list));
+}
+
+function getFlashListAndIndex() {
+    const list = flashData.filter(flash => !flash.act8r8k)
+    const flash = getFlash(document.body);
+    if (!flash) return {list};
+    const flashIndex = list.indexOf(flash);
+    return {list, index: flashIndex};
+}
+
+// TODO: This should also use urlSpec.
+function fetchData(type, directory) {
+    return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData'))
+        .then(res => res.json());
+}
+
+// JS-based links -----------------------------------------
+
+for (const a of document.body.querySelectorAll('[data-random]')) {
+    a.addEventListener('click', evt => {
+        if (!ready) {
+            evt.preventDefault();
+            return;
+        }
+
+        setTimeout(() => {
+            a.href = rebase('js-disabled');
+        });
+        switch (a.dataset.random) {
+            case 'album': return a.href = openAlbum(pick(albumData).directory);
+            case 'album-in-fandom': return a.href = openAlbum(pick(fandomAlbumData).directory);
+            case 'album-in-official': return a.href = openAlbum(pick(officialAlbumData).directory);
+            case 'track': return a.href = openTrack(getRefDirectory(pick(albumData.map(a => a.tracks).reduce((a, b) => a.concat(b), []))));
+            case 'track-in-album': return a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)));
+            case 'track-in-fandom': return a.href = openTrack(getRefDirectory(pick(fandomAlbumData.reduce((acc, album) => acc.concat(album.tracks), []))));
+            case 'track-in-official': return a.href = openTrack(getRefDirectory(pick(officialAlbumData.reduce((acc, album) => acc.concat(album.tracks), []))));
+            case 'artist': return a.href = openArtist(pick(artistData).directory);
+            case 'artist-more-than-one-contrib': return a.href = openArtist(pick(artistData.filter(artist => C.getArtistNumContributions(artist) > 1)).directory);
+        }
+    });
+}
+
+const next = document.getElementById('next-button');
+const previous = document.getElementById('previous-button');
+const random = document.getElementById('random-button');
+
+const prependTitle = (el, prepend) => {
+    const existing = el.getAttribute('title');
+    if (existing) {
+        el.setAttribute('title', prepend + ' ' + existing);
+    } else {
+        el.setAttribute('title', prepend);
+    }
+};
+
+if (next) prependTitle(next, '(Shift+N)');
+if (previous) prependTitle(previous, '(Shift+P)');
+if (random) prependTitle(random, '(Shift+R)');
+
+document.addEventListener('keypress', event => {
+    if (event.shiftKey) {
+        if (event.charCode === 'N'.charCodeAt(0)) {
+            if (next) next.click();
+        } else if (event.charCode === 'P'.charCodeAt(0)) {
+            if (previous) previous.click();
+        } else if (event.charCode === 'R'.charCodeAt(0)) {
+            if (random && ready) random.click();
+        }
+    }
+});
+
+for (const reveal of document.querySelectorAll('.reveal')) {
+    reveal.addEventListener('click', event => {
+        if (!reveal.classList.contains('revealed')) {
+            reveal.classList.add('revealed');
+            event.preventDefault();
+            event.stopPropagation();
+        }
+    });
+}
+
+const elements1 = document.getElementsByClassName('js-hide-once-data');
+const elements2 = document.getElementsByClassName('js-show-once-data');
+
+for (const element of elements1) element.style.display = 'block';
+
+fetch(rebase('data.json', 'rebaseShared')).then(data => data.json()).then(data => {
+    albumData = data.albumData;
+    artistData = data.artistData;
+    flashData = data.flashData;
+
+    officialAlbumData = albumData.filter(album => album.groups.includes('group:official'));
+    fandomAlbumData = albumData.filter(album => !album.groups.includes('group:official'));
+    artistNames = artistData.filter(artist => !artist.alias).map(artist => artist.name);
+
+    for (const element of elements1) element.style.display = 'none';
+    for (const element of elements2) element.style.display = 'block';
+
+    ready = true;
+});
+
+// Data & info card ---------------------------------------
+
+const NORMAL_HOVER_INFO_DELAY = 750;
+const FAST_HOVER_INFO_DELAY = 250;
+const END_FAST_HOVER_DELAY = 500;
+const HIDE_HOVER_DELAY = 250;
+
+let fastHover = false;
+let endFastHoverTimeout = null;
+
+function colorLink(a, color) {
+    if (color) {
+        const { primary, dim } = getColors(color);
+        a.style.setProperty('--primary-color', primary);
+        a.style.setProperty('--dim-color', dim);
+    }
+}
+
+function link(a, type, {name, directory, color}) {
+    colorLink(a, color);
+    a.innerText = name
+    a.href = getLinkHref(type, directory);
+}
+
+function joinElements(type, elements) {
+    // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on
+    // strings. So instead, we'll pass the element's outer HTML's (which means
+    // the entire HTML of that element).
+    //
+    // That does mean this function returns a string, so always 8e sure to
+    // set innerHTML when using it (not appendChild).
+
+    return list[type](elements.map(el => el.outerHTML));
+}
+
+const infoCard = (() => {
+    const container = document.getElementById('info-card-container');
+
+    let cancelShow = false;
+    let hideTimeout = null;
+    let showing = false;
+
+    container.addEventListener('mouseenter', cancelHide);
+    container.addEventListener('mouseleave', readyHide);
+
+    function show(type, target) {
+        cancelShow = false;
+
+        fetchData(type, target.dataset[type]).then(data => {
+            // Manual DOM 'cuz we're laaaazy.
+
+            if (cancelShow) {
+                return;
+            }
+
+            showing = true;
+
+            const rect = target.getBoundingClientRect();
+
+            container.style.setProperty('--primary-color', data.color);
+
+            container.style.top = window.scrollY + rect.bottom + 'px';
+            container.style.left = window.scrollX + rect.left + 'px';
+
+            // Use a short timeout to let a currently hidden (or not yet shown)
+            // info card teleport to the position set a8ove. (If it's currently
+            // shown, it'll transition to that position.)
+            setTimeout(() => {
+                container.classList.remove('hide');
+                container.classList.add('show');
+            }, 50);
+
+            // 8asic details.
+
+            const nameLink = container.querySelector('.info-card-name a');
+            link(nameLink, 'track', data);
+
+            const albumLink = container.querySelector('.info-card-album a');
+            link(albumLink, 'album', data.album);
+
+            const artistSpan = container.querySelector('.info-card-artists span');
+            artistSpan.innerHTML = joinElements('conjunction', data.artists.map(({ artist }) => {
+                const a = document.createElement('a');
+                a.href = getLinkHref('artist', artist.directory);
+                a.innerText = artist.name;
+                return a;
+            }));
+
+            const coverArtistParagraph = container.querySelector('.info-card-cover-artists');
+            const coverArtistSpan = coverArtistParagraph.querySelector('span');
+            if (data.coverArtists.length) {
+                coverArtistParagraph.style.display = 'block';
+                coverArtistSpan.innerHTML = joinElements('conjunction', data.coverArtists.map(({ artist }) => {
+                    const a = document.createElement('a');
+                    a.href = getLinkHref('artist', artist.directory);
+                    a.innerText = artist.name;
+                    return a;
+                }));
+            } else {
+                coverArtistParagraph.style.display = 'none';
+            }
+
+            // Cover art.
+
+            const [ containerNoReveal, containerReveal ] = [
+                container.querySelector('.info-card-art-container.no-reveal'),
+                container.querySelector('.info-card-art-container.reveal')
+            ];
+
+            const [ containerShow, containerHide ] = (data.cover.warnings.length
+                ? [containerReveal, containerNoReveal]
+                : [containerNoReveal, containerReveal]);
+
+            containerHide.style.display = 'none';
+            containerShow.style.display = 'block';
+
+            const img = containerShow.querySelector('.info-card-art');
+            img.src = rebase(data.cover.paths.small, 'rebaseMedia');
+
+            const imgLink = containerShow.querySelector('a');
+            colorLink(imgLink, data.color);
+            imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia');
+
+            if (containerShow === containerReveal) {
+                const cw = containerShow.querySelector('.info-card-art-warnings');
+                cw.innerText = list.unit(data.cover.warnings);
+
+                const reveal = containerShow.querySelector('.reveal');
+                reveal.classList.remove('revealed');
+            }
+        });
+    }
+
+    function hide() {
+        container.classList.remove('show');
+        container.classList.add('hide');
+        cancelShow = true;
+        showing = false;
+    }
+
+    function readyHide() {
+        if (!hideTimeout && showing) {
+            hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY);
+        }
+    }
+
+    function cancelHide() {
+        if (hideTimeout) {
+            clearTimeout(hideTimeout);
+            hideTimeout = null;
+        }
+    }
+
+    return {
+        show,
+        hide,
+        readyHide,
+        cancelHide
+    };
+})();
+
+function makeInfoCardLinkHandlers(type) {
+    let hoverTimeout = null;
+
+    return {
+        mouseenter(evt) {
+            hoverTimeout = setTimeout(() => {
+                fastHover = true;
+                infoCard.show(type, evt.target);
+            }, fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY);
+
+            clearTimeout(endFastHoverTimeout);
+            endFastHoverTimeout = null;
+
+            infoCard.cancelHide();
+        },
+
+        mouseleave(evt) {
+            clearTimeout(hoverTimeout);
+
+            if (fastHover && !endFastHoverTimeout) {
+                endFastHoverTimeout = setTimeout(() => {
+                    endFastHoverTimeout = null;
+                    fastHover = false;
+                }, END_FAST_HOVER_DELAY);
+            }
+
+            infoCard.readyHide();
+        }
+    };
+}
+
+const infoCardLinkHandlers = {
+    track: makeInfoCardLinkHandlers('track')
+};
+
+function addInfoCardLinkHandlers(type) {
+    for (const a of document.querySelectorAll(`a[data-${type}]`)) {
+        for (const [ eventName, handler ] of Object.entries(infoCardLinkHandlers[type])) {
+            a.addEventListener(eventName, handler);
+        }
+    }
+}
+
+// Info cards are disa8led for now since they aren't quite ready for release,
+// 8ut you can try 'em out 8y setting this localStorage flag!
+//
+//     localStorage.tryInfoCards = true;
+//
+if (localStorage.tryInfoCards) {
+    addInfoCardLinkHandlers('track');
+}
diff --git a/src/static/icons.svg b/src/static/icons.svg
new file mode 100644
index 0000000..1e4351b
--- /dev/null
+++ b/src/static/icons.svg
@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40" display="none" width="0" height="0">
+	<symbol id="icon-globe" viewBox="0 0 40 40"><path d="M20,3C10.6,3,3,10.6,3,20s7.6,17,17,17s17-7.6,17-17S29.4,3,20,3z M34.8,18.9h-6.2c-0.1-2.2-0.3-4.2-0.8-6.1 c1.5-0.4,3-0.9,4.2-1.5c0.6,0.9,1.2,1.8,1.6,2.9C34.3,15.7,34.7,17.3,34.8,18.9z M25.7,26.7c-1.5-0.3-3.1-0.4-4.6-0.5v-5.1h5.4 c-0.1,1.8-0.3,3.5-0.6,5.1C25.8,26.3,25.8,26.5,25.7,26.7z M14.2,26.2c-0.3-1.6-0.6-3.3-0.6-5.1h5.4v5.1c-1.6,0-3.2,0.2-4.6,0.5 C14.2,26.5,14.2,26.3,14.2,26.2z M14.3,13.3c1.5,0.3,3.1,0.4,4.6,0.5v5.1h-5.4c0.1-1.8,0.3-3.5,0.6-5.1 C14.2,13.7,14.2,13.5,14.3,13.3z M21.1,5.4C21.4,5.6,21.7,5.7,22,6c0.8,0.7,1.6,1.7,2.2,3c0.4,0.7,0.7,1.5,0.9,2.3 c-1.3,0.2-2.7,0.4-4,0.4V5.4z M18,6c0.3-0.3,0.6-0.4,0.9-0.6v6.2c-1.4,0-2.8-0.2-4-0.4c0.3-0.8,0.6-1.6,0.9-2.3 C16.5,7.7,17.2,6.7,18,6z M18.9,28.4v6.2c-0.3-0.1-0.6-0.3-0.9-0.6c-0.8-0.7-1.6-1.7-2.2-3c-0.4-0.7-0.7-1.5-0.9-2.3 C16.2,28.6,17.5,28.4,18.9,28.4z M22,34c-0.3,0.3-0.6,0.4-0.9,0.6v-6.2c1.4,0,2.8,0.2,4,0.4c-0.3,0.8-0.6,1.6-0.9,2.3 C23.5,32.3,22.8,33.3,22,34z M21.1,18.9v-5.1c1.6,0,3.2-0.2,4.6-0.5c0,0.2,0.1,0.4,0.1,0.5c0.3,1.6,0.6,3.3,0.6,5.1H21.1z M30.5,9.5 c0,0,0.1,0.1,0.1,0.1c-1,0.4-2.2,0.8-3.4,1.1c-0.6-1.9-1.4-3.5-2.4-4.8c0.3,0.1,0.6,0.2,0.9,0.3C27.5,7.1,29.1,8.1,30.5,9.5z M14.2,6.3c0.3-0.1,0.6-0.2,0.9-0.3c-0.9,1.3-1.7,2.9-2.4,4.8c-1.2-0.3-2.3-0.7-3.4-1.1c0,0,0.1-0.1,0.1-0.1 C10.9,8.1,12.5,7.1,14.2,6.3z M7.9,11.4c1.3,0.6,2.7,1.1,4.2,1.5c-0.4,1.9-0.7,3.9-0.8,6.1H5.2c0.1-1.6,0.5-3.2,1.1-4.7 C6.8,13.2,7.3,12.3,7.9,11.4z M5.2,21.1h6.2c0.1,2.2,0.3,4.2,0.8,6.1c-1.5,0.4-3,0.9-4.2,1.5c-0.6-0.9-1.2-1.8-1.6-2.9 C5.7,24.3,5.3,22.7,5.2,21.1z M9.5,30.5c0,0-0.1-0.1-0.1-0.1c1-0.4,2.2-0.8,3.4-1.1c0.6,1.9,1.4,3.5,2.4,4.8 c-0.3-0.1-0.6-0.2-0.9-0.3C12.5,32.9,10.9,31.9,9.5,30.5z M25.8,33.7c-0.3,0.1-0.6,0.2-0.9,0.3c0.9-1.3,1.7-2.9,2.4-4.8 c1.2,0.3,2.3,0.7,3.4,1.1c0,0-0.1,0.1-0.1,0.1C29.1,31.9,27.5,32.9,25.8,33.7z M32.1,28.6c-1.3-0.6-2.7-1.1-4.2-1.5 c0.4-1.9,0.7-3.9,0.8-6.1h6.2c-0.1,1.6-0.5,3.2-1.1,4.7C33.2,26.8,32.7,27.7,32.1,28.6z"/></symbol>
+	<symbol id="icon-bandcamp" viewBox="0 0 40 40"><path d="M7.1,13.3c5.6,0,11.1,0,16.7,0c0,1.5,0,3.1,0,4.6c0.7-0.7,1.5-1.5,3.2-1.3c2.6,0.3,3.8,3,3.6,5.6c-0.1,1.1-0.5,2.4-1.3,3.1 c-0.9,0.9-2.9,1.4-4.6,0.5c-0.4-0.2-0.7-0.6-1-1.1c0,0.4,0,0.8,0,1.3c-0.6,0-1.3,0-1.9,0c0-4.2,0-8.3,0-12.5 c-2.3,3.9-4.6,8.4-6.9,12.5c-4.9,0-9.8,0-14.7,0C2.5,21.7,4.8,17.5,7.1,13.3L7.1,13.3z M24.3,19c-1.4,1.9-0.4,6.7,2.8,5.5 c2.4-0.9,2-6.6-1.2-6.3C25.2,18.3,24.7,18.5,24.3,19L24.3,19z"/> <path d="M39.7,19.9c-0.6,0-1.3,0-2,0c0-1.6-1.9-2-3.1-1.5c-2.3,1.1-1.8,7.1,1.6,6.2c0.8-0.2,1.2-0.9,1.4-2c0.6,0,1.3,0,2,0 c-0.1,2.4-2.1,3.9-4.4,3.7c-2.1-0.1-3.8-1.8-4-4.2c-0.2-2.9,1.3-5.9,5-5.5C38.3,16.8,39.6,17.9,39.7,19.9z"/></symbol>
+	<symbol id="icon-deviantart" viewBox="0 0 40 40"><path d="M30,9.2L24,20.9l0.5,0.6H30v8.3H19.9L19,30.5l-2.8,5.5l-0.6,0.6h-6v-6.1l6.1-11.7l-0.5-0.6H9.5V9.8h10.2l0.9-0.6l2.8-5.5 L24,3.2h6C30,3.2,30,9.2,30,9.2z"/></symbol>
+	<symbol id="icon-soundcloud" viewBox="0 0 40 40"><path d="M13.8,27.4l0.3-4.2L13.8,14c0-0.1-0.1-0.2-0.1-0.3c-0.1-0.1-0.2-0.1-0.3-0.1c-0.1,0-0.2,0-0.3,0.1C13.1,13.8,13,13.9,13,14 l-0.2,9.2l0.2,4.2c0,0.1,0.1,0.2,0.1,0.3c0.1,0.1,0.2,0.1,0.3,0.1C13.7,27.8,13.8,27.7,13.8,27.4z M18.8,26.9l0.2-3.7l-0.2-10.3 c0-0.2-0.1-0.3-0.2-0.4c-0.1-0.1-0.2-0.1-0.3-0.1s-0.2,0-0.3,0.1c-0.1,0.1-0.2,0.2-0.2,0.4l0,0.1l-0.2,10.1c0,0,0.1,1.4,0.2,4.1v0 c0,0.1,0,0.2,0.1,0.3c0.1,0.1,0.2,0.2,0.4,0.2c0.1,0,0.2-0.1,0.3-0.2c0.1-0.1,0.2-0.2,0.2-0.4L18.8,26.9z M1.2,20.9l0.3,2.2 l-0.3,2.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.1-0.1-0.2-0.2l-0.3-2.2l0.3-2.2c0-0.1,0.1-0.2,0.2-0.2S1.2,20.8,1.2,20.9z M2.7,19.5 l0.4,3.6l-0.4,3.6c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L2,23.2l0.4-3.6c0-0.1,0.1-0.2,0.2-0.2C2.6,19.4,2.7,19.4,2.7,19.5z M4.2,18.9l0.4,4.3l-0.4,4.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2l-0.4-4.2l0.4-4.3c0-0.1,0.1-0.2,0.2-0.2 C4.2,18.7,4.2,18.7,4.2,18.9z M5.8,18.8l0.4,4.4l-0.4,4.3c0,0.2-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L5,23.2l0.4-4.4 c0-0.2,0.1-0.2,0.2-0.2C5.7,18.5,5.8,18.6,5.8,18.8z M7.4,19.1l0.4,4.1l-0.4,4.3c0,0.2-0.1,0.3-0.3,0.3c-0.1,0-0.1,0-0.2-0.1 c-0.1-0.1-0.1-0.1-0.1-0.2l-0.3-4.3l0.3-4.1c0-0.1,0-0.1,0.1-0.2C7,18.8,7,18.8,7.1,18.8C7.3,18.8,7.4,18.9,7.4,19.1L7.4,19.1z M9,16.5l0.4,6.7L9,27.5c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3l0.3-6.7 c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.1,0,0.2,0.1S9,16.4,9,16.5z M10.5,15l0.3,8.2l-0.3,4.3c0,0.1,0,0.2-0.1,0.2 c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3L9.9,15c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.2,0,0.2,0.1 C10.5,14.8,10.5,14.9,10.5,15z M12.2,14.3l0.3,8.9l-0.3,4.2c0,0.2-0.1,0.4-0.4,0.4c-0.2,0-0.3-0.1-0.4-0.4l-0.3-4.2l0.3-8.9 c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.2-0.1c0.1,0,0.2,0,0.3,0.1C12.1,14.1,12.2,14.2,12.2,14.3z M18.8,27.3L18.8,27.3L18.8,27.3z M15.4,14.2l0.3,8.9l-0.3,4.2c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1s-0.1-0.2-0.1-0.3l-0.2-4.2 l0.2-8.9c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C15.4,14,15.4,14.1,15.4,14.2L15.4,14.2z M17.1,14.6 l0.2,8.6l-0.2,4.1c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1c-0.1-0.1-0.1-0.2-0.2-0.3L16,23.2l0.2-8.6 c0-0.1,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C17.1,14.3,17.1,14.4,17.1,14.6z M20.7,23.2l-0.2,4 c0,0.2-0.1,0.3-0.2,0.4c-0.1,0.1-0.2,0.2-0.4,0.2c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4l-0.1-2l-0.1-2L19.4,12V12 c0-0.2,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1c0.2,0.1,0.2,0.2,0.3,0.5L20.7,23.2z M39.4,22.9 c0,1.4-0.5,2.5-1.4,3.5c-0.9,1-2,1.4-3.4,1.4H21.4c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4V11.5c0-0.3,0.2-0.5,0.5-0.6 c1-0.4,2-0.6,3-0.6c2.2,0,4.1,0.8,5.7,2.3c1.6,1.5,2.5,3.4,2.7,5.7c0.6-0.3,1.2-0.4,1.8-0.4c1.3,0,2.4,0.5,3.4,1.5 C38.9,20.3,39.4,21.5,39.4,22.9L39.4,22.9z"/></symbol>
+	<symbol id="icon-tumblr" viewBox="0 0 40 40"><path d="M26.8,29.7l1.6,4.6c-0.3,0.5-1,0.9-2.2,1.3s-2.3,0.6-3.4,0.6c-1.4,0-2.6-0.1-3.7-0.5s-2.1-0.8-2.8-1.4 c-0.7-0.6-1.3-1.3-1.9-2.1c-0.5-0.8-0.9-1.6-1.1-2.3c-0.2-0.8-0.3-1.5-0.3-2.3V16.9H9.7v-4.2c0.9-0.3,1.8-0.8,2.5-1.4 s1.3-1.1,1.8-1.8s0.8-1.3,1.1-2c0.3-0.7,0.5-1.4,0.7-1.9S16,4.6,16.1,4c0-0.1,0-0.1,0.1-0.2s0.1-0.1,0.1-0.1h4.8V12h6.5v4.9h-6.5V27 c0,0.4,0,0.8,0.1,1.1c0.1,0.3,0.2,0.7,0.4,1s0.5,0.6,1,0.8c0.4,0.2,1,0.3,1.6,0.3C25.2,30.2,26.1,30,26.8,29.7L26.8,29.7z"/></symbol>
+	<symbol id="icon-twitter" viewBox="0 0 40 40"><path d="M36.3,10.2c-1,1.3-2.1,2.5-3.4,3.5c0,0.2,0,0.4,0,1c0,1.7-0.2,3.6-0.9,5.3c-0.6,1.7-1.2,3.5-2.4,5.1 c-1.1,1.5-2.3,3.1-3.7,4.3c-1.4,1.2-3.3,2.3-5.3,3c-2.1,0.8-4.2,1.2-6.6,1.2c-3.6,0-7-1-10.2-3c0.4,0,1.1,0.1,1.5,0.1 c3.1,0,5.9-1,8.2-2.9c-1.4,0-2.7-0.4-3.8-1.3c-1.2-1-1.9-2-2.2-3.3c0.4,0.1,1,0.1,1.2,0.1c0.6,0,1.2-0.1,1.7-0.2 c-1.4-0.3-2.7-1.1-3.7-2.3s-1.4-2.6-1.4-4.2v-0.1c1,0.6,2,0.9,3,0.9c-1-0.6-1.5-1.3-2.2-2.4c-0.6-1-0.9-2.1-0.9-3.3s0.3-2.3,1-3.4 c1.5,2.1,3.6,3.6,6,4.9s4.9,2,7.6,2.1c-0.1-0.6-0.1-1.1-0.1-1.4c0-1.8,0.8-3.5,2-4.7c1.2-1.2,2.9-2,4.7-2c2,0,3.6,0.8,4.8,2.1 c1.4-0.3,2.9-0.9,4.2-1.5c-0.4,1.5-1.4,2.7-2.9,3.6C33.8,11.2,35.1,10.9,36.3,10.2L36.3,10.2z"/></symbol>
+	<symbol id="icon-youtube" viewBox="0 0 40 40"><path d="M24.3,27v4.2c0,0.9-0.3,1.3-0.8,1.3c-0.3,0-0.6-0.1-0.9-0.4v-6c0.3-0.3,0.6-0.4,0.9-0.4C24,25.6,24.3,26.1,24.3,27L24.3,27z M31.1,27v0.9h-1.8V27c0-0.9,0.3-1.4,0.9-1.4C30.8,25.6,31.1,26.1,31.1,27L31.1,27z M11.7,22.6h2.1v-1.9H7.6v1.9h2.1v11.4h2 L11.7,22.6L11.7,22.6z M17.5,34.1h1.8v-9.9h-1.8v7.6c-0.4,0.6-0.8,0.8-1.1,0.8c-0.2,0-0.4-0.1-0.4-0.4c0,0,0-0.3,0-0.7v-7.3h-1.8V32 c0,0.7,0.1,1.1,0.2,1.5c0.2,0.5,0.5,0.7,1.2,0.7c0.6,0,1.3-0.4,2-1.2L17.5,34.1L17.5,34.1z M26.1,31.1v-4c0-1-0.1-1.6-0.2-2 c-0.2-0.7-0.7-1.1-1.4-1.1c-0.7,0-1.3,0.4-1.9,1.1v-4.4h-1.8v13.3h1.8v-1c0.6,0.7,1.2,1.1,1.9,1.1c0.7,0,1.2-0.4,1.4-1.1 C26,32.7,26.1,32.1,26.1,31.1L26.1,31.1z M32.9,30.9v-0.3H31c0,0.7,0,1.1,0,1.2c-0.1,0.5-0.4,0.7-0.8,0.7c-0.6,0-0.9-0.5-0.9-1.4 v-1.7h3.6v-2.1c0-1.1-0.2-1.8-0.5-2.3c-0.5-0.7-1.2-1-2.1-1c-0.9,0-1.6,0.3-2.1,1c-0.4,0.5-0.6,1.3-0.6,2.3v3.5 c0,1.1,0.2,1.8,0.6,2.3c0.5,0.7,1.2,1,2.2,1c1,0,1.7-0.4,2.2-1.1c0.2-0.4,0.4-0.7,0.4-1.1C32.9,31.9,32.9,31.5,32.9,30.9L32.9,30.9z M20.7,12.5V8.3c0-0.9-0.3-1.4-0.9-1.4c-0.6,0-0.9,0.5-0.9,1.4v4.2c0,0.9,0.3,1.4,0.9,1.4C20.4,14,20.7,13.5,20.7,12.5z M35.1,27.6 c0,3.1-0.2,5.5-0.5,7c-0.2,0.8-0.6,1.5-1.2,2c-0.6,0.5-1.3,0.8-2,0.9c-2.5,0.3-6.2,0.4-11.1,0.4s-8.7-0.1-11.1-0.4 c-0.8-0.1-1.5-0.4-2.1-0.9c-0.6-0.5-1-1.2-1.2-2c-0.3-1.5-0.5-3.8-0.5-7c0-3.1,0.2-5.5,0.5-7c0.2-0.8,0.6-1.5,1.2-2 c0.6-0.5,1.3-0.9,2.1-0.9c2.5-0.3,6.2-0.4,11.1-0.4s8.7,0.1,11.1,0.4c0.8,0.1,1.5,0.4,2.1,0.9c0.6,0.5,1,1.2,1.2,2 C34.9,22.1,35.1,24.4,35.1,27.6z M15.1,2h2l-2.4,8v5.4h-2V10c-0.2-1-0.6-2.4-1.2-4.3c-0.5-1.4-0.9-2.6-1.3-3.8h2.1l1.4,5.3L15.1,2z M22.5,8.7v3.5c0,1.1-0.2,1.9-0.6,2.4c-0.5,0.7-1.2,1-2.1,1c-0.9,0-1.6-0.3-2.1-1c-0.4-0.5-0.6-1.3-0.6-2.4V8.7 c0-1.1,0.2-1.9,0.6-2.3c0.5-0.7,1.2-1,2.1-1c0.9,0,1.6,0.3,2.1,1C22.3,6.8,22.5,7.6,22.5,8.7z M29.2,5.4v10h-1.8v-1.1 c-0.7,0.8-1.4,1.2-2.1,1.2c-0.6,0-1-0.2-1.2-0.7C24,14.5,24,14,24,13.4V5.4h1.8v7.4c0,0.4,0,0.7,0,0.7c0,0.3,0.2,0.4,0.4,0.4 c0.4,0,0.7-0.3,1.1-0.9V5.4C27.4,5.4,29.2,5.4,29.2,5.4z"/></symbol>
+    <symbol id="icon-instagram" viewBox="0 0 40 40"><path d="M20,7c4.2,0,4.7,0,6.3,0.1c1.5,0.1,2.3,0.3,3,0.5C30,8,30.5,8.3,31.1,8.9c0.5,0.5,0.9,1.1,1.2,1.8c0.2,0.5,0.5,1.4,0.5,3 C33,15.3,33,15.8,33,20s0,4.7-0.1,6.3c-0.1,1.5-0.3,2.3-0.5,3c-0.3,0.7-0.6,1.2-1.2,1.8c-0.5,0.5-1.1,0.9-1.8,1.2 c-0.5,0.2-1.4,0.5-3,0.5C24.7,33,24.2,33,20,33s-4.7,0-6.3-0.1c-1.5-0.1-2.3-0.3-3-0.5C10,32,9.5,31.7,8.9,31.1 C8.4,30.6,8,30,7.7,29.3c-0.2-0.5-0.5-1.4-0.5-3C7,24.7,7,24.2,7,20s0-4.7,0.1-6.3c0.1-1.5,0.3-2.3,0.5-3C8,10,8.3,9.5,8.9,8.9 C9.4,8.4,10,8,10.7,7.7c0.5-0.2,1.4-0.5,3-0.5C15.3,7.1,15.8,7,20,7z M20,4.3c-4.3,0-4.8,0-6.5,0.1c-1.6,0-2.8,0.3-3.8,0.7 C8.7,5.5,7.8,6,6.9,6.9C6,7.8,5.5,8.7,5.1,9.7c-0.4,1-0.6,2.1-0.7,3.8c-0.1,1.7-0.1,2.2-0.1,6.5s0,4.8,0.1,6.5 c0,1.6,0.3,2.8,0.7,3.8c0.4,1,0.9,1.9,1.8,2.8c0.9,0.9,1.7,1.4,2.8,1.8c1,0.4,2.1,0.6,3.8,0.7c1.6,0.1,2.2,0.1,6.5,0.1 s4.8,0,6.5-0.1c1.6-0.1,2.9-0.3,3.8-0.7c1-0.4,1.9-0.9,2.8-1.8c0.9-0.9,1.4-1.7,1.8-2.8c0.4-1,0.6-2.1,0.7-3.8 c0.1-1.6,0.1-2.2,0.1-6.5s0-4.8-0.1-6.5c-0.1-1.6-0.3-2.9-0.7-3.8c-0.4-1-0.9-1.9-1.8-2.8c-0.9-0.9-1.7-1.4-2.8-1.8 c-1-0.4-2.1-0.6-3.8-0.7C24.8,4.3,24.3,4.3,20,4.3L20,4.3L20,4.3z"/><path d="M20,11.9c-4.5,0-8.1,3.7-8.1,8.1s3.7,8.1,8.1,8.1s8.1-3.7,8.1-8.1S24.5,11.9,20,11.9z M20,25.2c-2.9,0-5.2-2.3-5.2-5.2 s2.3-5.2,5.2-5.2s5.2,2.3,5.2,5.2S22.9,25.2,20,25.2z"/><path d="M30.6,11.6c0,1-0.8,1.9-1.9,1.9c-1,0-1.9-0.8-1.9-1.9s0.8-1.9,1.9-1.9C29.8,9.7,30.6,10.5,30.6,11.6z"/></symbol>
+    <symbol id="icon-mastodon" viewBox="-20 -20 237 255"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z"/></symbol>
+</svg>
diff --git a/src/static/lazy-loading.js b/src/static/lazy-loading.js
new file mode 100644
index 0000000..a403d7c
--- /dev/null
+++ b/src/static/lazy-loading.js
@@ -0,0 +1,51 @@
+// Lazy loading! Roll your own. Woot.
+// This file includes a 8unch of fall8acks and stuff like that, and is written
+// with fairly Olden JavaScript(TM), so as to work on pretty much any 8rowser
+// with JS ena8led. (If it's disa8led, there are gener8ted <noscript> tags to
+// work there.)
+
+var observer;
+
+function loadImage(image) {
+    image.src = image.dataset.original;
+}
+
+function lazyLoad(elements) {
+    for (var i = 0; i < elements.length; i++) {
+        var item = elements[i];
+        if (item.intersectionRatio > 0) {
+            observer.unobserve(item.target);
+            loadImage(item.target);
+        }
+    }
+}
+
+function lazyLoadMain() {
+    // This is a live HTMLCollection! We can't iter8te over it normally 'cuz
+    // we'd 8e mutating its value just 8y interacting with the DOM elements it
+    // contains. A while loop works just fine, even though you'd think reading
+    // over this code that this would 8e an infinitely hanging loop. It isn't!
+    var elements = document.getElementsByClassName('js-hide');
+    while (elements.length) {
+        elements[0].classList.remove('js-hide');
+    }
+
+    var lazyElements = document.getElementsByClassName('lazy');
+    if (window.IntersectionObserver) {
+        observer = new IntersectionObserver(lazyLoad, {
+            rootMargin: '200px',
+            threshold: 1.0
+        });
+        for (var i = 0; i < lazyElements.length; i++) {
+            observer.observe(lazyElements[i]);
+        }
+    } else {
+        for (var i = 0; i < lazyElements.length; i++) {
+            var element = lazyElements[i];
+            var original = element.getAttribute('data-original');
+            element.setAttribute('src', original);
+        }
+    }
+}
+
+document.addEventListener('DOMContentLoaded', lazyLoadMain);
diff --git a/src/static/site-basic.css b/src/static/site-basic.css
new file mode 100644
index 0000000..d26584a
--- /dev/null
+++ b/src/static/site-basic.css
@@ -0,0 +1,19 @@
+/**
+ * For redirects and stuff like that.
+ * Small file, not so much helped 8y this comment.
+ */
+
+html {
+    background-color: #222222;
+    color: white;
+}
+
+body {
+    padding: 15px;
+}
+
+main {
+    background-color: rgba(0, 0, 0, 0.6);
+    border: 1px dotted white;
+    padding: 20px;
+}
diff --git a/src/static/site.css b/src/static/site.css
new file mode 100644
index 0000000..ae41f88
--- /dev/null
+++ b/src/static/site.css
@@ -0,0 +1,872 @@
+/* A frontend file! Wow.
+ * This file is just loaded statically 8y <link>s in the HTML files, so there's
+ * no need to re-run upd8.js when tweaking values here. Handy!
+ */
+
+:root {
+    --primary-color: #0088ff;
+}
+
+body {
+    background: black;
+    margin: 10px;
+    overflow-y: scroll;
+}
+
+body::before {
+    content: "";
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    z-index: -1;
+
+    background-image: url("../media/bg.jpg");
+    background-position: center;
+    background-size: cover;
+    opacity: 0.5;
+}
+
+#page-container {
+    background-color: rgba(35, 35, 35, 0.80);
+    backdrop-filter: blur(4px);
+    color: #ffffff;
+
+    max-width: 1100px;
+    margin: 10px auto 50px;
+    padding: 15px 0;
+
+    box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
+}
+
+#page-container > * {
+    margin-left: 15px;
+    margin-right: 15px;
+}
+
+#banner {
+    margin: 10px 0;
+    width: 100%;
+    background: black;
+    background-color: var(--dim-color);
+    border-bottom: 1px solid var(--primary-color);
+    position: relative;
+}
+
+#banner::after {
+    content: "";
+    box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35);
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    pointer-events: none;
+}
+
+#banner.dim img {
+    opacity: 0.8;
+}
+
+#banner img {
+    display: block;
+    width: 100%;
+    height: auto;
+}
+
+a {
+    color: var(--primary-color);
+    text-decoration: none;
+}
+
+a:hover {
+    text-decoration: underline;
+}
+
+#skippers {
+    position: absolute;
+    left: -10000px;
+    top: auto;
+    width: 1px;
+    height: 1px;
+}
+
+#skippers:focus-within {
+    position: static;
+    width: unset;
+    height: unset;
+}
+
+#skippers > .skipper:not(:last-child)::after {
+    content: " \00b7 ";
+    font-weight: 800;
+}
+
+.layout-columns {
+    display: flex;
+}
+
+#header, #skippers, #footer {
+    padding: 5px;
+    font-size: 0.85em;
+}
+
+#header, #skippers {
+    margin-bottom: 10px;
+}
+
+#footer {
+    margin-top: 10px;
+}
+
+#header {
+    display: flex;
+}
+
+#header > h2 {
+    font-size: 1em;
+    margin: 0 20px 0 0;
+    font-weight: normal;
+}
+
+#header > h2 a.current {
+    font-weight: 800;
+}
+
+#header > h2.dot-between-spans > span:not(:last-child)::after {
+    content: " \00b7 ";
+    font-weight: 800;
+}
+
+#header > h2 > span {
+    white-space: nowrap;
+}
+
+#header > div {
+    flex-grow: 1;
+}
+
+#header > div > *:not(:last-child)::after {
+    content: " \00b7 ";
+    font-weight: 800;
+}
+
+#header .chronology {
+    display: inline-block;
+}
+
+#header .chronology .heading,
+#header .chronology .buttons {
+    display: inline-block;
+}
+
+footer {
+    text-align: center;
+    font-style: oblique;
+}
+
+footer > :first-child {
+    margin-top: 0;
+}
+
+footer > :last-child {
+    margin-bottom: 0;
+}
+
+.nowrap {
+    white-space: nowrap;
+}
+
+.icons {
+    font-style: normal;
+    white-space: nowrap;
+}
+
+.icon {
+    display: inline-block;
+    width: 24px;
+    height: 1em;
+    position: relative;
+}
+
+.icon > svg {
+    width: 24px;
+    height: 24px;
+    top: -0.25em;
+    position: absolute;
+    fill: var(--primary-color);
+}
+
+.rerelease {
+    opacity: 0.7;
+    font-style: oblique;
+}
+
+.content-columns {
+    columns: 2;
+}
+
+.content-columns .column {
+    break-inside: avoid;
+}
+
+.content-columns .column h2 {
+    margin-top: 0;
+    font-size: 1em;
+}
+
+.sidebar, #content, #header, #skippers, #footer {
+    background-color: rgba(0, 0, 0, 0.6);
+    border: 1px dotted var(--primary-color);
+    border-radius: 3px;
+}
+
+.sidebar-column {
+    flex: 1 1 20%;
+    min-width: 150px;
+    max-width: 250px;
+    flex-basis: 250px;
+    height: 100%;
+}
+
+.sidebar-multiple {
+    display: flex;
+    flex-direction: column;
+}
+
+.sidebar-multiple .sidebar:not(:first-child) {
+    margin-top: 10px;
+}
+
+.sidebar {
+    padding: 5px;
+    font-size: 0.85em;
+}
+
+#sidebar-left {
+    margin-right: 10px;
+}
+
+#sidebar-right {
+    margin-left: 10px;
+}
+
+.sidebar.wide {
+    max-width: 350px;
+    flex-basis: 300px;
+    flex-shrink: 0;
+    flex-grow: 1;
+}
+
+#content {
+    padding: 20px;
+    flex-grow: 1;
+    flex-shrink: 3;
+}
+
+.sidebar > h1,
+.sidebar > h2,
+.sidebar > h3,
+.sidebar > p {
+    text-align: center;
+}
+
+.sidebar h1 {
+    font-size: 1.25em;
+}
+
+.sidebar h2 {
+    font-size: 1.1em;
+    margin: 0;
+}
+
+.sidebar h3 {
+    font-size: 1.1em;
+    font-style: oblique;
+    font-variant: small-caps;
+    margin-top: 0.3em;
+    margin-bottom: 0em;
+}
+
+.sidebar > p {
+    margin: 0.5em 0;
+    padding: 0 5px;
+}
+
+.sidebar hr {
+    color: #555;
+    margin: 10px 5px;
+}
+
+.sidebar > ol, .sidebar > ul {
+    padding-left: 30px;
+    padding-right: 15px;
+}
+
+.sidebar > dl {
+    padding-right: 15px;
+    padding-left: 0;
+}
+
+.sidebar > dl dt {
+    padding-left: 10px;
+    margin-top: 0.5em;
+}
+
+.sidebar > dl dt.current {
+    font-weight: 800;
+}
+
+.sidebar > dl dd {
+    margin-left: 0;
+}
+
+.sidebar > dl dd ul {
+    padding-left: 30px;
+    margin-left: 0;
+}
+
+.sidebar > dl .side {
+    padding-left: 10px;
+}
+
+.sidebar li.current {
+    font-weight: 800;
+}
+
+.sidebar li {
+    overflow-wrap: break-word;
+}
+
+.sidebar article {
+    text-align: left;
+    margin: 5px 5px 15px 5px;
+}
+
+.sidebar article:last-child {
+    margin-bottom: 5px;
+}
+
+.sidebar article h2,
+.news-index h2 {
+    border-bottom: 1px dotted;
+}
+
+.sidebar article h2 time,
+.news-index time {
+    float: right;
+    font-weight: normal;
+}
+
+#cover-art-container {
+    float: right;
+    width: 40%;
+    max-width: 400px;
+    margin: 0 0 10px 10px;
+    font-size: 0.8em;
+}
+
+#cover-art img {
+    display: block;
+    width: 100%;
+    height: 100%;
+}
+
+#cover-art-container p {
+    margin-top: 5px;
+}
+
+.image-container {
+    border: 2px solid var(--primary-color);
+    box-sizing: border-box;
+    position: relative;
+    padding: 5px;
+    text-align: left;
+    background-color: var(--dim-color);
+    color: white;
+    display: inline-block;
+    width: 100%;
+    height: 100%;
+}
+
+.image-inner-area {
+    overflow: hidden;
+    width: 100%;
+    height: 100%;
+}
+
+img {
+    object-fit: cover;
+    /* these unfortunately dont take effect while loading, so...
+    text-align: center;
+    line-height: 2em;
+    text-shadow: 0 0 5px black;
+    font-style: oblique;
+    */
+}
+
+.js-hide,
+.js-show-once-data,
+.js-hide-once-data {
+    display: none;
+}
+
+a.box:focus {
+    outline: 3px double var(--primary-color);
+}
+
+a.box:focus:not(:focus-visible) {
+    outline: none;
+}
+
+a.box img {
+    display: block;
+    width: 100%;
+    height: 100%;
+}
+
+h1 {
+    font-size: 1.5em;
+}
+
+#content li {
+    margin-bottom: 4px;
+}
+
+#content li i {
+    white-space: nowrap;
+}
+
+.grid-listing {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: center;
+    align-items: center;
+}
+
+.grid-item {
+    display: inline-block;
+    margin: 15px;
+    text-align: center;
+    background-color: #111111;
+    border: 1px dotted var(--primary-color);
+    border-radius: 2px;
+    padding: 5px;
+}
+
+.grid-item img {
+    width: 100%;
+    height: 100%;
+    margin-top: auto;
+    margin-bottom: auto;
+}
+
+.grid-item span {
+    overflow-wrap: break-word;
+    hyphens: auto;
+}
+
+.grid-item:hover {
+    text-decoration: none;
+}
+
+.grid-actions .grid-item:hover {
+    text-decoration: underline;
+}
+
+.grid-item > span:first-of-type {
+    margin-top: 0.45em;
+    display: block;
+}
+
+.grid-item:hover > span:first-of-type {
+    text-decoration: underline;
+}
+
+.grid-listing > .grid-item {
+    flex: 1 1 26%;
+}
+
+.grid-actions {
+    display: flex;
+    flex-direction: column;
+    margin: 15px;
+}
+
+.grid-actions > .grid-item {
+    flex-basis: unset !important;
+    margin: 5px;
+    --primary-color: inherit !important;
+    --dim-color: inherit !important;
+}
+
+.grid-item {
+    flex-basis: 240px;
+    min-width: 200px;
+    max-width: 240px;
+}
+
+.grid-item:not(.large-grid-item) {
+    flex-basis: 180px;
+    min-width: 120px;
+    max-width: 180px;
+    font-size: 0.9em;
+}
+
+.square {
+    position: relative;
+    width: 100%;
+}
+
+.square::after {
+    content: "";
+    display: block;
+    padding-bottom: 100%;
+}
+
+.square-content {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+}
+
+.vertical-square {
+    position: relative;
+    height: 100%;
+}
+
+.vertical-square::after {
+    content: "";
+    display: block;
+    padding-right: 100%;
+}
+
+.reveal {
+    position: relative;
+    width: 100%;
+    height: 100%;
+}
+
+.reveal img {
+    filter: blur(20px);
+    opacity: 0.4;
+}
+
+.reveal-text {
+    color: white;
+    position: absolute;
+    top: 15px;
+    left: 10px;
+    right: 10px;
+    text-align: center;
+    font-weight: bold;
+}
+
+.reveal-interaction {
+    opacity: 0.8;
+}
+
+.reveal.revealed img {
+    filter: none;
+    opacity: 1;
+}
+
+.reveal.revealed .reveal-text {
+    display: none;
+}
+
+#content.top-index h1,
+#content.flash-index h1 {
+    text-align: center;
+    font-size: 2em;
+}
+
+#content.flash-index h2 {
+    text-align: center;
+    font-size: 2.5em;
+    font-variant: small-caps;
+    font-style: oblique;
+    margin-bottom: 0;
+    text-align: center;
+    width: 100%;
+}
+
+#content.top-index h2 {
+    text-align: center;
+    font-size: 2em;
+    font-weight: normal;
+    margin-bottom: 0.25em;
+}
+
+.quick-info {
+    text-align: center;
+}
+
+ul.quick-info {
+    list-style: none;
+    padding-left: 0;
+}
+
+ul.quick-info li {
+    display: inline-block;
+}
+
+ul.quick-info li:not(:last-child)::after {
+    content: " \00b7 ";
+    font-weight: 800;
+}
+
+#intro-menu {
+    margin: 24px 0;
+    padding: 10px;
+    background-color: #222222;
+    text-align: center;
+    border: 1px dotted var(--primary-color);
+    border-radius: 2px;
+}
+
+#intro-menu p {
+    margin: 12px 0;
+}
+
+#intro-menu a {
+    margin: 0 6px;
+}
+
+li .by {
+    white-space: nowrap;
+    font-style: oblique;
+}
+
+p code {
+    font-size: 1em;
+    font-family: 'courier new';
+    font-weight: 800;
+}
+
+blockquote {
+    max-width: 600px;
+    margin-right: 0;
+}
+
+.long-content {
+    margin-left: 12%;
+    margin-right: 12%;
+}
+
+p img {
+    max-width: 100%;
+    height: auto;
+}
+
+dl dt {
+    padding-left: 2em;
+}
+
+dl dt {
+    margin-bottom: 2px;
+}
+
+dl dd {
+    margin-bottom: 1em;
+}
+
+dl ul, dl ol {
+    margin-top: 0;
+    margin-bottom: 0;
+}
+
+.album-group-list dt {
+    font-style: oblique;
+    padding-left: 0;
+}
+
+.album-group-list dd {
+    margin-left: 0;
+}
+
+.group-chronology-link {
+    font-style: oblique;
+}
+
+hr.split::before {
+    content: "(split)";
+    color: #808080;
+}
+
+hr.split {
+    position: relative;
+    overflow: hidden;
+    border: none;
+}
+
+hr.split::after {
+    display: inline-block;
+    content: "";
+    border: 1px inset #808080;
+    width: 100%;
+    position: absolute;
+    top: 50%;
+    margin-top: -2px;
+    margin-left: 10px;
+}
+
+li > ul {
+    margin-top: 5px;
+}
+
+#info-card-container {
+    position: absolute;
+
+    left: 0;
+    right: 10px;
+
+    pointer-events: none; /* Padding area shouldn't 8e interactive. */
+    display: none;
+}
+
+#info-card-container.show,
+#info-card-container.hide {
+    display: flex;
+}
+
+#info-card-container > * {
+    flex-basis: 400px;
+}
+
+#info-card-container.show {
+    animation: 0.2s linear forwards info-card-show;
+    transition: top 0.1s, left 0.1s;
+}
+
+#info-card-container.hide {
+    animation: 0.2s linear forwards info-card-hide;
+}
+
+@keyframes info-card-show {
+    0% {
+        opacity: 0;
+        margin-top: -5px;
+    }
+
+    100% {
+        opacity: 1;
+        margin-top: 0;
+    }
+}
+
+@keyframes info-card-hide {
+    0% {
+        opacity: 1;
+        margin-top: 0;
+    }
+
+    100% {
+        opacity: 0;
+        margin-top: 5px;
+        display: none !important;
+    }
+}
+
+.info-card-decor {
+    padding-left: 3ch;
+    border-top: 1px solid white;
+}
+
+.info-card {
+    background-color: black;
+    color: white;
+
+    border: 1px dotted var(--primary-color);
+    border-radius: 3px;
+    box-shadow: 0 5px 5px black;
+
+    padding: 5px;
+    font-size: 0.9em;
+
+    pointer-events: none;
+}
+
+.info-card::after {
+    content: "";
+    display: block;
+    clear: both;
+}
+
+#info-card-container.show .info-card {
+    animation: 0.01s linear 0.2s forwards info-card-become-interactive;
+}
+
+@keyframes info-card-become-interactive {
+    to {
+        pointer-events: auto;
+    }
+}
+
+.info-card-art-container {
+    float: right;
+
+    width: 40%;
+    margin: 5px;
+    font-size: 0.8em;
+
+    /* Dynamically shown. */
+    display: none;
+}
+
+.info-card-art-container .image-container {
+    padding: 2px;
+}
+
+.info-card-art {
+    display: block;
+    width: 100%;
+    height: 100%;
+}
+
+.info-card-name {
+    font-size: 1em;
+    border-bottom: 1px dotted;
+    margin: 0;
+}
+
+.info-card p {
+    margin-top: 0.25em;
+    margin-bottom: 0.25em;
+}
+
+.info-card p:last-child {
+    margin-bottom: 0;
+}
+
+@media (max-width: 900px) {
+    .sidebar-column:not(.no-hide) {
+        display: none;
+    }
+
+    .layout-columns.vertical-when-thin {
+        flex-direction: column;
+    }
+
+    .layout-columns.vertical-when-thin > *:not(:last-child) {
+        margin-bottom: 10px;
+    }
+
+    .sidebar-column.no-hide {
+        max-width: unset !important;
+        flex-basis: unset !important;
+        margin-right: 0 !important;
+        margin-left: 0 !important;
+    }
+
+    .sidebar .news-entry:not(.first-news-entry) {
+        display: none;
+    }
+}
+
+@media (max-width: 600px) {
+    .content-columns {
+        columns: 1;
+    }
+}
diff --git a/src/strings-default.json b/src/strings-default.json
new file mode 100644
index 0000000..7a948d6
--- /dev/null
+++ b/src/strings-default.json
@@ -0,0 +1,305 @@
+{
+    "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/src/upd8.js b/src/upd8.js
new file mode 100755
index 0000000..c0bea08
--- /dev/null
+++ b/src/upd8.js
@@ -0,0 +1,6395 @@
+#!/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!
+
+import * as path from 'path';
+import { promisify } from 'util';
+import { fileURLToPath } from 'url';
+
+// 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.
+import fixWS from 'fix-whitespace';
+// Wait nevermind, I forgot a8out why-do-kids-love-the-taste-of-cinnamon-toast-
+// crunch. THAT is my 8est li8rary.
+
+// It stands for "HTML Entities", apparently. Cursed.
+import he from 'he';
+
+import {
+    // 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.
+    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! ~~
+    //
+    // 2021 ADDENDUM: Ok, a year and a half later the a8ove is still true,
+    //                except for the part a8out promisifying, since fs/promises
+    //                already does that for us. 8ut I could STILL import it
+    //                using my own name (`readdir as readDirectory`), and yet
+    //                here I am, defin8tely not doing that.
+    //                SOME THINGS NEVER CHANGE.
+    //
+    // 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."
+    readFile,
+    writeFile,
+    access,
+    mkdir,
+    symlink,
+    unlink
+} from 'fs/promises';
+
+import genThumbs from './gen-thumbs.js';
+import * as html from './util/html.js';
+import link from './util/link.js';
+
+import {
+    decorateTime,
+    logWarn,
+    logInfo,
+    logError,
+    parseOptions,
+    progressPromiseAll
+} from './util/cli.js';
+
+import {
+    getLinkThemeString,
+    getThemeString
+} from './util/colors.js';
+
+import {
+    chunkByConditions,
+    chunkByProperties,
+    getAllTracks,
+    getArtistCommentary,
+    getArtistNumContributions,
+    getKebabCase,
+    sortByArtDate,
+    sortByDate,
+    sortByName
+} from './util/wiki-data.js';
+
+import {
+    call,
+    filterEmptyLines,
+    mapInPlace,
+    queue,
+    splitArray,
+    unique,
+    withEntries
+} from './util/sugar.js';
+
+import {
+    generateURLs,
+    thumb
+} from './util/urls.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+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 UNRELEASED_TRACKS_DIRECTORY = 'unreleased-tracks';
+const OFFICIAL_GROUP_DIRECTORY = 'official';
+const FANDOM_GROUP_DIRECTORY = 'fandom';
+
+// Code that's common 8etween the 8uild code (i.e. upd8.js) and gener8ted
+// site code should 8e put here. Which, uh, ~~only really means this one
+// file~~ is now a variety of useful utilities!
+//
+// Rather than hard code it, anything in this directory can 8e shared across
+// 8oth ends of the code8ase.
+// (This gets symlinked into the --data directory.)
+const UTILITY_DIRECTORY = 'util';
+
+// Code that's used only in the static site! CSS, cilent JS, etc.
+// (This gets symlinked into the --data directory.)
+const STATIC_DIRECTORY = 'static';
+
+// Su8directory under provided --data directory for al8um files, which are
+// read from and processed to compose the majority of album and track data.
+const DATA_ALBUM_DIRECTORY = 'album';
+
+// 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 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: '<>',
+
+            utilityRoot: 'util',
+            staticRoot: 'static',
+
+            utilityFile: 'util/<>',
+            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 urls = generateURLs(urlSpec);
+
+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 = 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 = 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 = getKebabCase(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 = 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 = 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.color);
+
+    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 type="module" src="${to('shared.staticFile', `client.js?${CACHEBUST}`)}"></script>
+            </body>
+        </html>
+    `);
+};
+
+writePage.write = async (content, {paths}) => {
+    await mkdir(paths.outputDirectory, {recursive: true});
+    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.color)}">
+            ${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, UTILITY_DIRECTORY), 'shared.utilityRoot'),
+        link(path.join(__dirname, STATIC_DIRECTORY), 'shared.staticRoot'),
+        link(mediaPath, 'media.root')
+    ]);
+
+    async function link(directory, urlKey) {
+        const pathname = urls.from('shared.root').to(urlKey);
+        const file = path.join(outputPath, pathname);
+        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 mkdir(path.join(outputPath, from), {recursive: true});
+        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.color)}">
+                        <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.color)}">${
+            (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.color, [
+            `--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 === OFFICIAL_GROUP_DIRECTORY);
+    const ttrFanon = (useDividedReferences &&
+        tracksThatReference.filter(t => t.album.groups.every(group => group.directory !== OFFICIAL_GROUP_DIRECTORY)));
+    const ttrOfficial = (useDividedReferences &&
+        tracksThatReference.filter(t => t.album.groups.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY)));
+
+    const tracksReferenced = track.references;
+    const otherReleases = track.otherReleases;
+    const listTag = getAlbumListTag(album);
+
+    let flashesThatFeature;
+    if (wikiInfo.features.flashesAndGames) {
+        flashesThatFeature = 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.color, [
+            `--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 !== 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 = sortByDate(unique([...artist.albums.asCoverArtist, ...artist.albums.asWallpaperArtist, ...artist.albums.asBannerArtist, ...artist.tracks.asCoverArtist]));
+    const artThingsGallery = sortByDate([...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist]);
+    const commentaryThings = 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 = sortByDate(unique([...artist.tracks.asArtist, ...artist.tracks.asContributor]));
+    const unreleasedTracks = allTracks.filter(track => track.album.directory === UNRELEASED_TRACKS_DIRECTORY);
+    const releasedTracks = allTracks.filter(track => track.album.directory !== 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 = 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(jumpColor)}">${jump}</a></li>
+                        `).join('\n')}
+                    </ul>
+                </div>
+                ${flashActData.map((act, i) => fixWS`
+                    <h2 id="${act.anchor}" style="${getLinkThemeString(act.color)}"><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.color, [
+            `--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.color)}">${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 sortByDate(albumData
+                .filter(album => album.directory !== 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: 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 !== 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: sortByDate(artistData
+                    .filter(artist => !artist.alias)
+                    .map(artist => ({
+                        artist,
+                        date: reversedTracks.find(({ album, artists, contributors }) => (
+                            album.directory !== 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: sortByDate(artistData
+                    .filter(artist => !artist.alias)
+                    .map(artist => {
+                        const thing = reversedArtThings.find(({ album, coverArtists, contributors }) => (
+                            album?.directory !== 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 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(
+                sortByDate(trackData.filter(track => track.album.directory !== 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 !== 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 !== 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>
+                    ${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.color)}; --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 !== UNRELEASED_TRACKS_DIRECTORY);
+    const releasedAlbums = albumData.filter(album => album.directory !== 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.color),
+
+        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.color)}">
+                            ${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.color),
+
+        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 getFlashDirectory(flash) {
+    // const kebab = getKebabCase(flash.name.replace('[S] ', ''));
+    // return flash.page + (kebab ? '-' + kebab : '');
+    // return '' + flash.page;
+    return '' + flash.directory;
+}
+
+function getTagDirectory({name}) {
+    return getKebabCase(name);
+}
+
+function getAlbumListTag(album) {
+    if (album.directory === 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 = sortByDate(unique(getThings(artist)));
+        const releasedThings = things.filter(thing => {
+            const album = albumData.includes(thing) ? thing : thing.album;
+            return !(album && album.directory === 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.color)}">${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.color)}">${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.color)}">${category.name}</a>`
+                            })
+                        }</dt>
+                        <dd><ul>
+                            ${category.groups.map(group => fixWS`
+                                <li ${classes(group === currentGroup && 'current')} style="${getLinkThemeString(group.color)}">${
+                                    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 !== 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.color),
+
+            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.color)}">${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.color),
+
+            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: 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_ALBUM_ART_DIRECTORY and other constants
+        // near the top of this file (upd8.js).
+        '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'
+        },
+
+        // Or, if you *only* want to gener8te newly upd8ted thum8nails, you can
+        // pass this flag! It exits 8efore 8uilding the rest of the site.
+        'thumbs-only': {
+            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;
+    const thumbsOnly = miscOptions['thumbs-only'] ?? false;
+
+    if (skipThumbs && thumbsOnly) {
+        logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`;
+        return;
+    }
+
+    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;
+        if (thumbsOnly) 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, 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;
+        }
+    }
+
+    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 = 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;
+        }
+
+        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 = sortByDate([...albumData, ...trackData, ...(flashData || [])]);
+    justEverythingSortedByArtDateMan = 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 = 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 === OFFICIAL_GROUP_DIRECTORY));
+    fandomAlbumData = albumData.filter(album => album.groups.every(group => group.directory !== 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/src/util/cli.js b/src/util/cli.js
new file mode 100644
index 0000000..7771156
--- /dev/null
+++ b/src/util/cli.js
@@ -0,0 +1,210 @@
+// 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.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
new file mode 100644
index 0000000..1df591b
--- /dev/null
+++ b/src/util/colors.js
@@ -0,0 +1,47 @@
+// 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)}%)`;
+
+    return {primary, dim};
+}
+
+export function getLinkThemeString(color) {
+    if (!color) return '';
+
+    const { primary, dim } = getColors(color);
+    return `--primary-color: ${primary}; --dim-color: ${dim}`;
+}
+
+export function getThemeString(color, additionalVariables = []) {
+    if (!color) return '';
+
+    const { primary, dim } = getColors(color);
+
+    const variables = [
+        `--primary-color: ${primary}`,
+        `--dim-color: ${dim}`,
+        ...additionalVariables
+    ].filter(Boolean);
+
+    if (!variables.length) return '';
+
+    return (
+        `:root {\n` +
+        variables.map(line => `    ` + line + ';\n').join('') +
+        `}`
+    );
+}
diff --git a/src/util/html.js b/src/util/html.js
new file mode 100644
index 0000000..4895301
--- /dev/null
+++ b/src/util/html.js
@@ -0,0 +1,92 @@
+// 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 (!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}="${escapeAttributeValue(val)}"`))
+        .join(' ');
+}
diff --git a/src/util/link.js b/src/util/link.js
new file mode 100644
index 0000000..e5c3c59
--- /dev/null
+++ b/src/util/link.js
@@ -0,0 +1,67 @@
+// 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 { getLinkThemeString } from './colors.js'
+
+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.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/node-utils.js b/src/util/node-utils.js
new file mode 100644
index 0000000..d660612
--- /dev/null
+++ b/src/util/node-utils.js
@@ -0,0 +1,27 @@
+// 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/sugar.js b/src/util/sugar.js
new file mode 100644
index 0000000..9970bad
--- /dev/null
+++ b/src/util/sugar.js
@@ -0,0 +1,70 @@
+// 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));
+}
diff --git a/src/util/urls.js b/src/util/urls.js
new file mode 100644
index 0000000..f0f9cdb
--- /dev/null
+++ b/src/util/urls.js
@@ -0,0 +1,102 @@
+// 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
new file mode 100644
index 0000000..e76722b
--- /dev/null
+++ b/src/util/wiki-data.js
@@ -0,0 +1,126 @@
+// Utility functions for interacting with wiki data.
+
+// 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) {
+    // 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.date - b.date);
+}
+
+// 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
+
+// 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>')));
+}