« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/util')
-rw-r--r--src/util/cli.js420
-rw-r--r--src/util/colors.js40
-rw-r--r--src/util/find.js255
-rw-r--r--src/util/html.js159
-rw-r--r--src/util/io.js14
-rw-r--r--src/util/link.js199
-rw-r--r--src/util/magic-constants.js2
-rw-r--r--src/util/node-utils.js53
-rw-r--r--src/util/replacer.js659
-rw-r--r--src/util/serialize.js107
-rw-r--r--src/util/sugar.js600
-rw-r--r--src/util/urls.js196
-rw-r--r--src/util/wiki-data.js630
13 files changed, 1756 insertions, 1578 deletions
diff --git a/src/util/cli.js b/src/util/cli.js
index 0bbf3af4..d28ef40a 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -1,51 +1,58 @@
+/** @format */
+
 // 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 { process } = globalThis;
+const {process} = globalThis;
 
-export const ENABLE_COLOR = process && (
-    (process.env.CLICOLOR_FORCE && process.env.CLICOLOR_FORCE === '1')
-    ?? (process.env.CLICOLOR && process.env.CLICOLOR === '1' && process.stdout.hasColors && process.stdout.hasColors())
-    ?? (process.stdout.hasColors ? process.stdout.hasColors() : true));
+export const ENABLE_COLOR =
+  process &&
+  ((process.env.CLICOLOR_FORCE && process.env.CLICOLOR_FORCE === '1') ??
+    (process.env.CLICOLOR &&
+      process.env.CLICOLOR === '1' &&
+      process.stdout.hasColors &&
+      process.stdout.hasColors()) ??
+    (process.stdout.hasColors ? process.stdout.hasColors() : true));
 
-const C = n => (ENABLE_COLOR
-    ? text => `\x1b[${n}m${text}\x1b[0m`
-    : text => text);
+const C = (n) =>
+  ENABLE_COLOR ? (text) => `\x1b[${n}m${text}\x1b[0m` : (text) => text;
 
 export const color = {
-    bright: C('1'),
-    dim: C('2'),
-    normal: C('22'),
-    black: C('30'),
-    red: C('31'),
-    green: C('32'),
-    yellow: C('33'),
-    blue: C('34'),
-    magenta: C('35'),
-    cyan: C('36'),
-    white: C('37')
+  bright: C('1'),
+  dim: C('2'),
+  normal: C('22'),
+  black: C('30'),
+  red: C('31'),
+  green: C('32'),
+  yellow: C('33'),
+  blue: C('34'),
+  magenta: C('35'),
+  cyan: C('36'),
+  white: C('37'),
 };
 
-const logColor = color => (literals, ...values) => {
-    const w = s => process.stdout.write(s);
-    const wc = text => {
-        if (ENABLE_COLOR) w(text);
+const logColor =
+  (color) =>
+  (literals, ...values) => {
+    const w = (s) => process.stdout.write(s);
+    const wc = (text) => {
+      if (ENABLE_COLOR) w(text);
     };
 
     wc(`\x1b[${color}m`);
     for (let i = 0; i < literals.length; i++) {
-        w(literals[i]);
-        if (values[i] !== undefined) {
-            wc(`\x1b[1m`);
-            w(String(values[i]));
-            wc(`\x1b[0;${color}m`);
-        }
+      w(literals[i]);
+      if (values[i] !== undefined) {
+        wc(`\x1b[1m`);
+        w(String(values[i]));
+        wc(`\x1b[0;${color}m`);
+      }
     }
     wc(`\x1b[0m`);
     w('\n');
-};
+  };
 
 export const logInfo = logColor(2);
 export const logWarn = logColor(33);
@@ -53,205 +60,220 @@ 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.
+  // 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);
+  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;
+  }
+  return result;
 }
 
 export const handleDashless = Symbol();
 export const handleUnknown = Symbol();
 
 export function decorateTime(arg1, arg2) {
-    const [ id, functionToBeWrapped ] =
-        ((typeof arg1 === 'string' || typeof arg1 === 'symbol')
-            ? [arg1, arg2]
-            : [Symbol(arg1.name), arg1]);
+  const [id, functionToBeWrapped] =
+    typeof arg1 === 'string' || typeof arg1 === 'symbol'
+      ? [arg1, arg2]
+      : [Symbol(arg1.name), arg1];
 
-    const meta = decorateTime.idMetaMap[id] ?? {
-        wrappedName: functionToBeWrapped.name,
-        timeSpent: 0,
-        timesCalled: 0,
-        displayTime() {
-            const averageTime = meta.timeSpent / meta.timesCalled;
-            console.log(`\x1b[1m${typeof id === 'symbol' ? id.description : id}(...):\x1b[0m ${meta.timeSpent} ms / ${meta.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`);
-        }
-    };
+  const meta = decorateTime.idMetaMap[id] ?? {
+    wrappedName: functionToBeWrapped.name,
+    timeSpent: 0,
+    timesCalled: 0,
+    displayTime() {
+      const averageTime = meta.timeSpent / meta.timesCalled;
+      console.log(
+        `\x1b[1m${typeof id === 'symbol' ? id.description : id}(...):\x1b[0m ${
+          meta.timeSpent
+        } ms / ${meta.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`
+      );
+    },
+  };
 
-    decorateTime.idMetaMap[id] = meta;
+  decorateTime.idMetaMap[id] = meta;
 
-    const fn = function(...args) {
-        const start = Date.now();
-        const ret = functionToBeWrapped(...args);
-        const end = Date.now();
-        meta.timeSpent += end - start;
-        meta.timesCalled++;
-        return ret;
-    };
+  const fn = function (...args) {
+    const start = Date.now();
+    const ret = functionToBeWrapped(...args);
+    const end = Date.now();
+    meta.timeSpent += end - start;
+    meta.timesCalled++;
+    return ret;
+  };
 
-    fn.displayTime = meta.displayTime;
+  fn.displayTime = meta.displayTime;
 
-    return fn;
+  return fn;
 }
 
 decorateTime.idMetaMap = Object.create(null);
 
-decorateTime.displayTime = function() {
-    const map = decorateTime.idMetaMap;
+decorateTime.displayTime = function () {
+  const map = decorateTime.idMetaMap;
 
-    const keys = [
-        ...Object.getOwnPropertySymbols(map),
-        ...Object.getOwnPropertyNames(map)
-    ];
+  const keys = [
+    ...Object.getOwnPropertySymbols(map),
+    ...Object.getOwnPropertyNames(map),
+  ];
 
-    if (keys.length) {
-        console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m');
-        for (const key of keys) {
-            map[key].displayTime();
-        }
+  if (keys.length) {
+    console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m');
+    for (const key of keys) {
+      map[key].displayTime();
     }
+  }
 };
 
 export function progressPromiseAll(msgOrMsgFn, array) {
-    if (!array.length) {
-        return Promise.resolve([]);
-    }
+  if (!array.length) {
+    return Promise.resolve([]);
+  }
 
-    const msgFn = (typeof msgOrMsgFn === 'function'
-        ? msgOrMsgFn
-        : () => msgOrMsgFn);
+  const msgFn =
+    typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn;
 
-    let done = 0, total = array.length;
-    process.stdout.write(`\r${msgFn()} [0/${total}]`);
-    const start = Date.now();
-    return Promise.all(array.map(promise => Promise.resolve(promise).then(val => {
+  let done = 0,
+    total = array.length;
+  process.stdout.write(`\r${msgFn()} [0/${total}]`);
+  const start = Date.now();
+  return Promise.all(
+    array.map((promise) =>
+      Promise.resolve(promise).then((val) => {
         done++;
         // const pc = `${done}/${total}`;
-        const pc = (Math.round(done / total * 1000) / 10 + '%').padEnd('99.9%'.length, ' ');
+        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`)
+          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}] `);
+          process.stdout.write(`\r${msgFn()} [${pc}] `);
         }
         return val;
-    })));
+      })
+    )
+  );
 }
diff --git a/src/util/colors.js b/src/util/colors.js
index f568557a..5848a820 100644
--- a/src/util/colors.js
+++ b/src/util/colors.js
@@ -1,25 +1,35 @@
+/** @format */
+
 // 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];
+  let a = Math.max(r, g, b),
+    n = a - Math.min(r, g, b),
+    f = 1 - Math.abs(a + a - n - 1);
+  let h =
+    n && (a == r ? (g - b) / n : a == g ? 2 + (b - r) / n : 4 + (r - g) / n);
+  return [60 * (h < 0 ? h + 6 : h), f ? n / f : 0, (a + a - n) / 2];
 }
 
 export function getColors(primary) {
-    const [ r, g, b ] = primary.slice(1)
-        .match(/[0-9a-fA-F]{2,2}/g)
-        .slice(0, 3)
-        .map(val => parseInt(val, 16) / 255);
-    const [ h, s, l ] = rgb2hsl(r, g, b);
-    const dim = `hsl(${Math.round(h)}deg, ${Math.round(s * 50)}%, ${Math.round(l * 80)}%)`;
-    const bg = `hsla(${Math.round(h)}deg, ${Math.round(s * 15)}%, 12%, 0.80)`;
+  const [r, g, b] = primary
+    .slice(1)
+    .match(/[0-9a-fA-F]{2,2}/g)
+    .slice(0, 3)
+    .map((val) => parseInt(val, 16) / 255);
+  const [h, s, l] = rgb2hsl(r, g, b);
+  const dim = `hsl(${Math.round(h)}deg, ${Math.round(s * 50)}%, ${Math.round(
+    l * 80
+  )}%)`;
+  const bg = `hsla(${Math.round(h)}deg, ${Math.round(s * 15)}%, 12%, 0.80)`;
 
-    return {
-        primary, dim, bg,
-        rgb: [r, g, b],
-        hsl: [h, s, l],
-    };
+  return {
+    primary,
+    dim,
+    bg,
+    rgb: [r, g, b],
+    hsl: [h, s, l],
+  };
 }
diff --git a/src/util/find.js b/src/util/find.js
index 7cedb3d2..71026fa2 100644
--- a/src/util/find.js
+++ b/src/util/find.js
@@ -1,126 +1,134 @@
-import {
-    color,
-    logError,
-    logWarn
-} from './cli.js';
+/** @format */
 
-import { inspect } from 'util';
+import {color, logWarn} from './cli.js';
+
+import {inspect} from 'util';
 
 function warnOrThrow(mode, message) {
-    switch (mode) {
-        case 'error':
-            throw new Error(message);
-        case 'warn':
-            logWarn(message);
-        default:
-            return null;
-    }
+  if (mode === 'error') {
+    throw new Error(message);
+  }
+
+  if (mode === 'warn') {
+    logWarn(message);
+  }
+
+  return null;
 }
 
 function findHelper(keys, findFns = {}) {
-    // Note: This cache explicitly *doesn't* support mutable data arrays. If the
-    // data array is modified, make sure it's actually a new array object, not
-    // the original, or the cache here will break and act as though the data
-    // hasn't changed!
-    const cache = new WeakMap();
-
-    const byDirectory = findFns.byDirectory || matchDirectory;
-    const byName = findFns.byName || matchName;
-
-    const keyRefRegex = new RegExp(String.raw`^(?:(${keys.join('|')}):(?=\S))?(.*)$`);
-
-    // The mode argument here may be 'warn', 'error', or 'quiet'. 'error' throws
-    // errors for null matches (with details about the error), while 'warn' and
-    // 'quiet' both return null, with 'warn' logging details directly to the
-    // console.
-    return (fullRef, data, {mode = 'warn'} = {}) => {
-        if (!fullRef) return null;
-        if (typeof fullRef !== 'string') {
-            throw new Error(`Got a reference that is ${typeof fullRef}, not string: ${fullRef}`);
-        }
-
-        if (!data) {
-            throw new Error(`Expected data to be present`);
-        }
-
-        if (!Array.isArray(data) && data.wikiData) {
-            throw new Error(`Old {wikiData: {...}} format provided`);
-        }
-
-        let cacheForThisData = cache.get(data);
-        const cachedValue = cacheForThisData?.[fullRef];
-        if (cachedValue) {
-            globalThis.NUM_CACHE = (globalThis.NUM_CACHE || 0) + 1;
-            return cachedValue;
-        }
-        if (!cacheForThisData) {
-            cacheForThisData = Object.create(null);
-            cache.set(data, cacheForThisData);
-        }
-
-        const match = fullRef.match(keyRefRegex);
-        if (!match) {
-            return warnOrThrow(mode, `Malformed link reference: "${fullRef}"`);
-        }
-
-        const key = match[1];
-        const ref = match[2];
-
-        const found = (key
-            ? byDirectory(ref, data, mode)
-            : byName(ref, data, mode));
-
-        if (!found) {
-            warnOrThrow(mode, `Didn't match anything for ${color.bright(fullRef)}`);
-        }
-
-        cacheForThisData[fullRef] = found;
-
-        return found;
-    };
-}
+  // Note: This cache explicitly *doesn't* support mutable data arrays. If the
+  // data array is modified, make sure it's actually a new array object, not
+  // the original, or the cache here will break and act as though the data
+  // hasn't changed!
+  const cache = new WeakMap();
+
+  const byDirectory = findFns.byDirectory || matchDirectory;
+  const byName = findFns.byName || matchName;
+
+  const keyRefRegex = new RegExp(
+    String.raw`^(?:(${keys.join('|')}):(?=\S))?(.*)$`
+  );
+
+  // The mode argument here may be 'warn', 'error', or 'quiet'. 'error' throws
+  // errors for null matches (with details about the error), while 'warn' and
+  // 'quiet' both return null, with 'warn' logging details directly to the
+  // console.
+  return (fullRef, data, {mode = 'warn'} = {}) => {
+    if (!fullRef) return null;
+    if (typeof fullRef !== 'string') {
+      throw new Error(
+        `Got a reference that is ${typeof fullRef}, not string: ${fullRef}`
+      );
+    }
 
-function matchDirectory(ref, data, mode) {
-    return data.find(({ directory }) => directory === ref);
-}
+    if (!data) {
+      throw new Error(`Expected data to be present`);
+    }
 
-function matchName(ref, data, mode) {
-    const matches = data.filter(({ name }) => name.toLowerCase() === ref.toLowerCase());
+    if (!Array.isArray(data) && data.wikiData) {
+      throw new Error(`Old {wikiData: {...}} format provided`);
+    }
 
-    if (matches.length > 1) {
-        return warnOrThrow(mode,
-            `Multiple matches for reference "${ref}". Please resolve:\n` +
-            matches.map(match => `- ${inspect(match)}\n`).join('') +
-            `Returning null for this reference.`);
+    let cacheForThisData = cache.get(data);
+    const cachedValue = cacheForThisData?.[fullRef];
+    if (cachedValue) {
+      globalThis.NUM_CACHE = (globalThis.NUM_CACHE || 0) + 1;
+      return cachedValue;
+    }
+    if (!cacheForThisData) {
+      cacheForThisData = Object.create(null);
+      cache.set(data, cacheForThisData);
     }
 
-    if (matches.length === 0) {
-        return null;
+    const match = fullRef.match(keyRefRegex);
+    if (!match) {
+      return warnOrThrow(mode, `Malformed link reference: "${fullRef}"`);
     }
 
-    const thing = matches[0];
+    const key = match[1];
+    const ref = match[2];
+
+    const found = key ? byDirectory(ref, data, mode) : byName(ref, data, mode);
 
-    if (ref !== thing.name) {
-        warnOrThrow(mode, `Bad capitalization: ${color.red(ref)} -> ${color.green(thing.name)}`);
+    if (!found) {
+      warnOrThrow(mode, `Didn't match anything for ${color.bright(fullRef)}`);
     }
 
-    return thing;
+    cacheForThisData[fullRef] = found;
+
+    return found;
+  };
+}
+
+function matchDirectory(ref, data) {
+  return data.find(({directory}) => directory === ref);
+}
+
+function matchName(ref, data, mode) {
+  const matches = data.filter(
+    ({name}) => name.toLowerCase() === ref.toLowerCase()
+  );
+
+  if (matches.length > 1) {
+    return warnOrThrow(
+      mode,
+      `Multiple matches for reference "${ref}". Please resolve:\n` +
+        matches.map((match) => `- ${inspect(match)}\n`).join('') +
+        `Returning null for this reference.`
+    );
+  }
+
+  if (matches.length === 0) {
+    return null;
+  }
+
+  const thing = matches[0];
+
+  if (ref !== thing.name) {
+    warnOrThrow(
+      mode,
+      `Bad capitalization: ${color.red(ref)} -> ${color.green(thing.name)}`
+    );
+  }
+
+  return thing;
 }
 
 function matchTagName(ref, data, quiet) {
-    return matchName(ref.startsWith('cw: ') ? ref.slice(4) : ref, data, quiet);
+  return matchName(ref.startsWith('cw: ') ? ref.slice(4) : ref, data, quiet);
 }
 
 const find = {
-    album: findHelper(['album', 'album-commentary']),
-    artist: findHelper(['artist', 'artist-gallery']),
-    artTag: findHelper(['tag'], {byName: matchTagName}),
-    flash: findHelper(['flash']),
-    group: findHelper(['group', 'group-gallery']),
-    listing: findHelper(['listing']),
-    newsEntry: findHelper(['news-entry']),
-    staticPage: findHelper(['static']),
-    track: findHelper(['track'])
+  album: findHelper(['album', 'album-commentary']),
+  artist: findHelper(['artist', 'artist-gallery']),
+  artTag: findHelper(['tag'], {byName: matchTagName}),
+  flash: findHelper(['flash']),
+  group: findHelper(['group', 'group-gallery']),
+  listing: findHelper(['listing']),
+  newsEntry: findHelper(['news-entry']),
+  staticPage: findHelper(['static']),
+  track: findHelper(['track']),
 };
 
 export default find;
@@ -131,25 +139,30 @@ export default find;
 // called, so if their values change, you'll have to continue with a fresh call
 // to bindFind.
 export function bindFind(wikiData, opts1) {
-    return Object.fromEntries(Object.entries({
-        album: 'albumData',
-        artist: 'artistData',
-        artTag: 'artTagData',
-        flash: 'flashData',
-        group: 'groupData',
-        listing: 'listingSpec',
-        newsEntry: 'newsData',
-        staticPage: 'staticPageData',
-        track: 'trackData',
-    }).map(([ key, value ]) => {
-        const findFn = find[key];
-        const thingData = wikiData[value];
-        return [key, (opts1
-            ? (ref, opts2) => (opts2
+  return Object.fromEntries(
+    Object.entries({
+      album: 'albumData',
+      artist: 'artistData',
+      artTag: 'artTagData',
+      flash: 'flashData',
+      group: 'groupData',
+      listing: 'listingSpec',
+      newsEntry: 'newsData',
+      staticPage: 'staticPageData',
+      track: 'trackData',
+    }).map(([key, value]) => {
+      const findFn = find[key];
+      const thingData = wikiData[value];
+      return [
+        key,
+        opts1
+          ? (ref, opts2) =>
+              opts2
                 ? findFn(ref, thingData, {...opts1, ...opts2})
-                : findFn(ref, thingData, opts1))
-            : (ref, opts2) => (opts2
-                ? findFn(ref, thingData, opts2)
-                : findFn(ref, thingData)))];
-    }));
+                : findFn(ref, thingData, opts1)
+          : (ref, opts2) =>
+              opts2 ? findFn(ref, thingData, opts2) : findFn(ref, thingData),
+      ];
+    })
+  );
 }
diff --git a/src/util/html.js b/src/util/html.js
index a9b4bb9b..0ba923b3 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -1,21 +1,23 @@
+/** @format */
+
 // Some really simple functions for formatting HTML content.
 
 // COMPREHENSIVE!
 // https://html.spec.whatwg.org/multipage/syntax.html#void-elements
 export const selfClosingTags = [
-    'area',
-    'base',
-    'br',
-    'col',
-    'embed',
-    'hr',
-    'img',
-    'input',
-    'link',
-    'meta',
-    'source',
-    'track',
-    'wbr',
+  'area',
+  'base',
+  'br',
+  'col',
+  'embed',
+  'hr',
+  'img',
+  'input',
+  'link',
+  'meta',
+  'source',
+  'track',
+  'wbr',
 ];
 
 // Pass to tag() as an attri8utes key to make tag() return a 8lank string
@@ -24,86 +26,87 @@ export const selfClosingTags = [
 export const onlyIfContent = Symbol();
 
 export function tag(tagName, ...args) {
-    const selfClosing = selfClosingTags.includes(tagName);
+  const selfClosing = selfClosingTags.includes(tagName);
 
-    let openTag;
-    let content;
-    let attrs;
+  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 (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 (selfClosing && content) {
+    throw new Error(`Tag <${tagName}> is self-closing but got content!`);
+  }
 
-    if (attrs?.[onlyIfContent] && !content) {
-        return '';
-    }
+  if (attrs?.[onlyIfContent] && !content) {
+    return '';
+  }
 
-    if (attrs) {
-        const attrString = attributes(args[0]);
-        if (attrString) {
-            openTag = `${tagName} ${attrString}`;
-        }
+  if (attrs) {
+    const attrString = attributes(args[0]);
+    if (attrString) {
+      openTag = `${tagName} ${attrString}`;
     }
+  }
 
-    if (!openTag) {
-        openTag = tagName;
-    }
+  if (!openTag) {
+    openTag = tagName;
+  }
 
-    if (Array.isArray(content)) {
-        content = content.filter(Boolean).join('\n');
-    }
+  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}>`;
-        }
+  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 {
-        if (selfClosing) {
-            return `<${openTag}>`;
-        } else {
-            return `<${openTag}></${tagName}>`;
-        }
+      return `<${openTag}></${tagName}>`;
     }
+  }
 }
 
 export function escapeAttributeValue(value) {
-    return value
-        .replaceAll('"', '&quot;')
-        .replaceAll("'", '&apos;');
+  return value.replaceAll('"', '&quot;').replaceAll("'", '&apos;');
 }
 
 export function attributes(attribs) {
-    return Object.entries(attribs)
-        .map(([ key, val ]) => {
-            if (typeof val === 'undefined' || val === null)
-                return [key, val, false];
-            else if (typeof val === 'string')
-                return [key, val, true];
-            else if (typeof val === 'boolean')
-                return [key, val, val];
-            else if (typeof val === 'number')
-                return [key, val.toString(), true];
-            else if (Array.isArray(val))
-                return [key, val.filter(Boolean).join(' '), val.length > 0];
-            else
-                throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
-        })
-        .filter(([ key, val, keep ]) => keep)
-        .map(([ key, val ]) => (typeof val === 'boolean'
-            ? `${key}`
-            : `${key}="${escapeAttributeValue(val)}"`))
-        .join(' ');
+  return Object.entries(attribs)
+    .map(([key, val]) => {
+      if (typeof val === 'undefined' || val === null) return [key, val, false];
+      else if (typeof val === 'string') return [key, val, true];
+      else if (typeof val === 'boolean') return [key, val, val];
+      else if (typeof val === 'number') return [key, val.toString(), true];
+      else if (Array.isArray(val))
+        return [key, val.filter(Boolean).join(' '), val.length > 0];
+      else
+        throw new Error(
+          `Attribute value for ${key} should be primitive or array, got ${typeof val}`
+        );
+    })
+    .filter(([_key, _val, keep]) => keep)
+    .map(([key, val]) =>
+      typeof val === 'boolean'
+        ? `${key}`
+        : `${key}="${escapeAttributeValue(val)}"`
+    )
+    .join(' ');
 }
diff --git a/src/util/io.js b/src/util/io.js
index 1d74399f..4a6e95f3 100644
--- a/src/util/io.js
+++ b/src/util/io.js
@@ -1,14 +1,16 @@
+/** @format */
+
 // Utility functions for interacting with files and other external data
 // interfacey constructs.
 
-import { readdir } from 'fs/promises';
+import {readdir} from 'fs/promises';
 import * as path from 'path';
 
 export async function findFiles(dataPath, {
-    filter = f => true,
-    joinParentDirectory = true,
+  filter = () => true,
+  joinParentDirectory = true,
 } = {}) {
-    return (await readdir(dataPath))
-        .filter(file => filter(file))
-        .map(file => joinParentDirectory ? path.join(dataPath, file) : file);
+  return (await readdir(dataPath))
+    .filter((file) => filter(file))
+    .map((file) => (joinParentDirectory ? path.join(dataPath, file) : file));
 }
diff --git a/src/util/link.js b/src/util/link.js
index 68539621..ee3579d5 100644
--- a/src/util/link.js
+++ b/src/util/link.js
@@ -1,3 +1,5 @@
+/** @format */
+
 // 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")
@@ -9,108 +11,129 @@
 // options availa8le in all the functions, making a common interface for
 // gener8ting just a8out any link on the site.
 
-import * as html from './html.js'
-import { getColors } from './colors.js'
+import * as html from './html.js';
+import {getColors} from './colors.js';
 
 export function getLinkThemeString(color) {
-    if (!color) return '';
+  if (!color) return '';
 
-    const { primary, dim } = getColors(color);
-    return `--primary-color: ${primary}; --dim-color: ${dim}`;
+  const {primary, dim} = getColors(color);
+  return `--primary-color: ${primary}; --dim-color: ${dim}`;
 }
 
 const appendIndexHTMLRegex = /^(?!https?:\/\/).+\/$/;
 
-const linkHelper = (hrefFn, {color = true, attr = null} = {}) =>
-    (thing, {
-        to,
-        text = '',
-        attributes = null,
-        class: className = '',
-        color: color2 = true,
-        hash = ''
-    }) => {
-        let href = hrefFn(thing, {to});
-
-        if (link.globalOptions.appendIndexHTML) {
-            if (appendIndexHTMLRegex.test(href)) {
-                href += 'index.html';
-            }
-        }
-
-        if (hash) {
-            href += (hash.startsWith('#') ? '' : '#') + hash;
-        }
-
-        return html.tag('a', {
-            ...attr ? attr(thing) : {},
-            ...attributes ? attributes : {},
-            href,
-            style: (
-                typeof color2 === 'string' ? getLinkThemeString(color2) :
-                color2 && color ? getLinkThemeString(thing.color) :
-                ''),
-            class: className
-        }, text || thing.name)
-    };
+const linkHelper =
+  (hrefFn, {color = true, attr = null} = {}) =>
+  (
+    thing,
+    {
+      to,
+      text = '',
+      attributes = null,
+      class: className = '',
+      color: color2 = true,
+      hash = '',
+    }
+  ) => {
+    let href = hrefFn(thing, {to});
+
+    if (link.globalOptions.appendIndexHTML) {
+      if (appendIndexHTMLRegex.test(href)) {
+        href += 'index.html';
+      }
+    }
+
+    if (hash) {
+      href += (hash.startsWith('#') ? '' : '#') + hash;
+    }
+
+    return html.tag(
+      'a',
+      {
+        ...(attr ? attr(thing) : {}),
+        ...(attributes ? attributes : {}),
+        href,
+        style:
+          typeof color2 === 'string'
+            ? getLinkThemeString(color2)
+            : color2 && color
+            ? getLinkThemeString(thing.color)
+            : '',
+        class: className,
+      },
+      text || thing.name
+    );
+  };
 
 const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) =>
-    linkHelper((thing, {to}) => to('localized.' + key, thing.directory), {
-        attr: thing => ({
-            ...attr ? attr(thing) : {},
-            ...expose ? {[expose]: thing.directory} : {}
-        }),
-        ...conf
-    });
+  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 linkPathname = (key, conf) =>
+  linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf);
+const linkIndex = (key, conf) =>
+  linkHelper((_, {to}) => to('localized.' + key), conf);
 
 const link = {
-    globalOptions: {
-        // This should usually only 8e used during development! It'll take any
-        // href that ends with `/` and append `index.html` to the returned
-        // value (for to.thing() functions). This is handy when developing
-        // without a local server (i.e. using file:// protocol URLs in your
-        // 8rowser), 8ut isn't guaranteed to 8e 100% 8ug-free.
-        appendIndexHTML: false
-    },
-
-    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'}),
-
-    // TODO: This is a bit hacky. Files are just strings (not objects), so we
-    // have to manually provide the album alongside the file. They also don't
-    // follow the usual {name: whatever} type shape, so we have to provide that
-    // ourselves.
-    _albumAdditionalFileHelper: linkHelper(
-        ((fakeFileObject, { to }) =>
-            to('media.albumAdditionalFile', fakeFileObject.album.directory, fakeFileObject.name)),
-        {color: false}),
-    albumAdditionalFile: ({ file, album }, { to }) => link._albumAdditionalFileHelper({
+  globalOptions: {
+    // This should usually only 8e used during development! It'll take any
+    // href that ends with `/` and append `index.html` to the returned
+    // value (for to.thing() functions). This is handy when developing
+    // without a local server (i.e. using file:// protocol URLs in your
+    // 8rowser), 8ut isn't guaranteed to 8e 100% 8ug-free.
+    appendIndexHTML: false,
+  },
+
+  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'}),
+
+  // TODO: This is a bit hacky. Files are just strings (not objects), so we
+  // have to manually provide the album alongside the file. They also don't
+  // follow the usual {name: whatever} type shape, so we have to provide that
+  // ourselves.
+  _albumAdditionalFileHelper: linkHelper(
+    (fakeFileObject, {to}) =>
+      to(
+        'media.albumAdditionalFile',
+        fakeFileObject.album.directory,
+        fakeFileObject.name
+      ),
+    {color: false}
+  ),
+  albumAdditionalFile: ({file, album}, {to}) =>
+    link._albumAdditionalFileHelper(
+      {
         name: file,
-        album
-    }, {to}),
+        album,
+      },
+      {to}
+    ),
 
-    media: linkPathname('media.path', {color: false}),
-    root: linkPathname('shared.path', {color: false}),
-    data: linkPathname('data.path', {color: false}),
-    site: linkPathname('localized.path', {color: false})
+  media: linkPathname('media.path', {color: false}),
+  root: linkPathname('shared.path', {color: false}),
+  data: linkPathname('data.path', {color: false}),
+  site: linkPathname('localized.path', {color: false}),
 };
 
 export default link;
diff --git a/src/util/magic-constants.js b/src/util/magic-constants.js
index 73fdbc6d..dbdbcfda 100644
--- a/src/util/magic-constants.js
+++ b/src/util/magic-constants.js
@@ -1,3 +1,5 @@
+/** @format */
+
 // Magic constants only! These are hard-coded, and any use of them should be
 // considered a flaw in the codebase - areas where we use hard-coded behavior
 // to support one use of the wiki software (i.e. HSMusic, usually), rather than
diff --git a/src/util/node-utils.js b/src/util/node-utils.js
index ad87cae3..df446654 100644
--- a/src/util/node-utils.js
+++ b/src/util/node-utils.js
@@ -1,6 +1,8 @@
+/** @format */
+
 // Utility functions which are only relevant to particular Node.js constructs.
 
-import { fileURLToPath } from 'url';
+import {fileURLToPath} from 'url';
 
 import _commandExists from 'command-exists';
 
@@ -8,33 +10,36 @@ import _commandExists from 'command-exists';
 // doesn't exist, for some reason. Yay for making logic more difficult!
 // Here's a straightforward workaround.
 export function commandExists(command) {
-    return _commandExists(command).then(() => true, () => false);
+  return _commandExists(command).then(
+    () => true,
+    () => false
+  );
 }
 
 // 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);
-            }
-        })
-    })
+  // 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);
+      }
+    });
+  });
 }
 
 // Handy-dandy utility function for detecting whether the passed URL is the
@@ -42,5 +47,5 @@ export function promisifyProcess(proc, showLogging = true) {
 // is great 'cuz (module === require.main) doesn't work without CommonJS
 // modules.
 export function isMain(importMetaURL) {
-    return (process.argv[1] === fileURLToPath(importMetaURL));
+  return process.argv[1] === fileURLToPath(importMetaURL);
 }
diff --git a/src/util/replacer.js b/src/util/replacer.js
index b29044f2..70c17e5f 100644
--- a/src/util/replacer.js
+++ b/src/util/replacer.js
@@ -1,21 +1,28 @@
+/** @format */
+
+import fixWS from 'fix-whitespace';
+
 import {logError, logWarn} from './cli.js';
 import {escapeRegex} from './sugar.js';
 
 export function validateReplacerSpec(replacerSpec, {find, link}) {
-    let success = true;
-
-    for (const [key, {link: linkKey, find: findKey, value, html}] of Object.entries(replacerSpec)) {
-        if (!html && !link[linkKey]) {
-            logError`The replacer spec ${key} has invalid link key ${linkKey}! Specify it in link specs or fix typo.`;
-            success = false;
-        }
-        if (findKey && !find[findKey]) {
-            logError`The replacer spec ${key} has invalid find key ${findKey}! Specify it in find specs or fix typo.`;
-            success = false;
-        }
+  let success = true;
+
+  for (const [
+    key,
+    {link: linkKey, find: findKey, html},
+  ] of Object.entries(replacerSpec)) {
+    if (!html && !link[linkKey]) {
+      logError`The replacer spec ${key} has invalid link key ${linkKey}! Specify it in link specs or fix typo.`;
+      success = false;
+    }
+    if (findKey && !find[findKey]) {
+      logError`The replacer spec ${key} has invalid find key ${findKey}! Specify it in find specs or fix typo.`;
+      success = false;
     }
+  }
 
-    return success;
+  return success;
 }
 
 // Syntax literals.
@@ -29,401 +36,427 @@ const tagLabel = '|';
 
 const noPrecedingWhitespace = '(?<!\\s)';
 
-const R_tagBeginning =
-    escapeRegex(tagBeginning);
+const R_tagBeginning = escapeRegex(tagBeginning);
 
-const R_tagEnding =
-    escapeRegex(tagEnding);
+const R_tagEnding = escapeRegex(tagEnding);
 
 const R_tagReplacerValue =
-    noPrecedingWhitespace +
-    escapeRegex(tagReplacerValue);
+  noPrecedingWhitespace + escapeRegex(tagReplacerValue);
 
-const R_tagHash =
-    noPrecedingWhitespace +
-    escapeRegex(tagHash);
+const R_tagHash = noPrecedingWhitespace + escapeRegex(tagHash);
 
-const R_tagArgument =
-    escapeRegex(tagArgument);
+const R_tagArgument = escapeRegex(tagArgument);
 
-const R_tagArgumentValue =
-    escapeRegex(tagArgumentValue);
+const R_tagArgumentValue = escapeRegex(tagArgumentValue);
 
-const R_tagLabel =
-    escapeRegex(tagLabel);
+const R_tagLabel = escapeRegex(tagLabel);
 
 const regexpCache = {};
 
 const makeError = (i, message) => ({i, type: 'error', data: {message}});
-const endOfInput = (i, comment) => makeError(i, `Unexpected end of input (${comment}).`);
+const endOfInput = (i, comment) =>
+  makeError(i, `Unexpected end of input (${comment}).`);
 
 // These are 8asically stored on the glo8al scope, which might seem odd
 // for a recursive function, 8ut the values are only ever used immediately
 // after they're set.
-let stopped,
-    stop_iMatch,
-    stop_iParse,
-    stop_literal;
+let stopped, stop_iParse, stop_literal;
 
 function parseOneTextNode(input, i, stopAt) {
-    return parseNodes(input, i, stopAt, true)[0];
+  return parseNodes(input, i, stopAt, true)[0];
 }
 
 function parseNodes(input, i, stopAt, textOnly) {
-    let nodes = [];
-    let escapeNext = false;
-    let string = '';
-    let iString = 0;
+  let nodes = [];
+  let string = '';
+  let iString = 0;
 
-    stopped = false;
+  stopped = false;
 
-    const pushTextNode = (isLast) => {
-        string = input.slice(iString, i);
+  const pushTextNode = (isLast) => {
+    string = input.slice(iString, i);
 
-        // If this is the last text node 8efore stopping (at a stopAt match
-        // or the end of the input), trim off whitespace at the end.
-        if (isLast) {
-            string = string.trimEnd();
-        }
-
-        if (string.length) {
-            nodes.push({i: iString, iEnd: i, type: 'text', data: string});
-            string = '';
-        }
-    };
-
-    const literalsToMatch = stopAt ? stopAt.concat([R_tagBeginning]) : [R_tagBeginning];
-
-    // The 8ackslash stuff here is to only match an even (or zero) num8er
-    // of sequential 'slashes. Even amounts always cancel out! Odd amounts
-    // don't, which would mean the following literal is 8eing escaped and
-    // should 8e counted only as part of the current string/text.
-    //
-    // Inspired 8y this: https://stackoverflow.com/a/41470813
-    const regexpSource = `(?<!\\\\)(?:\\\\{2})*(${literalsToMatch.join('|')})`;
-
-    // There are 8asically only a few regular expressions we'll ever use,
-    // 8ut it's a pain to hard-code them all, so we dynamically gener8te
-    // and cache them for reuse instead.
-    let regexp;
-    if (regexpCache.hasOwnProperty(regexpSource)) {
-        regexp = regexpCache[regexpSource];
-    } else {
-        regexp = new RegExp(regexpSource);
-        regexpCache[regexpSource] = regexp;
+    // If this is the last text node 8efore stopping (at a stopAt match
+    // or the end of the input), trim off whitespace at the end.
+    if (isLast) {
+      string = string.trimEnd();
     }
 
-    // Skip whitespace at the start of parsing. This is run every time
-    // parseNodes is called (and thus parseOneTextNode too), so spaces
-    // at the start of syntax elements will always 8e skipped. We don't
-    // skip whitespace that shows up inside content (i.e. once we start
-    // parsing below), though!
-    const whitespaceOffset = input.slice(i).search(/[^\s]/);
-
-    // If the string is all whitespace, that's just zero content, so
-    // return the empty nodes array.
-    if (whitespaceOffset === -1) {
-        return nodes;
+    if (string.length) {
+      nodes.push({i: iString, iEnd: i, type: 'text', data: string});
+      string = '';
     }
+  };
+
+  const literalsToMatch = stopAt
+    ? stopAt.concat([R_tagBeginning])
+    : [R_tagBeginning];
+
+  // The 8ackslash stuff here is to only match an even (or zero) num8er
+  // of sequential 'slashes. Even amounts always cancel out! Odd amounts
+  // don't, which would mean the following literal is 8eing escaped and
+  // should 8e counted only as part of the current string/text.
+  //
+  // Inspired 8y this: https://stackoverflow.com/a/41470813
+  const regexpSource = `(?<!\\\\)(?:\\\\{2})*(${literalsToMatch.join('|')})`;
+
+  // There are 8asically only a few regular expressions we'll ever use,
+  // 8ut it's a pain to hard-code them all, so we dynamically gener8te
+  // and cache them for reuse instead.
+  let regexp;
+  if (Object.hasOwn(regexpCache, regexpSource)) {
+    regexp = regexpCache[regexpSource];
+  } else {
+    regexp = new RegExp(regexpSource);
+    regexpCache[regexpSource] = regexp;
+  }
+
+  // Skip whitespace at the start of parsing. This is run every time
+  // parseNodes is called (and thus parseOneTextNode too), so spaces
+  // at the start of syntax elements will always 8e skipped. We don't
+  // skip whitespace that shows up inside content (i.e. once we start
+  // parsing below), though!
+  const whitespaceOffset = input.slice(i).search(/[^\s]/);
+
+  // If the string is all whitespace, that's just zero content, so
+  // return the empty nodes array.
+  if (whitespaceOffset === -1) {
+    return nodes;
+  }
 
-    i += whitespaceOffset;
+  i += whitespaceOffset;
 
-    while (i < input.length) {
-        const match = input.slice(i).match(regexp);
+  while (i < input.length) {
+    const match = input.slice(i).match(regexp);
 
-        if (!match) {
-            iString = i;
-            i = input.length;
-            pushTextNode(true);
-            break;
-        }
+    if (!match) {
+      iString = i;
+      i = input.length;
+      pushTextNode(true);
+      break;
+    }
 
-        const closestMatch = match[0];
-        const closestMatchIndex = i + match.index;
+    const closestMatch = match[0];
+    const closestMatchIndex = i + match.index;
 
-        if (textOnly && closestMatch === tagBeginning)
-            throw makeError(i, `Unexpected [[tag]] - expected only text here.`);
+    if (textOnly && closestMatch === tagBeginning)
+      throw makeError(i, `Unexpected [[tag]] - expected only text here.`);
 
-        const stopHere = (closestMatch !== tagBeginning);
+    const stopHere = closestMatch !== tagBeginning;
 
-        iString = i;
-        i = closestMatchIndex;
-        pushTextNode(stopHere);
+    iString = i;
+    i = closestMatchIndex;
+    pushTextNode(stopHere);
 
-        i += closestMatch.length;
+    i += closestMatch.length;
 
-        if (stopHere) {
-            stopped = true;
-            stop_iMatch = closestMatchIndex;
-            stop_iParse = i;
-            stop_literal = closestMatch;
-            break;
-        }
+    if (stopHere) {
+      stopped = true;
+      stop_iParse = i;
+      stop_literal = closestMatch;
+      break;
+    }
 
-        if (closestMatch === tagBeginning) {
-            const iTag = closestMatchIndex;
+    if (closestMatch === tagBeginning) {
+      const iTag = closestMatchIndex;
 
-            let N;
+      let N;
 
-            // Replacer key (or value)
+      // Replacer key (or value)
 
-            N = parseOneTextNode(input, i, [R_tagReplacerValue, R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]);
+      N = parseOneTextNode(input, i, [
+        R_tagReplacerValue,
+        R_tagHash,
+        R_tagArgument,
+        R_tagLabel,
+        R_tagEnding,
+      ]);
 
-            if (!stopped) throw endOfInput(i, `reading replacer key`);
+      if (!stopped) throw endOfInput(i, `reading replacer key`);
 
-            if (!N) {
-                switch (stop_literal) {
-                    case tagReplacerValue:
-                    case tagArgument:
-                        throw makeError(i, `Expected text (replacer key).`);
-                    case tagLabel:
-                    case tagHash:
-                    case tagEnding:
-                        throw makeError(i, `Expected text (replacer key/value).`);
-                }
-            }
+      if (!N) {
+        switch (stop_literal) {
+          case tagReplacerValue:
+          case tagArgument:
+            throw makeError(i, `Expected text (replacer key).`);
+          case tagLabel:
+          case tagHash:
+          case tagEnding:
+            throw makeError(i, `Expected text (replacer key/value).`);
+        }
+      }
 
-            const replacerFirst = N;
-            i = stop_iParse;
+      const replacerFirst = N;
+      i = stop_iParse;
 
-            // Replacer value (if explicit)
+      // Replacer value (if explicit)
 
-            let replacerSecond;
+      let replacerSecond;
 
-            if (stop_literal === tagReplacerValue) {
-                N = parseNodes(input, i, [R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]);
+      if (stop_literal === tagReplacerValue) {
+        N = parseNodes(input, i, [
+          R_tagHash,
+          R_tagArgument,
+          R_tagLabel,
+          R_tagEnding,
+        ]);
 
-                if (!stopped) throw endOfInput(i, `reading replacer value`);
-                if (!N.length) throw makeError(i, `Expected content (replacer value).`);
+        if (!stopped) throw endOfInput(i, `reading replacer value`);
+        if (!N.length) throw makeError(i, `Expected content (replacer value).`);
 
-                replacerSecond = N;
-                i = stop_iParse
-            }
+        replacerSecond = N;
+        i = stop_iParse;
+      }
 
-            // Assign first & second to replacer key/value
+      // Assign first & second to replacer key/value
 
-            let replacerKey,
-                replacerValue;
+      let replacerKey, replacerValue;
 
-            // Value is an array of nodes, 8ut key is just one (or null).
-            // So if we use replacerFirst as the value, we need to stick
-            // it in an array (on its own).
-            if (replacerSecond) {
-                replacerKey = replacerFirst;
-                replacerValue = replacerSecond;
-            } else {
-                replacerKey = null;
-                replacerValue = [replacerFirst];
-            }
+      // Value is an array of nodes, 8ut key is just one (or null).
+      // So if we use replacerFirst as the value, we need to stick
+      // it in an array (on its own).
+      if (replacerSecond) {
+        replacerKey = replacerFirst;
+        replacerValue = replacerSecond;
+      } else {
+        replacerKey = null;
+        replacerValue = [replacerFirst];
+      }
 
-            // Hash
+      // Hash
 
-            let hash;
+      let hash;
 
-            if (stop_literal === tagHash) {
-                N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+      if (stop_literal === tagHash) {
+        N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
 
-                if (!stopped) throw endOfInput(i, `reading hash`);
+        if (!stopped) throw endOfInput(i, `reading hash`);
 
-                if (!N)
-                    throw makeError(i, `Expected content (hash).`);
+        if (!N) throw makeError(i, `Expected content (hash).`);
 
-                hash = N;
-                i = stop_iParse;
-            }
+        hash = N;
+        i = stop_iParse;
+      }
 
-            // Arguments
+      // Arguments
 
-            const args = [];
+      const args = [];
 
-            while (stop_literal === tagArgument) {
-                N = parseOneTextNode(input, i, [R_tagArgumentValue, R_tagArgument, R_tagLabel, R_tagEnding]);
+      while (stop_literal === tagArgument) {
+        N = parseOneTextNode(input, i, [
+          R_tagArgumentValue,
+          R_tagArgument,
+          R_tagLabel,
+          R_tagEnding,
+        ]);
 
-                if (!stopped) throw endOfInput(i, `reading argument key`);
+        if (!stopped) throw endOfInput(i, `reading argument key`);
 
-                if (stop_literal !== tagArgumentValue)
-                    throw makeError(i, `Expected ${tagArgumentValue.literal} (tag argument).`);
+        if (stop_literal !== tagArgumentValue)
+          throw makeError(
+            i,
+            `Expected ${tagArgumentValue.literal} (tag argument).`
+          );
 
-                if (!N)
-                    throw makeError(i, `Expected text (argument key).`);
+        if (!N) throw makeError(i, `Expected text (argument key).`);
 
-                const key = N;
-                i = stop_iParse;
+        const key = N;
+        i = stop_iParse;
 
-                N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+        N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
 
-                if (!stopped) throw endOfInput(i, `reading argument value`);
-                if (!N.length) throw makeError(i, `Expected content (argument value).`);
+        if (!stopped) throw endOfInput(i, `reading argument value`);
+        if (!N.length) throw makeError(i, `Expected content (argument value).`);
 
-                const value = N;
-                i = stop_iParse;
+        const value = N;
+        i = stop_iParse;
 
-                args.push({key, value});
-            }
+        args.push({key, value});
+      }
 
-            let label;
+      let label;
 
-            if (stop_literal === tagLabel) {
-                N = parseOneTextNode(input, i, [R_tagEnding]);
+      if (stop_literal === tagLabel) {
+        N = parseOneTextNode(input, i, [R_tagEnding]);
 
-                if (!stopped) throw endOfInput(i, `reading label`);
-                if (!N) throw makeError(i, `Expected text (label).`);
+        if (!stopped) throw endOfInput(i, `reading label`);
+        if (!N) throw makeError(i, `Expected text (label).`);
 
-                label = N;
-                i = stop_iParse;
-            }
+        label = N;
+        i = stop_iParse;
+      }
 
-            nodes.push({i: iTag, iEnd: i, type: 'tag', data: {replacerKey, replacerValue, hash, args, label}});
+      nodes.push({
+        i: iTag,
+        iEnd: i,
+        type: 'tag',
+        data: {replacerKey, replacerValue, hash, args, label},
+      });
 
-            continue;
-        }
+      continue;
     }
+  }
 
-    return nodes;
-};
-
-export function parseInput(input) {
-    try {
-        return parseNodes(input, 0);
-    } catch (errorNode) {
-        if (errorNode.type !== 'error') {
-            throw errorNode;
-        }
-
-        const { i, data: { message } } = errorNode;
-
-        let lineStart = input.slice(0, i).lastIndexOf('\n');
-        if (lineStart >= 0) {
-            lineStart += 1;
-        } else {
-            lineStart = 0;
-        }
-
-        let lineEnd = input.slice(i).indexOf('\n');
-        if (lineEnd >= 0) {
-            lineEnd += i;
-        } else {
-            lineEnd = input.length;
-        }
-
-        const line = input.slice(lineStart, lineEnd);
-
-        const cursor = i - lineStart;
-
-        throw new SyntaxError(fixWS`
-            Parse error (at pos ${i}): ${message}
-            ${line}
-            ${'-'.repeat(cursor) + '^'}
-        `);
-    }
+  return nodes;
 }
 
-function evaluateTag(node, opts) {
-    const { find, input, language, link, replacerSpec, to, wikiData } = opts;
-
-    const source = input.slice(node.i, node.iEnd);
-
-    const replacerKeyImplied = !node.data.replacerKey;
-    const replacerKey = (replacerKeyImplied
-        ? 'track'
-        : node.data.replacerKey.data);
-
-    if (!replacerSpec[replacerKey]) {
-        logWarn`The link ${source} has an invalid replacer key!`;
-        return source;
+export function parseInput(input) {
+  try {
+    return parseNodes(input, 0);
+  } catch (errorNode) {
+    if (errorNode.type !== 'error') {
+      throw errorNode;
     }
 
     const {
-        find: findKey,
-        link: linkKey,
-        value: valueFn,
-        html: htmlFn,
-        transformName
-    } = replacerSpec[replacerKey];
-
-    const replacerValue = transformNodes(node.data.replacerValue, opts);
-
-    const value = (
-        valueFn ? valueFn(replacerValue) :
-        findKey ? find[findKey]((replacerKeyImplied
-            ? replacerValue
-            : replacerKey + `:` + replacerValue)) :
-        {
-            directory: replacerValue,
-            name: null
-        });
-
-    if (!value) {
-        logWarn`The link ${source} does not match anything!`;
-        return source;
-    }
-
-    const enteredLabel = node.data.label && transformNode(node.data.label, opts);
+      i,
+      data: {message},
+    } = errorNode;
 
-    const label = (enteredLabel
-        || transformName && transformName(value.name, node, input)
-        || value.name);
+    let lineStart = input.slice(0, i).lastIndexOf('\n');
+    if (lineStart >= 0) {
+      lineStart += 1;
+    } else {
+      lineStart = 0;
+    }
 
-    if (!valueFn && !label) {
-        logWarn`The link ${source} requires a label be entered!`;
-        return source;
+    let lineEnd = input.slice(i).indexOf('\n');
+    if (lineEnd >= 0) {
+      lineEnd += i;
+    } else {
+      lineEnd = input.length;
     }
 
-    const hash = node.data.hash && transformNodes(node.data.hash, opts);
+    const line = input.slice(lineStart, lineEnd);
 
-    const args = node.data.args && Object.fromEntries(node.data.args.map(
-        ({ key, value }) => [
-            transformNode(key, opts),
-            transformNodes(value, opts)
-        ]));
+    const cursor = i - lineStart;
 
-    const fn = (htmlFn
-        ? htmlFn
-        : link[linkKey]);
+    throw new SyntaxError(fixWS`
+      Parse error (at pos ${i}): ${message}
+      ${line}
+      ${'-'.repeat(cursor) + '^'}
+    `);
+  }
+}
 
-    try {
-        return fn(value, {text: label, hash, args, language, to});
-    } catch (error) {
-        logError`The link ${source} failed to be processed: ${error}`;
-        return source;
-    }
+function evaluateTag(node, opts) {
+  const {find, input, language, link, replacerSpec, to} = opts;
+
+  const source = input.slice(node.i, node.iEnd);
+
+  const replacerKeyImplied = !node.data.replacerKey;
+  const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data;
+
+  if (!replacerSpec[replacerKey]) {
+    logWarn`The link ${source} has an invalid replacer key!`;
+    return source;
+  }
+
+  const {
+    find: findKey,
+    link: linkKey,
+    value: valueFn,
+    html: htmlFn,
+    transformName,
+  } = replacerSpec[replacerKey];
+
+  const replacerValue = transformNodes(node.data.replacerValue, opts);
+
+  const value = valueFn
+    ? valueFn(replacerValue)
+    : findKey
+    ? find[findKey](
+        replacerKeyImplied ? replacerValue : replacerKey + `:` + replacerValue
+      )
+    : {
+        directory: replacerValue,
+        name: null,
+      };
+
+  if (!value) {
+    logWarn`The link ${source} does not match anything!`;
+    return source;
+  }
+
+  const enteredLabel = node.data.label && transformNode(node.data.label, opts);
+
+  const label =
+    enteredLabel ||
+    (transformName && transformName(value.name, node, input)) ||
+    value.name;
+
+  if (!valueFn && !label) {
+    logWarn`The link ${source} requires a label be entered!`;
+    return source;
+  }
+
+  const hash = node.data.hash && transformNodes(node.data.hash, opts);
+
+  const args =
+    node.data.args &&
+    Object.fromEntries(
+      node.data.args.map(({key, value}) => [
+        transformNode(key, opts),
+        transformNodes(value, opts),
+      ])
+    );
+
+  const fn = htmlFn ? htmlFn : link[linkKey];
+
+  try {
+    return fn(value, {text: label, hash, args, language, to});
+  } catch (error) {
+    logError`The link ${source} failed to be processed: ${error}`;
+    return source;
+  }
 }
 
 function transformNode(node, opts) {
-    if (!node) {
-        throw new Error('Expected a node!');
-    }
-
-    if (Array.isArray(node)) {
-        throw new Error('Got an array - use transformNodes here!');
-    }
-
-    switch (node.type) {
-        case 'text':
-            return node.data;
-        case 'tag':
-            return evaluateTag(node, opts);
-        default:
-            throw new Error(`Unknown node type ${node.type}`);
-    }
+  if (!node) {
+    throw new Error('Expected a node!');
+  }
+
+  if (Array.isArray(node)) {
+    throw new Error('Got an array - use transformNodes here!');
+  }
+
+  switch (node.type) {
+    case 'text':
+      return node.data;
+    case 'tag':
+      return evaluateTag(node, opts);
+    default:
+      throw new Error(`Unknown node type ${node.type}`);
+  }
 }
 
 function transformNodes(nodes, opts) {
-    if (!nodes || !Array.isArray(nodes)) {
-        throw new Error(`Expected an array of nodes! Got: ${nodes}`);
-    }
+  if (!nodes || !Array.isArray(nodes)) {
+    throw new Error(`Expected an array of nodes! Got: ${nodes}`);
+  }
 
-    return nodes.map(node => transformNode(node, opts)).join('');
+  return nodes.map((node) => transformNode(node, opts)).join('');
 }
 
-export function transformInline(input, {replacerSpec, find, link, language, to, wikiData}) {
-    if (!replacerSpec) throw new Error('Expected replacerSpec');
-    if (!find) throw new Error('Expected find');
-    if (!link) throw new Error('Expected link');
-    if (!language) throw new Error('Expected language');
-    if (!to) throw new Error('Expected to');
-    if (!wikiData) throw new Error('Expected wikiData');
-
-    const nodes = parseInput(input);
-    return transformNodes(nodes, {input, find, link, replacerSpec, language, to, wikiData});
+export function transformInline(
+  input,
+  {replacerSpec, find, link, language, to, wikiData}
+) {
+  if (!replacerSpec) throw new Error('Expected replacerSpec');
+  if (!find) throw new Error('Expected find');
+  if (!link) throw new Error('Expected link');
+  if (!language) throw new Error('Expected language');
+  if (!to) throw new Error('Expected to');
+  if (!wikiData) throw new Error('Expected wikiData');
+
+  const nodes = parseInput(input);
+  return transformNodes(nodes, {
+    input,
+    find,
+    link,
+    replacerSpec,
+    language,
+    to,
+    wikiData,
+  });
 }
diff --git a/src/util/serialize.js b/src/util/serialize.js
index e30951f6..9aa8b0c5 100644
--- a/src/util/serialize.js
+++ b/src/util/serialize.js
@@ -1,71 +1,72 @@
+/** @format */
+
 export function serializeLink(thing) {
-    const ret = {};
-    ret.name = thing.name;
-    ret.directory = thing.directory;
-    if (thing.color) ret.color = thing.color;
-    return ret;
+  const ret = {};
+  ret.name = thing.name;
+  ret.directory = thing.directory;
+  if (thing.color) ret.color = thing.color;
+  return ret;
 }
 
 export function serializeContribs(contribs) {
-    return contribs.map(({ who, what }) => {
-        const ret = {};
-        ret.artist = serializeLink(who);
-        if (what) ret.contribution = what;
-        return ret;
-    });
+  return contribs.map(({who, what}) => {
+    const ret = {};
+    ret.artist = serializeLink(who);
+    if (what) ret.contribution = what;
+    return ret;
+  });
 }
 
 export function serializeImagePaths(original, {thumb}) {
-    return {
-        original,
-        medium: thumb.medium(original),
-        small: thumb.small(original)
-    };
+  return {
+    original,
+    medium: thumb.medium(original),
+    small: thumb.small(original),
+  };
 }
 
-export function serializeCover(thing, pathFunction, {
-    serializeImagePaths,
-    urls
-}) {
-    const coverPath = pathFunction(thing, {
-        to: urls.from('media.root').to
-    });
+export function serializeCover(
+  thing,
+  pathFunction,
+  {serializeImagePaths, urls}
+) {
+  const coverPath = pathFunction(thing, {
+    to: urls.from('media.root').to,
+  });
 
-    const { artTags } = thing;
+  const {artTags} = thing;
 
-    const cwTags = artTags.filter(tag => tag.isContentWarning);
-    const linkTags = artTags.filter(tag => !tag.isContentWarning);
+  const cwTags = artTags.filter((tag) => tag.isContentWarning);
+  const linkTags = artTags.filter((tag) => !tag.isContentWarning);
 
-    return {
-        paths: serializeImagePaths(coverPath),
-        tags: linkTags.map(serializeLink),
-        warnings: cwTags.map(tag => tag.name)
-    };
+  return {
+    paths: serializeImagePaths(coverPath),
+    tags: linkTags.map(serializeLink),
+    warnings: cwTags.map((tag) => tag.name),
+  };
 }
 
-export function serializeGroupsForAlbum(album, {
-    serializeLink
-}) {
-    return album.groups.map(group => {
-        const index = group.albums.indexOf(album);
-        const next = group.albums[index + 1] || null;
-        const previous = group.albums[index - 1] || null;
-        return {group, index, next, previous};
-    }).map(({group, index, next, previous}) => ({
-        link: serializeLink(group),
-        descriptionShort: group.descriptionShort,
-        albumIndex: index,
-        nextAlbum: next && serializeLink(next),
-        previousAlbum: previous && serializeLink(previous),
-        urls: group.urls
+export function serializeGroupsForAlbum(album, {serializeLink}) {
+  return album.groups
+    .map((group) => {
+      const index = group.albums.indexOf(album);
+      const next = group.albums[index + 1] || null;
+      const previous = group.albums[index - 1] || null;
+      return {group, index, next, previous};
+    })
+    .map(({group, index, next, previous}) => ({
+      link: serializeLink(group),
+      descriptionShort: group.descriptionShort,
+      albumIndex: index,
+      nextAlbum: next && serializeLink(next),
+      previousAlbum: previous && serializeLink(previous),
+      urls: group.urls,
     }));
 }
 
-export function serializeGroupsForTrack(track, {
-    serializeLink
-}) {
-    return track.album.groups.map(group => ({
-        link: serializeLink(group),
-        urls: group.urls,
-    }));
+export function serializeGroupsForTrack(track, {serializeLink}) {
+  return track.album.groups.map((group) => ({
+    link: serializeLink(group),
+    urls: group.urls,
+  }));
 }
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 99f706f1..2883d949 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -1,3 +1,5 @@
+/** @format */
+
 // Syntactic sugar! (Mostly.)
 // Generic functions - these are useful just a8out everywhere.
 //
@@ -6,69 +8,81 @@
 // It will likely only do exactly what I want it to, and only in the cases I
 // decided were relevant enough to 8other handling.
 
-import { color } from './cli.js';
+import {color} from './cli.js';
 
 // 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;
+  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 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 filterEmptyLines = (string) =>
+  string
+    .split('\n')
+    .filter((line) => line.trim())
+    .join('\n');
 
-export const unique = arr => Array.from(new Set(arr));
+export const unique = (arr) => Array.from(new Set(arr));
 
-export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) => (
-    arr1.length === arr2.length && (checkOrder
-        ? (arr1.every((x, i) => arr2[i] === x))
-        : (arr1.every(x => arr2.includes(x)))));
+export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
+  arr1.length === arr2.length &&
+  (checkOrder
+    ? arr1.every((x, i) => arr2[i] === x)
+    : arr1.every((x) => arr2.includes(x)));
 
 // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
-export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj)));
+export const withEntries = (obj, fn) =>
+  Object.fromEntries(fn(Object.entries(obj)));
 
 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) => {
+  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);
+          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()();
-    }
+  for (let i = 0; i < max && begin.length; i++) {
+    begin.shift()();
+  }
 
-    return ret;
+  return ret;
 }
 
 export function delay(ms) {
-    return new Promise(res => setTimeout(res, ms));
+  return new Promise((res) => setTimeout(res, ms));
 }
 
 // Stolen from here: https://stackoverflow.com/a/3561711
@@ -76,22 +90,22 @@ export function delay(ms) {
 // There's a proposal for a native JS function like this, 8ut it's not even
 // past stage 1 yet: https://github.com/tc39/proposal-regex-escaping
 export function escapeRegex(string) {
-    return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
+  return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
 }
 
 export function bindOpts(fn, bind) {
-    const bindIndex = bind[bindOpts.bindIndex] ?? 1;
+  const bindIndex = bind[bindOpts.bindIndex] ?? 1;
 
-    const bound = function(...args) {
-        const opts = args[bindIndex] ?? {};
-        return fn(...args.slice(0, bindIndex), {...bind, ...opts});
-    };
+  const bound = function (...args) {
+    const opts = args[bindIndex] ?? {};
+    return fn(...args.slice(0, bindIndex), {...bind, ...opts});
+  };
 
-    Object.defineProperty(bound, 'name', {
-        value: (fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`)
-    });
+  Object.defineProperty(bound, 'name', {
+    value: fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`,
+  });
 
-    return bound;
+  return bound;
 }
 
 bindOpts.bindIndex = Symbol();
@@ -108,103 +122,108 @@ bindOpts.bindIndex = Symbol();
 // object containing all caught errors (or doesn't throw anything if there were
 // no errors).
 export function openAggregate({
-    // Constructor to use, defaulting to the builtin AggregateError class.
-    // Anything passed here should probably extend from that! May be used for
-    // letting callers programatically distinguish between multiple aggregate
-    // errors.
-    //
-    // This should be provided using the aggregateThrows utility function.
-    [openAggregate.errorClassSymbol]: errorClass = AggregateError,
-
-    // Optional human-readable message to describe the aggregate error, if
-    // constructed.
-    message = '',
-
-    // Value to return when a provided function throws an error. If this is a
-    // function, it will be called with the arguments given to the function.
-    // (This is primarily useful when wrapping a function and then providing it
-    // to another utility, e.g. array.map().)
-    returnOnFail = null
+  // Constructor to use, defaulting to the builtin AggregateError class.
+  // Anything passed here should probably extend from that! May be used for
+  // letting callers programatically distinguish between multiple aggregate
+  // errors.
+  //
+  // This should be provided using the aggregateThrows utility function.
+  [openAggregate.errorClassSymbol]: errorClass = AggregateError,
+
+  // Optional human-readable message to describe the aggregate error, if
+  // constructed.
+  message = '',
+
+  // Value to return when a provided function throws an error. If this is a
+  // function, it will be called with the arguments given to the function.
+  // (This is primarily useful when wrapping a function and then providing it
+  // to another utility, e.g. array.map().)
+  returnOnFail = null,
 } = {}) {
-    const errors = [];
-
-    const aggregate = {};
-
-    aggregate.wrap = fn => (...args) => {
-        try {
-            return fn(...args);
-        } catch (error) {
-            errors.push(error);
-            return (typeof returnOnFail === 'function'
-                ? returnOnFail(...args)
-                : returnOnFail);
-        }
-    };
-
-    aggregate.wrapAsync = fn => (...args) => {
-        return fn(...args).then(
-            value => value,
-            error => {
-                errors.push(error);
-                return (typeof returnOnFail === 'function'
-                    ? returnOnFail(...args)
-                    : returnOnFail);
-            });
-    };
-
-    aggregate.call = (fn, ...args) => {
-        return aggregate.wrap(fn)(...args);
-    };
-
-    aggregate.callAsync = (fn, ...args) => {
-        return aggregate.wrapAsync(fn)(...args);
+  const errors = [];
+
+  const aggregate = {};
+
+  aggregate.wrap =
+    (fn) =>
+    (...args) => {
+      try {
+        return fn(...args);
+      } catch (error) {
+        errors.push(error);
+        return typeof returnOnFail === 'function'
+          ? returnOnFail(...args)
+          : returnOnFail;
+      }
     };
 
-    aggregate.nest = (...args) => {
-        return aggregate.call(() => withAggregate(...args));
-    };
-
-    aggregate.nestAsync = (...args) => {
-        return aggregate.callAsync(() => withAggregateAsync(...args));
-    };
-
-    aggregate.map = (...args) => {
-        const parent = aggregate;
-        const { result, aggregate: child } = mapAggregate(...args);
-        parent.call(child.close);
-        return result;
-    };
-
-    aggregate.mapAsync = async (...args) => {
-        const parent = aggregate;
-        const { result, aggregate: child } = await mapAggregateAsync(...args);
-        parent.call(child.close);
-        return result;
-    };
-
-    aggregate.filter = (...args) => {
-        const parent = aggregate;
-        const { result, aggregate: child } = filterAggregate(...args);
-        parent.call(child.close);
-        return result;
-    };
-
-    aggregate.throws = aggregateThrows;
-
-    aggregate.close = () => {
-        if (errors.length) {
-            throw Reflect.construct(errorClass, [errors, message]);
+  aggregate.wrapAsync =
+    (fn) =>
+    (...args) => {
+      return fn(...args).then(
+        (value) => value,
+        (error) => {
+          errors.push(error);
+          return typeof returnOnFail === 'function'
+            ? returnOnFail(...args)
+            : returnOnFail;
         }
+      );
     };
 
-    return aggregate;
+  aggregate.call = (fn, ...args) => {
+    return aggregate.wrap(fn)(...args);
+  };
+
+  aggregate.callAsync = (fn, ...args) => {
+    return aggregate.wrapAsync(fn)(...args);
+  };
+
+  aggregate.nest = (...args) => {
+    return aggregate.call(() => withAggregate(...args));
+  };
+
+  aggregate.nestAsync = (...args) => {
+    return aggregate.callAsync(() => withAggregateAsync(...args));
+  };
+
+  aggregate.map = (...args) => {
+    const parent = aggregate;
+    const {result, aggregate: child} = mapAggregate(...args);
+    parent.call(child.close);
+    return result;
+  };
+
+  aggregate.mapAsync = async (...args) => {
+    const parent = aggregate;
+    const {result, aggregate: child} = await mapAggregateAsync(...args);
+    parent.call(child.close);
+    return result;
+  };
+
+  aggregate.filter = (...args) => {
+    const parent = aggregate;
+    const {result, aggregate: child} = filterAggregate(...args);
+    parent.call(child.close);
+    return result;
+  };
+
+  aggregate.throws = aggregateThrows;
+
+  aggregate.close = () => {
+    if (errors.length) {
+      throw Reflect.construct(errorClass, [errors, message]);
+    }
+  };
+
+  return aggregate;
 }
 
 openAggregate.errorClassSymbol = Symbol('error class');
 
 // Utility function for providing {errorClass} parameter to aggregate functions.
 export function aggregateThrows(errorClass) {
-    return {[openAggregate.errorClassSymbol]: errorClass};
+  return {[openAggregate.errorClassSymbol]: errorClass};
 }
 
 // Performs an ordinary array map with the given function, collating into a
@@ -217,36 +236,38 @@ export function aggregateThrows(errorClass) {
 // use aggregate.close() to throw the error. (This aggregate may be passed to a
 // parent aggregate: `parent.call(aggregate.close)`!)
 export function mapAggregate(array, fn, aggregateOpts) {
-    return _mapAggregate('sync', null, array, fn, aggregateOpts);
+  return _mapAggregate('sync', null, array, fn, aggregateOpts);
 }
 
-export function mapAggregateAsync(array, fn, {
-    promiseAll = Promise.all.bind(Promise),
-    ...aggregateOpts
-} = {}) {
-    return _mapAggregate('async', promiseAll, array, fn, aggregateOpts);
+export function mapAggregateAsync(
+  array,
+  fn,
+  {promiseAll = Promise.all.bind(Promise), ...aggregateOpts} = {}
+) {
+  return _mapAggregate('async', promiseAll, array, fn, aggregateOpts);
 }
 
 // Helper function for mapAggregate which holds code common between sync and
 // async versions.
 export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) {
-    const failureSymbol = Symbol();
-
-    const aggregate = openAggregate({
-        returnOnFail: failureSymbol,
-        ...aggregateOpts
+  const failureSymbol = Symbol();
+
+  const aggregate = openAggregate({
+    returnOnFail: failureSymbol,
+    ...aggregateOpts,
+  });
+
+  if (mode === 'sync') {
+    const result = array
+      .map(aggregate.wrap(fn))
+      .filter((value) => value !== failureSymbol);
+    return {result, aggregate};
+  } else {
+    return promiseAll(array.map(aggregate.wrapAsync(fn))).then((values) => {
+      const result = values.filter((value) => value !== failureSymbol);
+      return {result, aggregate};
     });
-
-    if (mode === 'sync') {
-        const result = array.map(aggregate.wrap(fn))
-            .filter(value => value !== failureSymbol);
-        return {result, aggregate};
-    } else {
-        return promiseAll(array.map(aggregate.wrapAsync(fn))).then(values => {
-            const result = values.filter(value => value !== failureSymbol);
-            return {result, aggregate};
-        });
-    }
+  }
 }
 
 // Performs an ordinary array filter with the given function, collating into a
@@ -257,162 +278,165 @@ export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 //
 // As with mapAggregate, the returned aggregate property is not yet closed.
 export function filterAggregate(array, fn, aggregateOpts) {
-    return _filterAggregate('sync', null, array, fn, aggregateOpts);
+  return _filterAggregate('sync', null, array, fn, aggregateOpts);
 }
 
-export async function filterAggregateAsync(array, fn, {
-    promiseAll = Promise.all.bind(Promise),
-    ...aggregateOpts
-} = {}) {
-    return _filterAggregate('async', promiseAll, array, fn, aggregateOpts);
+export async function filterAggregateAsync(
+  array,
+  fn,
+  {promiseAll = Promise.all.bind(Promise), ...aggregateOpts} = {}
+) {
+  return _filterAggregate('async', promiseAll, array, fn, aggregateOpts);
 }
 
 // Helper function for filterAggregate which holds code common between sync and
 // async versions.
 function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) {
-    const failureSymbol = Symbol();
-
-    const aggregate = openAggregate({
-        returnOnFail: failureSymbol,
-        ...aggregateOpts
+  const failureSymbol = Symbol();
+
+  const aggregate = openAggregate({
+    returnOnFail: failureSymbol,
+    ...aggregateOpts,
+  });
+
+  function filterFunction(value) {
+    // Filter out results which match the failureSymbol, i.e. errored
+    // inputs.
+    if (value === failureSymbol) return false;
+
+    // Always keep results which match the overridden returnOnFail
+    // value, if provided.
+    if (value === aggregateOpts.returnOnFail) return true;
+
+    // Otherwise, filter according to the returned value of the wrapped
+    // function.
+    return value.output;
+  }
+
+  function mapFunction(value) {
+    // Then turn the results back into their corresponding input, or, if
+    // provided, the overridden returnOnFail value.
+    return value === aggregateOpts.returnOnFail ? value : value.input;
+  }
+
+  if (mode === 'sync') {
+    const result = array
+      .map(
+        aggregate.wrap((input, index, array) => {
+          const output = fn(input, index, array);
+          return {input, output};
+        })
+      )
+      .filter(filterFunction)
+      .map(mapFunction);
+
+    return {result, aggregate};
+  } else {
+    return promiseAll(
+      array.map(
+        aggregate.wrapAsync(async (input, index, array) => {
+          const output = await fn(input, index, array);
+          return {input, output};
+        })
+      )
+    ).then((values) => {
+      const result = values.filter(filterFunction).map(mapFunction);
+
+      return {result, aggregate};
     });
-
-    function filterFunction(value) {
-        // Filter out results which match the failureSymbol, i.e. errored
-        // inputs.
-        if (value === failureSymbol) return false;
-
-        // Always keep results which match the overridden returnOnFail
-        // value, if provided.
-        if (value === aggregateOpts.returnOnFail) return true;
-
-        // Otherwise, filter according to the returned value of the wrapped
-        // function.
-        return value.output;
-    }
-
-    function mapFunction(value) {
-        // Then turn the results back into their corresponding input, or, if
-        // provided, the overridden returnOnFail value.
-        return (value === aggregateOpts.returnOnFail
-            ? value
-            : value.input);
-    }
-
-    function wrapperFunction(x, ...rest) {
-        return {
-            input: x,
-            output: fn(x, ...rest)
-        };
-    }
-
-    if (mode === 'sync') {
-        const result = array
-            .map(aggregate.wrap((input, index, array) => {
-                const output = fn(input, index, array);
-                return {input, output};
-            }))
-            .filter(filterFunction)
-            .map(mapFunction);
-
-        return {result, aggregate};
-    } else {
-        return promiseAll(array.map(aggregate.wrapAsync(async (input, index, array) => {
-            const output = await fn(input, index, array);
-            return {input, output};
-        }))).then(values => {
-            const result = values
-                .filter(filterFunction)
-                .map(mapFunction);
-
-            return {result, aggregate};
-        });
-    }
+  }
 }
 
 // Totally sugar function for opening an aggregate, running the provided
 // function with it, then closing the function and returning the result (if
 // there's no throw).
 export function withAggregate(aggregateOpts, fn) {
-    return _withAggregate('sync', aggregateOpts, fn);
+  return _withAggregate('sync', aggregateOpts, fn);
 }
 
 export function withAggregateAsync(aggregateOpts, fn) {
-    return _withAggregate('async', aggregateOpts, fn);
+  return _withAggregate('async', aggregateOpts, fn);
 }
 
 export function _withAggregate(mode, aggregateOpts, fn) {
-    if (typeof aggregateOpts === 'function') {
-        fn = aggregateOpts;
-        aggregateOpts = {};
-    }
-
-    const aggregate = openAggregate(aggregateOpts);
+  if (typeof aggregateOpts === 'function') {
+    fn = aggregateOpts;
+    aggregateOpts = {};
+  }
+
+  const aggregate = openAggregate(aggregateOpts);
+
+  if (mode === 'sync') {
+    const result = fn(aggregate);
+    aggregate.close();
+    return result;
+  } else {
+    return fn(aggregate).then((result) => {
+      aggregate.close();
+      return result;
+    });
+  }
+}
 
-    if (mode === 'sync') {
-        const result = fn(aggregate);
-        aggregate.close();
-        return result;
+export function showAggregate(
+  topError,
+  {pathToFile = (p) => p, showTraces = true} = {}
+) {
+  const recursive = (error, {level}) => {
+    let header = showTraces
+      ? `[${error.constructor.name || 'unnamed'}] ${
+          error.message || '(no message)'
+        }`
+      : error instanceof AggregateError
+      ? `[${error.message || '(no message)'}]`
+      : error.message || '(no message)';
+    if (showTraces) {
+      const stackLines = error.stack?.split('\n');
+      const stackLine = stackLines?.find(
+        (line) =>
+          line.trim().startsWith('at') &&
+          !line.includes('sugar') &&
+          !line.includes('node:') &&
+          !line.includes('<anonymous>')
+      );
+      const tracePart = stackLine
+        ? '- ' +
+          stackLine
+            .trim()
+            .replace(/file:\/\/(.*\.js)/, (match, pathname) =>
+              pathToFile(pathname)
+            )
+        : '(no stack trace)';
+      header += ` ${color.dim(tracePart)}`;
+    }
+    const bar = level % 2 === 0 ? '\u2502' : color.dim('\u254e');
+    const head = level % 2 === 0 ? '\u257f' : color.dim('\u257f');
+
+    if (error instanceof AggregateError) {
+      return (
+        header +
+        '\n' +
+        error.errors
+          .map((error) => recursive(error, {level: level + 1}))
+          .flatMap((str) => str.split('\n'))
+          .map((line, i) => i === 0 ? ` ${head} ${line}` : ` ${bar} ${line}`)
+          .join('\n')
+      );
     } else {
-        return fn(aggregate).then(result => {
-            aggregate.close();
-            return result;
-        });
+      return header;
     }
-}
+  };
 
-export function showAggregate(topError, {
-    pathToFile = p => p,
-    showTraces = true
-} = {}) {
-    const recursive = (error, {level}) => {
-        let header = (showTraces
-            ? `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'}`
-            : (error instanceof AggregateError
-                ? `[${error.message || '(no message)'}]`
-                : error.message || '(no message)'));
-        if (showTraces) {
-            const stackLines = error.stack?.split('\n');
-            const stackLine = stackLines?.find(line =>
-                line.trim().startsWith('at')
-                && !line.includes('sugar')
-                && !line.includes('node:')
-                && !line.includes('<anonymous>'));
-            const tracePart = (stackLine
-                ? '- ' + stackLine.trim().replace(/file:\/\/(.*\.js)/, (match, pathname) => pathToFile(pathname))
-                : '(no stack trace)');
-            header += ` ${color.dim(tracePart)}`;
-        }
-        const bar = (level % 2 === 0
-            ? '\u2502'
-            : color.dim('\u254e'));
-        const head = (level % 2 === 0
-            ? '\u257f'
-            : color.dim('\u257f'));
-
-        if (error instanceof AggregateError) {
-            return header + '\n' + (error.errors
-                .map(error => recursive(error, {level: level + 1}))
-                .flatMap(str => str.split('\n'))
-                .map((line, i, lines) => (i === 0
-                    ? ` ${head} ${line}`
-                    : ` ${bar} ${line}`))
-                .join('\n'));
-        } else {
-            return header;
-        }
-    };
-
-    console.error(recursive(topError, {level: 0}));
+  console.error(recursive(topError, {level: 0}));
 }
 
 export function decorateErrorWithIndex(fn) {
-    return (x, index, array) => {
-        try {
-            return fn(x, index, array);
-        } catch (error) {
-            error.message = `(${color.yellow(`#${index + 1}`)}) ${error.message}`;
-            throw error;
-        }
+  return (x, index, array) => {
+    try {
+      return fn(x, index, array);
+    } catch (error) {
+      error.message = `(${color.yellow(`#${index + 1}`)}) ${error.message}`;
+      throw error;
     }
+  };
 }
diff --git a/src/util/urls.js b/src/util/urls.js
index e15c018b..45ec4c85 100644
--- a/src/util/urls.js
+++ b/src/util/urls.js
@@ -1,3 +1,5 @@
+/** @format */
+
 // 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
@@ -9,116 +11,132 @@
 // the domain of link.js.
 
 import * as path from 'path';
-import { withEntries } from './sugar.js';
+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 getValueForFullKey = (obj, fullKey) => {
+    const [groupKey, subKey] = fullKey.split('.');
+    if (!groupKey || !subKey) {
+      throw new Error(`Expected group key and subkey (got ${fullKey})`);
+    }
+
+    if (!Object.hasOwn(obj, groupKey)) {
+      throw new Error(`Expected valid group key (got ${groupKey})`);
+    }
+
+    const group = obj[groupKey];
+
+    if (!Object.hasOwn(group, subKey)) {
+      throw new Error(
+        `Expected valid subkey (got ${subKey} for group ${groupKey})`
+      );
+    }
+
+    return {
+      value: group[subKey],
+      group,
     };
+  };
 
-    // This should be called on values which are going to be passed to
-    // path.relative, because relative will resolve a leading slash as the root
-    // directory of the working device, which we aren't looking for here.
-    const trimLeadingSlash = P => P.startsWith('/') ? P.slice(1) : P;
-
-    const generateTo = (fromPath, fromGroup) => {
-        const A = trimLeadingSlash(fromPath);
+  // This should be called on values which are going to be passed to
+  // path.relative, because relative will resolve a leading slash as the root
+  // directory of the working device, which we aren't looking for here.
+  const trimLeadingSlash = (P) => (P.startsWith('/') ? P.slice(1) : P);
 
-        const rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length);
+  const generateTo = (fromPath, fromGroup) => {
+    const A = trimLeadingSlash(fromPath);
 
-        const pathHelper = (toPath, toGroup) => {
-            let B = trimLeadingSlash(toPath);
+    const rebasePrefix = '../'.repeat(
+      (fromGroup.prefix || '').split('/').filter(Boolean).length
+    );
 
-            let argIndex = 0;
-            B = B.replaceAll('<>', () => `<${argIndex++}>`);
+    const pathHelper = (toPath, toGroup) => {
+      let B = trimLeadingSlash(toPath);
 
-            if (toGroup.prefix !== fromGroup.prefix) {
-                // TODO: Handle differing domains in prefixes.
-                B = rebasePrefix + (toGroup.prefix || '') + B;
-            }
+      let argIndex = 0;
+      B = B.replaceAll('<>', () => `<${argIndex++}>`);
 
-            const suffix = (toPath.endsWith('/') ? '/' : '');
+      if (toGroup.prefix !== fromGroup.prefix) {
+        // TODO: Handle differing domains in prefixes.
+        B = rebasePrefix + (toGroup.prefix || '') + B;
+      }
 
-            return {
-                posix: path.posix.relative(A, B) + suffix,
-                device: path.relative(A, B) + suffix
-            };
-        };
+      const suffix = toPath.endsWith('/') ? '/' : '';
 
-        const groupSymbol = Symbol();
+      return {
+        posix: path.posix.relative(A, B) + suffix,
+        device: path.relative(A, B) + suffix,
+      };
+    };
 
-        const groupHelper = urlGroup => ({
-            [groupSymbol]: urlGroup,
-            ...withEntries(urlGroup.paths, entries => entries
-                .map(([key, path]) => [key, pathHelper(path, urlGroup)]))
+    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 toHelper =
+      (delimiterMode) =>
+      (key, ...args) => {
+        const {
+          value: {[delimiterMode]: template},
+        } = getValueForFullKey(relative, key);
+
+        let missing = 0;
+        let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => {
+          if (n < args.length) {
+            return args[n];
+          } else {
+            missing++;
+          }
         });
 
-        const relative = withEntries(urlSpec, entries => entries
-            .map(([key, urlGroup]) => [key, groupHelper(urlGroup)]));
-
-        const toHelper = (delimiterMode) => (key, ...args) => {
-            const {
-                value: {[delimiterMode]: template}
-            } = getValueForFullKey(relative, key);
-
-            let missing = 0;
-            let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => {
-                if (n < args.length) {
-                    return args[n];
-                } else {
-                    missing++;
-                }
-            });
-
-            if (missing) {
-                throw new Error(`Expected ${missing + args.length} arguments, got ${args.length} (key ${key}, args [${args}])`);
-            }
-
-            return result;
-        };
-
-        return {
-            to: toHelper('posix'),
-            toDevice: toHelper('device')
-        };
+        if (missing) {
+          throw new Error(
+            `Expected ${missing + args.length} arguments, got ${
+              args.length
+            } (key ${key}, args [${args}])`
+          );
+        }
+
+        return result;
+      };
+
+    return {
+      to: toHelper('posix'),
+      toDevice: toHelper('device'),
     };
+  };
 
-    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 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;
+    const from = (key) => getValueForFullKey(map, key).value;
 
-        return {from, map};
-    };
+    return {from, map};
+  };
 
-    return generateFrom();
+  return generateFrom();
 }
 
-const thumbnailHelper = name => file =>
-    file.replace(/\.(jpg|png)$/, name + '.jpg');
+const thumbnailHelper = (name) => (file) =>
+  file.replace(/\.(jpg|png)$/, name + '.jpg');
 
 export const thumb = {
-    medium: thumbnailHelper('.medium'),
-    small: thumbnailHelper('.small')
+  medium: thumbnailHelper('.medium'),
+  small: thumbnailHelper('.small'),
 };
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
index 5aef812d..3e564b96 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -1,65 +1,68 @@
+/** @format */
+
 // 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();
+  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];
+  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;
+      }
     }
-
-    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);
-        }
+    if (chunk) {
+      out.push(cur);
+      cur = [item];
+    } else {
+      cur.push(item);
     }
-    out.push(cur);
-    return out;
+  }
+  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
-        }));
+  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 - all utils here are mutating, so make sure to initially
@@ -71,37 +74,42 @@ export function chunkByProperties(array, properties) {
 // handy in the sorting functions below (or if you're making your own sort).
 
 export function compareCaseLessSensitive(a, b) {
-    // Compare two strings without considering capitalization... unless they
-    // happen to be the same that way.
+  // Compare two strings without considering capitalization... unless they
+  // happen to be the same that way.
 
-    const al = a.toLowerCase();
-    const bl = b.toLowerCase();
+  const al = a.toLowerCase();
+  const bl = b.toLowerCase();
 
-    return (al === bl
-        ? a.localeCompare(b, undefined, {numeric: true})
-        : al.localeCompare(bl, undefined, {numeric: true}));
+  return al === bl
+    ? a.localeCompare(b, undefined, {numeric: true})
+    : al.localeCompare(bl, undefined, {numeric: true});
 }
 
 // Subtract common prefixes and other characters which some people don't like
 // to have considered while sorting. The words part of this is English-only for
 // now, which is totally evil.
 export function normalizeName(s) {
-    // Turn (some) ligatures into expanded variant for cleaner sorting, e.g.
-    // "ff" into "ff", in decompose mode, so that "ü" is represented as two
-    // bytes ("u" + \u0308 combining diaeresis).
-    s = s.normalize('NFKD');
-
-    // Replace one or more whitespace of any kind in a row, as well as certain
-    // punctuation, with a single typical space, then trim the ends.
-    s = s.replace(/[\p{Separator}\p{Dash_Punctuation}\p{Connector_Punctuation}]+/gu, ' ').trim();
-
-    // Discard anything that isn't a letter, number, or space.
-    s = s.replace(/[^\p{Letter}\p{Number} ]/gu, '');
-
-    // Remove common English (only, for now) prefixes.
-    s = s.replace(/^(?:an?|the) /i, '');
-
-    return s;
+  // Turn (some) ligatures into expanded variant for cleaner sorting, e.g.
+  // "ff" into "ff", in decompose mode, so that "ü" is represented as two
+  // bytes ("u" + \u0308 combining diaeresis).
+  s = s.normalize('NFKD');
+
+  // Replace one or more whitespace of any kind in a row, as well as certain
+  // punctuation, with a single typical space, then trim the ends.
+  s = s
+    .replace(
+      /[\p{Separator}\p{Dash_Punctuation}\p{Connector_Punctuation}]+/gu,
+      ' '
+    )
+    .trim();
+
+  // Discard anything that isn't a letter, number, or space.
+  s = s.replace(/[^\p{Letter}\p{Number} ]/gu, '');
+
+  // Remove common English (only, for now) prefixes.
+  s = s.replace(/^(?:an?|the) /i, '');
+
+  return s;
 }
 
 // Component sort functions - these sort by one particular property, applying
@@ -132,106 +140,103 @@ export function normalizeName(s) {
 // ...trackData]), because the initial sort places albums before tracks - and
 // sortByDirectory will handle the rest, given all directories are unique
 // except when album and track directories overlap with each other.
-export function sortByDirectory(data, {
-    getDirectory = o => o.directory
-} = {}) {
-    return data.sort((a, b) => {
-        const ad = getDirectory(a);
-        const bd = getDirectory(b);
-        return compareCaseLessSensitive(ad, bd)
-    });
+export function sortByDirectory(
+  data,
+  {getDirectory = (o) => o.directory} = {}
+) {
+  return data.sort((a, b) => {
+    const ad = getDirectory(a);
+    const bd = getDirectory(b);
+    return compareCaseLessSensitive(ad, bd);
+  });
 }
 
-export function sortByName(data, {
-    getName = o => o.name
-} = {}) {
-    return data.sort((a, b) => {
-        const an = getName(a);
-        const bn = getName(b);
-        const ann = normalizeName(an);
-        const bnn = normalizeName(bn);
-        return (
-            compareCaseLessSensitive(ann, bnn) ||
-            compareCaseLessSensitive(an, bn));
-    });
+export function sortByName(data, {getName = (o) => o.name} = {}) {
+  return data.sort((a, b) => {
+    const an = getName(a);
+    const bn = getName(b);
+    const ann = normalizeName(an);
+    const bnn = normalizeName(bn);
+    return (
+      compareCaseLessSensitive(ann, bnn) || compareCaseLessSensitive(an, bn)
+    );
+  });
 }
 
-export function sortByDate(data, {
-    getDate = o => o.date
-} = {}) {
-    return data.sort((a, b) => {
-        const ad = getDate(a);
-        const bd = getDate(b);
-
-        // It's possible for objects with and without dates to be mixed
-        // together in the same array. If that's the case, we put all items
-        // without dates at the end.
-        if (ad && bd) {
-            return ad - bd;
-        } else if (ad) {
-            return -1;
-        } else if (bd) {
-            return 1;
-        } else {
-            // If neither of the items being compared have a date, don't move
-            // them relative to each other. This is basically the same as
-            // filtering out all non-date items and then pushing them at the
-            // end after sorting the rest.
-            return 0;
-        }
-    });
+export function sortByDate(data, {getDate = (o) => o.date} = {}) {
+  return data.sort((a, b) => {
+    const ad = getDate(a);
+    const bd = getDate(b);
+
+    // It's possible for objects with and without dates to be mixed
+    // together in the same array. If that's the case, we put all items
+    // without dates at the end.
+    if (ad && bd) {
+      return ad - bd;
+    } else if (ad) {
+      return -1;
+    } else if (bd) {
+      return 1;
+    } else {
+      // If neither of the items being compared have a date, don't move
+      // them relative to each other. This is basically the same as
+      // filtering out all non-date items and then pushing them at the
+      // end after sorting the rest.
+      return 0;
+    }
+  });
 }
 
 export function sortByPositionInAlbum(data) {
-    return data.sort((a, b) => {
-        const aa = a.album;
-        const ba = b.album;
-
-        // Don't change the sort when the two tracks are from separate albums.
-        // This function doesn't change the order of albums or try to "merge"
-        // two separated chunks of tracks from the same album together.
-        if (aa !== ba) {
-            return 0;
-        }
+  return data.sort((a, b) => {
+    const aa = a.album;
+    const ba = b.album;
+
+    // Don't change the sort when the two tracks are from separate albums.
+    // This function doesn't change the order of albums or try to "merge"
+    // two separated chunks of tracks from the same album together.
+    if (aa !== ba) {
+      return 0;
+    }
 
-        // Don't change the sort when only one (or neither) item is actually
-        // a track (i.e. has an album).
-        if (!aa || !ba) {
-            return 0;
-        }
+    // Don't change the sort when only one (or neither) item is actually
+    // a track (i.e. has an album).
+    if (!aa || !ba) {
+      return 0;
+    }
 
-        const ai = aa.tracks.indexOf(a);
-        const bi = ba.tracks.indexOf(b);
+    const ai = aa.tracks.indexOf(a);
+    const bi = ba.tracks.indexOf(b);
 
-        // There's no reason this two-way reference (a track's album and the
-        // album's track list) should be broken, but if for any reason it is,
-        // don't change the sort.
-        if (ai === -1 || bi === -1) {
-            return 0;
-        }
+    // There's no reason this two-way reference (a track's album and the
+    // album's track list) should be broken, but if for any reason it is,
+    // don't change the sort.
+    if (ai === -1 || bi === -1) {
+      return 0;
+    }
 
-        return ai - bi;
-    });
+    return ai - bi;
+  });
 }
 
 // Sorts data so that items are grouped together according to whichever of a
 // set of arbitrary given conditions is true first. If no conditions are met
 // for a given item, it's moved over to the end!
 export function sortByConditions(data, conditions) {
-    data.sort((a, b) => {
-        const ai = conditions.findIndex(f => f(a));
-        const bi = conditions.findIndex(f => f(b));
-
-        if (ai >= 0 && bi >= 0) {
-            return ai - bi;
-        } else if (ai >= 0) {
-            return -1;
-        } else if (bi >= 0) {
-            return 1;
-        } else {
-            return 0;
-        }
-    });
+  data.sort((a, b) => {
+    const ai = conditions.findIndex((f) => f(a));
+    const bi = conditions.findIndex((f) => f(b));
+
+    if (ai >= 0 && bi >= 0) {
+      return ai - bi;
+    } else if (ai >= 0) {
+      return -1;
+    } else if (bi >= 0) {
+      return 1;
+    } else {
+      return 0;
+    }
+  });
 }
 
 // Composite sorting functions - these consider multiple properties, generally
@@ -250,19 +255,22 @@ export function sortByConditions(data, conditions) {
 //  * directory (or override getDirectory)
 //  * name (or override getName)
 export function sortAlphabetically(data, {getDirectory, getName} = {}) {
-    sortByDirectory(data, {getDirectory});
-    sortByName(data, {getName});
-    return data;
+  sortByDirectory(data, {getDirectory});
+  sortByName(data, {getName});
+  return data;
 }
 
 // Expects thing properties:
 //  * directory (or override getDirectory)
 //  * name (or override getName)
 //  * date (or override getDate)
-export function sortChronologically(data, {getDirectory, getName, getDate} = {}) {
-    sortAlphabetically(data, {getDirectory, getName});
-    sortByDate(data, {getDate});
-    return data;
+export function sortChronologically(
+  data,
+  {getDirectory, getName, getDate} = {}
+) {
+  sortAlphabetically(data, {getDirectory, getName});
+  sortByDate(data, {getDate});
+  return data;
 }
 
 // Highly contextual sort functions - these are only for very specific types
@@ -274,43 +282,45 @@ export function sortChronologically(data, {getDirectory, getName, getDate} = {})
 //
 // This function also works for data lists which contain only tracks.
 export function sortAlbumsTracksChronologically(data, {getDate} = {}) {
-    // Sort albums before tracks...
-    sortByConditions(data, [t => t.album === undefined]);
+  // Sort albums before tracks...
+  sortByConditions(data, [(t) => t.album === undefined]);
 
-    // Group tracks by album...
-    sortByDirectory(data, {
-        getDirectory: t => (t.album ? t.album.directory : t.directory)
-    });
+  // Group tracks by album...
+  sortByDirectory(data, {
+    getDirectory: (t) => (t.album ? t.album.directory : t.directory),
+  });
 
-    // Sort tracks by position in album...
-    sortByPositionInAlbum(data);
+  // Sort tracks by position in album...
+  sortByPositionInAlbum(data);
 
-    // ...and finally sort by date. If tracks from more than one album were
-    // released on the same date, they'll still be grouped together by album,
-    // and tracks within an album will retain their relative positioning (i.e.
-    // stay in the same order as part of the album's track listing).
-    sortByDate(data, {getDate});
+  // ...and finally sort by date. If tracks from more than one album were
+  // released on the same date, they'll still be grouped together by album,
+  // and tracks within an album will retain their relative positioning (i.e.
+  // stay in the same order as part of the album's track listing).
+  sortByDate(data, {getDate});
 
-    return data;
+  return data;
 }
 
 // Specific data utilities
 
 export function filterAlbumsByCommentary(albums) {
-    return albums.filter(album => [album, ...album.tracks].some(x => x.commentary));
+  return albums.filter((album) =>
+    [album, ...album.tracks].some((x) => x.commentary)
+  );
 }
 
 export function getAlbumCover(album, {to}) {
-    // Some albums don't have art! This function returns null in that case.
-    if (album.hasCoverArt) {
-        return to('media.albumCover', album.directory, album.coverArtFileExtension);
-    } else {
-        return null;
-    }
+  // Some albums don't have art! This function returns null in that case.
+  if (album.hasCoverArt) {
+    return to('media.albumCover', album.directory, album.coverArtFileExtension);
+  } else {
+    return null;
+  }
 }
 
 export function getAlbumListTag(album) {
-    return (album.hasTrackNumbers ? 'ol' : 'ul');
+  return album.hasTrackNumbers ? 'ol' : 'ul';
 }
 
 // This gets all the track o8jects defined in every al8um, and sorts them 8y
@@ -331,157 +341,169 @@ export function getAlbumListTag(album) {
 // 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));
+  return sortByDate(albumData.flatMap((album) => album.tracks));
 }
 
 export function getArtistNumContributions(artist) {
-    return (
-        (artist.tracksAsAny?.length ?? 0) +
-        (artist.albumsAsCoverArtist?.length ?? 0) +
-        (artist.flashesAsContributor?.length ?? 0)
-    );
+  return (
+    (artist.tracksAsAny?.length ?? 0) +
+    (artist.albumsAsCoverArtist?.length ?? 0) +
+    (artist.flashesAsContributor?.length ?? 0)
+  );
 }
 
 export function getFlashCover(flash, {to}) {
-    return to('media.flashArt', flash.directory, flash.coverArtFileExtension);
+  return to('media.flashArt', flash.directory, flash.coverArtFileExtension);
 }
 
 export function getFlashLink(flash) {
-    return `https://homestuck.com/story/${flash.page}`;
+  return `https://homestuck.com/story/${flash.page}`;
 }
 
 export function getTotalDuration(tracks) {
-    return tracks.reduce((duration, track) => duration + track.duration, 0);
+  return tracks.reduce((duration, track) => duration + track.duration, 0);
 }
 
 export function getTrackCover(track, {to}) {
-    // Some albums don't have any track art at all, and in those, every track
-    // just inherits the album's own cover art. Note that since cover art isn't
-    // guaranteed on albums either, it's possible that this function returns
-    // null!
-    if (!track.hasCoverArt) {
-        return getAlbumCover(track.album, {to});
-    } else {
-        return to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension);
-    }
+  // Some albums don't have any track art at all, and in those, every track
+  // just inherits the album's own cover art. Note that since cover art isn't
+  // guaranteed on albums either, it's possible that this function returns
+  // null!
+  if (!track.hasCoverArt) {
+    return getAlbumCover(track.album, {to});
+  } else {
+    return to(
+      'media.trackCover',
+      track.album.directory,
+      track.directory,
+      track.coverArtFileExtension
+    );
+  }
 }
 
 export function getArtistAvatar(artist, {to}) {
-    return to('media.artistAvatar', artist.directory, artist.avatarFileExtension);
+  return to('media.artistAvatar', artist.directory, artist.avatarFileExtension);
 }
 
 // Big-ass homepage row functions
 
 export function getNewAdditions(numAlbums, {wikiData}) {
-    const { albumData } = wikiData;
-
-    // Sort al8ums, in descending order of priority, 8y...
-    //
-    // * D8te of addition to the wiki (descending).
-    // * Major releases first.
-    // * D8te of release (descending).
-    //
-    // Major releases go first to 8etter ensure they show up in the list (and
-    // are usually at the start of the final output for a given d8 of release
-    // too).
-    const sortedAlbums = albumData.filter(album => album.isListedOnHomepage).sort((a, b) => {
-        if (a.dateAddedToWiki > b.dateAddedToWiki) return -1;
-        if (a.dateAddedToWiki < b.dateAddedToWiki) 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;
+  const {albumData} = wikiData;
+
+  // Sort al8ums, in descending order of priority, 8y...
+  //
+  // * D8te of addition to the wiki (descending).
+  // * Major releases first.
+  // * D8te of release (descending).
+  //
+  // Major releases go first to 8etter ensure they show up in the list (and
+  // are usually at the start of the final output for a given d8 of release
+  // too).
+  const sortedAlbums = albumData
+    .filter((album) => album.isListedOnHomepage)
+    .sort((a, b) => {
+      if (a.dateAddedToWiki > b.dateAddedToWiki) return -1;
+      if (a.dateAddedToWiki < b.dateAddedToWiki) 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].dateAddedToWiki;
-        const groupMap = new Map();
-        const groupArray = [];
-        for (let album; (album = sortedAlbums[i]) && +album.dateAddedToWiki === +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);
-            }
+  // 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].dateAddedToWiki;
+    const groupMap = new Map();
+    const groupArray = [];
+    for (
+      let album;
+      (album = sortedAlbums[i]) && +album.dateAddedToWiki === +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;
         }
 
-        // 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);
-                }
-            }
+        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}));
+  // Finally, do some quick mapping shenanigans to 8etter display the result
+  // in a grid. (This should pro8a8ly 8e a separ8te, shared function, 8ut
+  // whatevs.)
+  return albums.map((album) => ({large: album.isMajorRelease, item: album}));
 }
 
 export function getNewReleases(numReleases, {wikiData}) {
-    const { albumData } = wikiData;
-
-    const latestFirst = albumData.filter(album => album.isListedOnHomepage).reverse();
-    const majorReleases = latestFirst.filter(album => album.isMajorRelease);
-    majorReleases.splice(1);
-
-    const otherReleases = latestFirst
-        .filter(album => !majorReleases.includes(album))
-        .slice(0, numReleases - majorReleases.length);
-
-    return [
-        ...majorReleases.map(album => ({large: true, item: album})),
-        ...otherReleases.map(album => ({large: false, item: album}))
-    ];
+  const {albumData} = wikiData;
+
+  const latestFirst = albumData
+    .filter((album) => album.isListedOnHomepage)
+    .reverse();
+  const majorReleases = latestFirst.filter((album) => album.isMajorRelease);
+  majorReleases.splice(1);
+
+  const otherReleases = latestFirst
+    .filter((album) => !majorReleases.includes(album))
+    .slice(0, numReleases - majorReleases.length);
+
+  return [
+    ...majorReleases.map((album) => ({large: true, item: album})),
+    ...otherReleases.map((album) => ({large: false, item: album})),
+  ];
 }