« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.json14
-rw-r--r--.prettierrc.json1
-rw-r--r--package-lock.json1369
-rw-r--r--package.json2
-rw-r--r--src/data/cacheable-object.js393
-rw-r--r--src/data/patches.js616
-rw-r--r--src/data/serialize.js30
-rw-r--r--src/data/things.js2751
-rw-r--r--src/data/validators.js404
-rw-r--r--src/data/yaml.js2188
-rw-r--r--src/file-size-preloader.js128
-rw-r--r--src/gen-thumbs.js555
-rw-r--r--src/listing-spec.js1623
-rw-r--r--src/misc-templates.js895
-rw-r--r--src/page/album-commentary.js261
-rw-r--r--src/page/album.js960
-rw-r--r--src/page/artist-alias.js25
-rw-r--r--src/page/artist.js1142
-rw-r--r--src/page/flash.js474
-rw-r--r--src/page/group.js488
-rw-r--r--src/page/homepage.js269
-rw-r--r--src/page/index.js24
-rw-r--r--src/page/listing.js341
-rw-r--r--src/page/news.js203
-rw-r--r--src/page/static.js41
-rw-r--r--src/page/tag.js157
-rw-r--r--src/page/track.js674
-rw-r--r--src/repl.js108
-rw-r--r--src/static/client.js638
-rw-r--r--src/static/lazy-loading.js62
-rw-r--r--src/static/site-basic.css12
-rw-r--r--src/static/site.css966
-rw-r--r--src/strings-default.json754
-rwxr-xr-xsrc/upd8.js3843
-rw-r--r--src/url-spec.js127
-rw-r--r--src/util/cli.js418
-rw-r--r--src/util/colors.js38
-rw-r--r--src/util/find.js254
-rw-r--r--src/util/html.js157
-rw-r--r--src/util/io.js18
-rw-r--r--src/util/link.js189
-rw-r--r--src/util/magic-constants.js4
-rw-r--r--src/util/node-utils.js49
-rw-r--r--src/util/replacer.js679
-rw-r--r--src/util/serialize.js107
-rw-r--r--src/util/sugar.js609
-rw-r--r--src/util/urls.js196
-rw-r--r--src/util/wiki-data.js638
48 files changed, 14690 insertions, 11204 deletions
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 00000000..066effea
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,14 @@
+{
+    "env": {
+        "browser": true,
+        "es2021": true,
+        "node": true
+    },
+    "extends": "eslint:recommended",
+    "parserOptions": {
+        "ecmaVersion": "latest",
+        "sourceType": "module"
+    },
+    "rules": {
+    }
+}
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1 @@
+{}
diff --git a/package-lock.json b/package-lock.json
index e8a42819..2eb1495c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,9 +17,112 @@
                 "hsmusic": "src/upd8.js"
             },
             "devDependencies": {
+                "eslint": "^8.18.0",
+                "prettier": "2.7.1",
                 "tape": "^5.4.1"
             }
         },
+        "node_modules/@eslint/eslintrc": {
+            "version": "1.3.0",
+            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.0.tgz",
+            "integrity": "sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==",
+            "dev": true,
+            "dependencies": {
+                "ajv": "^6.12.4",
+                "debug": "^4.3.2",
+                "espree": "^9.3.2",
+                "globals": "^13.15.0",
+                "ignore": "^5.2.0",
+                "import-fresh": "^3.2.1",
+                "js-yaml": "^4.1.0",
+                "minimatch": "^3.1.2",
+                "strip-json-comments": "^3.1.1"
+            },
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            }
+        },
+        "node_modules/@humanwhocodes/config-array": {
+            "version": "0.9.5",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
+            "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==",
+            "dev": true,
+            "dependencies": {
+                "@humanwhocodes/object-schema": "^1.2.1",
+                "debug": "^4.1.1",
+                "minimatch": "^3.0.4"
+            },
+            "engines": {
+                "node": ">=10.10.0"
+            }
+        },
+        "node_modules/@humanwhocodes/object-schema": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+            "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+            "dev": true
+        },
+        "node_modules/acorn": {
+            "version": "8.7.1",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
+            "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==",
+            "dev": true,
+            "bin": {
+                "acorn": "bin/acorn"
+            },
+            "engines": {
+                "node": ">=0.4.0"
+            }
+        },
+        "node_modules/acorn-jsx": {
+            "version": "5.3.2",
+            "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+            "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+            "dev": true,
+            "peerDependencies": {
+                "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+            }
+        },
+        "node_modules/ajv": {
+            "version": "6.12.6",
+            "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+            "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+            "dev": true,
+            "dependencies": {
+                "fast-deep-equal": "^3.1.1",
+                "fast-json-stable-stringify": "^2.0.0",
+                "json-schema-traverse": "^0.4.1",
+                "uri-js": "^4.2.2"
+            },
+            "funding": {
+                "type": "github",
+                "url": "https://github.com/sponsors/epoberezkin"
+            }
+        },
+        "node_modules/ansi-regex": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/ansi-styles": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+            "dev": true,
+            "dependencies": {
+                "color-convert": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            }
+        },
         "node_modules/argparse": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -84,12 +187,86 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/callsites": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+            "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/chalk": {
+            "version": "4.1.2",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+            "dev": true,
+            "dependencies": {
+                "ansi-styles": "^4.1.0",
+                "supports-color": "^7.1.0"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/chalk?sponsor=1"
+            }
+        },
+        "node_modules/color-convert": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+            "dev": true,
+            "dependencies": {
+                "color-name": "~1.1.4"
+            },
+            "engines": {
+                "node": ">=7.0.0"
+            }
+        },
+        "node_modules/color-name": {
+            "version": "1.1.4",
+            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+            "dev": true
+        },
         "node_modules/concat-map": {
             "version": "0.0.1",
             "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
             "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
             "dev": true
         },
+        "node_modules/cross-spawn": {
+            "version": "7.0.3",
+            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+            "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+            "dev": true,
+            "dependencies": {
+                "path-key": "^3.1.0",
+                "shebang-command": "^2.0.0",
+                "which": "^2.0.1"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/debug": {
+            "version": "4.3.4",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+            "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+            "dev": true,
+            "dependencies": {
+                "ms": "2.1.2"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
         "node_modules/deep-equal": {
             "version": "2.0.5",
             "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.0.5.tgz",
@@ -116,6 +293,12 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/deep-is": {
+            "version": "0.1.4",
+            "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+            "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+            "dev": true
+        },
         "node_modules/define-properties": {
             "version": "1.1.3",
             "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
@@ -134,6 +317,18 @@
             "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
             "dev": true
         },
+        "node_modules/doctrine": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+            "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+            "dev": true,
+            "dependencies": {
+                "esutils": "^2.0.2"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
         "node_modules/dotignore": {
             "version": "0.1.2",
             "resolved": "https://registry.npmjs.org/dotignore/-/dotignore-0.1.2.tgz",
@@ -216,11 +411,229 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/escape-string-regexp": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+            "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+            "dev": true,
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/eslint": {
+            "version": "8.18.0",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.18.0.tgz",
+            "integrity": "sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA==",
+            "dev": true,
+            "dependencies": {
+                "@eslint/eslintrc": "^1.3.0",
+                "@humanwhocodes/config-array": "^0.9.2",
+                "ajv": "^6.10.0",
+                "chalk": "^4.0.0",
+                "cross-spawn": "^7.0.2",
+                "debug": "^4.3.2",
+                "doctrine": "^3.0.0",
+                "escape-string-regexp": "^4.0.0",
+                "eslint-scope": "^7.1.1",
+                "eslint-utils": "^3.0.0",
+                "eslint-visitor-keys": "^3.3.0",
+                "espree": "^9.3.2",
+                "esquery": "^1.4.0",
+                "esutils": "^2.0.2",
+                "fast-deep-equal": "^3.1.3",
+                "file-entry-cache": "^6.0.1",
+                "functional-red-black-tree": "^1.0.1",
+                "glob-parent": "^6.0.1",
+                "globals": "^13.15.0",
+                "ignore": "^5.2.0",
+                "import-fresh": "^3.0.0",
+                "imurmurhash": "^0.1.4",
+                "is-glob": "^4.0.0",
+                "js-yaml": "^4.1.0",
+                "json-stable-stringify-without-jsonify": "^1.0.1",
+                "levn": "^0.4.1",
+                "lodash.merge": "^4.6.2",
+                "minimatch": "^3.1.2",
+                "natural-compare": "^1.4.0",
+                "optionator": "^0.9.1",
+                "regexpp": "^3.2.0",
+                "strip-ansi": "^6.0.1",
+                "strip-json-comments": "^3.1.0",
+                "text-table": "^0.2.0",
+                "v8-compile-cache": "^2.0.3"
+            },
+            "bin": {
+                "eslint": "bin/eslint.js"
+            },
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
+            }
+        },
+        "node_modules/eslint-scope": {
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
+            "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+            "dev": true,
+            "dependencies": {
+                "esrecurse": "^4.3.0",
+                "estraverse": "^5.2.0"
+            },
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            }
+        },
+        "node_modules/eslint-utils": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
+            "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
+            "dev": true,
+            "dependencies": {
+                "eslint-visitor-keys": "^2.0.0"
+            },
+            "engines": {
+                "node": "^10.0.0 || ^12.0.0 || >= 14.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/mysticatea"
+            },
+            "peerDependencies": {
+                "eslint": ">=5"
+            }
+        },
+        "node_modules/eslint-utils/node_modules/eslint-visitor-keys": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+            "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+            "dev": true,
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/eslint-visitor-keys": {
+            "version": "3.3.0",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
+            "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
+            "dev": true,
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            }
+        },
+        "node_modules/espree": {
+            "version": "9.3.2",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.2.tgz",
+            "integrity": "sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==",
+            "dev": true,
+            "dependencies": {
+                "acorn": "^8.7.1",
+                "acorn-jsx": "^5.3.2",
+                "eslint-visitor-keys": "^3.3.0"
+            },
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            }
+        },
+        "node_modules/esquery": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+            "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+            "dev": true,
+            "dependencies": {
+                "estraverse": "^5.1.0"
+            },
+            "engines": {
+                "node": ">=0.10"
+            }
+        },
+        "node_modules/esrecurse": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+            "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+            "dev": true,
+            "dependencies": {
+                "estraverse": "^5.2.0"
+            },
+            "engines": {
+                "node": ">=4.0"
+            }
+        },
+        "node_modules/estraverse": {
+            "version": "5.3.0",
+            "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+            "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+            "dev": true,
+            "engines": {
+                "node": ">=4.0"
+            }
+        },
+        "node_modules/esutils": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+            "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/fast-deep-equal": {
+            "version": "3.1.3",
+            "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+            "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+            "dev": true
+        },
+        "node_modules/fast-json-stable-stringify": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+            "dev": true
+        },
+        "node_modules/fast-levenshtein": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+            "dev": true
+        },
+        "node_modules/file-entry-cache": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+            "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+            "dev": true,
+            "dependencies": {
+                "flat-cache": "^3.0.4"
+            },
+            "engines": {
+                "node": "^10.12.0 || >=12.0.0"
+            }
+        },
         "node_modules/fix-whitespace": {
             "version": "1.0.4",
             "resolved": "https://registry.npmjs.org/fix-whitespace/-/fix-whitespace-1.0.4.tgz",
             "integrity": "sha512-TYJpw4orIgDpaINRkw1BVJQF8rPTNSUbW/s4mLYSApUt0MquGfI+iripYHibg9l9fe795VauuVCLTpDvy8KFWQ=="
         },
+        "node_modules/flat-cache": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+            "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+            "dev": true,
+            "dependencies": {
+                "flatted": "^3.1.0",
+                "rimraf": "^3.0.2"
+            },
+            "engines": {
+                "node": "^10.12.0 || >=12.0.0"
+            }
+        },
+        "node_modules/flatted": {
+            "version": "3.2.5",
+            "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
+            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
+            "dev": true
+        },
         "node_modules/for-each": {
             "version": "0.3.3",
             "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -248,6 +661,12 @@
             "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
             "dev": true
         },
+        "node_modules/functional-red-black-tree": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+            "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
+            "dev": true
+        },
         "node_modules/get-intrinsic": {
             "version": "1.1.1",
             "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
@@ -307,6 +726,33 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
+        "node_modules/glob-parent": {
+            "version": "6.0.2",
+            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+            "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+            "dev": true,
+            "dependencies": {
+                "is-glob": "^4.0.3"
+            },
+            "engines": {
+                "node": ">=10.13.0"
+            }
+        },
+        "node_modules/globals": {
+            "version": "13.15.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-13.15.0.tgz",
+            "integrity": "sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==",
+            "dev": true,
+            "dependencies": {
+                "type-fest": "^0.20.2"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
         "node_modules/has": {
             "version": "1.0.3",
             "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@@ -341,6 +787,15 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/has-flag": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/has-symbols": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
@@ -376,6 +831,40 @@
                 "he": "bin/he"
             }
         },
+        "node_modules/ignore": {
+            "version": "5.2.0",
+            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
+            "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
+            "dev": true,
+            "engines": {
+                "node": ">= 4"
+            }
+        },
+        "node_modules/import-fresh": {
+            "version": "3.3.0",
+            "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+            "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+            "dev": true,
+            "dependencies": {
+                "parent-module": "^1.0.0",
+                "resolve-from": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=6"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/imurmurhash": {
+            "version": "0.1.4",
+            "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+            "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.8.19"
+            }
+        },
         "node_modules/inflight": {
             "version": "1.0.6",
             "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -489,6 +978,27 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/is-extglob": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+            "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/is-glob": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+            "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+            "dev": true,
+            "dependencies": {
+                "is-extglob": "^2.1.1"
+            },
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
         "node_modules/is-map": {
             "version": "2.0.2",
             "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
@@ -648,6 +1158,12 @@
             "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
             "dev": true
         },
+        "node_modules/isexe": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+            "dev": true
+        },
         "node_modules/js-yaml": {
             "version": "4.1.0",
             "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -659,6 +1175,37 @@
                 "js-yaml": "bin/js-yaml.js"
             }
         },
+        "node_modules/json-schema-traverse": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+            "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+            "dev": true
+        },
+        "node_modules/json-stable-stringify-without-jsonify": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+            "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+            "dev": true
+        },
+        "node_modules/levn": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+            "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+            "dev": true,
+            "dependencies": {
+                "prelude-ls": "^1.2.1",
+                "type-check": "~0.4.0"
+            },
+            "engines": {
+                "node": ">= 0.8.0"
+            }
+        },
+        "node_modules/lodash.merge": {
+            "version": "4.6.2",
+            "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+            "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+            "dev": true
+        },
         "node_modules/minimatch": {
             "version": "3.1.2",
             "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -677,6 +1224,18 @@
             "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
             "dev": true
         },
+        "node_modules/ms": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+            "dev": true
+        },
+        "node_modules/natural-compare": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+            "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+            "dev": true
+        },
         "node_modules/object-inspect": {
             "version": "1.12.0",
             "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
@@ -738,6 +1297,35 @@
                 "wrappy": "1"
             }
         },
+        "node_modules/optionator": {
+            "version": "0.9.1",
+            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+            "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+            "dev": true,
+            "dependencies": {
+                "deep-is": "^0.1.3",
+                "fast-levenshtein": "^2.0.6",
+                "levn": "^0.4.1",
+                "prelude-ls": "^1.2.1",
+                "type-check": "^0.4.0",
+                "word-wrap": "^1.2.3"
+            },
+            "engines": {
+                "node": ">= 0.8.0"
+            }
+        },
+        "node_modules/parent-module": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+            "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+            "dev": true,
+            "dependencies": {
+                "callsites": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=6"
+            }
+        },
         "node_modules/path-is-absolute": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -747,12 +1335,54 @@
                 "node": ">=0.10.0"
             }
         },
+        "node_modules/path-key": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+            "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/path-parse": {
             "version": "1.0.7",
             "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
             "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
             "dev": true
         },
+        "node_modules/prelude-ls": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+            "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.8.0"
+            }
+        },
+        "node_modules/prettier": {
+            "version": "2.7.1",
+            "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
+            "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
+            "dev": true,
+            "bin": {
+                "prettier": "bin-prettier.js"
+            },
+            "engines": {
+                "node": ">=10.13.0"
+            },
+            "funding": {
+                "url": "https://github.com/prettier/prettier?sponsor=1"
+            }
+        },
+        "node_modules/punycode": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+            "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+            "dev": true,
+            "engines": {
+                "node": ">=6"
+            }
+        },
         "node_modules/regexp.prototype.flags": {
             "version": "1.4.1",
             "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.1.tgz",
@@ -769,6 +1399,18 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/regexpp": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
+            "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/mysticatea"
+            }
+        },
         "node_modules/resolve": {
             "version": "2.0.0-next.3",
             "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz",
@@ -782,6 +1424,15 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/resolve-from": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+            "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
+        },
         "node_modules/resumer": {
             "version": "0.0.0",
             "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz",
@@ -791,6 +1442,42 @@
                 "through": "~2.3.4"
             }
         },
+        "node_modules/rimraf": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+            "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+            "dev": true,
+            "dependencies": {
+                "glob": "^7.1.3"
+            },
+            "bin": {
+                "rimraf": "bin.js"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/shebang-command": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+            "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+            "dev": true,
+            "dependencies": {
+                "shebang-regex": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/shebang-regex": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+            "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/side-channel": {
             "version": "1.0.4",
             "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
@@ -848,6 +1535,42 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/strip-ansi": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+            "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+            "dev": true,
+            "dependencies": {
+                "ansi-regex": "^5.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/strip-json-comments": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+            "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/supports-color": {
+            "version": "7.2.0",
+            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+            "dev": true,
+            "dependencies": {
+                "has-flag": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/tape": {
             "version": "5.4.1",
             "resolved": "https://registry.npmjs.org/tape/-/tape-5.4.1.tgz",
@@ -880,12 +1603,42 @@
                 "tape": "bin/tape"
             }
         },
+        "node_modules/text-table": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+            "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+            "dev": true
+        },
         "node_modules/through": {
             "version": "2.3.8",
             "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
             "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
             "dev": true
         },
+        "node_modules/type-check": {
+            "version": "0.4.0",
+            "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+            "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+            "dev": true,
+            "dependencies": {
+                "prelude-ls": "^1.2.1"
+            },
+            "engines": {
+                "node": ">= 0.8.0"
+            }
+        },
+        "node_modules/type-fest": {
+            "version": "0.20.2",
+            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+            "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
         "node_modules/unbox-primitive": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
@@ -901,6 +1654,36 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/uri-js": {
+            "version": "4.4.1",
+            "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+            "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+            "dev": true,
+            "dependencies": {
+                "punycode": "^2.1.0"
+            }
+        },
+        "node_modules/v8-compile-cache": {
+            "version": "2.3.0",
+            "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
+            "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+            "dev": true
+        },
+        "node_modules/which": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+            "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+            "dev": true,
+            "dependencies": {
+                "isexe": "^2.0.0"
+            },
+            "bin": {
+                "node-which": "bin/node-which"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
         "node_modules/which-boxed-primitive": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
@@ -952,6 +1735,15 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/word-wrap": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+            "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
         "node_modules/wrappy": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -960,6 +1752,80 @@
         }
     },
     "dependencies": {
+        "@eslint/eslintrc": {
+            "version": "1.3.0",
+            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.0.tgz",
+            "integrity": "sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==",
+            "dev": true,
+            "requires": {
+                "ajv": "^6.12.4",
+                "debug": "^4.3.2",
+                "espree": "^9.3.2",
+                "globals": "^13.15.0",
+                "ignore": "^5.2.0",
+                "import-fresh": "^3.2.1",
+                "js-yaml": "^4.1.0",
+                "minimatch": "^3.1.2",
+                "strip-json-comments": "^3.1.1"
+            }
+        },
+        "@humanwhocodes/config-array": {
+            "version": "0.9.5",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
+            "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==",
+            "dev": true,
+            "requires": {
+                "@humanwhocodes/object-schema": "^1.2.1",
+                "debug": "^4.1.1",
+                "minimatch": "^3.0.4"
+            }
+        },
+        "@humanwhocodes/object-schema": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+            "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+            "dev": true
+        },
+        "acorn": {
+            "version": "8.7.1",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
+            "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==",
+            "dev": true
+        },
+        "acorn-jsx": {
+            "version": "5.3.2",
+            "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+            "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+            "dev": true,
+            "requires": {}
+        },
+        "ajv": {
+            "version": "6.12.6",
+            "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+            "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+            "dev": true,
+            "requires": {
+                "fast-deep-equal": "^3.1.1",
+                "fast-json-stable-stringify": "^2.0.0",
+                "json-schema-traverse": "^0.4.1",
+                "uri-js": "^4.2.2"
+            }
+        },
+        "ansi-regex": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+            "dev": true
+        },
+        "ansi-styles": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+            "dev": true,
+            "requires": {
+                "color-convert": "^2.0.1"
+            }
+        },
         "argparse": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -1009,12 +1875,63 @@
                 "get-intrinsic": "^1.0.2"
             }
         },
+        "callsites": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+            "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+            "dev": true
+        },
+        "chalk": {
+            "version": "4.1.2",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+            "dev": true,
+            "requires": {
+                "ansi-styles": "^4.1.0",
+                "supports-color": "^7.1.0"
+            }
+        },
+        "color-convert": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+            "dev": true,
+            "requires": {
+                "color-name": "~1.1.4"
+            }
+        },
+        "color-name": {
+            "version": "1.1.4",
+            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+            "dev": true
+        },
         "concat-map": {
             "version": "0.0.1",
             "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
             "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
             "dev": true
         },
+        "cross-spawn": {
+            "version": "7.0.3",
+            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+            "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+            "dev": true,
+            "requires": {
+                "path-key": "^3.1.0",
+                "shebang-command": "^2.0.0",
+                "which": "^2.0.1"
+            }
+        },
+        "debug": {
+            "version": "4.3.4",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+            "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+            "dev": true,
+            "requires": {
+                "ms": "2.1.2"
+            }
+        },
         "deep-equal": {
             "version": "2.0.5",
             "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.0.5.tgz",
@@ -1038,6 +1955,12 @@
                 "which-typed-array": "^1.1.2"
             }
         },
+        "deep-is": {
+            "version": "0.1.4",
+            "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+            "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+            "dev": true
+        },
         "define-properties": {
             "version": "1.1.3",
             "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
@@ -1053,6 +1976,15 @@
             "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
             "dev": true
         },
+        "doctrine": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+            "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+            "dev": true,
+            "requires": {
+                "esutils": "^2.0.2"
+            }
+        },
         "dotignore": {
             "version": "0.1.2",
             "resolved": "https://registry.npmjs.org/dotignore/-/dotignore-0.1.2.tgz",
@@ -1117,11 +2049,177 @@
                 "is-symbol": "^1.0.2"
             }
         },
+        "escape-string-regexp": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+            "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+            "dev": true
+        },
+        "eslint": {
+            "version": "8.18.0",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.18.0.tgz",
+            "integrity": "sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA==",
+            "dev": true,
+            "requires": {
+                "@eslint/eslintrc": "^1.3.0",
+                "@humanwhocodes/config-array": "^0.9.2",
+                "ajv": "^6.10.0",
+                "chalk": "^4.0.0",
+                "cross-spawn": "^7.0.2",
+                "debug": "^4.3.2",
+                "doctrine": "^3.0.0",
+                "escape-string-regexp": "^4.0.0",
+                "eslint-scope": "^7.1.1",
+                "eslint-utils": "^3.0.0",
+                "eslint-visitor-keys": "^3.3.0",
+                "espree": "^9.3.2",
+                "esquery": "^1.4.0",
+                "esutils": "^2.0.2",
+                "fast-deep-equal": "^3.1.3",
+                "file-entry-cache": "^6.0.1",
+                "functional-red-black-tree": "^1.0.1",
+                "glob-parent": "^6.0.1",
+                "globals": "^13.15.0",
+                "ignore": "^5.2.0",
+                "import-fresh": "^3.0.0",
+                "imurmurhash": "^0.1.4",
+                "is-glob": "^4.0.0",
+                "js-yaml": "^4.1.0",
+                "json-stable-stringify-without-jsonify": "^1.0.1",
+                "levn": "^0.4.1",
+                "lodash.merge": "^4.6.2",
+                "minimatch": "^3.1.2",
+                "natural-compare": "^1.4.0",
+                "optionator": "^0.9.1",
+                "regexpp": "^3.2.0",
+                "strip-ansi": "^6.0.1",
+                "strip-json-comments": "^3.1.0",
+                "text-table": "^0.2.0",
+                "v8-compile-cache": "^2.0.3"
+            }
+        },
+        "eslint-scope": {
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
+            "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+            "dev": true,
+            "requires": {
+                "esrecurse": "^4.3.0",
+                "estraverse": "^5.2.0"
+            }
+        },
+        "eslint-utils": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
+            "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
+            "dev": true,
+            "requires": {
+                "eslint-visitor-keys": "^2.0.0"
+            },
+            "dependencies": {
+                "eslint-visitor-keys": {
+                    "version": "2.1.0",
+                    "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+                    "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+                    "dev": true
+                }
+            }
+        },
+        "eslint-visitor-keys": {
+            "version": "3.3.0",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
+            "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
+            "dev": true
+        },
+        "espree": {
+            "version": "9.3.2",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.2.tgz",
+            "integrity": "sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==",
+            "dev": true,
+            "requires": {
+                "acorn": "^8.7.1",
+                "acorn-jsx": "^5.3.2",
+                "eslint-visitor-keys": "^3.3.0"
+            }
+        },
+        "esquery": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+            "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+            "dev": true,
+            "requires": {
+                "estraverse": "^5.1.0"
+            }
+        },
+        "esrecurse": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+            "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+            "dev": true,
+            "requires": {
+                "estraverse": "^5.2.0"
+            }
+        },
+        "estraverse": {
+            "version": "5.3.0",
+            "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+            "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+            "dev": true
+        },
+        "esutils": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+            "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+            "dev": true
+        },
+        "fast-deep-equal": {
+            "version": "3.1.3",
+            "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+            "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+            "dev": true
+        },
+        "fast-json-stable-stringify": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+            "dev": true
+        },
+        "fast-levenshtein": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+            "dev": true
+        },
+        "file-entry-cache": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+            "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+            "dev": true,
+            "requires": {
+                "flat-cache": "^3.0.4"
+            }
+        },
         "fix-whitespace": {
             "version": "1.0.4",
             "resolved": "https://registry.npmjs.org/fix-whitespace/-/fix-whitespace-1.0.4.tgz",
             "integrity": "sha512-TYJpw4orIgDpaINRkw1BVJQF8rPTNSUbW/s4mLYSApUt0MquGfI+iripYHibg9l9fe795VauuVCLTpDvy8KFWQ=="
         },
+        "flat-cache": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+            "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+            "dev": true,
+            "requires": {
+                "flatted": "^3.1.0",
+                "rimraf": "^3.0.2"
+            }
+        },
+        "flatted": {
+            "version": "3.2.5",
+            "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
+            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
+            "dev": true
+        },
         "for-each": {
             "version": "0.3.3",
             "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -1149,6 +2247,12 @@
             "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
             "dev": true
         },
+        "functional-red-black-tree": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+            "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
+            "dev": true
+        },
         "get-intrinsic": {
             "version": "1.1.1",
             "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
@@ -1190,6 +2294,24 @@
                 "path-is-absolute": "^1.0.0"
             }
         },
+        "glob-parent": {
+            "version": "6.0.2",
+            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+            "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+            "dev": true,
+            "requires": {
+                "is-glob": "^4.0.3"
+            }
+        },
+        "globals": {
+            "version": "13.15.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-13.15.0.tgz",
+            "integrity": "sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==",
+            "dev": true,
+            "requires": {
+                "type-fest": "^0.20.2"
+            }
+        },
         "has": {
             "version": "1.0.3",
             "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@@ -1215,6 +2337,12 @@
                 "get-intrinsic": "^1.1.1"
             }
         },
+        "has-flag": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+            "dev": true
+        },
         "has-symbols": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
@@ -1235,6 +2363,28 @@
             "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
             "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
         },
+        "ignore": {
+            "version": "5.2.0",
+            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
+            "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
+            "dev": true
+        },
+        "import-fresh": {
+            "version": "3.3.0",
+            "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+            "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+            "dev": true,
+            "requires": {
+                "parent-module": "^1.0.0",
+                "resolve-from": "^4.0.0"
+            }
+        },
+        "imurmurhash": {
+            "version": "0.1.4",
+            "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+            "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+            "dev": true
+        },
         "inflight": {
             "version": "1.0.6",
             "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -1315,6 +2465,21 @@
                 "has-tostringtag": "^1.0.0"
             }
         },
+        "is-extglob": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+            "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+            "dev": true
+        },
+        "is-glob": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+            "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+            "dev": true,
+            "requires": {
+                "is-extglob": "^2.1.1"
+            }
+        },
         "is-map": {
             "version": "2.0.2",
             "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
@@ -1420,6 +2585,12 @@
             "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
             "dev": true
         },
+        "isexe": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+            "dev": true
+        },
         "js-yaml": {
             "version": "4.1.0",
             "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -1428,6 +2599,34 @@
                 "argparse": "^2.0.1"
             }
         },
+        "json-schema-traverse": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+            "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+            "dev": true
+        },
+        "json-stable-stringify-without-jsonify": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+            "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+            "dev": true
+        },
+        "levn": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+            "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+            "dev": true,
+            "requires": {
+                "prelude-ls": "^1.2.1",
+                "type-check": "~0.4.0"
+            }
+        },
+        "lodash.merge": {
+            "version": "4.6.2",
+            "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+            "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+            "dev": true
+        },
         "minimatch": {
             "version": "3.1.2",
             "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -1443,6 +2642,18 @@
             "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
             "dev": true
         },
+        "ms": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+            "dev": true
+        },
+        "natural-compare": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+            "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+            "dev": true
+        },
         "object-inspect": {
             "version": "1.12.0",
             "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
@@ -1486,18 +2697,65 @@
                 "wrappy": "1"
             }
         },
+        "optionator": {
+            "version": "0.9.1",
+            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+            "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+            "dev": true,
+            "requires": {
+                "deep-is": "^0.1.3",
+                "fast-levenshtein": "^2.0.6",
+                "levn": "^0.4.1",
+                "prelude-ls": "^1.2.1",
+                "type-check": "^0.4.0",
+                "word-wrap": "^1.2.3"
+            }
+        },
+        "parent-module": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+            "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+            "dev": true,
+            "requires": {
+                "callsites": "^3.0.0"
+            }
+        },
         "path-is-absolute": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
             "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
             "dev": true
         },
+        "path-key": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+            "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+            "dev": true
+        },
         "path-parse": {
             "version": "1.0.7",
             "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
             "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
             "dev": true
         },
+        "prelude-ls": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+            "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+            "dev": true
+        },
+        "prettier": {
+            "version": "2.7.1",
+            "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
+            "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
+            "dev": true
+        },
+        "punycode": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+            "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+            "dev": true
+        },
         "regexp.prototype.flags": {
             "version": "1.4.1",
             "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.1.tgz",
@@ -1508,6 +2766,12 @@
                 "define-properties": "^1.1.3"
             }
         },
+        "regexpp": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
+            "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+            "dev": true
+        },
         "resolve": {
             "version": "2.0.0-next.3",
             "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz",
@@ -1518,6 +2782,12 @@
                 "path-parse": "^1.0.6"
             }
         },
+        "resolve-from": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+            "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+            "dev": true
+        },
         "resumer": {
             "version": "0.0.0",
             "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz",
@@ -1527,6 +2797,30 @@
                 "through": "~2.3.4"
             }
         },
+        "rimraf": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+            "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+            "dev": true,
+            "requires": {
+                "glob": "^7.1.3"
+            }
+        },
+        "shebang-command": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+            "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+            "dev": true,
+            "requires": {
+                "shebang-regex": "^3.0.0"
+            }
+        },
+        "shebang-regex": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+            "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+            "dev": true
+        },
         "side-channel": {
             "version": "1.0.4",
             "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
@@ -1569,6 +2863,30 @@
                 "define-properties": "^1.1.3"
             }
         },
+        "strip-ansi": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+            "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+            "dev": true,
+            "requires": {
+                "ansi-regex": "^5.0.1"
+            }
+        },
+        "strip-json-comments": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+            "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+            "dev": true
+        },
+        "supports-color": {
+            "version": "7.2.0",
+            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+            "dev": true,
+            "requires": {
+                "has-flag": "^4.0.0"
+            }
+        },
         "tape": {
             "version": "5.4.1",
             "resolved": "https://registry.npmjs.org/tape/-/tape-5.4.1.tgz",
@@ -1598,12 +2916,33 @@
                 "through": "^2.3.8"
             }
         },
+        "text-table": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+            "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+            "dev": true
+        },
         "through": {
             "version": "2.3.8",
             "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
             "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
             "dev": true
         },
+        "type-check": {
+            "version": "0.4.0",
+            "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+            "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+            "dev": true,
+            "requires": {
+                "prelude-ls": "^1.2.1"
+            }
+        },
+        "type-fest": {
+            "version": "0.20.2",
+            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+            "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+            "dev": true
+        },
         "unbox-primitive": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
@@ -1616,6 +2955,30 @@
                 "which-boxed-primitive": "^1.0.2"
             }
         },
+        "uri-js": {
+            "version": "4.4.1",
+            "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+            "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+            "dev": true,
+            "requires": {
+                "punycode": "^2.1.0"
+            }
+        },
+        "v8-compile-cache": {
+            "version": "2.3.0",
+            "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
+            "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+            "dev": true
+        },
+        "which": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+            "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+            "dev": true,
+            "requires": {
+                "isexe": "^2.0.0"
+            }
+        },
         "which-boxed-primitive": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
@@ -1655,6 +3018,12 @@
                 "is-typed-array": "^1.1.7"
             }
         },
+        "word-wrap": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+            "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+            "dev": true
+        },
         "wrappy": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
diff --git a/package.json b/package.json
index c48d868e..938ec6ac 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,8 @@
     },
     "license": "GPL-3.0",
     "devDependencies": {
+        "eslint": "^8.18.0",
+        "prettier": "2.7.1",
         "tape": "^5.4.1"
     }
 }
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index 4afb0368..76efbd83 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -74,21 +74,21 @@
 //      function, which provides a mapping of exposed property names to whether
 //      or not their dependencies are yet met.
 
-import { color, ENABLE_COLOR } from '../util/cli.js';
+import { color, ENABLE_COLOR } from "../util/cli.js";
 
-import { inspect as nodeInspect } from 'util';
+import { inspect as nodeInspect } from "util";
 
 function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
+  return nodeInspect(value, { colors: ENABLE_COLOR });
 }
 
 export default class CacheableObject {
-    static instance = Symbol('CacheableObject `this` instance');
+  static instance = Symbol("CacheableObject `this` instance");
 
-    #propertyUpdateValues = Object.create(null);
-    #propertyUpdateCacheInvalidators = Object.create(null);
+  #propertyUpdateValues = Object.create(null);
+  #propertyUpdateCacheInvalidators = Object.create(null);
 
-    /*
+  /*
     // Note the constructor doesn't take an initial data source. Due to a quirk
     // of JavaScript, private members can't be accessed before the superclass's
     // constructor is finished processing - so if we call the overridden
@@ -99,211 +99,238 @@ export default class CacheableObject {
     // after constructing the new instance of the Thing (sub)class.
     */
 
-    constructor() {
-        this.#defineProperties();
-        this.#initializeUpdatingPropertyValues();
-
-        if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-            return new Proxy(this, {
-                get: (obj, key) => {
-                    if (!Object.hasOwn(obj, key)) {
-                        if (key !== 'constructor') {
-                            CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`);
-                        }
-                    }
-                    return obj[key];
-                }
-            });
-        }
-    }
-
-    #initializeUpdatingPropertyValues() {
-        for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) {
-            const { flags, update } = descriptor;
-
-            if (!flags.update) {
-                continue;
-            }
-
-            if (update?.default) {
-                this[property] = update?.default;
-            } else {
-                this[property] = null;
+  constructor() {
+    this.#defineProperties();
+    this.#initializeUpdatingPropertyValues();
+
+    if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
+      return new Proxy(this, {
+        get: (obj, key) => {
+          if (!Object.hasOwn(obj, key)) {
+            if (key !== "constructor") {
+              CacheableObject._invalidAccesses.add(
+                `(${obj.constructor.name}).${key}`
+              );
             }
-        }
+          }
+          return obj[key];
+        },
+      });
     }
-
-    #defineProperties() {
-        if (!this.constructor.propertyDescriptors) {
-            throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`);
-        }
-
-        for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) {
-            const { flags } = descriptor;
-
-            const definition = {
-                configurable: false,
-                enumerable: true
-            };
-
-            if (flags.update) {
-                definition.set = this.#getUpdateObjectDefinitionSetterFunction(property);
-            }
-
-            if (flags.expose) {
-                definition.get = this.#getExposeObjectDefinitionGetterFunction(property);
-            }
-
-            Object.defineProperty(this, property, definition);
-        }
-
-        Object.seal(this);
+  }
+
+  #initializeUpdatingPropertyValues() {
+    for (const [property, descriptor] of Object.entries(
+      this.constructor.propertyDescriptors
+    )) {
+      const { flags, update } = descriptor;
+
+      if (!flags.update) {
+        continue;
+      }
+
+      if (update?.default) {
+        this[property] = update?.default;
+      } else {
+        this[property] = null;
+      }
     }
+  }
 
-    #getUpdateObjectDefinitionSetterFunction(property) {
-        const { update } = this.#getPropertyDescriptor(property);
-        const validate = update?.validate;
-        const allowNull = update?.allowNull;
-
-        return (newValue) => {
-            const oldValue = this.#propertyUpdateValues[property];
+  #defineProperties() {
+    if (!this.constructor.propertyDescriptors) {
+      throw new Error(
+        `Expected constructor ${this.constructor.name} to define propertyDescriptors`
+      );
+    }
 
-            if (newValue === undefined) {
-                throw new TypeError(`Properties cannot be set to undefined`);
-            }
+    for (const [property, descriptor] of Object.entries(
+      this.constructor.propertyDescriptors
+    )) {
+      const { flags } = descriptor;
 
-            if (newValue === oldValue) {
-                return;
-            }
+      const definition = {
+        configurable: false,
+        enumerable: true,
+      };
 
-            if (newValue !== null && validate) {
-                try {
-                    const result = validate(newValue);
-                    if (result === undefined) {
-                        throw new TypeError(`Validate function returned undefined`);
-                    } else if (result !== true) {
-                        throw new TypeError(`Validation failed for value ${newValue}`);
-                    }
-                } catch (error) {
-                    error.message = `Property ${color.green(property)} (${inspect(this[property])} -> ${inspect(newValue)}): ${error.message}`;
-                    throw error;
-                }
-            }
+      if (flags.update) {
+        definition.set =
+          this.#getUpdateObjectDefinitionSetterFunction(property);
+      }
 
-            this.#propertyUpdateValues[property] = newValue;
-            this.#invalidateCachesDependentUpon(property);
-        };
-    }
+      if (flags.expose) {
+        definition.get =
+          this.#getExposeObjectDefinitionGetterFunction(property);
+      }
 
-    #getUpdatePropertyValidateFunction(property) {
-        const descriptor = this.#getPropertyDescriptor(property);
+      Object.defineProperty(this, property, definition);
     }
 
-    #getPropertyDescriptor(property) {
-        return this.constructor.propertyDescriptors[property];
-    }
-
-    #invalidateCachesDependentUpon(property) {
-        for (const invalidate of this.#propertyUpdateCacheInvalidators[property] || []) {
-            invalidate();
-        }
-    }
-
-    #getExposeObjectDefinitionGetterFunction(property) {
-        const { flags } = this.#getPropertyDescriptor(property);
-        const compute = this.#getExposeComputeFunction(property);
-
-        if (compute) {
-            let cachedValue;
-            const checkCacheValid = this.#getExposeCheckCacheValidFunction(property);
-            return () => {
-                if (checkCacheValid()) {
-                    return cachedValue;
-                } else {
-                    return (cachedValue = compute());
-                }
-            };
-        } else if (!flags.update && !compute) {
-            throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-        } else {
-            return () => this.#propertyUpdateValues[property];
+    Object.seal(this);
+  }
+
+  #getUpdateObjectDefinitionSetterFunction(property) {
+    const { update } = this.#getPropertyDescriptor(property);
+    const validate = update?.validate;
+    const allowNull = update?.allowNull;
+
+    return (newValue) => {
+      const oldValue = this.#propertyUpdateValues[property];
+
+      if (newValue === undefined) {
+        throw new TypeError(`Properties cannot be set to undefined`);
+      }
+
+      if (newValue === oldValue) {
+        return;
+      }
+
+      if (newValue !== null && validate) {
+        try {
+          const result = validate(newValue);
+          if (result === undefined) {
+            throw new TypeError(`Validate function returned undefined`);
+          } else if (result !== true) {
+            throw new TypeError(`Validation failed for value ${newValue}`);
+          }
+        } catch (error) {
+          error.message = `Property ${color.green(property)} (${inspect(
+            this[property]
+          )} -> ${inspect(newValue)}): ${error.message}`;
+          throw error;
         }
-    }
-
-    #getExposeComputeFunction(property) {
-        const { flags, expose } = this.#getPropertyDescriptor(property);
+      }
 
-        const compute = expose?.compute;
-        const transform = expose?.transform;
+      this.#propertyUpdateValues[property] = newValue;
+      this.#invalidateCachesDependentUpon(property);
+    };
+  }
 
-        if (flags.update && !transform) {
-            return null;
-        } else if (flags.update && compute) {
-            throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
-        } else if (!flags.update && !compute) {
-            throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-        }
+  #getUpdatePropertyValidateFunction(property) {
+    const descriptor = this.#getPropertyDescriptor(property);
+  }
 
-        const dependencyKeys = expose.dependencies || [];
-        const dependencyGetters = dependencyKeys.map(key => () => [key, this.#propertyUpdateValues[key]]);
-        const getAllDependencies = () => Object.fromEntries(dependencyGetters.map(f => f())
-            .concat([[this.constructor.instance, this]]));
+  #getPropertyDescriptor(property) {
+    return this.constructor.propertyDescriptors[property];
+  }
 
-        if (flags.update) {
-            return () => transform(this.#propertyUpdateValues[property], getAllDependencies());
+  #invalidateCachesDependentUpon(property) {
+    for (const invalidate of this.#propertyUpdateCacheInvalidators[property] ||
+      []) {
+      invalidate();
+    }
+  }
+
+  #getExposeObjectDefinitionGetterFunction(property) {
+    const { flags } = this.#getPropertyDescriptor(property);
+    const compute = this.#getExposeComputeFunction(property);
+
+    if (compute) {
+      let cachedValue;
+      const checkCacheValid = this.#getExposeCheckCacheValidFunction(property);
+      return () => {
+        if (checkCacheValid()) {
+          return cachedValue;
         } else {
-            return () => compute(getAllDependencies());
+          return (cachedValue = compute());
         }
+      };
+    } else if (!flags.update && !compute) {
+      throw new Error(
+        `Exposed property ${property} does not update and is missing compute function`
+      );
+    } else {
+      return () => this.#propertyUpdateValues[property];
+    }
+  }
+
+  #getExposeComputeFunction(property) {
+    const { flags, expose } = this.#getPropertyDescriptor(property);
+
+    const compute = expose?.compute;
+    const transform = expose?.transform;
+
+    if (flags.update && !transform) {
+      return null;
+    } else if (flags.update && compute) {
+      throw new Error(
+        `Updating property ${property} has compute function, should be formatted as transform`
+      );
+    } else if (!flags.update && !compute) {
+      throw new Error(
+        `Exposed property ${property} does not update and is missing compute function`
+      );
     }
 
-    #getExposeCheckCacheValidFunction(property) {
-        const { flags, expose } = this.#getPropertyDescriptor(property);
-
-        let valid = false;
+    const dependencyKeys = expose.dependencies || [];
+    const dependencyGetters = dependencyKeys.map((key) => () => [
+      key,
+      this.#propertyUpdateValues[key],
+    ]);
+    const getAllDependencies = () =>
+      Object.fromEntries(
+        dependencyGetters
+          .map((f) => f())
+          .concat([[this.constructor.instance, this]])
+      );
+
+    if (flags.update) {
+      return () =>
+        transform(this.#propertyUpdateValues[property], getAllDependencies());
+    } else {
+      return () => compute(getAllDependencies());
+    }
+  }
 
-        const invalidate = () => {
-            valid = false;
-        };
+  #getExposeCheckCacheValidFunction(property) {
+    const { flags, expose } = this.#getPropertyDescriptor(property);
 
-        const dependencyKeys = new Set(expose?.dependencies);
+    let valid = false;
 
-        if (flags.update) {
-            dependencyKeys.add(property);
-        }
+    const invalidate = () => {
+      valid = false;
+    };
 
-        for (const key of dependencyKeys) {
-            if (this.#propertyUpdateCacheInvalidators[key]) {
-                this.#propertyUpdateCacheInvalidators[key].push(invalidate);
-            } else {
-                this.#propertyUpdateCacheInvalidators[key] = [invalidate];
-            }
-        }
+    const dependencyKeys = new Set(expose?.dependencies);
 
-        return () => {
-            if (!valid) {
-                valid = true;
-                return false;
-            } else {
-                return true;
-            }
-        };
+    if (flags.update) {
+      dependencyKeys.add(property);
     }
 
-    static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false;
-    static _invalidAccesses = new Set();
+    for (const key of dependencyKeys) {
+      if (this.#propertyUpdateCacheInvalidators[key]) {
+        this.#propertyUpdateCacheInvalidators[key].push(invalidate);
+      } else {
+        this.#propertyUpdateCacheInvalidators[key] = [invalidate];
+      }
+    }
 
-    static showInvalidAccesses() {
-        if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-            return;
-        }
+    return () => {
+      if (!valid) {
+        valid = true;
+        return false;
+      } else {
+        return true;
+      }
+    };
+  }
+
+  static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false;
+  static _invalidAccesses = new Set();
+
+  static showInvalidAccesses() {
+    if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
+      return;
+    }
 
-        if (!this._invalidAccesses.size) {
-            return;
-        }
+    if (!this._invalidAccesses.size) {
+      return;
+    }
 
-        console.log(`${this._invalidAccesses.size} unique invalid accesses:`);
-        for (const line of this._invalidAccesses) {
-            console.log(` - ${line}`);
-        }
+    console.log(`${this._invalidAccesses.size} unique invalid accesses:`);
+    for (const line of this._invalidAccesses) {
+      console.log(` - ${line}`);
     }
+  }
 }
diff --git a/src/data/patches.js b/src/data/patches.js
index 3ed4fad0..0ff56ad0 100644
--- a/src/data/patches.js
+++ b/src/data/patches.js
@@ -1,291 +1,309 @@
 // --> Patch
 
 export class Patch {
-    static INPUT_NONE = 0;
-    static INPUT_CONSTANT = 1;
-    static INPUT_DIRECT_CONNECTION = 2;
-    static INPUT_MANAGED_CONNECTION = 3;
+  static INPUT_NONE = 0;
+  static INPUT_CONSTANT = 1;
+  static INPUT_DIRECT_CONNECTION = 2;
+  static INPUT_MANAGED_CONNECTION = 3;
 
-    static INPUT_UNAVAILABLE = 0;
-    static INPUT_AVAILABLE = 1;
+  static INPUT_UNAVAILABLE = 0;
+  static INPUT_AVAILABLE = 1;
 
-    static OUTPUT_UNAVAILABLE = 0;
-    static OUTPUT_AVAILABLE = 1;
+  static OUTPUT_UNAVAILABLE = 0;
+  static OUTPUT_AVAILABLE = 1;
 
-    static inputNames = []; inputNames = null;
-    static outputNames = []; outputNames = null;
+  static inputNames = [];
+  inputNames = null;
+  static outputNames = [];
+  outputNames = null;
 
-    manager = null;
-    inputs = Object.create(null);
+  manager = null;
+  inputs = Object.create(null);
 
-    constructor({
-        manager,
+  constructor({
+    manager,
 
-        inputNames,
-        outputNames,
+    inputNames,
+    outputNames,
 
-        inputs,
-    } = {}) {
-        this.inputNames = inputNames ?? this.constructor.inputNames;
-        this.outputNames = outputNames ?? this.constructor.outputNames;
+    inputs,
+  } = {}) {
+    this.inputNames = inputNames ?? this.constructor.inputNames;
+    this.outputNames = outputNames ?? this.constructor.outputNames;
 
-        manager?.addManagedPatch(this);
+    manager?.addManagedPatch(this);
 
-        if (inputs) {
-            Object.assign(this.inputs, inputs);
-        }
-
-        this.initializeInputs();
+    if (inputs) {
+      Object.assign(this.inputs, inputs);
     }
 
-    initializeInputs() {
-        for (const inputName of this.inputNames) {
-            if (!this.inputs[inputName]) {
-                this.inputs[inputName] = [Patch.INPUT_NONE];
-            }
-        }
-    }
+    this.initializeInputs();
+  }
 
-    computeInputs() {
-        const inputs = Object.create(null);
-
-        for (const inputName of this.inputNames) {
-            const input = this.inputs[inputName];
-            switch (input[0]) {
-                case Patch.INPUT_NONE:
-                    inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
-                    break;
-
-                case Patch.INPUT_CONSTANT:
-                    inputs[inputName] = [Patch.INPUT_AVAILABLE, input[1]];
-                    break;
-
-                case Patch.INPUT_DIRECT_CONNECTION: {
-                    const patch = input[1];
-                    const outputName = input[2];
-                    const output = patch.computeOutputs()[outputName];
-                    switch (output[0]) {
-                        case Patch.OUTPUT_UNAVAILABLE:
-                            inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
-                            break;
-                        case Patch.OUTPUT_AVAILABLE:
-                            inputs[inputName] = [Patch.INPUT_AVAILABLE, output[1]];
-                            break;
-                    }
-                    throw new Error('Unreachable');
-                }
-
-                case Patch.INPUT_MANAGED_CONNECTION: {
-                    if (!this.manager) {
-                        inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
-                        break;
-                    }
-
-                    inputs[inputName] = this.manager.getManagedInput(input[1]);
-                    break;
-                }
-            }
+  initializeInputs() {
+    for (const inputName of this.inputNames) {
+      if (!this.inputs[inputName]) {
+        this.inputs[inputName] = [Patch.INPUT_NONE];
+      }
+    }
+  }
+
+  computeInputs() {
+    const inputs = Object.create(null);
+
+    for (const inputName of this.inputNames) {
+      const input = this.inputs[inputName];
+      switch (input[0]) {
+        case Patch.INPUT_NONE:
+          inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
+          break;
+
+        case Patch.INPUT_CONSTANT:
+          inputs[inputName] = [Patch.INPUT_AVAILABLE, input[1]];
+          break;
+
+        case Patch.INPUT_DIRECT_CONNECTION: {
+          const patch = input[1];
+          const outputName = input[2];
+          const output = patch.computeOutputs()[outputName];
+          switch (output[0]) {
+            case Patch.OUTPUT_UNAVAILABLE:
+              inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
+              break;
+            case Patch.OUTPUT_AVAILABLE:
+              inputs[inputName] = [Patch.INPUT_AVAILABLE, output[1]];
+              break;
+          }
+          throw new Error("Unreachable");
         }
 
-        return inputs;
-    }
+        case Patch.INPUT_MANAGED_CONNECTION: {
+          if (!this.manager) {
+            inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
+            break;
+          }
 
-    computeOutputs() {
-        const inputs = this.computeInputs();
-        const outputs = Object.create(null);
-        console.log(`Compute: ${this.constructor.name}`);
-        this.compute(inputs, outputs);
-        return outputs;
+          inputs[inputName] = this.manager.getManagedInput(input[1]);
+          break;
+        }
+      }
     }
 
-    compute(inputs, outputs) {
-        // No-op. Return all outputs as unavailable. This should be overridden
-        // in subclasses.
+    return inputs;
+  }
 
-        for (const outputName of this.constructor.outputNames) {
-            outputs[outputName] = [Patch.OUTPUT_UNAVAILABLE];
-        }
-    }
+  computeOutputs() {
+    const inputs = this.computeInputs();
+    const outputs = Object.create(null);
+    console.log(`Compute: ${this.constructor.name}`);
+    this.compute(inputs, outputs);
+    return outputs;
+  }
 
-    attachToManager(manager) {
-        manager.addManagedPatch(this);
+  compute(inputs, outputs) {
+    // No-op. Return all outputs as unavailable. This should be overridden
+    // in subclasses.
+
+    for (const outputName of this.constructor.outputNames) {
+      outputs[outputName] = [Patch.OUTPUT_UNAVAILABLE];
     }
+  }
 
-    detachFromManager() {
-        if (this.manager) {
-            this.manager.removeManagedPatch(this);
-        }
+  attachToManager(manager) {
+    manager.addManagedPatch(this);
+  }
+
+  detachFromManager() {
+    if (this.manager) {
+      this.manager.removeManagedPatch(this);
     }
+  }
 }
 
 // --> PatchManager
 
 export class PatchManager extends Patch {
-    managedPatches = [];
-    managedInputs = {};
-
-    #externalInputPatch = null;
-    #externalOutputPatch = null;
-
-    constructor(...args) {
-        super(...args);
-
-        this.#externalInputPatch = new PatchManagerExternalInputPatch({manager: this});
-        this.#externalOutputPatch = new PatchManagerExternalOutputPatch({manager: this});
+  managedPatches = [];
+  managedInputs = {};
+
+  #externalInputPatch = null;
+  #externalOutputPatch = null;
+
+  constructor(...args) {
+    super(...args);
+
+    this.#externalInputPatch = new PatchManagerExternalInputPatch({
+      manager: this,
+    });
+    this.#externalOutputPatch = new PatchManagerExternalOutputPatch({
+      manager: this,
+    });
+  }
+
+  addManagedPatch(patch) {
+    if (patch.manager === this) {
+      return false;
     }
 
-    addManagedPatch(patch) {
-        if (patch.manager === this) {
-            return false;
-        }
-
-        patch.detachFromManager();
-        patch.manager = this;
+    patch.detachFromManager();
+    patch.manager = this;
 
-        if (patch.manager === this) {
-            this.managedPatches.push(patch);
-            return true;
-        } else {
-            return false;
-        }
+    if (patch.manager === this) {
+      this.managedPatches.push(patch);
+      return true;
+    } else {
+      return false;
     }
+  }
 
-    removeManagedPatch(patch) {
-        if (patch.manager !== this) {
-            return false;
-        }
-
-        patch.manager = null;
-
-        if (patch.manager === this) {
-            return false;
-        }
-
-        for (const inputNames of patch.inputNames) {
-            const input = patch.inputs[inputName];
-            if (input[0] === Patch.INPUT_MANAGED_CONNECTION) {
-                this.dropManagedInput(input[1]);
-                patch.inputs[inputName] = [Patch.INPUT_NONE];
-            }
-        }
-
-        this.managedPatches.splice(this.managedPatches.indexOf(patch), 1);
-
-        return true;
+  removeManagedPatch(patch) {
+    if (patch.manager !== this) {
+      return false;
     }
 
-    addManagedInput(patchWithInput, inputName, patchWithOutput, outputName) {
-        if (patchWithInput.manager !== this || patchWithOutput.manager !== this) {
-            throw new Error(`Input and output patches must belong to same manager (this)`);
-        }
-
-        const input = patchWithInput.inputs[inputName];
-        if (input[0] === Patch.INPUT_MANAGED_CONNECTION) {
-            this.managedInputs[input[1]] = [patchWithOutput, outputName, {}];
-        } else {
-            const key = this.getManagedConnectionIdentifier();
-            this.managedInputs[key] = [patchWithOutput, outputName, {}];
-            patchWithInput.inputs[inputName] = [Patch.INPUT_MANAGED_CONNECTION, key];
-        }
+    patch.manager = null;
 
-        return true;
+    if (patch.manager === this) {
+      return false;
     }
 
-    dropManagedInput(identifier) {
-        return delete this.managedInputs[key];
+    for (const inputNames of patch.inputNames) {
+      const input = patch.inputs[inputName];
+      if (input[0] === Patch.INPUT_MANAGED_CONNECTION) {
+        this.dropManagedInput(input[1]);
+        patch.inputs[inputName] = [Patch.INPUT_NONE];
+      }
     }
 
-    getManagedInput(identifier) {
-        const connection = this.managedInputs[identifier];
-        const patch = connection[0];
-        const outputName = connection[1];
-        const memory = connection[2];
-        return this.computeManagedInput(patch, outputName, memory);
-    }
-
-    computeManagedInput(patch, outputName, memory) {
-        // Override this function in subclasses to alter behavior of the "wire"
-        // used for connecting patches.
-
-        const output = patch.computeOutputs()[outputName];
-        switch (output[0]) {
-            case Patch.OUTPUT_UNAVAILABLE:
-                return [Patch.INPUT_UNAVAILABLE];
-            case Patch.OUTPUT_AVAILABLE:
-                return [Patch.INPUT_AVAILABLE, output[1]];
-        }
-    }
+    this.managedPatches.splice(this.managedPatches.indexOf(patch), 1);
 
-    #managedConnectionIdentifier = 0;
-    getManagedConnectionIdentifier() {
-        return this.#managedConnectionIdentifier++;
-    }
+    return true;
+  }
 
-    addExternalInput(patchWithInput, patchInputName, managerInputName) {
-        return this.addManagedInput(patchWithInput, patchInputName, this.#externalInputPatch, managerInputName);
+  addManagedInput(patchWithInput, inputName, patchWithOutput, outputName) {
+    if (patchWithInput.manager !== this || patchWithOutput.manager !== this) {
+      throw new Error(
+        `Input and output patches must belong to same manager (this)`
+      );
     }
 
-    setExternalOutput(managerOutputName, patchWithOutput, patchOutputName) {
-        return this.addManagedInput(this.#externalOutputPatch, managerOutputName, patchWithOutput, patchOutputName);
+    const input = patchWithInput.inputs[inputName];
+    if (input[0] === Patch.INPUT_MANAGED_CONNECTION) {
+      this.managedInputs[input[1]] = [patchWithOutput, outputName, {}];
+    } else {
+      const key = this.getManagedConnectionIdentifier();
+      this.managedInputs[key] = [patchWithOutput, outputName, {}];
+      patchWithInput.inputs[inputName] = [Patch.INPUT_MANAGED_CONNECTION, key];
     }
 
-    compute(inputs, outputs) {
-        Object.assign(outputs, this.#externalOutputPatch.computeOutputs());
+    return true;
+  }
+
+  dropManagedInput(identifier) {
+    return delete this.managedInputs[key];
+  }
+
+  getManagedInput(identifier) {
+    const connection = this.managedInputs[identifier];
+    const patch = connection[0];
+    const outputName = connection[1];
+    const memory = connection[2];
+    return this.computeManagedInput(patch, outputName, memory);
+  }
+
+  computeManagedInput(patch, outputName, memory) {
+    // Override this function in subclasses to alter behavior of the "wire"
+    // used for connecting patches.
+
+    const output = patch.computeOutputs()[outputName];
+    switch (output[0]) {
+      case Patch.OUTPUT_UNAVAILABLE:
+        return [Patch.INPUT_UNAVAILABLE];
+      case Patch.OUTPUT_AVAILABLE:
+        return [Patch.INPUT_AVAILABLE, output[1]];
     }
+  }
+
+  #managedConnectionIdentifier = 0;
+  getManagedConnectionIdentifier() {
+    return this.#managedConnectionIdentifier++;
+  }
+
+  addExternalInput(patchWithInput, patchInputName, managerInputName) {
+    return this.addManagedInput(
+      patchWithInput,
+      patchInputName,
+      this.#externalInputPatch,
+      managerInputName
+    );
+  }
+
+  setExternalOutput(managerOutputName, patchWithOutput, patchOutputName) {
+    return this.addManagedInput(
+      this.#externalOutputPatch,
+      managerOutputName,
+      patchWithOutput,
+      patchOutputName
+    );
+  }
+
+  compute(inputs, outputs) {
+    Object.assign(outputs, this.#externalOutputPatch.computeOutputs());
+  }
 }
 
 class PatchManagerExternalInputPatch extends Patch {
-    constructor({manager, ...rest}) {
-        super({
-            manager,
-            inputNames: manager.inputNames,
-            outputNames: manager.inputNames,
-            ...rest
-        });
-    }
-
-    computeInputs() {
-        return this.manager.computeInputs();
-    }
-
-    compute(inputs, outputs) {
-        for (const name of this.inputNames) {
-            const input = inputs[name];
-            switch (input[0]) {
-                case Patch.INPUT_UNAVAILABLE:
-                    outputs[name] = [Patch.OUTPUT_UNAVAILABLE];
-                    break;
-                case Patch.INPUT_AVAILABLE:
-                    outputs[name] = [Patch.INPUT_AVAILABLE, input[1]];
-                    break;
-            }
-        }
+  constructor({ manager, ...rest }) {
+    super({
+      manager,
+      inputNames: manager.inputNames,
+      outputNames: manager.inputNames,
+      ...rest,
+    });
+  }
+
+  computeInputs() {
+    return this.manager.computeInputs();
+  }
+
+  compute(inputs, outputs) {
+    for (const name of this.inputNames) {
+      const input = inputs[name];
+      switch (input[0]) {
+        case Patch.INPUT_UNAVAILABLE:
+          outputs[name] = [Patch.OUTPUT_UNAVAILABLE];
+          break;
+        case Patch.INPUT_AVAILABLE:
+          outputs[name] = [Patch.INPUT_AVAILABLE, input[1]];
+          break;
+      }
     }
+  }
 }
 
 class PatchManagerExternalOutputPatch extends Patch {
-    constructor({manager, ...rest}) {
-        super({
-            manager,
-            inputNames: manager.outputNames,
-            outputNames: manager.outputNames,
-            ...rest
-        });
-    }
-
-    compute(inputs, outputs) {
-        for (const name of this.inputNames) {
-            const input = inputs[name];
-            switch (input[0]) {
-                case Patch.INPUT_UNAVAILABLE:
-                    outputs[name] = [Patch.OUTPUT_UNAVAILABLE];
-                    break;
-                case Patch.INPUT_AVAILABLE:
-                    outputs[name] = [Patch.INPUT_AVAILABLE, input[1]];
-                    break;
-            }
-        }
+  constructor({ manager, ...rest }) {
+    super({
+      manager,
+      inputNames: manager.outputNames,
+      outputNames: manager.outputNames,
+      ...rest,
+    });
+  }
+
+  compute(inputs, outputs) {
+    for (const name of this.inputNames) {
+      const input = inputs[name];
+      switch (input[0]) {
+        case Patch.INPUT_UNAVAILABLE:
+          outputs[name] = [Patch.OUTPUT_UNAVAILABLE];
+          break;
+        case Patch.INPUT_AVAILABLE:
+          outputs[name] = [Patch.INPUT_AVAILABLE, input[1]];
+          break;
+      }
     }
+  }
 }
 
 // --> demo
@@ -295,84 +313,84 @@ const common = Symbol();
 const hsmusic = Symbol();
 
 Patch[caches] = {
-    WireCachedPatchManager: class extends PatchManager {
-        // "Wire" caching for PatchManager: Remembers the last outputs to come
-        // from each patch. As long as the inputs for a patch do not change, its
-        // cached outputs are reused.
-
-        // TODO: This has a unique cache for each managed input. It should
-        // re-use a cache for the same patch and output name. How can we ensure
-        // the cache is dropped when the patch is removed, though? (Spoilers:
-        // probably just override removeManagedPatch)
-        computeManagedInput(patch, outputName, memory) {
-            let cache = true;
-
-            const { previousInputs } = memory;
-            const { inputs } = patch;
-            if (memory.previousInputs) {
-                for (const inputName of patch.inputNames) {
-                    // TODO: This doesn't account for connections whose values
-                    // have changed (analogous to bubbling cache invalidation).
-                    if (inputs[inputName] !== previousInputs[inputName]) {
-                        cache = false;
-                        break;
-                    }
-                }
-            } else {
-                cache = false;
-            }
-
-            if (cache) {
-                return memory.previousOutputs[outputName];
-            }
-
-            const outputs = patch.computeOutputs();
-            memory.previousOutputs = outputs;
-            memory.previousInputs = {...inputs};
-            return outputs[outputName];
+  WireCachedPatchManager: class extends PatchManager {
+    // "Wire" caching for PatchManager: Remembers the last outputs to come
+    // from each patch. As long as the inputs for a patch do not change, its
+    // cached outputs are reused.
+
+    // TODO: This has a unique cache for each managed input. It should
+    // re-use a cache for the same patch and output name. How can we ensure
+    // the cache is dropped when the patch is removed, though? (Spoilers:
+    // probably just override removeManagedPatch)
+    computeManagedInput(patch, outputName, memory) {
+      let cache = true;
+
+      const { previousInputs } = memory;
+      const { inputs } = patch;
+      if (memory.previousInputs) {
+        for (const inputName of patch.inputNames) {
+          // TODO: This doesn't account for connections whose values
+          // have changed (analogous to bubbling cache invalidation).
+          if (inputs[inputName] !== previousInputs[inputName]) {
+            cache = false;
+            break;
+          }
         }
-    },
+      } else {
+        cache = false;
+      }
+
+      if (cache) {
+        return memory.previousOutputs[outputName];
+      }
+
+      const outputs = patch.computeOutputs();
+      memory.previousOutputs = outputs;
+      memory.previousInputs = { ...inputs };
+      return outputs[outputName];
+    }
+  },
 };
 
 Patch[common] = {
-    Stringify: class extends Patch {
-        static inputNames = ['value'];
-        static outputNames = ['value'];
-
-        compute(inputs, outputs) {
-            if (inputs.value[0] === Patch.INPUT_AVAILABLE) {
-                outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1].toString()];
-            } else {
-                outputs.value = [Patch.OUTPUT_UNAVAILABLE];
-            }
-        }
-    },
-
-    Echo: class extends Patch {
-        static inputNames = ['value'];
-        static outputNames = ['value'];
-
-        compute(inputs, outputs) {
-            if (inputs.value[0] === Patch.INPUT_AVAILABLE) {
-                outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1]];
-            } else {
-                outputs.value = [Patch.OUTPUT_UNAVAILABLE];
-            }
-        }
-    },
+  Stringify: class extends Patch {
+    static inputNames = ["value"];
+    static outputNames = ["value"];
+
+    compute(inputs, outputs) {
+      if (inputs.value[0] === Patch.INPUT_AVAILABLE) {
+        outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1].toString()];
+      } else {
+        outputs.value = [Patch.OUTPUT_UNAVAILABLE];
+      }
+    }
+  },
+
+  Echo: class extends Patch {
+    static inputNames = ["value"];
+    static outputNames = ["value"];
+
+    compute(inputs, outputs) {
+      if (inputs.value[0] === Patch.INPUT_AVAILABLE) {
+        outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1]];
+      } else {
+        outputs.value = [Patch.OUTPUT_UNAVAILABLE];
+      }
+    }
+  },
 };
 
 const PM = new Patch[caches].WireCachedPatchManager({
-    inputNames: ['externalInput'],
-    outputNames: ['externalOutput'],
+  inputNames: ["externalInput"],
+  outputNames: ["externalOutput"],
 });
 
-const P1 = new Patch[common].Stringify({manager: PM});
-const P2 = new Patch[common].Echo({manager: PM});
+const P1 = new Patch[common].Stringify({ manager: PM });
+const P2 = new Patch[common].Echo({ manager: PM });
 
-PM.addExternalInput(P1, 'value', 'externalInput');
-PM.addManagedInput(P2, 'value', P1, 'value');
-PM.setExternalOutput('externalOutput', P2, 'value');
+PM.addExternalInput(P1, "value", "externalInput");
+PM.addManagedInput(P2, "value", P1, "value");
+PM.setExternalOutput("externalOutput", P2, "value");
 
 PM.inputs.externalInput = [Patch.INPUT_CONSTANT, 123];
 console.log(PM.computeOutputs());
diff --git a/src/data/serialize.js b/src/data/serialize.js
index 9d4e8885..fc84d1ef 100644
--- a/src/data/serialize.js
+++ b/src/data/serialize.js
@@ -4,19 +4,19 @@
 // Utility functions
 
 export function id(x) {
-    return x;
+  return x;
 }
 
 export function toRef(thing) {
-    return thing?.constructor.getReference(thing);
+  return thing?.constructor.getReference(thing);
 }
 
 export function toRefs(things) {
-    return things?.map(toRef);
+  return things?.map(toRef);
 }
 
 export function toContribRefs(contribs) {
-    return contribs?.map(({ who, what }) => ({who: toRef(who), what}));
+  return contribs?.map(({ who, what }) => ({ who: toRef(who), what }));
 }
 
 // Interface
@@ -24,15 +24,21 @@ export function toContribRefs(contribs) {
 export const serializeDescriptors = Symbol();
 
 export function serializeThing(thing) {
-    const descriptors = thing.constructor[serializeDescriptors];
-    if (!descriptors) {
-        throw new Error(`Constructor ${thing.constructor.name} does not provide serialize descriptors`);
-    }
-
-    return Object.fromEntries(Object.entries(descriptors)
-        .map(([ property, transform ]) => [property, transform(thing[property])]));
+  const descriptors = thing.constructor[serializeDescriptors];
+  if (!descriptors) {
+    throw new Error(
+      `Constructor ${thing.constructor.name} does not provide serialize descriptors`
+    );
+  }
+
+  return Object.fromEntries(
+    Object.entries(descriptors).map(([property, transform]) => [
+      property,
+      transform(thing[property]),
+    ])
+  );
 }
 
 export function serializeThings(things) {
-    return things.map(serializeThing);
+  return things.map(serializeThing);
 }
diff --git a/src/data/things.js b/src/data/things.js
index 6a5cdb5e..62c01411 100644
--- a/src/data/things.js
+++ b/src/data/things.js
@@ -1,45 +1,45 @@
 // things.js: class definitions for various object types used across the wiki,
 // most of which correspond to an output page, such as Track, Album, Artist
 
-import CacheableObject from './cacheable-object.js';
+import CacheableObject from "./cacheable-object.js";
 
 import {
-    isAdditionalFileList,
-    isBoolean,
-    isColor,
-    isCommentary,
-    isCountingNumber,
-    isContributionList,
-    isDate,
-    isDimensions,
-    isDirectory,
-    isDuration,
-    isInstance,
-    isFileExtension,
-    isLanguageCode,
-    isName,
-    isNumber,
-    isURL,
-    isString,
-    isWholeNumber,
-    oneOf,
-    validateArrayItems,
-    validateInstanceOf,
-    validateReference,
-    validateReferenceList,
-} from './validators.js';
-
-import * as S from './serialize.js';
+  isAdditionalFileList,
+  isBoolean,
+  isColor,
+  isCommentary,
+  isCountingNumber,
+  isContributionList,
+  isDate,
+  isDimensions,
+  isDirectory,
+  isDuration,
+  isInstance,
+  isFileExtension,
+  isLanguageCode,
+  isName,
+  isNumber,
+  isURL,
+  isString,
+  isWholeNumber,
+  oneOf,
+  validateArrayItems,
+  validateInstanceOf,
+  validateReference,
+  validateReferenceList,
+} from "./validators.js";
+
+import * as S from "./serialize.js";
 
 import {
-    getKebabCase,
-    sortAlbumsTracksChronologically,
-} from '../util/wiki-data.js';
+  getKebabCase,
+  sortAlbumsTracksChronologically,
+} from "../util/wiki-data.js";
 
-import find from '../util/find.js';
+import find from "../util/find.js";
 
-import { inspect } from 'util';
-import { color } from '../util/cli.js';
+import { inspect } from "util";
+import { color } from "../util/cli.js";
 
 // Stub classes (and their exports) at the top of the file - these are
 // referenced later when we actually define static class fields. We deliberately
@@ -94,16 +94,16 @@ export class Language extends CacheableObject {}
 // Before initializing property descriptors, set additional independent
 // constants on the classes (which are referenced later).
 
-Thing.referenceType = Symbol('Thing.referenceType');
+Thing.referenceType = Symbol("Thing.referenceType");
 
-Album[Thing.referenceType] = 'album';
-Track[Thing.referenceType] = 'track';
-Artist[Thing.referenceType] = 'artist';
-Group[Thing.referenceType] = 'group';
-ArtTag[Thing.referenceType] = 'tag';
-NewsEntry[Thing.referenceType] = 'news-entry';
-StaticPage[Thing.referenceType] = 'static';
-Flash[Thing.referenceType] = 'flash';
+Album[Thing.referenceType] = "album";
+Track[Thing.referenceType] = "track";
+Artist[Thing.referenceType] = "artist";
+Group[Thing.referenceType] = "group";
+ArtTag[Thing.referenceType] = "tag";
+NewsEntry[Thing.referenceType] = "news-entry";
+StaticPage[Thing.referenceType] = "static";
+Flash[Thing.referenceType] = "flash";
 
 // -> Thing: base class for wiki data types, providing wiki-specific utility
 // functions on top of essential CacheableObject behavior.
@@ -112,551 +112,580 @@ Flash[Thing.referenceType] = 'flash';
 // duplicating less code across wiki data types. These are specialized utility
 // functions, so check each for how its own arguments behave!
 Thing.common = {
-    name: (defaultName) => ({
-        flags: {update: true, expose: true},
-        update: {validate: isName, default: defaultName}
-    }),
-
-    color: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isColor}
-    }),
-
-    directory: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isDirectory},
-        expose: {
-            dependencies: ['name'],
-            transform(directory, { name }) {
-                if (directory === null && name === null)
-                    return null;
-                else if (directory === null)
-                    return getKebabCase(name);
-                else
-                    return directory;
-            }
-        }
-    }),
-
-    urls: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: validateArrayItems(isURL)}
-    }),
-
-    // A file extension! Or the default, if provided when calling this.
-    fileExtension: (defaultFileExtension = null) => ({
-        flags: {update: true, expose: true},
-        update: {validate: isFileExtension},
-        expose: {transform: value => value ?? defaultFileExtension}
-    }),
-
-    // Straightforward flag descriptor for a variety of property purposes.
-    // Provide a default value, true or false!
-    flag: (defaultValue = false) => {
-        if (typeof defaultValue !== 'boolean') {
-            throw new TypeError(`Always set explicit defaults for flags!`);
-        }
+  name: (defaultName) => ({
+    flags: { update: true, expose: true },
+    update: { validate: isName, default: defaultName },
+  }),
+
+  color: () => ({
+    flags: { update: true, expose: true },
+    update: { validate: isColor },
+  }),
+
+  directory: () => ({
+    flags: { update: true, expose: true },
+    update: { validate: isDirectory },
+    expose: {
+      dependencies: ["name"],
+      transform(directory, { name }) {
+        if (directory === null && name === null) return null;
+        else if (directory === null) return getKebabCase(name);
+        else return directory;
+      },
+    },
+  }),
+
+  urls: () => ({
+    flags: { update: true, expose: true },
+    update: { validate: validateArrayItems(isURL) },
+  }),
+
+  // A file extension! Or the default, if provided when calling this.
+  fileExtension: (defaultFileExtension = null) => ({
+    flags: { update: true, expose: true },
+    update: { validate: isFileExtension },
+    expose: { transform: (value) => value ?? defaultFileExtension },
+  }),
+
+  // Straightforward flag descriptor for a variety of property purposes.
+  // Provide a default value, true or false!
+  flag: (defaultValue = false) => {
+    if (typeof defaultValue !== "boolean") {
+      throw new TypeError(`Always set explicit defaults for flags!`);
+    }
 
-        return {
-            flags: {update: true, expose: true},
-            update: {validate: isBoolean, default: defaultValue}
-        };
-    },
+    return {
+      flags: { update: true, expose: true },
+      update: { validate: isBoolean, default: defaultValue },
+    };
+  },
+
+  // General date type, used as the descriptor for a bunch of properties.
+  // This isn't dynamic though - it won't inherit from a date stored on
+  // another object, for example.
+  simpleDate: () => ({
+    flags: { update: true, expose: true },
+    update: { validate: isDate },
+  }),
+
+  // General string type. This should probably generally be avoided in favor
+  // of more specific validation, but using it makes it easy to find where we
+  // might want to improve later, and it's a useful shorthand meanwhile.
+  simpleString: () => ({
+    flags: { update: true, expose: true },
+    update: { validate: isString },
+  }),
+
+  // External function. These should only be used as dependencies for other
+  // properties, so they're left unexposed.
+  externalFunction: () => ({
+    flags: { update: true },
+    update: { validate: (t) => typeof t === "function" },
+  }),
+
+  // Super simple "contributions by reference" list, used for a variety of
+  // properties (Artists, Cover Artists, etc). This is the property which is
+  // externally provided, in the form:
+  //
+  //     [
+  //         {who: 'Artist Name', what: 'Viola'},
+  //         {who: 'artist:john-cena', what: null},
+  //         ...
+  //     ]
+  //
+  // ...processed from YAML, spreadsheet, or any other kind of input.
+  contribsByRef: () => ({
+    flags: { update: true, expose: true },
+    update: { validate: isContributionList },
+  }),
+
+  // Artist commentary! Generally present on tracks and albums.
+  commentary: () => ({
+    flags: { update: true, expose: true },
+    update: { validate: isCommentary },
+  }),
+
+  // This is a somewhat more involved data structure - it's for additional
+  // or "bonus" files associated with albums or tracks (or anything else).
+  // It's got this form:
+  //
+  //     [
+  //         {title: 'Booklet', files: ['Booklet.pdf']},
+  //         {
+  //             title: 'Wallpaper',
+  //             description: 'Cool Wallpaper!',
+  //             files: ['1440x900.png', '1920x1080.png']
+  //         },
+  //         {title: 'Alternate Covers', description: null, files: [...]},
+  //         ...
+  //     ]
+  //
+  additionalFiles: () => ({
+    flags: { update: true, expose: true },
+    update: { validate: isAdditionalFileList },
+  }),
+
+  // A reference list! Keep in mind this is for general references to wiki
+  // objects of (usually) other Thing subclasses, not specifically leitmotif
+  // references in tracks (although that property uses referenceList too!).
+  //
+  // The underlying function validateReferenceList expects a string like
+  // 'artist' or 'track', but this utility keeps from having to hard-code the
+  // string in multiple places by referencing the value saved on the class
+  // instead.
+  referenceList: (thingClass) => {
+    const { [Thing.referenceType]: referenceType } = thingClass;
+    if (!referenceType) {
+      throw new Error(
+        `The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`
+      );
+    }
 
-    // General date type, used as the descriptor for a bunch of properties.
-    // This isn't dynamic though - it won't inherit from a date stored on
-    // another object, for example.
-    simpleDate: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isDate}
-    }),
-
-    // General string type. This should probably generally be avoided in favor
-    // of more specific validation, but using it makes it easy to find where we
-    // might want to improve later, and it's a useful shorthand meanwhile.
-    simpleString: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isString}
-    }),
-
-    // External function. These should only be used as dependencies for other
-    // properties, so they're left unexposed.
-    externalFunction: () => ({
-        flags: {update: true},
-        update: {validate: t => typeof t === 'function'}
-    }),
-
-    // Super simple "contributions by reference" list, used for a variety of
-    // properties (Artists, Cover Artists, etc). This is the property which is
-    // externally provided, in the form:
-    //
-    //     [
-    //         {who: 'Artist Name', what: 'Viola'},
-    //         {who: 'artist:john-cena', what: null},
-    //         ...
-    //     ]
-    //
-    // ...processed from YAML, spreadsheet, or any other kind of input.
-    contribsByRef: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isContributionList}
-    }),
-
-    // Artist commentary! Generally present on tracks and albums.
-    commentary: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isCommentary}
-    }),
-
-    // This is a somewhat more involved data structure - it's for additional
-    // or "bonus" files associated with albums or tracks (or anything else).
-    // It's got this form:
-    //
-    //     [
-    //         {title: 'Booklet', files: ['Booklet.pdf']},
-    //         {
-    //             title: 'Wallpaper',
-    //             description: 'Cool Wallpaper!',
-    //             files: ['1440x900.png', '1920x1080.png']
-    //         },
-    //         {title: 'Alternate Covers', description: null, files: [...]},
-    //         ...
-    //     ]
-    //
-    additionalFiles: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isAdditionalFileList}
-    }),
-
-    // A reference list! Keep in mind this is for general references to wiki
-    // objects of (usually) other Thing subclasses, not specifically leitmotif
-    // references in tracks (although that property uses referenceList too!).
-    //
-    // The underlying function validateReferenceList expects a string like
-    // 'artist' or 'track', but this utility keeps from having to hard-code the
-    // string in multiple places by referencing the value saved on the class
-    // instead.
-    referenceList: thingClass => {
-        const { [Thing.referenceType]: referenceType } = thingClass;
-        if (!referenceType) {
-            throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
-        }
+    return {
+      flags: { update: true, expose: true },
+      update: { validate: validateReferenceList(referenceType) },
+    };
+  },
+
+  // Corresponding function for a single reference.
+  singleReference: (thingClass) => {
+    const { [Thing.referenceType]: referenceType } = thingClass;
+    if (!referenceType) {
+      throw new Error(
+        `The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`
+      );
+    }
 
-        return {
-            flags: {update: true, expose: true},
-            update: {validate: validateReferenceList(referenceType)}
-        };
-    },
+    return {
+      flags: { update: true, expose: true },
+      update: { validate: validateReference(referenceType) },
+    };
+  },
+
+  // Corresponding dynamic property to referenceList, which takes the values
+  // in the provided property and searches the specified wiki data for
+  // matching actual Thing-subclass objects.
+  dynamicThingsFromReferenceList: (
+    referenceListProperty,
+    thingDataProperty,
+    findFn
+  ) => ({
+    flags: { expose: true },
 
-    // Corresponding function for a single reference.
-    singleReference: thingClass => {
-        const { [Thing.referenceType]: referenceType } = thingClass;
-        if (!referenceType) {
-            throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
-        }
+    expose: {
+      dependencies: [referenceListProperty, thingDataProperty],
+      compute: ({
+        [referenceListProperty]: refs,
+        [thingDataProperty]: thingData,
+      }) =>
+        refs && thingData
+          ? refs
+              .map((ref) => findFn(ref, thingData, { mode: "quiet" }))
+              .filter(Boolean)
+          : [],
+    },
+  }),
+
+  // Corresponding function for a single reference.
+  dynamicThingFromSingleReference: (
+    singleReferenceProperty,
+    thingDataProperty,
+    findFn
+  ) => ({
+    flags: { expose: true },
 
-        return {
-            flags: {update: true, expose: true},
-            update: {validate: validateReference(referenceType)}
-        };
-    },
+    expose: {
+      dependencies: [singleReferenceProperty, thingDataProperty],
+      compute: ({
+        [singleReferenceProperty]: ref,
+        [thingDataProperty]: thingData,
+      }) =>
+        ref && thingData ? findFn(ref, thingData, { mode: "quiet" }) : null,
+    },
+  }),
+
+  // Corresponding dynamic property to contribsByRef, which takes the values
+  // in the provided property and searches the object's artistData for
+  // matching actual Artist objects. The computed structure has the same form
+  // as contribsByRef, but with Artist objects instead of string references:
+  //
+  //     [
+  //         {who: (an Artist), what: 'Viola'},
+  //         {who: (an Artist), what: null},
+  //         ...
+  //     ]
+  //
+  // Contributions whose "who" values don't match anything in artistData are
+  // filtered out. (So if the list is all empty, chances are that either the
+  // reference list is somehow messed up, or artistData isn't being provided
+  // properly.)
+  dynamicContribs: (contribsByRefProperty) => ({
+    flags: { expose: true },
+    expose: {
+      dependencies: ["artistData", contribsByRefProperty],
+      compute: ({ artistData, [contribsByRefProperty]: contribsByRef }) =>
+        contribsByRef && artistData
+          ? contribsByRef
+              .map(({ who: ref, what }) => ({
+                who: find.artist(ref, artistData),
+                what,
+              }))
+              .filter(({ who }) => who)
+          : [],
+    },
+  }),
+
+  // Dynamically inherit a contribution list from some other object, if it
+  // hasn't been overridden on this object. This is handy for solo albums
+  // where all tracks have the same artist, for example.
+  //
+  // Note: The arguments of this function aren't currently final! The final
+  // format will look more like (contribsByRef, parentContribsByRef), e.g.
+  // ('artistContribsByRef', '@album/artistContribsByRef').
+  dynamicInheritContribs: (
+    contribsByRefProperty,
+    parentContribsByRefProperty,
+    thingDataProperty,
+    findFn
+  ) => ({
+    flags: { expose: true },
+    expose: {
+      dependencies: [contribsByRefProperty, thingDataProperty, "artistData"],
+      compute({
+        [Thing.instance]: thing,
+        [contribsByRefProperty]: contribsByRef,
+        [thingDataProperty]: thingData,
+        artistData,
+      }) {
+        if (!artistData) return [];
+        const refs =
+          contribsByRef ??
+          findFn(thing, thingData, { mode: "quiet" })?.[
+            parentContribsByRefProperty
+          ];
+        if (!refs) return [];
+        return refs
+          .map(({ who: ref, what }) => ({
+            who: find.artist(ref, artistData),
+            what,
+          }))
+          .filter(({ who }) => who);
+      },
+    },
+  }),
+
+  // Neat little shortcut for "reversing" the reference lists stored on other
+  // things - for example, tracks specify a "referenced tracks" property, and
+  // you would use this to compute a corresponding "referenced *by* tracks"
+  // property. Naturally, the passed ref list property is of the things in the
+  // wiki data provided, not the requesting Thing itself.
+  reverseReferenceList: (wikiDataProperty, referencerRefListProperty) => ({
+    flags: { expose: true },
 
-    // Corresponding dynamic property to referenceList, which takes the values
-    // in the provided property and searches the specified wiki data for
-    // matching actual Thing-subclass objects.
-    dynamicThingsFromReferenceList: (
-        referenceListProperty,
-        thingDataProperty,
-        findFn
-    ) => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: [referenceListProperty, thingDataProperty],
-            compute: ({ [referenceListProperty]: refs, [thingDataProperty]: thingData }) => (
-                (refs && thingData
-                    ? (refs
-                        .map(ref => findFn(ref, thingData, {mode: 'quiet'}))
-                        .filter(Boolean))
-                    : [])
-            )
-        }
-    }),
-
-    // Corresponding function for a single reference.
-    dynamicThingFromSingleReference: (
-        singleReferenceProperty,
-        thingDataProperty,
-        findFn
-    ) => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: [singleReferenceProperty, thingDataProperty],
-            compute: ({ [singleReferenceProperty]: ref, [thingDataProperty]: thingData }) => (
-                (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null)
-            )
-        }
-    }),
-
-    // Corresponding dynamic property to contribsByRef, which takes the values
-    // in the provided property and searches the object's artistData for
-    // matching actual Artist objects. The computed structure has the same form
-    // as contribsByRef, but with Artist objects instead of string references:
-    //
-    //     [
-    //         {who: (an Artist), what: 'Viola'},
-    //         {who: (an Artist), what: null},
-    //         ...
-    //     ]
-    //
-    // Contributions whose "who" values don't match anything in artistData are
-    // filtered out. (So if the list is all empty, chances are that either the
-    // reference list is somehow messed up, or artistData isn't being provided
-    // properly.)
-    dynamicContribs: (contribsByRefProperty) => ({
-        flags: {expose: true},
-        expose: {
-            dependencies: ['artistData', contribsByRefProperty],
-            compute: ({ artistData, [contribsByRefProperty]: contribsByRef }) => (
-                ((contribsByRef && artistData)
-                    ? (contribsByRef
-                        .map(({ who: ref, what }) => ({
-                            who: find.artist(ref, artistData),
-                            what
-                        }))
-                        .filter(({ who }) => who))
-                    : [])
-            )
-        }
-    }),
-
-    // Dynamically inherit a contribution list from some other object, if it
-    // hasn't been overridden on this object. This is handy for solo albums
-    // where all tracks have the same artist, for example.
-    //
-    // Note: The arguments of this function aren't currently final! The final
-    // format will look more like (contribsByRef, parentContribsByRef), e.g.
-    // ('artistContribsByRef', '@album/artistContribsByRef').
-    dynamicInheritContribs: (
-        contribsByRefProperty,
-        parentContribsByRefProperty,
-        thingDataProperty,
-        findFn
-    ) => ({
-        flags: {expose: true},
-        expose: {
-            dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'],
-            compute({
-                [Thing.instance]: thing,
-                [contribsByRefProperty]: contribsByRef,
-                [thingDataProperty]: thingData,
-                artistData
-            }) {
-                if (!artistData) return [];
-                const refs = (contribsByRef ?? findFn(thing, thingData, {mode: 'quiet'})?.[parentContribsByRefProperty]);
-                if (!refs) return [];
-                return (refs
-                    .map(({ who: ref, what }) => ({
-                        who: find.artist(ref, artistData),
-                        what
-                    }))
-                    .filter(({ who }) => who));
-            }
-        }
-    }),
-
-    // Neat little shortcut for "reversing" the reference lists stored on other
-    // things - for example, tracks specify a "referenced tracks" property, and
-    // you would use this to compute a corresponding "referenced *by* tracks"
-    // property. Naturally, the passed ref list property is of the things in the
-    // wiki data provided, not the requesting Thing itself.
-    reverseReferenceList: (wikiDataProperty, referencerRefListProperty) => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: [wikiDataProperty],
-
-            compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => (
-                (wikiData
-                    ? wikiData.filter(t => t[referencerRefListProperty]?.includes(thing))
-                    : [])
+    expose: {
+      dependencies: [wikiDataProperty],
+
+      compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) =>
+        wikiData
+          ? wikiData.filter((t) =>
+              t[referencerRefListProperty]?.includes(thing)
             )
-        }
-    }),
+          : [],
+    },
+  }),
 
-    // Corresponding function for single references. Note that the return value
-    // is still a list - this is for matching all the objects whose single
-    // reference (in the given property) matches this Thing.
-    reverseSingleReference: (wikiDataProperty, referencerRefListProperty) => ({
-        flags: {expose: true},
+  // Corresponding function for single references. Note that the return value
+  // is still a list - this is for matching all the objects whose single
+  // reference (in the given property) matches this Thing.
+  reverseSingleReference: (wikiDataProperty, referencerRefListProperty) => ({
+    flags: { expose: true },
 
-        expose: {
-            dependencies: [wikiDataProperty],
+    expose: {
+      dependencies: [wikiDataProperty],
 
-            compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => (
-                wikiData?.filter(t => t[referencerRefListProperty] === thing))
-        }
-    }),
-
-    // General purpose wiki data constructor, for properties like artistData,
-    // trackData, etc.
-    wikiData: (thingClass) => ({
-        flags: {update: true},
-        update: {
-            validate: validateArrayItems(validateInstanceOf(thingClass))
-        }
-    }),
-
-    // This one's kinda tricky: it parses artist "references" from the
-    // commentary content, and finds the matching artist for each reference.
-    // This is mostly useful for credits and listings on artist pages.
-    commentatorArtists: () => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['artistData', 'commentary'],
-
-            compute: ({ artistData, commentary }) => (
-                (artistData && commentary
-                    ? Array.from(new Set((Array
-                        .from(commentary
-                            .replace(/<\/?b>/g, '')
-                            .matchAll(/<i>(?<who>.*?):<\/i>/g))
-                        .map(({ groups: {who} }) => find.artist(who, artistData, {mode: 'quiet'})))))
-                    : []))
-        }
-    }),
+      compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) =>
+        wikiData?.filter((t) => t[referencerRefListProperty] === thing),
+    },
+  }),
+
+  // General purpose wiki data constructor, for properties like artistData,
+  // trackData, etc.
+  wikiData: (thingClass) => ({
+    flags: { update: true },
+    update: {
+      validate: validateArrayItems(validateInstanceOf(thingClass)),
+    },
+  }),
+
+  // This one's kinda tricky: it parses artist "references" from the
+  // commentary content, and finds the matching artist for each reference.
+  // This is mostly useful for credits and listings on artist pages.
+  commentatorArtists: () => ({
+    flags: { expose: true },
+
+    expose: {
+      dependencies: ["artistData", "commentary"],
+
+      compute: ({ artistData, commentary }) =>
+        artistData && commentary
+          ? Array.from(
+              new Set(
+                Array.from(
+                  commentary
+                    .replace(/<\/?b>/g, "")
+                    .matchAll(/<i>(?<who>.*?):<\/i>/g)
+                ).map(({ groups: { who } }) =>
+                  find.artist(who, artistData, { mode: "quiet" })
+                )
+              )
+            )
+          : [],
+    },
+  }),
 };
 
 // Get a reference to a thing (e.g. track:showtime-piano-refrain), using its
 // constructor's [Thing.referenceType] as the prefix. This will throw an error
 // if the thing's directory isn't yet provided/computable.
-Thing.getReference = function(thing) {
-    if (!thing.constructor[Thing.referenceType])
-        throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`);
-
-    if (!thing.directory)
-        throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
-
-    return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
+Thing.getReference = function (thing) {
+  if (!thing.constructor[Thing.referenceType])
+    throw TypeError(
+      `Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`
+    );
+
+  if (!thing.directory)
+    throw TypeError(
+      `Passed ${thing.constructor.name} is missing its directory`
+    );
+
+  return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
 };
 
 // Default custom inspect function, which may be overridden by Thing subclasses.
 // This will be used when displaying aggregate errors and other in command-line
 // logging - it's the place to provide information useful in identifying the
 // Thing being presented.
-Thing.prototype[inspect.custom] = function() {
-    const cname = this.constructor.name;
-
-    return (this.name
-        ? `${cname} ${color.green(`"${this.name}"`)}`
-        : `${cname}`) + (this.directory
-            ? ` (${color.blue(Thing.getReference(this))})`
-            : '');
+Thing.prototype[inspect.custom] = function () {
+  const cname = this.constructor.name;
+
+  return (
+    (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) +
+    (this.directory ? ` (${color.blue(Thing.getReference(this))})` : "")
+  );
 };
 
 // -> Album
 
 Album.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Album'),
-    color: Thing.common.color(),
-    directory: Thing.common.directory(),
-    urls: Thing.common.urls(),
+  name: Thing.common.name("Unnamed Album"),
+  color: Thing.common.color(),
+  directory: Thing.common.directory(),
+  urls: Thing.common.urls(),
 
-    date: Thing.common.simpleDate(),
-    trackArtDate: Thing.common.simpleDate(),
-    dateAddedToWiki: Thing.common.simpleDate(),
+  date: Thing.common.simpleDate(),
+  trackArtDate: Thing.common.simpleDate(),
+  dateAddedToWiki: Thing.common.simpleDate(),
 
-    coverArtDate: {
-        flags: {update: true, expose: true},
+  coverArtDate: {
+    flags: { update: true, expose: true },
 
-        update: {validate: isDate},
+    update: { validate: isDate },
 
-        expose: {
-            dependencies: ['date'],
-            transform: (coverArtDate, { date }) => coverArtDate ?? date ?? null
-        }
+    expose: {
+      dependencies: ["date"],
+      transform: (coverArtDate, { date }) => coverArtDate ?? date ?? null,
     },
+  },
 
-    artistContribsByRef: Thing.common.contribsByRef(),
-    coverArtistContribsByRef: Thing.common.contribsByRef(),
-    trackCoverArtistContribsByRef: Thing.common.contribsByRef(),
-    wallpaperArtistContribsByRef: Thing.common.contribsByRef(),
-    bannerArtistContribsByRef: Thing.common.contribsByRef(),
+  artistContribsByRef: Thing.common.contribsByRef(),
+  coverArtistContribsByRef: Thing.common.contribsByRef(),
+  trackCoverArtistContribsByRef: Thing.common.contribsByRef(),
+  wallpaperArtistContribsByRef: Thing.common.contribsByRef(),
+  bannerArtistContribsByRef: Thing.common.contribsByRef(),
 
-    groupsByRef: Thing.common.referenceList(Group),
-    artTagsByRef: Thing.common.referenceList(ArtTag),
+  groupsByRef: Thing.common.referenceList(Group),
+  artTagsByRef: Thing.common.referenceList(ArtTag),
 
-    trackGroups: {
-        flags: {update: true, expose: true},
+  trackGroups: {
+    flags: { update: true, expose: true },
 
-        update: {
-            validate: validateArrayItems(validateInstanceOf(TrackGroup))
-        }
+    update: {
+      validate: validateArrayItems(validateInstanceOf(TrackGroup)),
     },
+  },
 
-    coverArtFileExtension: Thing.common.fileExtension('jpg'),
-    trackCoverArtFileExtension: Thing.common.fileExtension('jpg'),
+  coverArtFileExtension: Thing.common.fileExtension("jpg"),
+  trackCoverArtFileExtension: Thing.common.fileExtension("jpg"),
 
-    wallpaperStyle: Thing.common.simpleString(),
-    wallpaperFileExtension: Thing.common.fileExtension('jpg'),
+  wallpaperStyle: Thing.common.simpleString(),
+  wallpaperFileExtension: Thing.common.fileExtension("jpg"),
 
-    bannerStyle: Thing.common.simpleString(),
-    bannerFileExtension: Thing.common.fileExtension('jpg'),
-    bannerDimensions: {
-        flags: {update: true, expose: true},
-        update: {validate: isDimensions}
-    },
+  bannerStyle: Thing.common.simpleString(),
+  bannerFileExtension: Thing.common.fileExtension("jpg"),
+  bannerDimensions: {
+    flags: { update: true, expose: true },
+    update: { validate: isDimensions },
+  },
 
-    hasCoverArt: Thing.common.flag(true),
-    hasTrackArt: Thing.common.flag(true),
-    hasTrackNumbers: Thing.common.flag(true),
-    isMajorRelease: Thing.common.flag(false),
-    isListedOnHomepage: Thing.common.flag(true),
+  hasCoverArt: Thing.common.flag(true),
+  hasTrackArt: Thing.common.flag(true),
+  hasTrackNumbers: Thing.common.flag(true),
+  isMajorRelease: Thing.common.flag(false),
+  isListedOnHomepage: Thing.common.flag(true),
 
-    commentary: Thing.common.commentary(),
-    additionalFiles: Thing.common.additionalFiles(),
+  commentary: Thing.common.commentary(),
+  additionalFiles: Thing.common.additionalFiles(),
 
-    // Update only
+  // Update only
 
-    artistData: Thing.common.wikiData(Artist),
-    artTagData: Thing.common.wikiData(ArtTag),
-    groupData: Thing.common.wikiData(Group),
-    trackData: Thing.common.wikiData(Track),
+  artistData: Thing.common.wikiData(Artist),
+  artTagData: Thing.common.wikiData(ArtTag),
+  groupData: Thing.common.wikiData(Group),
+  trackData: Thing.common.wikiData(Track),
 
-    // Expose only
+  // Expose only
 
-    artistContribs: Thing.common.dynamicContribs('artistContribsByRef'),
-    coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'),
-    trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'),
-    wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'),
-    bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'),
+  artistContribs: Thing.common.dynamicContribs("artistContribsByRef"),
+  coverArtistContribs: Thing.common.dynamicContribs("coverArtistContribsByRef"),
+  trackCoverArtistContribs: Thing.common.dynamicContribs(
+    "trackCoverArtistContribsByRef"
+  ),
+  wallpaperArtistContribs: Thing.common.dynamicContribs(
+    "wallpaperArtistContribsByRef"
+  ),
+  bannerArtistContribs: Thing.common.dynamicContribs(
+    "bannerArtistContribsByRef"
+  ),
 
-    commentatorArtists: Thing.common.commentatorArtists(),
+  commentatorArtists: Thing.common.commentatorArtists(),
 
-    tracks: {
-        flags: {expose: true},
+  tracks: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['trackGroups', 'trackData'],
-            compute: ({ trackGroups, trackData }) => (
-                (trackGroups && trackData
-                    ? (trackGroups
-                        .flatMap(group => group.tracksByRef ?? [])
-                        .map(ref => find.track(ref, trackData, {mode: 'quiet'}))
-                        .filter(Boolean))
-                    : [])
-            )
-        }
-    },
-
-    groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group),
-
-    artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag),
+    expose: {
+      dependencies: ["trackGroups", "trackData"],
+      compute: ({ trackGroups, trackData }) =>
+        trackGroups && trackData
+          ? trackGroups
+              .flatMap((group) => group.tracksByRef ?? [])
+              .map((ref) => find.track(ref, trackData, { mode: "quiet" }))
+              .filter(Boolean)
+          : [],
+    },
+  },
+
+  groups: Thing.common.dynamicThingsFromReferenceList(
+    "groupsByRef",
+    "groupData",
+    find.group
+  ),
+
+  artTags: Thing.common.dynamicThingsFromReferenceList(
+    "artTagsByRef",
+    "artTagData",
+    find.artTag
+  ),
 };
 
 Album[S.serializeDescriptors] = {
-    name: S.id,
-    color: S.id,
-    directory: S.id,
-    urls: S.id,
-
-    date: S.id,
-    coverArtDate: S.id,
-    trackArtDate: S.id,
-    dateAddedToWiki: S.id,
-
-    artistContribs: S.toContribRefs,
-    coverArtistContribs: S.toContribRefs,
-    trackCoverArtistContribs: S.toContribRefs,
-    wallpaperArtistContribs: S.toContribRefs,
-    bannerArtistContribs: S.toContribRefs,
-
-    coverArtFileExtension: S.id,
-    trackCoverArtFileExtension: S.id,
-    wallpaperStyle: S.id,
-    wallpaperFileExtension: S.id,
-    bannerStyle: S.id,
-    bannerFileExtension: S.id,
-    bannerDimensions: S.id,
-
-    hasTrackArt: S.id,
-    isMajorRelease: S.id,
-    isListedOnHomepage: S.id,
-
-    commentary: S.id,
-    additionalFiles: S.id,
-
-    tracks: S.toRefs,
-    groups: S.toRefs,
-    artTags: S.toRefs,
-    commentatorArtists: S.toRefs,
+  name: S.id,
+  color: S.id,
+  directory: S.id,
+  urls: S.id,
+
+  date: S.id,
+  coverArtDate: S.id,
+  trackArtDate: S.id,
+  dateAddedToWiki: S.id,
+
+  artistContribs: S.toContribRefs,
+  coverArtistContribs: S.toContribRefs,
+  trackCoverArtistContribs: S.toContribRefs,
+  wallpaperArtistContribs: S.toContribRefs,
+  bannerArtistContribs: S.toContribRefs,
+
+  coverArtFileExtension: S.id,
+  trackCoverArtFileExtension: S.id,
+  wallpaperStyle: S.id,
+  wallpaperFileExtension: S.id,
+  bannerStyle: S.id,
+  bannerFileExtension: S.id,
+  bannerDimensions: S.id,
+
+  hasTrackArt: S.id,
+  isMajorRelease: S.id,
+  isListedOnHomepage: S.id,
+
+  commentary: S.id,
+  additionalFiles: S.id,
+
+  tracks: S.toRefs,
+  groups: S.toRefs,
+  artTags: S.toRefs,
+  commentatorArtists: S.toRefs,
 };
 
 TrackGroup.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Track Group'),
+  name: Thing.common.name("Unnamed Track Group"),
 
-    color: {
-        flags: {update: true, expose: true},
+  color: {
+    flags: { update: true, expose: true },
 
-        update: {validate: isColor},
+    update: { validate: isColor },
 
-        expose: {
-            dependencies: ['album'],
+    expose: {
+      dependencies: ["album"],
 
-            transform(color, { album }) {
-                return color ?? album?.color ?? null;
-            }
-        }
+      transform(color, { album }) {
+        return color ?? album?.color ?? null;
+      },
     },
+  },
 
-    dateOriginallyReleased: Thing.common.simpleDate(),
+  dateOriginallyReleased: Thing.common.simpleDate(),
 
-    tracksByRef: Thing.common.referenceList(Track),
+  tracksByRef: Thing.common.referenceList(Track),
 
-    isDefaultTrackGroup: Thing.common.flag(false),
+  isDefaultTrackGroup: Thing.common.flag(false),
 
-    // Update only
+  // Update only
 
-    album: {
-        flags: {update: true},
-        update: {validate: validateInstanceOf(Album)}
-    },
+  album: {
+    flags: { update: true },
+    update: { validate: validateInstanceOf(Album) },
+  },
 
-    trackData: Thing.common.wikiData(Track),
+  trackData: Thing.common.wikiData(Track),
 
-    // Expose only
+  // Expose only
 
-    tracks: {
-        flags: {expose: true},
+  tracks: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['tracksByRef', 'trackData'],
-            compute: ({ tracksByRef, trackData }) => (
-                (tracksByRef && trackData
-                    ? (tracksByRef
-                        .map(ref => find.track(ref, trackData))
-                        .filter(Boolean))
-                    : [])
-            )
-        }
+    expose: {
+      dependencies: ["tracksByRef", "trackData"],
+      compute: ({ tracksByRef, trackData }) =>
+        tracksByRef && trackData
+          ? tracksByRef.map((ref) => find.track(ref, trackData)).filter(Boolean)
+          : [],
     },
+  },
 
-    startIndex: {
-        flags: {expose: true},
+  startIndex: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['album'],
-            compute: ({ album, [TrackGroup.instance]: trackGroup }) => (album.trackGroups
-                .slice(0, album.trackGroups.indexOf(trackGroup))
-                .reduce((acc, tg) => acc + tg.tracks.length, 0))
-        }
+    expose: {
+      dependencies: ["album"],
+      compute: ({ album, [TrackGroup.instance]: trackGroup }) =>
+        album.trackGroups
+          .slice(0, album.trackGroups.indexOf(trackGroup))
+          .reduce((acc, tg) => acc + tg.tracks.length, 0),
     },
+  },
 };
 
 // -> Track
@@ -665,1059 +694,1191 @@ TrackGroup.propertyDescriptors = {
 // several places. Ideally it wouldn't be - we'd just reuse the `album` property
 // - but support for that hasn't been coded yet :P
 Track.findAlbum = (track, albumData) => {
-    return albumData?.find(album => album.tracks.includes(track));
+  return albumData?.find((album) => album.tracks.includes(track));
 };
 
 // Another reused utility function. This one's logic is a bit more complicated.
-Track.hasCoverArt = (track, albumData, coverArtistContribsByRef, hasCoverArt) => {
-    return (
-        hasCoverArt ??
-        (coverArtistContribsByRef?.length > 0 || null) ??
-        Track.findAlbum(track, albumData)?.hasTrackArt ??
-        true);
+Track.hasCoverArt = (
+  track,
+  albumData,
+  coverArtistContribsByRef,
+  hasCoverArt
+) => {
+  return (
+    hasCoverArt ??
+    (coverArtistContribsByRef?.length > 0 || null) ??
+    Track.findAlbum(track, albumData)?.hasTrackArt ??
+    true
+  );
 };
 
 Track.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Track'),
-    directory: Thing.common.directory(),
+  name: Thing.common.name("Unnamed Track"),
+  directory: Thing.common.directory(),
 
-    duration: {
-        flags: {update: true, expose: true},
-        update: {validate: isDuration}
-    },
+  duration: {
+    flags: { update: true, expose: true },
+    update: { validate: isDuration },
+  },
 
-    urls: Thing.common.urls(),
-    dateFirstReleased: Thing.common.simpleDate(),
+  urls: Thing.common.urls(),
+  dateFirstReleased: Thing.common.simpleDate(),
 
-    hasURLs: Thing.common.flag(true),
+  hasURLs: Thing.common.flag(true),
 
-    artistContribsByRef: Thing.common.contribsByRef(),
-    contributorContribsByRef: Thing.common.contribsByRef(),
-    coverArtistContribsByRef: Thing.common.contribsByRef(),
+  artistContribsByRef: Thing.common.contribsByRef(),
+  contributorContribsByRef: Thing.common.contribsByRef(),
+  coverArtistContribsByRef: Thing.common.contribsByRef(),
 
-    referencedTracksByRef: Thing.common.referenceList(Track),
-    artTagsByRef: Thing.common.referenceList(ArtTag),
+  referencedTracksByRef: Thing.common.referenceList(Track),
+  artTagsByRef: Thing.common.referenceList(ArtTag),
 
-    hasCoverArt: {
-        flags: {update: true, expose: true},
+  hasCoverArt: {
+    flags: { update: true, expose: true },
 
-        update: {validate: isBoolean},
+    update: { validate: isBoolean },
 
-        expose: {
-            dependencies: ['albumData', 'coverArtistContribsByRef'],
-            transform: (hasCoverArt, { albumData, coverArtistContribsByRef, [Track.instance]: track }) => (
-                Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt))
-        }
+    expose: {
+      dependencies: ["albumData", "coverArtistContribsByRef"],
+      transform: (
+        hasCoverArt,
+        { albumData, coverArtistContribsByRef, [Track.instance]: track }
+      ) =>
+        Track.hasCoverArt(
+          track,
+          albumData,
+          coverArtistContribsByRef,
+          hasCoverArt
+        ),
     },
+  },
 
-    coverArtFileExtension: {
-        flags: {update: true, expose: true},
+  coverArtFileExtension: {
+    flags: { update: true, expose: true },
 
-        update: {validate: isFileExtension},
+    update: { validate: isFileExtension },
 
-        expose: {
-            dependencies: ['albumData', 'coverArtistContribsByRef'],
-            transform: (coverArtFileExtension, { albumData, coverArtistContribsByRef, hasCoverArt, [Track.instance]: track }) => (
-                coverArtFileExtension ??
-                (Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt)
-                    ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension
-                    : Track.findAlbum(track, albumData)?.coverArtFileExtension) ??
-                'jpg')
+    expose: {
+      dependencies: ["albumData", "coverArtistContribsByRef"],
+      transform: (
+        coverArtFileExtension,
+        {
+          albumData,
+          coverArtistContribsByRef,
+          hasCoverArt,
+          [Track.instance]: track,
         }
+      ) =>
+        coverArtFileExtension ??
+        (Track.hasCoverArt(
+          track,
+          albumData,
+          coverArtistContribsByRef,
+          hasCoverArt
+        )
+          ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension
+          : Track.findAlbum(track, albumData)?.coverArtFileExtension) ??
+        "jpg",
     },
+  },
 
-    // Previously known as: (track).aka
-    originalReleaseTrackByRef: Thing.common.singleReference(Track),
+  // Previously known as: (track).aka
+  originalReleaseTrackByRef: Thing.common.singleReference(Track),
 
-    dataSourceAlbumByRef: Thing.common.singleReference(Album),
+  dataSourceAlbumByRef: Thing.common.singleReference(Album),
 
-    commentary: Thing.common.commentary(),
-    lyrics: Thing.common.simpleString(),
-    additionalFiles: Thing.common.additionalFiles(),
+  commentary: Thing.common.commentary(),
+  lyrics: Thing.common.simpleString(),
+  additionalFiles: Thing.common.additionalFiles(),
 
-    // Update only
+  // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    artistData: Thing.common.wikiData(Artist),
-    artTagData: Thing.common.wikiData(ArtTag),
-    flashData: Thing.common.wikiData(Flash),
-    trackData: Thing.common.wikiData(Track),
+  albumData: Thing.common.wikiData(Album),
+  artistData: Thing.common.wikiData(Artist),
+  artTagData: Thing.common.wikiData(ArtTag),
+  flashData: Thing.common.wikiData(Flash),
+  trackData: Thing.common.wikiData(Track),
 
-    // Expose only
+  // Expose only
 
-    commentatorArtists: Thing.common.commentatorArtists(),
+  commentatorArtists: Thing.common.commentatorArtists(),
 
-    album: {
-        flags: {expose: true},
+  album: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['albumData'],
-            compute: ({ [Track.instance]: track, albumData }) => (
-                albumData?.find(album => album.tracks.includes(track)) ?? null)
-        }
-    },
+    expose: {
+      dependencies: ["albumData"],
+      compute: ({ [Track.instance]: track, albumData }) =>
+        albumData?.find((album) => album.tracks.includes(track)) ?? null,
+    },
+  },
+
+  // Note - this is an internal property used only to help identify a track.
+  // It should not be assumed in general that the album and dataSourceAlbum match
+  // (i.e. a track may dynamically be moved from one album to another, at
+  // which point dataSourceAlbum refers to where it was originally from, and is
+  // not generally relevant information). It's also not guaranteed that
+  // dataSourceAlbum is available (depending on the Track creator to optionally
+  // provide dataSourceAlbumByRef).
+  dataSourceAlbum: Thing.common.dynamicThingFromSingleReference(
+    "dataSourceAlbumByRef",
+    "albumData",
+    find.album
+  ),
+
+  date: {
+    flags: { expose: true },
 
-    // Note - this is an internal property used only to help identify a track.
-    // It should not be assumed in general that the album and dataSourceAlbum match
-    // (i.e. a track may dynamically be moved from one album to another, at
-    // which point dataSourceAlbum refers to where it was originally from, and is
-    // not generally relevant information). It's also not guaranteed that
-    // dataSourceAlbum is available (depending on the Track creator to optionally
-    // provide dataSourceAlbumByRef).
-    dataSourceAlbum: Thing.common.dynamicThingFromSingleReference('dataSourceAlbumByRef', 'albumData', find.album),
-
-    date: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['albumData', 'dateFirstReleased'],
-            compute: ({ albumData, dateFirstReleased, [Track.instance]: track }) => (
-                dateFirstReleased ??
-                Track.findAlbum(track, albumData)?.date ??
-                null
-            )
-        }
+    expose: {
+      dependencies: ["albumData", "dateFirstReleased"],
+      compute: ({ albumData, dateFirstReleased, [Track.instance]: track }) =>
+        dateFirstReleased ?? Track.findAlbum(track, albumData)?.date ?? null,
     },
+  },
 
-    color: {
-        flags: {expose: true},
+  color: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['albumData'],
+    expose: {
+      dependencies: ["albumData"],
 
-            compute: ({ albumData, [Track.instance]: track }) => (
-                (Track.findAlbum(track, albumData)?.trackGroups
-                    .find(tg => tg.tracks.includes(track))?.color)
-                ?? null
-            )
-        }
+      compute: ({ albumData, [Track.instance]: track }) =>
+        Track.findAlbum(track, albumData)?.trackGroups.find((tg) =>
+          tg.tracks.includes(track)
+        )?.color ?? null,
     },
+  },
 
-    coverArtDate: {
-        flags: {update: true, expose: true},
+  coverArtDate: {
+    flags: { update: true, expose: true },
 
-        update: {validate: isDate},
+    update: { validate: isDate },
 
-        expose: {
-            dependencies: ['albumData', 'dateFirstReleased'],
-            transform: (coverArtDate, { albumData, dateFirstReleased, [Track.instance]: track }) => (
-                coverArtDate ??
-                dateFirstReleased ??
-                Track.findAlbum(track, albumData)?.trackArtDate ??
-                Track.findAlbum(track, albumData)?.date ??
-                null
-            )
-        }
-    },
-
-    originalReleaseTrack: Thing.common.dynamicThingFromSingleReference('originalReleaseTrackByRef', 'trackData', find.track),
-
-    otherReleases: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['originalReleaseTrackByRef', 'trackData'],
-
-            compute: ({ originalReleaseTrackByRef: t1origRef, trackData, [Track.instance]: t1 }) => {
-                if (!trackData) {
-                    return [];
-                }
-
-                const t1orig = find.track(t1origRef, trackData);
-
-                return [
-                    t1orig,
-                    ...trackData.filter(t2 => {
-                        const { originalReleaseTrack: t2orig } = t2;
-                        return (
-                            t2 !== t1 &&
-                            t2orig &&
-                            (t2orig === t1orig || t2orig === t1)
-                        );
-                    })
-                ].filter(Boolean);
-            }
-        }
-    },
+    expose: {
+      dependencies: ["albumData", "dateFirstReleased"],
+      transform: (
+        coverArtDate,
+        { albumData, dateFirstReleased, [Track.instance]: track }
+      ) =>
+        coverArtDate ??
+        dateFirstReleased ??
+        Track.findAlbum(track, albumData)?.trackArtDate ??
+        Track.findAlbum(track, albumData)?.date ??
+        null,
+    },
+  },
+
+  originalReleaseTrack: Thing.common.dynamicThingFromSingleReference(
+    "originalReleaseTrackByRef",
+    "trackData",
+    find.track
+  ),
+
+  otherReleases: {
+    flags: { expose: true },
 
-    // Previously known as: (track).artists
-    artistContribs: Thing.common.dynamicInheritContribs('artistContribsByRef', 'artistContribsByRef', 'albumData', Track.findAlbum),
-
-    // Previously known as: (track).contributors
-    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
-
-    // Previously known as: (track).coverArtists
-    coverArtistContribs: Thing.common.dynamicInheritContribs('coverArtistContribsByRef', 'trackCoverArtistContribsByRef', 'albumData', Track.findAlbum),
-
-    // Previously known as: (track).references
-    referencedTracks: Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track),
-
-    // Specifically exclude re-releases from this list - while it's useful to
-    // get from a re-release to the tracks it references, re-releases aren't
-    // generally relevant from the perspective of the tracks being referenced.
-    // Filtering them from data here hides them from the corresponding field
-    // on the site (obviously), and has the bonus of not counting them when
-    // counting the number of times a track has been referenced, for use in
-    // the "Tracks - by Times Referenced" listing page (or other data
-    // processing).
-    referencedByTracks: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['trackData'],
-
-            compute: ({ trackData, [Track.instance]: track }) => (trackData
-                ? (trackData
-                    .filter(t => !t.originalReleaseTrack)
-                    .filter(t => t.referencedTracks?.includes(track)))
-                : [])
+    expose: {
+      dependencies: ["originalReleaseTrackByRef", "trackData"],
+
+      compute: ({
+        originalReleaseTrackByRef: t1origRef,
+        trackData,
+        [Track.instance]: t1,
+      }) => {
+        if (!trackData) {
+          return [];
         }
-    },
 
-    // Previously known as: (track).flashes
-    featuredInFlashes: Thing.common.reverseReferenceList('flashData', 'featuredTracks'),
+        const t1orig = find.track(t1origRef, trackData);
+
+        return [
+          t1orig,
+          ...trackData.filter((t2) => {
+            const { originalReleaseTrack: t2orig } = t2;
+            return t2 !== t1 && t2orig && (t2orig === t1orig || t2orig === t1);
+          }),
+        ].filter(Boolean);
+      },
+    },
+  },
+
+  // Previously known as: (track).artists
+  artistContribs: Thing.common.dynamicInheritContribs(
+    "artistContribsByRef",
+    "artistContribsByRef",
+    "albumData",
+    Track.findAlbum
+  ),
+
+  // Previously known as: (track).contributors
+  contributorContribs: Thing.common.dynamicContribs("contributorContribsByRef"),
+
+  // Previously known as: (track).coverArtists
+  coverArtistContribs: Thing.common.dynamicInheritContribs(
+    "coverArtistContribsByRef",
+    "trackCoverArtistContribsByRef",
+    "albumData",
+    Track.findAlbum
+  ),
+
+  // Previously known as: (track).references
+  referencedTracks: Thing.common.dynamicThingsFromReferenceList(
+    "referencedTracksByRef",
+    "trackData",
+    find.track
+  ),
+
+  // Specifically exclude re-releases from this list - while it's useful to
+  // get from a re-release to the tracks it references, re-releases aren't
+  // generally relevant from the perspective of the tracks being referenced.
+  // Filtering them from data here hides them from the corresponding field
+  // on the site (obviously), and has the bonus of not counting them when
+  // counting the number of times a track has been referenced, for use in
+  // the "Tracks - by Times Referenced" listing page (or other data
+  // processing).
+  referencedByTracks: {
+    flags: { expose: true },
 
-    artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag),
+    expose: {
+      dependencies: ["trackData"],
+
+      compute: ({ trackData, [Track.instance]: track }) =>
+        trackData
+          ? trackData
+              .filter((t) => !t.originalReleaseTrack)
+              .filter((t) => t.referencedTracks?.includes(track))
+          : [],
+    },
+  },
+
+  // Previously known as: (track).flashes
+  featuredInFlashes: Thing.common.reverseReferenceList(
+    "flashData",
+    "featuredTracks"
+  ),
+
+  artTags: Thing.common.dynamicThingsFromReferenceList(
+    "artTagsByRef",
+    "artTagData",
+    find.artTag
+  ),
 };
 
-Track.prototype[inspect.custom] = function() {
-    const base = Thing.prototype[inspect.custom].apply(this);
+Track.prototype[inspect.custom] = function () {
+  const base = Thing.prototype[inspect.custom].apply(this);
 
-    const { album, dataSourceAlbum } = this;
-    const albumName = (album ? album.name : dataSourceAlbum?.name);
-    const albumIndex = albumName && (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this));
-    const trackNum = (albumIndex === -1 ? '#?' : `#${albumIndex + 1}`);
+  const { album, dataSourceAlbum } = this;
+  const albumName = album ? album.name : dataSourceAlbum?.name;
+  const albumIndex =
+    albumName &&
+    (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this));
+  const trackNum = albumIndex === -1 ? "#?" : `#${albumIndex + 1}`;
 
-    return (albumName
-        ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})`
-        : base);
+  return albumName
+    ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})`
+    : base;
 };
 
 // -> Artist
 
 Artist.filterByContrib = (thingDataProperty, contribsProperty) => ({
-    flags: {expose: true},
+  flags: { expose: true },
 
-    expose: {
-        dependencies: [thingDataProperty],
+  expose: {
+    dependencies: [thingDataProperty],
 
-        compute: ({ [thingDataProperty]: thingData, [Artist.instance]: artist }) => (
-            thingData?.filter(({ [contribsProperty]: contribs }) => (
-                contribs?.some(contrib => contrib.who === artist))))
-    }
+    compute: ({ [thingDataProperty]: thingData, [Artist.instance]: artist }) =>
+      thingData?.filter(({ [contribsProperty]: contribs }) =>
+        contribs?.some((contrib) => contrib.who === artist)
+      ),
+  },
 });
 
 Artist.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Artist'),
-    directory: Thing.common.directory(),
-    urls: Thing.common.urls(),
-    contextNotes: Thing.common.simpleString(),
+  name: Thing.common.name("Unnamed Artist"),
+  directory: Thing.common.directory(),
+  urls: Thing.common.urls(),
+  contextNotes: Thing.common.simpleString(),
 
-    hasAvatar: Thing.common.flag(false),
-    avatarFileExtension: Thing.common.fileExtension('jpg'),
+  hasAvatar: Thing.common.flag(false),
+  avatarFileExtension: Thing.common.fileExtension("jpg"),
 
-    aliasNames: {
-        flags: {update: true, expose: true},
-        update: {
-            validate: validateArrayItems(isName)
-        }
+  aliasNames: {
+    flags: { update: true, expose: true },
+    update: {
+      validate: validateArrayItems(isName),
     },
+  },
 
-    isAlias: Thing.common.flag(),
-    aliasedArtistRef: Thing.common.singleReference(Artist),
-
-    // Update only
-
-    albumData: Thing.common.wikiData(Album),
-    artistData: Thing.common.wikiData(Artist),
-    flashData: Thing.common.wikiData(Flash),
-    trackData: Thing.common.wikiData(Track),
-
-    // Expose only
-
-    aliasedArtist: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['artistData', 'aliasedArtistRef'],
-            compute: ({ artistData, aliasedArtistRef }) => (
-                (aliasedArtistRef && artistData
-                    ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'})
-                    : null)
-            )
-        }
-    },
+  isAlias: Thing.common.flag(),
+  aliasedArtistRef: Thing.common.singleReference(Artist),
 
-    tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'),
-    tracksAsContributor: Artist.filterByContrib('trackData', 'contributorContribs'),
-    tracksAsCoverArtist: Artist.filterByContrib('trackData', 'coverArtistContribs'),
+  // Update only
 
-    tracksAsAny: {
-        flags: {expose: true},
+  albumData: Thing.common.wikiData(Album),
+  artistData: Thing.common.wikiData(Artist),
+  flashData: Thing.common.wikiData(Flash),
+  trackData: Thing.common.wikiData(Track),
 
-        expose: {
-            dependencies: ['trackData'],
+  // Expose only
 
-            compute: ({ trackData, [Artist.instance]: artist }) => (
-                trackData?.filter(track => (
-                    [
-                        ...track.artistContribs,
-                        ...track.contributorContribs,
-                        ...track.coverArtistContribs
-                    ].some(({ who }) => who === artist))))
-        }
-    },
+  aliasedArtist: {
+    flags: { expose: true },
 
-    tracksAsCommentator: {
-        flags: {expose: true},
+    expose: {
+      dependencies: ["artistData", "aliasedArtistRef"],
+      compute: ({ artistData, aliasedArtistRef }) =>
+        aliasedArtistRef && artistData
+          ? find.artist(aliasedArtistRef, artistData, { mode: "quiet" })
+          : null,
+    },
+  },
+
+  tracksAsArtist: Artist.filterByContrib("trackData", "artistContribs"),
+  tracksAsContributor: Artist.filterByContrib(
+    "trackData",
+    "contributorContribs"
+  ),
+  tracksAsCoverArtist: Artist.filterByContrib(
+    "trackData",
+    "coverArtistContribs"
+  ),
+
+  tracksAsAny: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['trackData'],
+    expose: {
+      dependencies: ["trackData"],
 
-            compute: ({ trackData, [Artist.instance]: artist }) => (
-                trackData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist)))
-        }
+      compute: ({ trackData, [Artist.instance]: artist }) =>
+        trackData?.filter((track) =>
+          [
+            ...track.artistContribs,
+            ...track.contributorContribs,
+            ...track.coverArtistContribs,
+          ].some(({ who }) => who === artist)
+        ),
     },
+  },
 
-    albumsAsAlbumArtist: Artist.filterByContrib('albumData', 'artistContribs'),
-    albumsAsCoverArtist: Artist.filterByContrib('albumData', 'coverArtistContribs'),
-    albumsAsWallpaperArtist: Artist.filterByContrib('albumData', 'wallpaperArtistContribs'),
-    albumsAsBannerArtist: Artist.filterByContrib('albumData', 'bannerArtistContribs'),
+  tracksAsCommentator: {
+    flags: { expose: true },
 
-    albumsAsCommentator: {
-        flags: {expose: true},
+    expose: {
+      dependencies: ["trackData"],
+
+      compute: ({ trackData, [Artist.instance]: artist }) =>
+        trackData.filter(({ commentatorArtists }) =>
+          commentatorArtists?.includes(artist)
+        ),
+    },
+  },
+
+  albumsAsAlbumArtist: Artist.filterByContrib("albumData", "artistContribs"),
+  albumsAsCoverArtist: Artist.filterByContrib(
+    "albumData",
+    "coverArtistContribs"
+  ),
+  albumsAsWallpaperArtist: Artist.filterByContrib(
+    "albumData",
+    "wallpaperArtistContribs"
+  ),
+  albumsAsBannerArtist: Artist.filterByContrib(
+    "albumData",
+    "bannerArtistContribs"
+  ),
+
+  albumsAsCommentator: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['albumData'],
+    expose: {
+      dependencies: ["albumData"],
 
-            compute: ({ albumData, [Artist.instance]: artist }) => (
-                albumData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist)))
-        }
+      compute: ({ albumData, [Artist.instance]: artist }) =>
+        albumData.filter(({ commentatorArtists }) =>
+          commentatorArtists?.includes(artist)
+        ),
     },
+  },
 
-    flashesAsContributor: Artist.filterByContrib('flashData', 'contributorContribs'),
+  flashesAsContributor: Artist.filterByContrib(
+    "flashData",
+    "contributorContribs"
+  ),
 };
 
 Artist[S.serializeDescriptors] = {
-    name: S.id,
-    directory: S.id,
-    urls: S.id,
-    contextNotes: S.id,
+  name: S.id,
+  directory: S.id,
+  urls: S.id,
+  contextNotes: S.id,
 
-    hasAvatar: S.id,
-    avatarFileExtension: S.id,
+  hasAvatar: S.id,
+  avatarFileExtension: S.id,
 
-    aliasNames: S.id,
+  aliasNames: S.id,
 
-    tracksAsArtist: S.toRefs,
-    tracksAsContributor: S.toRefs,
-    tracksAsCoverArtist: S.toRefs,
-    tracksAsCommentator: S.toRefs,
+  tracksAsArtist: S.toRefs,
+  tracksAsContributor: S.toRefs,
+  tracksAsCoverArtist: S.toRefs,
+  tracksAsCommentator: S.toRefs,
 
-    albumsAsAlbumArtist: S.toRefs,
-    albumsAsCoverArtist: S.toRefs,
-    albumsAsWallpaperArtist: S.toRefs,
-    albumsAsBannerArtist: S.toRefs,
-    albumsAsCommentator: S.toRefs,
+  albumsAsAlbumArtist: S.toRefs,
+  albumsAsCoverArtist: S.toRefs,
+  albumsAsWallpaperArtist: S.toRefs,
+  albumsAsBannerArtist: S.toRefs,
+  albumsAsCommentator: S.toRefs,
 
-    flashesAsContributor: S.toRefs,
+  flashesAsContributor: S.toRefs,
 };
 
 // -> Group
 
 Group.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Group'),
-    directory: Thing.common.directory(),
+  name: Thing.common.name("Unnamed Group"),
+  directory: Thing.common.directory(),
 
-    description: Thing.common.simpleString(),
+  description: Thing.common.simpleString(),
 
-    urls: Thing.common.urls(),
+  urls: Thing.common.urls(),
 
-    // Update only
+  // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    groupCategoryData: Thing.common.wikiData(GroupCategory),
+  albumData: Thing.common.wikiData(Album),
+  groupCategoryData: Thing.common.wikiData(GroupCategory),
 
-    // Expose only
+  // Expose only
 
-    descriptionShort: {
-        flags: {expose: true},
+  descriptionShort: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['description'],
-            compute: ({ description }) => description.split('<hr class="split">')[0]
-        }
+    expose: {
+      dependencies: ["description"],
+      compute: ({ description }) => description.split('<hr class="split">')[0],
     },
+  },
 
-    albums: {
-        flags: {expose: true},
+  albums: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['albumData'],
-            compute: ({ albumData, [Group.instance]: group }) => (
-                albumData?.filter(album => album.groups.includes(group)) ?? [])
-        }
+    expose: {
+      dependencies: ["albumData"],
+      compute: ({ albumData, [Group.instance]: group }) =>
+        albumData?.filter((album) => album.groups.includes(group)) ?? [],
     },
+  },
 
-    color: {
-        flags: {expose: true},
+  color: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['groupCategoryData'],
+    expose: {
+      dependencies: ["groupCategoryData"],
 
-            compute: ({ groupCategoryData, [Group.instance]: group }) => (
-                groupCategoryData.find(category => category.groups.includes(group))?.color ?? null)
-        }
+      compute: ({ groupCategoryData, [Group.instance]: group }) =>
+        groupCategoryData.find((category) => category.groups.includes(group))
+          ?.color ?? null,
     },
+  },
 
-    category: {
-        flags: {expose: true},
+  category: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['groupCategoryData'],
-            compute: ({ groupCategoryData, [Group.instance]: group }) => (
-                groupCategoryData.find(category => category.groups.includes(group)) ?? null)
-        }
+    expose: {
+      dependencies: ["groupCategoryData"],
+      compute: ({ groupCategoryData, [Group.instance]: group }) =>
+        groupCategoryData.find((category) => category.groups.includes(group)) ??
+        null,
     },
+  },
 };
 
 GroupCategory.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Group Category'),
-    color: Thing.common.color(),
+  name: Thing.common.name("Unnamed Group Category"),
+  color: Thing.common.color(),
 
-    groupsByRef: Thing.common.referenceList(Group),
+  groupsByRef: Thing.common.referenceList(Group),
 
-    // Update only
+  // Update only
 
-    groupData: Thing.common.wikiData(Group),
+  groupData: Thing.common.wikiData(Group),
 
-    // Expose only
+  // Expose only
 
-    groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group),
+  groups: Thing.common.dynamicThingsFromReferenceList(
+    "groupsByRef",
+    "groupData",
+    find.group
+  ),
 };
 
 // -> ArtTag
 
 ArtTag.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Art Tag'),
-    directory: Thing.common.directory(),
-    color: Thing.common.color(),
-    isContentWarning: Thing.common.flag(false),
+  name: Thing.common.name("Unnamed Art Tag"),
+  directory: Thing.common.directory(),
+  color: Thing.common.color(),
+  isContentWarning: Thing.common.flag(false),
 
-    // Update only
+  // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    trackData: Thing.common.wikiData(Track),
+  albumData: Thing.common.wikiData(Album),
+  trackData: Thing.common.wikiData(Track),
 
-    // Expose only
+  // Expose only
 
-    // Previously known as: (tag).things
-    taggedInThings: {
-        flags: {expose: true},
+  // Previously known as: (tag).things
+  taggedInThings: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['albumData', 'trackData'],
-            compute: ({ albumData, trackData, [ArtTag.instance]: artTag }) => (
-                sortAlbumsTracksChronologically(
-                    ([...albumData, ...trackData]
-                        .filter(thing => thing.artTags?.includes(artTag))),
-                    {getDate: o => o.coverArtDate}))
-        }
-    }
+    expose: {
+      dependencies: ["albumData", "trackData"],
+      compute: ({ albumData, trackData, [ArtTag.instance]: artTag }) =>
+        sortAlbumsTracksChronologically(
+          [...albumData, ...trackData].filter((thing) =>
+            thing.artTags?.includes(artTag)
+          ),
+          { getDate: (o) => o.coverArtDate }
+        ),
+    },
+  },
 };
 
 // -> NewsEntry
 
 NewsEntry.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed News Entry'),
-    directory: Thing.common.directory(),
-    date: Thing.common.simpleDate(),
+  name: Thing.common.name("Unnamed News Entry"),
+  directory: Thing.common.directory(),
+  date: Thing.common.simpleDate(),
 
-    content: Thing.common.simpleString(),
+  content: Thing.common.simpleString(),
 
-    // Expose only
+  // Expose only
 
-    contentShort: {
-        flags: {expose: true},
+  contentShort: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['content'],
+    expose: {
+      dependencies: ["content"],
 
-            compute: ({ content }) => content.split('<hr class="split">')[0]
-        }
+      compute: ({ content }) => content.split('<hr class="split">')[0],
     },
+  },
 };
 
 // -> StaticPage
 
 StaticPage.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Static Page'),
+  name: Thing.common.name("Unnamed Static Page"),
 
-    nameShort: {
-        flags: {update: true, expose: true},
-        update: {validate: isName},
+  nameShort: {
+    flags: { update: true, expose: true },
+    update: { validate: isName },
 
-        expose: {
-            dependencies: ['name'],
-            transform: (value, { name }) => value ?? name
-        }
+    expose: {
+      dependencies: ["name"],
+      transform: (value, { name }) => value ?? name,
     },
+  },
 
-    directory: Thing.common.directory(),
-    content: Thing.common.simpleString(),
-    stylesheet: Thing.common.simpleString(),
-    showInNavigationBar: Thing.common.flag(true),
+  directory: Thing.common.directory(),
+  content: Thing.common.simpleString(),
+  stylesheet: Thing.common.simpleString(),
+  showInNavigationBar: Thing.common.flag(true),
 };
 
 // -> HomepageLayout
 
 HomepageLayout.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    sidebarContent: Thing.common.simpleString(),
+  sidebarContent: Thing.common.simpleString(),
 
-    rows: {
-        flags: {update: true, expose: true},
+  rows: {
+    flags: { update: true, expose: true },
 
-        update: {
-            validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow))
-        }
+    update: {
+      validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)),
     },
+  },
 };
 
 HomepageLayoutRow.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Homepage Row'),
+  name: Thing.common.name("Unnamed Homepage Row"),
 
-    type: {
-        flags: {update: true, expose: true},
+  type: {
+    flags: { update: true, expose: true },
 
-        update: {
-            validate(value) {
-                throw new Error(`'type' property validator must be overridden`);
-            }
-        }
+    update: {
+      validate(value) {
+        throw new Error(`'type' property validator must be overridden`);
+      },
     },
+  },
 
-    color: Thing.common.color(),
+  color: Thing.common.color(),
 
-    // Update only
+  // Update only
 
-    // These aren't necessarily used by every HomepageLayoutRow subclass, but
-    // for convenience of providing this data, every row accepts all wiki data
-    // arrays depended upon by any subclass's behavior.
-    albumData: Thing.common.wikiData(Album),
-    groupData: Thing.common.wikiData(Group),
+  // These aren't necessarily used by every HomepageLayoutRow subclass, but
+  // for convenience of providing this data, every row accepts all wiki data
+  // arrays depended upon by any subclass's behavior.
+  albumData: Thing.common.wikiData(Album),
+  groupData: Thing.common.wikiData(Group),
 };
 
 HomepageLayoutAlbumsRow.propertyDescriptors = {
-    ...HomepageLayoutRow.propertyDescriptors,
-
-    // Update & expose
+  ...HomepageLayoutRow.propertyDescriptors,
 
-    type: {
-        flags: {update: true, expose: true},
-        update: {
-            validate(value) {
-                if (value !== 'albums') {
-                    throw new TypeError(`Expected 'albums'`);
-                }
+  // Update & expose
 
-                return true;
-            }
+  type: {
+    flags: { update: true, expose: true },
+    update: {
+      validate(value) {
+        if (value !== "albums") {
+          throw new TypeError(`Expected 'albums'`);
         }
+
+        return true;
+      },
     },
+  },
 
-    sourceGroupByRef: Thing.common.singleReference(Group),
-    sourceAlbumsByRef: Thing.common.referenceList(Album),
+  sourceGroupByRef: Thing.common.singleReference(Group),
+  sourceAlbumsByRef: Thing.common.referenceList(Album),
 
-    countAlbumsFromGroup: {
-        flags: {update: true, expose: true},
-        update: {validate: isCountingNumber}
-    },
+  countAlbumsFromGroup: {
+    flags: { update: true, expose: true },
+    update: { validate: isCountingNumber },
+  },
 
-    actionLinks: {
-        flags: {update: true, expose: true},
-        update: {validate: validateArrayItems(isString)}
-    },
+  actionLinks: {
+    flags: { update: true, expose: true },
+    update: { validate: validateArrayItems(isString) },
+  },
 
-    // Expose only
+  // Expose only
 
-    sourceGroup: Thing.common.dynamicThingFromSingleReference('sourceGroupByRef', 'groupData', find.group),
-    sourceAlbums: Thing.common.dynamicThingsFromReferenceList('sourceAlbumsByRef', 'albumData', find.album),
+  sourceGroup: Thing.common.dynamicThingFromSingleReference(
+    "sourceGroupByRef",
+    "groupData",
+    find.group
+  ),
+  sourceAlbums: Thing.common.dynamicThingsFromReferenceList(
+    "sourceAlbumsByRef",
+    "albumData",
+    find.album
+  ),
 };
 
 // -> Flash
 
 Flash.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Flash'),
-
-    directory: {
-        flags: {update: true, expose: true},
-        update: {validate: isDirectory},
-
-        // Flashes expose directory differently from other Things! Their
-        // default directory is dependent on the page number (or ID), not
-        // the name.
-        expose: {
-            dependencies: ['page'],
-            transform(directory, { page }) {
-                if (directory === null && page === null)
-                    return null;
-                else if (directory === null)
-                    return page;
-                else
-                    return directory;
-            }
-        }
+  // Update & expose
+
+  name: Thing.common.name("Unnamed Flash"),
+
+  directory: {
+    flags: { update: true, expose: true },
+    update: { validate: isDirectory },
+
+    // Flashes expose directory differently from other Things! Their
+    // default directory is dependent on the page number (or ID), not
+    // the name.
+    expose: {
+      dependencies: ["page"],
+      transform(directory, { page }) {
+        if (directory === null && page === null) return null;
+        else if (directory === null) return page;
+        else return directory;
+      },
     },
+  },
 
-    page: {
-        flags: {update: true, expose: true},
-        update: {validate: oneOf(isString, isNumber)},
+  page: {
+    flags: { update: true, expose: true },
+    update: { validate: oneOf(isString, isNumber) },
 
-        expose: {
-            transform: value => (value === null ? null : value.toString())
-        }
+    expose: {
+      transform: (value) => (value === null ? null : value.toString()),
     },
+  },
 
-    date: Thing.common.simpleDate(),
+  date: Thing.common.simpleDate(),
 
-    coverArtFileExtension: Thing.common.fileExtension('jpg'),
+  coverArtFileExtension: Thing.common.fileExtension("jpg"),
 
-    contributorContribsByRef: Thing.common.contribsByRef(),
+  contributorContribsByRef: Thing.common.contribsByRef(),
 
-    featuredTracksByRef: Thing.common.referenceList(Track),
+  featuredTracksByRef: Thing.common.referenceList(Track),
 
-    urls: Thing.common.urls(),
+  urls: Thing.common.urls(),
 
-    // Update only
+  // Update only
 
-    artistData: Thing.common.wikiData(Artist),
-    trackData: Thing.common.wikiData(Track),
-    flashActData: Thing.common.wikiData(FlashAct),
+  artistData: Thing.common.wikiData(Artist),
+  trackData: Thing.common.wikiData(Track),
+  flashActData: Thing.common.wikiData(FlashAct),
 
-    // Expose only
+  // Expose only
 
-    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
+  contributorContribs: Thing.common.dynamicContribs("contributorContribsByRef"),
 
-    featuredTracks: Thing.common.dynamicThingsFromReferenceList('featuredTracksByRef', 'trackData', find.track),
+  featuredTracks: Thing.common.dynamicThingsFromReferenceList(
+    "featuredTracksByRef",
+    "trackData",
+    find.track
+  ),
 
-    act: {
-        flags: {expose: true},
+  act: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['flashActData'],
+    expose: {
+      dependencies: ["flashActData"],
 
-            compute: ({ flashActData, [Flash.instance]: flash }) => (
-                flashActData.find(act => act.flashes.includes(flash)) ?? null)
-        }
+      compute: ({ flashActData, [Flash.instance]: flash }) =>
+        flashActData.find((act) => act.flashes.includes(flash)) ?? null,
     },
+  },
 
-    color: {
-        flags: {expose: true},
+  color: {
+    flags: { expose: true },
 
-        expose: {
-            dependencies: ['flashActData'],
+    expose: {
+      dependencies: ["flashActData"],
 
-            compute: ({ flashActData, [Flash.instance]: flash }) => (
-                flashActData.find(act => act.flashes.includes(flash))?.color ?? null)
-        }
+      compute: ({ flashActData, [Flash.instance]: flash }) =>
+        flashActData.find((act) => act.flashes.includes(flash))?.color ?? null,
     },
+  },
 };
 
 Flash[S.serializeDescriptors] = {
-    name: S.id,
-    page: S.id,
-    directory: S.id,
-    date: S.id,
-    contributors: S.toContribRefs,
-    tracks: S.toRefs,
-    urls: S.id,
-    color: S.id,
+  name: S.id,
+  page: S.id,
+  directory: S.id,
+  date: S.id,
+  contributors: S.toContribRefs,
+  tracks: S.toRefs,
+  urls: S.id,
+  color: S.id,
 };
 
 FlashAct.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Flash Act'),
-    color: Thing.common.color(),
-    anchor: Thing.common.simpleString(),
-    jump: Thing.common.simpleString(),
-    jumpColor: Thing.common.color(),
+  name: Thing.common.name("Unnamed Flash Act"),
+  color: Thing.common.color(),
+  anchor: Thing.common.simpleString(),
+  jump: Thing.common.simpleString(),
+  jumpColor: Thing.common.color(),
 
-    flashesByRef: Thing.common.referenceList(Flash),
+  flashesByRef: Thing.common.referenceList(Flash),
 
-    // Update only
+  // Update only
 
-    flashData: Thing.common.wikiData(Flash),
+  flashData: Thing.common.wikiData(Flash),
 
-    // Expose only
+  // Expose only
 
-    flashes: Thing.common.dynamicThingsFromReferenceList('flashesByRef', 'flashData', find.flash),
+  flashes: Thing.common.dynamicThingsFromReferenceList(
+    "flashesByRef",
+    "flashData",
+    find.flash
+  ),
 };
 
 // -> WikiInfo
 
 WikiInfo.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Wiki'),
+  name: Thing.common.name("Unnamed Wiki"),
 
-    // Displayed in nav bar.
-    nameShort: {
-        flags: {update: true, expose: true},
-        update: {validate: isName},
+  // Displayed in nav bar.
+  nameShort: {
+    flags: { update: true, expose: true },
+    update: { validate: isName },
 
-        expose: {
-            dependencies: ['name'],
-            transform: (value, { name }) => value ?? name
-        }
+    expose: {
+      dependencies: ["name"],
+      transform: (value, { name }) => value ?? name,
     },
+  },
 
-    color: Thing.common.color(),
+  color: Thing.common.color(),
 
-    // One-line description used for <meta rel="description"> tag.
-    description: Thing.common.simpleString(),
+  // One-line description used for <meta rel="description"> tag.
+  description: Thing.common.simpleString(),
 
-    footerContent: Thing.common.simpleString(),
+  footerContent: Thing.common.simpleString(),
 
-    defaultLanguage: {
-        flags: {update: true, expose: true},
-        update: {validate: isLanguageCode}
-    },
+  defaultLanguage: {
+    flags: { update: true, expose: true },
+    update: { validate: isLanguageCode },
+  },
 
-    canonicalBase: {
-        flags: {update: true, expose: true},
-        update: {validate: isURL}
-    },
+  canonicalBase: {
+    flags: { update: true, expose: true },
+    update: { validate: isURL },
+  },
 
-    divideTrackListsByGroupsByRef: Thing.common.referenceList(Group),
+  divideTrackListsByGroupsByRef: Thing.common.referenceList(Group),
 
-    // Feature toggles
-    enableFlashesAndGames: Thing.common.flag(false),
-    enableListings: Thing.common.flag(false),
-    enableNews: Thing.common.flag(false),
-    enableArtTagUI: Thing.common.flag(false),
-    enableGroupUI: Thing.common.flag(false),
+  // Feature toggles
+  enableFlashesAndGames: Thing.common.flag(false),
+  enableListings: Thing.common.flag(false),
+  enableNews: Thing.common.flag(false),
+  enableArtTagUI: Thing.common.flag(false),
+  enableGroupUI: Thing.common.flag(false),
 
-    // Update only
+  // Update only
 
-    groupData: Thing.common.wikiData(Group),
+  groupData: Thing.common.wikiData(Group),
 
-    // Expose only
+  // Expose only
 
-    divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList('divideTrackListsByGroupsByRef', 'groupData', find.group),
+  divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList(
+    "divideTrackListsByGroupsByRef",
+    "groupData",
+    find.group
+  ),
 };
 
 // -> Language
 
 const intlHelper = (constructor, opts) => ({
-    flags: {expose: true},
-    expose: {
-        dependencies: ['code', 'intlCode'],
-        compute: ({ code, intlCode }) => {
-            const constructCode = intlCode ?? code;
-            if (!constructCode) return null;
-            return Reflect.construct(constructor, [constructCode, opts]);
-        }
-    }
+  flags: { expose: true },
+  expose: {
+    dependencies: ["code", "intlCode"],
+    compute: ({ code, intlCode }) => {
+      const constructCode = intlCode ?? code;
+      if (!constructCode) return null;
+      return Reflect.construct(constructor, [constructCode, opts]);
+    },
+  },
 });
 
 Language.propertyDescriptors = {
-    // Update & expose
-
-    // General language code. This is used to identify the language distinctly
-    // from other languages (similar to how "Directory" operates in many data
-    // objects).
-    code: {
-        flags: {update: true, expose: true},
-        update: {validate: isLanguageCode}
-    },
-
-    // Human-readable name. This should be the language's own native name, not
-    // localized to any other language.
-    name: Thing.common.simpleString(),
-
-    // Language code specific to JavaScript's Internationalization (Intl) API.
-    // Usually this will be the same as the language's general code, but it
-    // may be overridden to provide Intl constructors an alternative value.
-    intlCode: {
-        flags: {update: true, expose: true},
-        update: {validate: isLanguageCode},
-        expose: {
-            dependencies: ['code'],
-            transform: (intlCode, { code }) => intlCode ?? code
-        }
-    },
-
-    // Flag which represents whether or not to hide a language from general
-    // access. If a language is hidden, its portion of the website will still
-    // be built (with all strings localized to the language), but it won't be
-    // included in controls for switching languages or the <link rel=alternate>
-    // tags used for search engine optimization. This flag is intended for use
-    // with languages that are currently in development and not ready for
-    // formal release, or which are just kept hidden as "experimental zones"
-    // for wiki development or content testing.
-    hidden: Thing.common.flag(false),
-
-    // Mapping of translation keys to values (strings). Generally, don't
-    // access this object directly - use methods instead.
-    strings: {
-        flags: {update: true, expose: true},
-        update: {validate: t => typeof t === 'object'},
-        expose: {
-            dependencies: ['inheritedStrings'],
-            transform(strings, { inheritedStrings }) {
-                if (strings || inheritedStrings) {
-                    return {...inheritedStrings ?? {}, ...strings ?? {}};
-                } else {
-                    return null;
-                }
-            }
+  // Update & expose
+
+  // General language code. This is used to identify the language distinctly
+  // from other languages (similar to how "Directory" operates in many data
+  // objects).
+  code: {
+    flags: { update: true, expose: true },
+    update: { validate: isLanguageCode },
+  },
+
+  // Human-readable name. This should be the language's own native name, not
+  // localized to any other language.
+  name: Thing.common.simpleString(),
+
+  // Language code specific to JavaScript's Internationalization (Intl) API.
+  // Usually this will be the same as the language's general code, but it
+  // may be overridden to provide Intl constructors an alternative value.
+  intlCode: {
+    flags: { update: true, expose: true },
+    update: { validate: isLanguageCode },
+    expose: {
+      dependencies: ["code"],
+      transform: (intlCode, { code }) => intlCode ?? code,
+    },
+  },
+
+  // Flag which represents whether or not to hide a language from general
+  // access. If a language is hidden, its portion of the website will still
+  // be built (with all strings localized to the language), but it won't be
+  // included in controls for switching languages or the <link rel=alternate>
+  // tags used for search engine optimization. This flag is intended for use
+  // with languages that are currently in development and not ready for
+  // formal release, or which are just kept hidden as "experimental zones"
+  // for wiki development or content testing.
+  hidden: Thing.common.flag(false),
+
+  // Mapping of translation keys to values (strings). Generally, don't
+  // access this object directly - use methods instead.
+  strings: {
+    flags: { update: true, expose: true },
+    update: { validate: (t) => typeof t === "object" },
+    expose: {
+      dependencies: ["inheritedStrings"],
+      transform(strings, { inheritedStrings }) {
+        if (strings || inheritedStrings) {
+          return { ...(inheritedStrings ?? {}), ...(strings ?? {}) };
+        } else {
+          return null;
         }
+      },
     },
+  },
 
-    // May be provided to specify "default" strings, generally (but not
-    // necessarily) inherited from another Language object.
-    inheritedStrings: {
-        flags: {update: true, expose: true},
-        update: {validate: t => typeof t === 'object'}
-    },
+  // May be provided to specify "default" strings, generally (but not
+  // necessarily) inherited from another Language object.
+  inheritedStrings: {
+    flags: { update: true, expose: true },
+    update: { validate: (t) => typeof t === "object" },
+  },
 
-    // Update only
+  // Update only
 
-    escapeHTML: Thing.common.externalFunction(),
+  escapeHTML: Thing.common.externalFunction(),
 
-    // Expose only
+  // Expose only
 
-    intl_date: intlHelper(Intl.DateTimeFormat, {full: true}),
-    intl_number: intlHelper(Intl.NumberFormat),
-    intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}),
-    intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}),
-    intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}),
-    intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}),
-    intl_pluralOrdinal: intlHelper(Intl.PluralRules, {type: 'ordinal'}),
+  intl_date: intlHelper(Intl.DateTimeFormat, { full: true }),
+  intl_number: intlHelper(Intl.NumberFormat),
+  intl_listConjunction: intlHelper(Intl.ListFormat, { type: "conjunction" }),
+  intl_listDisjunction: intlHelper(Intl.ListFormat, { type: "disjunction" }),
+  intl_listUnit: intlHelper(Intl.ListFormat, { type: "unit" }),
+  intl_pluralCardinal: intlHelper(Intl.PluralRules, { type: "cardinal" }),
+  intl_pluralOrdinal: intlHelper(Intl.PluralRules, { type: "ordinal" }),
 
-    validKeys: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['strings', 'inheritedStrings'],
-            compute: ({ strings, inheritedStrings }) => Array.from(new Set([
-                ...Object.keys(inheritedStrings ?? {}),
-                ...Object.keys(strings ?? {})
-            ]))
-        }
-    },
+  validKeys: {
+    flags: { expose: true },
 
-    strings_htmlEscaped: {
-        flags: {expose: true},
-        expose: {
-            dependencies: ['strings', 'inheritedStrings', 'escapeHTML'],
-            compute({ strings, inheritedStrings, escapeHTML }) {
-                if (!(strings || inheritedStrings) || !escapeHTML) return null;
-                const allStrings = {...inheritedStrings ?? {}, ...strings ?? {}};
-                return Object.fromEntries(Object.entries(allStrings)
-                    .map(([ k, v ]) => [k, escapeHTML(v)]));
-            }
-        }
-    },
+    expose: {
+      dependencies: ["strings", "inheritedStrings"],
+      compute: ({ strings, inheritedStrings }) =>
+        Array.from(
+          new Set([
+            ...Object.keys(inheritedStrings ?? {}),
+            ...Object.keys(strings ?? {}),
+          ])
+        ),
+    },
+  },
+
+  strings_htmlEscaped: {
+    flags: { expose: true },
+    expose: {
+      dependencies: ["strings", "inheritedStrings", "escapeHTML"],
+      compute({ strings, inheritedStrings, escapeHTML }) {
+        if (!(strings || inheritedStrings) || !escapeHTML) return null;
+        const allStrings = { ...(inheritedStrings ?? {}), ...(strings ?? {}) };
+        return Object.fromEntries(
+          Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)])
+        );
+      },
+    },
+  },
 };
 
-const countHelper = (stringKey, argName = stringKey) => function(value, {unit = false} = {}) {
+const countHelper = (stringKey, argName = stringKey) =>
+  function (value, { unit = false } = {}) {
     return this.$(
-        (unit
-            ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
-            : `count.${stringKey}`),
-        {[argName]: this.formatNumber(value)});
-};
+      unit
+        ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
+        : `count.${stringKey}`,
+      { [argName]: this.formatNumber(value) }
+    );
+  };
 
 Object.assign(Language.prototype, {
-    $(key, args = {}) {
-        return this.formatString(key, args);
-    },
+  $(key, args = {}) {
+    return this.formatString(key, args);
+  },
 
-    assertIntlAvailable(property) {
-        if (!this[property]) {
-            throw new Error(`Intl API ${property} unavailable`);
-        }
-    },
-
-    getUnitForm(value) {
-        this.assertIntlAvailable('intl_pluralCardinal');
-        return this.intl_pluralCardinal.select(value);
-    },
-
-    formatString(key, args = {}) {
-        if (this.strings && !this.strings_htmlEscaped) {
-            throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`);
-        }
-
-        return this.formatStringHelper(this.strings_htmlEscaped, key, args);
-    },
-
-    formatStringNoHTMLEscape(key, args = {}) {
-        return this.formatStringHelper(this.strings, key, args);
-    },
-
-    formatStringHelper(strings, key, args = {}) {
-        if (!strings) {
-            throw new Error(`Strings unavailable`);
-        }
-
-        if (!this.validKeys.includes(key)) {
-            throw new Error(`Invalid key ${key} accessed`);
-        }
-
-        const template = strings[key];
-
-        // Convert the keys on the args dict from camelCase to CONSTANT_CASE.
-        // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
-        // like, who cares, dude?) Also, this is an array, 8ecause it's handy
-        // for the iterating we're a8out to do.
-        const processedArgs = Object.entries(args)
-            .map(([ k, v ]) => [k.replace(/[A-Z]/g, '_$&').toUpperCase(), v]);
-
-        // Replacement time! Woot. Reduce comes in handy here!
-        const output = processedArgs.reduce(
-            (x, [ k, v ]) => x.replaceAll(`{${k}}`, v),
-            template);
-
-        // Post-processing: if any expected arguments *weren't* replaced, that
-        // is almost definitely an error.
-        if (output.match(/\{[A-Z_]+\}/)) {
-            throw new Error(`Args in ${key} were missing - output: ${output}`);
-        }
-
-        return output;
-    },
-
-    formatDate(date) {
-        this.assertIntlAvailable('intl_date');
-        return this.intl_date.format(date);
-    },
-
-    formatDateRange(startDate, endDate) {
-        this.assertIntlAvailable('intl_date');
-        return this.intl_date.formatRange(startDate, endDate);
-    },
-
-    formatDuration(secTotal, {approximate = false, unit = false} = {}) {
-        if (secTotal === 0) {
-            return this.formatString('count.duration.missing');
-        }
-
-        const hour = Math.floor(secTotal / 3600);
-        const min = Math.floor((secTotal - hour * 3600) / 60);
-        const sec = Math.floor(secTotal - hour * 3600 - min * 60);
-
-        const pad = val => val.toString().padStart(2, '0');
-
-        const stringSubkey = unit ? '.withUnit' : '';
-
-        const duration = (hour > 0
-            ? this.formatString('count.duration.hours' + stringSubkey, {
-                hours: hour,
-                minutes: pad(min),
-                seconds: pad(sec)
-            })
-            : this.formatString('count.duration.minutes' + stringSubkey, {
-                minutes: min,
-                seconds: pad(sec)
-            }));
-
-        return (approximate
-            ? this.formatString('count.duration.approximate', {duration})
-            : duration);
-    },
-
-    formatIndex(value) {
-        this.assertIntlAvailable('intl_pluralOrdinal');
-        return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
-    },
-
-    formatNumber(value) {
-        this.assertIntlAvailable('intl_number');
-        return this.intl_number.format(value);
-    },
-
-    formatWordCount(value) {
-        const num = this.formatNumber(value > 1000
-            ? Math.floor(value / 100) / 10
-            : value);
+  assertIntlAvailable(property) {
+    if (!this[property]) {
+      throw new Error(`Intl API ${property} unavailable`);
+    }
+  },
+
+  getUnitForm(value) {
+    this.assertIntlAvailable("intl_pluralCardinal");
+    return this.intl_pluralCardinal.select(value);
+  },
+
+  formatString(key, args = {}) {
+    if (this.strings && !this.strings_htmlEscaped) {
+      throw new Error(
+        `HTML-escaped strings unavailable - please ensure escapeHTML function is provided`
+      );
+    }
 
-        const words = (value > 1000
-            ? this.formatString('count.words.thousand', {words: num})
-            : this.formatString('count.words', {words: num}));
+    return this.formatStringHelper(this.strings_htmlEscaped, key, args);
+  },
 
-        return this.formatString('count.words.withUnit.' + this.getUnitForm(value), {words});
-    },
+  formatStringNoHTMLEscape(key, args = {}) {
+    return this.formatStringHelper(this.strings, key, args);
+  },
 
-    // Conjunction list: A, B, and C
-    formatConjunctionList(array) {
-        this.assertIntlAvailable('intl_listConjunction');
-        return this.intl_listConjunction.format(array);
-    },
+  formatStringHelper(strings, key, args = {}) {
+    if (!strings) {
+      throw new Error(`Strings unavailable`);
+    }
 
-    // Disjunction lists: A, B, or C
-    formatDisjunctionList(array) {
-        this.assertIntlAvailable('intl_listDisjunction');
-        return this.intl_listDisjunction.format(array);
-    },
+    if (!this.validKeys.includes(key)) {
+      throw new Error(`Invalid key ${key} accessed`);
+    }
 
-    // Unit lists: A, B, C
-    formatUnitList(array) {
-        this.assertIntlAvailable('intl_listUnit');
-        return this.intl_listUnit.format(array);
-    },
+    const template = strings[key];
+
+    // Convert the keys on the args dict from camelCase to CONSTANT_CASE.
+    // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
+    // like, who cares, dude?) Also, this is an array, 8ecause it's handy
+    // for the iterating we're a8out to do.
+    const processedArgs = Object.entries(args).map(([k, v]) => [
+      k.replace(/[A-Z]/g, "_$&").toUpperCase(),
+      v,
+    ]);
+
+    // Replacement time! Woot. Reduce comes in handy here!
+    const output = processedArgs.reduce(
+      (x, [k, v]) => x.replaceAll(`{${k}}`, v),
+      template
+    );
+
+    // Post-processing: if any expected arguments *weren't* replaced, that
+    // is almost definitely an error.
+    if (output.match(/\{[A-Z_]+\}/)) {
+      throw new Error(`Args in ${key} were missing - output: ${output}`);
+    }
 
-    // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
-    formatFileSize(bytes) {
-        if (!bytes) return '';
+    return output;
+  },
 
-        bytes = parseInt(bytes);
-        if (isNaN(bytes)) return '';
+  formatDate(date) {
+    this.assertIntlAvailable("intl_date");
+    return this.intl_date.format(date);
+  },
 
-        const round = exp => Math.round(bytes / 10 ** (exp - 1)) / 10;
+  formatDateRange(startDate, endDate) {
+    this.assertIntlAvailable("intl_date");
+    return this.intl_date.formatRange(startDate, endDate);
+  },
 
-        if (bytes >= 10 ** 12) {
-            return this.formatString('count.fileSize.terabytes', {terabytes: round(12)});
-        } else if (bytes >= 10 ** 9) {
-            return this.formatString('count.fileSize.gigabytes', {gigabytes: round(9)});
-        } else if (bytes >= 10 ** 6) {
-            return this.formatString('count.fileSize.megabytes', {megabytes: round(6)});
-        } else if (bytes >= 10 ** 3) {
-            return this.formatString('count.fileSize.kilobytes', {kilobytes: round(3)});
-        } else {
-            return this.formatString('count.fileSize.bytes', {bytes});
-        }
-    },
+  formatDuration(secTotal, { approximate = false, unit = false } = {}) {
+    if (secTotal === 0) {
+      return this.formatString("count.duration.missing");
+    }
 
-    // TODO: These are hard-coded. Is there a better way?
-    countAdditionalFiles: countHelper('additionalFiles', 'files'),
-    countAlbums: countHelper('albums'),
-    countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
-    countContributions: countHelper('contributions'),
-    countCoverArts: countHelper('coverArts'),
-    countTimesReferenced: countHelper('timesReferenced'),
-    countTimesUsed: countHelper('timesUsed'),
-    countTracks: countHelper('tracks'),
+    const hour = Math.floor(secTotal / 3600);
+    const min = Math.floor((secTotal - hour * 3600) / 60);
+    const sec = Math.floor(secTotal - hour * 3600 - min * 60);
+
+    const pad = (val) => val.toString().padStart(2, "0");
+
+    const stringSubkey = unit ? ".withUnit" : "";
+
+    const duration =
+      hour > 0
+        ? this.formatString("count.duration.hours" + stringSubkey, {
+            hours: hour,
+            minutes: pad(min),
+            seconds: pad(sec),
+          })
+        : this.formatString("count.duration.minutes" + stringSubkey, {
+            minutes: min,
+            seconds: pad(sec),
+          });
+
+    return approximate
+      ? this.formatString("count.duration.approximate", { duration })
+      : duration;
+  },
+
+  formatIndex(value) {
+    this.assertIntlAvailable("intl_pluralOrdinal");
+    return this.formatString(
+      "count.index." + this.intl_pluralOrdinal.select(value),
+      { index: value }
+    );
+  },
+
+  formatNumber(value) {
+    this.assertIntlAvailable("intl_number");
+    return this.intl_number.format(value);
+  },
+
+  formatWordCount(value) {
+    const num = this.formatNumber(
+      value > 1000 ? Math.floor(value / 100) / 10 : value
+    );
+
+    const words =
+      value > 1000
+        ? this.formatString("count.words.thousand", { words: num })
+        : this.formatString("count.words", { words: num });
+
+    return this.formatString(
+      "count.words.withUnit." + this.getUnitForm(value),
+      { words }
+    );
+  },
+
+  // Conjunction list: A, B, and C
+  formatConjunctionList(array) {
+    this.assertIntlAvailable("intl_listConjunction");
+    return this.intl_listConjunction.format(array);
+  },
+
+  // Disjunction lists: A, B, or C
+  formatDisjunctionList(array) {
+    this.assertIntlAvailable("intl_listDisjunction");
+    return this.intl_listDisjunction.format(array);
+  },
+
+  // Unit lists: A, B, C
+  formatUnitList(array) {
+    this.assertIntlAvailable("intl_listUnit");
+    return this.intl_listUnit.format(array);
+  },
+
+  // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
+  formatFileSize(bytes) {
+    if (!bytes) return "";
+
+    bytes = parseInt(bytes);
+    if (isNaN(bytes)) return "";
+
+    const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10;
+
+    if (bytes >= 10 ** 12) {
+      return this.formatString("count.fileSize.terabytes", {
+        terabytes: round(12),
+      });
+    } else if (bytes >= 10 ** 9) {
+      return this.formatString("count.fileSize.gigabytes", {
+        gigabytes: round(9),
+      });
+    } else if (bytes >= 10 ** 6) {
+      return this.formatString("count.fileSize.megabytes", {
+        megabytes: round(6),
+      });
+    } else if (bytes >= 10 ** 3) {
+      return this.formatString("count.fileSize.kilobytes", {
+        kilobytes: round(3),
+      });
+    } else {
+      return this.formatString("count.fileSize.bytes", { bytes });
+    }
+  },
+
+  // TODO: These are hard-coded. Is there a better way?
+  countAdditionalFiles: countHelper("additionalFiles", "files"),
+  countAlbums: countHelper("albums"),
+  countCommentaryEntries: countHelper("commentaryEntries", "entries"),
+  countContributions: countHelper("contributions"),
+  countCoverArts: countHelper("coverArts"),
+  countTimesReferenced: countHelper("timesReferenced"),
+  countTimesUsed: countHelper("timesUsed"),
+  countTracks: countHelper("tracks"),
 });
diff --git a/src/data/validators.js b/src/data/validators.js
index 0d325aed..714dc3a0 100644
--- a/src/data/validators.js
+++ b/src/data/validators.js
@@ -1,367 +1,387 @@
-import { withAggregate } from '../util/sugar.js';
+import { withAggregate } from "../util/sugar.js";
 
-import { color, ENABLE_COLOR, decorateTime } from '../util/cli.js';
+import { color, ENABLE_COLOR, decorateTime } from "../util/cli.js";
 
-import { inspect as nodeInspect } from 'util';
+import { inspect as nodeInspect } from "util";
 
 function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
+  return nodeInspect(value, { colors: ENABLE_COLOR });
 }
 
 // Basic types (primitives)
 
 function a(noun) {
-    return (/[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`);
+  return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`;
 }
 
 function isType(value, type) {
-    if (typeof value !== type)
-        throw new TypeError(`Expected ${a(type)}, got ${typeof value}`);
+  if (typeof value !== type)
+    throw new TypeError(`Expected ${a(type)}, got ${typeof value}`);
 
-    return true;
+  return true;
 }
 
 export function isBoolean(value) {
-    return isType(value, 'boolean');
+  return isType(value, "boolean");
 }
 
 export function isNumber(value) {
-    return isType(value, 'number');
+  return isType(value, "number");
 }
 
 export function isPositive(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number <= 0)
-        throw new TypeError(`Expected positive number`);
+  if (number <= 0) throw new TypeError(`Expected positive number`);
 
-    return true;
+  return true;
 }
 
 export function isNegative(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number >= 0)
-        throw new TypeError(`Expected negative number`);
+  if (number >= 0) throw new TypeError(`Expected negative number`);
 
-    return true;
+  return true;
 }
 
 export function isPositiveOrZero(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number < 0)
-        throw new TypeError(`Expected positive number or zero`);
+  if (number < 0) throw new TypeError(`Expected positive number or zero`);
 
-    return true;
+  return true;
 }
 
 export function isNegativeOrZero(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number > 0)
-        throw new TypeError(`Expected negative number or zero`);
+  if (number > 0) throw new TypeError(`Expected negative number or zero`);
 
-    return true;
+  return true;
 }
 
 export function isInteger(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number % 1 !== 0)
-        throw new TypeError(`Expected integer`);
+  if (number % 1 !== 0) throw new TypeError(`Expected integer`);
 
-    return true;
+  return true;
 }
 
 export function isCountingNumber(number) {
-    isInteger(number);
-    isPositive(number);
+  isInteger(number);
+  isPositive(number);
 
-    return true;
+  return true;
 }
 
 export function isWholeNumber(number) {
-    isInteger(number);
-    isPositiveOrZero(number);
+  isInteger(number);
+  isPositiveOrZero(number);
 
-    return true;
+  return true;
 }
 
 export function isString(value) {
-    return isType(value, 'string');
+  return isType(value, "string");
 }
 
 export function isStringNonEmpty(value) {
-    isString(value);
+  isString(value);
 
-    if (value.trim().length === 0)
-        throw new TypeError(`Expected non-empty string`);
+  if (value.trim().length === 0)
+    throw new TypeError(`Expected non-empty string`);
 
-    return true;
+  return true;
 }
 
 // Complex types (non-primitives)
 
 export function isInstance(value, constructor) {
-    isObject(value);
+  isObject(value);
 
-    if (!(value instanceof constructor))
-        throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`);
+  if (!(value instanceof constructor))
+    throw new TypeError(
+      `Expected ${constructor.name}, got ${value.constructor.name}`
+    );
 
-    return true;
+  return true;
 }
 
 export function isDate(value) {
-    return isInstance(value, Date);
+  return isInstance(value, Date);
 }
 
 export function isObject(value) {
-    isType(value, 'object');
+  isType(value, "object");
 
-    // Note: Please remember that null is always a valid value for properties
-    // held by a CacheableObject. This assertion is exclusively for use in other
-    // contexts.
-    if (value === null)
-        throw new TypeError(`Expected an object, got null`);
+  // Note: Please remember that null is always a valid value for properties
+  // held by a CacheableObject. This assertion is exclusively for use in other
+  // contexts.
+  if (value === null) throw new TypeError(`Expected an object, got null`);
 
-    return true;
+  return true;
 }
 
 export function isArray(value) {
-    if (typeof value !== 'object' || value === null || !Array.isArray(value))
-        throw new TypeError(`Expected an array, got ${value}`);
+  if (typeof value !== "object" || value === null || !Array.isArray(value))
+    throw new TypeError(`Expected an array, got ${value}`);
 
-    return true;
+  return true;
 }
 
 function validateArrayItemsHelper(itemValidator) {
-    return (item, index) => {
-        try {
-            const value = itemValidator(item);
-
-            if (value !== true) {
-                throw new Error(`Expected validator to return true`);
-            }
-        } catch (error) {
-            error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`;
-            throw error;
-        }
-    };
+  return (item, index) => {
+    try {
+      const value = itemValidator(item);
+
+      if (value !== true) {
+        throw new Error(`Expected validator to return true`);
+      }
+    } catch (error) {
+      error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${
+        error.message
+      }`;
+      throw error;
+    }
+  };
 }
 
 export function validateArrayItems(itemValidator) {
-    const fn = validateArrayItemsHelper(itemValidator);
+  const fn = validateArrayItemsHelper(itemValidator);
 
-    return array => {
-        isArray(array);
+  return (array) => {
+    isArray(array);
 
-        withAggregate({message: 'Errors validating array items'}, ({ wrap }) => {
-            array.forEach(wrap(fn));
-        });
+    withAggregate({ message: "Errors validating array items" }, ({ wrap }) => {
+      array.forEach(wrap(fn));
+    });
 
-        return true;
-    };
+    return true;
+  };
 }
 
 export function validateInstanceOf(constructor) {
-    return object => isInstance(object, constructor);
+  return (object) => isInstance(object, constructor);
 }
 
 // Wiki data (primitives & non-primitives)
 
 export function isColor(color) {
-    isStringNonEmpty(color);
+  isStringNonEmpty(color);
 
-    if (color.startsWith('#')) {
-        if (![1 + 3, 1 + 4, 1 + 6, 1 + 8].includes(color.length))
-            throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`);
+  if (color.startsWith("#")) {
+    if (![1 + 3, 1 + 4, 1 + 6, 1 + 8].includes(color.length))
+      throw new TypeError(
+        `Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`
+      );
 
-        if (/[^0-9a-fA-F]/.test(color.slice(1)))
-            throw new TypeError(`Expected hexadecimal digits`);
+    if (/[^0-9a-fA-F]/.test(color.slice(1)))
+      throw new TypeError(`Expected hexadecimal digits`);
 
-        return true;
-    }
+    return true;
+  }
 
-    throw new TypeError(`Unknown color format`);
+  throw new TypeError(`Unknown color format`);
 }
 
 export function isCommentary(commentary) {
-    return isString(commentary);
+  return isString(commentary);
 }
 
-const isArtistRef = validateReference('artist');
+const isArtistRef = validateReference("artist");
 
 export function validateProperties(spec) {
-    const specEntries = Object.entries(spec);
-    const specKeys = Object.keys(spec);
-
-    return object => {
-        isObject(object);
-
-        if (Array.isArray(object))
-            throw new TypeError(`Expected an object, got array`);
-
-        withAggregate({message: `Errors validating object properties`}, ({ call }) => {
-            for (const [ specKey, specValidator ] of specEntries) {
-                call(() => {
-                    const value = object[specKey];
-                    try {
-                        specValidator(value);
-                    } catch (error) {
-                        error.message = `(key: ${color.green(specKey)}, value: ${inspect(value)}) ${error.message}`;
-                        throw error;
-                    }
-                });
-            }
+  const specEntries = Object.entries(spec);
+  const specKeys = Object.keys(spec);
 
-            const unknownKeys = Object.keys(object).filter(key => !specKeys.includes(key));
-            if (unknownKeys.length > 0) {
-                call(() => {
-                    throw new Error(`Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`);
-                });
+  return (object) => {
+    isObject(object);
+
+    if (Array.isArray(object))
+      throw new TypeError(`Expected an object, got array`);
+
+    withAggregate(
+      { message: `Errors validating object properties` },
+      ({ call }) => {
+        for (const [specKey, specValidator] of specEntries) {
+          call(() => {
+            const value = object[specKey];
+            try {
+              specValidator(value);
+            } catch (error) {
+              error.message = `(key: ${color.green(specKey)}, value: ${inspect(
+                value
+              )}) ${error.message}`;
+              throw error;
             }
-        });
+          });
+        }
 
-        return true;
-    };
-}
+        const unknownKeys = Object.keys(object).filter(
+          (key) => !specKeys.includes(key)
+        );
+        if (unknownKeys.length > 0) {
+          call(() => {
+            throw new Error(
+              `Unknown keys present (${
+                unknownKeys.length
+              }): [${unknownKeys.join(", ")}]`
+            );
+          });
+        }
+      }
+    );
 
+    return true;
+  };
+}
 
 export const isContribution = validateProperties({
-    who: isArtistRef,
-    what: value => value === undefined || value === null || isStringNonEmpty(value),
+  who: isArtistRef,
+  what: (value) =>
+    value === undefined || value === null || isStringNonEmpty(value),
 });
 
 export const isContributionList = validateArrayItems(isContribution);
 
 export const isAdditionalFile = validateProperties({
-    title: isString,
-    description: value => (value === undefined || value === null || isString(value)),
-    files: validateArrayItems(isString)
+  title: isString,
+  description: (value) =>
+    value === undefined || value === null || isString(value),
+  files: validateArrayItems(isString),
 });
 
 export const isAdditionalFileList = validateArrayItems(isAdditionalFile);
 
 export function isDimensions(dimensions) {
-    isArray(dimensions);
+  isArray(dimensions);
 
-    if (dimensions.length !== 2)
-        throw new TypeError(`Expected 2 item array`);
+  if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`);
 
-    isPositive(dimensions[0]);
-    isInteger(dimensions[0]);
-    isPositive(dimensions[1]);
-    isInteger(dimensions[1]);
+  isPositive(dimensions[0]);
+  isInteger(dimensions[0]);
+  isPositive(dimensions[1]);
+  isInteger(dimensions[1]);
 
-    return true;
+  return true;
 }
 
 export function isDirectory(directory) {
-    isStringNonEmpty(directory);
+  isStringNonEmpty(directory);
 
-    if (directory.match(/[^a-zA-Z0-9_\-]/))
-        throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`);
+  if (directory.match(/[^a-zA-Z0-9_\-]/))
+    throw new TypeError(
+      `Expected only letters, numbers, dash, and underscore, got "${directory}"`
+    );
 
-    return true;
+  return true;
 }
 
 export function isDuration(duration) {
-    isNumber(duration);
-    isPositiveOrZero(duration);
+  isNumber(duration);
+  isPositiveOrZero(duration);
 
-    return true;
+  return true;
 }
 
 export function isFileExtension(string) {
-    isStringNonEmpty(string);
+  isStringNonEmpty(string);
 
-    if (string[0] === '.')
-        throw new TypeError(`Expected no dot (.) at the start of file extension`);
+  if (string[0] === ".")
+    throw new TypeError(`Expected no dot (.) at the start of file extension`);
 
-    if (string.match(/[^a-zA-Z0-9_]/))
-        throw new TypeError(`Expected only alphanumeric and underscore`);
+  if (string.match(/[^a-zA-Z0-9_]/))
+    throw new TypeError(`Expected only alphanumeric and underscore`);
 
-    return true;
+  return true;
 }
 
 export function isLanguageCode(string) {
-    // TODO: This is a stub function because really we don't need a detailed
-    // is-language-code parser right now.
+  // TODO: This is a stub function because really we don't need a detailed
+  // is-language-code parser right now.
 
-    isString(string);
+  isString(string);
 
-    return true;
+  return true;
 }
 
 export function isName(name) {
-    return isString(name);
+  return isString(name);
 }
 
 export function isURL(string) {
-    isStringNonEmpty(string);
+  isStringNonEmpty(string);
 
-    new URL(string);
+  new URL(string);
 
-    return true;
+  return true;
 }
 
-export function validateReference(type = 'track') {
-    return ref => {
-        isStringNonEmpty(ref);
+export function validateReference(type = "track") {
+  return (ref) => {
+    isStringNonEmpty(ref);
 
-        const match = ref.trim().match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
+    const match = ref
+      .trim()
+      .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
 
-        if (!match)
-            throw new TypeError(`Malformed reference`);
+    if (!match) throw new TypeError(`Malformed reference`);
 
-        const { groups: { typePart, directoryPart } } = match;
+    const {
+      groups: { typePart, directoryPart },
+    } = match;
 
-        if (typePart && typePart !== type)
-            throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`);
+    if (typePart && typePart !== type)
+      throw new TypeError(
+        `Expected ref to begin with "${type}:", got "${typePart}:"`
+      );
 
-        if (typePart)
-            isDirectory(directoryPart);
+    if (typePart) isDirectory(directoryPart);
 
-        isName(ref);
+    isName(ref);
 
-        return true;
-    };
+    return true;
+  };
 }
 
-export function validateReferenceList(type = '') {
-    return validateArrayItems(validateReference(type));
+export function validateReferenceList(type = "") {
+  return validateArrayItems(validateReference(type));
 }
 
 // Compositional utilities
 
 export function oneOf(...checks) {
-    return value => {
-        const errorMeta = [];
+  return (value) => {
+    const errorMeta = [];
 
-        for (let i = 0, check; check = checks[i]; i++) {
-            try {
-                const result = check(value);
-
-                if (result !== true) {
-                    throw new Error(`Check returned false`);
-                }
+    for (let i = 0, check; (check = checks[i]); i++) {
+      try {
+        const result = check(value);
 
-                return true;
-            } catch (error) {
-                errorMeta.push([check, i, error]);
-            }
+        if (result !== true) {
+          throw new Error(`Check returned false`);
         }
 
-        // Don't process error messages until every check has failed.
-        const errors = [];
-        for (const [ check, i, error ] of errorMeta) {
-            error.message = (check.name
-                ? `(#${i} "${check.name}") ${error.message}`
-                : `(#${i}) ${error.message}`);
-            error.check = check;
-            errors.push(error);
-        }
-        throw new AggregateError(errors, `Expected one of ${checks.length} possible checks, but none were true`);
-    };
+        return true;
+      } catch (error) {
+        errorMeta.push([check, i, error]);
+      }
+    }
+
+    // Don't process error messages until every check has failed.
+    const errors = [];
+    for (const [check, i, error] of errorMeta) {
+      error.message = check.name
+        ? `(#${i} "${check.name}") ${error.message}`
+        : `(#${i}) ${error.message}`;
+      error.check = check;
+      errors.push(error);
+    }
+    throw new AggregateError(
+      errors,
+      `Expected one of ${checks.length} possible checks, but none were true`
+    );
+  };
 }
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 763dfd28..cfbb985a 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -1,74 +1,69 @@
 // yaml.js - specification for HSMusic YAML data file format and utilities for
 // loading and processing YAML files and documents
 
-import * as path from 'path';
-import yaml from 'js-yaml';
+import * as path from "path";
+import yaml from "js-yaml";
 
-import { readFile } from 'fs/promises';
-import { inspect as nodeInspect } from 'util';
+import { readFile } from "fs/promises";
+import { inspect as nodeInspect } from "util";
 
 import {
-    Album,
-    Artist,
-    ArtTag,
-    Flash,
-    FlashAct,
-    Group,
-    GroupCategory,
-    HomepageLayout,
-    HomepageLayoutAlbumsRow,
-    HomepageLayoutRow,
-    NewsEntry,
-    StaticPage,
-    Thing,
-    Track,
-    TrackGroup,
-    WikiInfo,
-} from './things.js';
+  Album,
+  Artist,
+  ArtTag,
+  Flash,
+  FlashAct,
+  Group,
+  GroupCategory,
+  HomepageLayout,
+  HomepageLayoutAlbumsRow,
+  HomepageLayoutRow,
+  NewsEntry,
+  StaticPage,
+  Thing,
+  Track,
+  TrackGroup,
+  WikiInfo,
+} from "./things.js";
+
+import { color, ENABLE_COLOR, logInfo, logWarn } from "../util/cli.js";
 
 import {
-    color,
-    ENABLE_COLOR,
-    logInfo,
-    logWarn,
-} from '../util/cli.js';
+  decorateErrorWithIndex,
+  mapAggregate,
+  openAggregate,
+  showAggregate,
+  withAggregate,
+} from "../util/sugar.js";
 
 import {
-    decorateErrorWithIndex,
-    mapAggregate,
-    openAggregate,
-    showAggregate,
-    withAggregate,
-} from '../util/sugar.js';
+  sortAlbumsTracksChronologically,
+  sortAlphabetically,
+  sortChronologically,
+} from "../util/wiki-data.js";
 
-import {
-    sortAlbumsTracksChronologically,
-    sortAlphabetically,
-    sortChronologically,
-} from '../util/wiki-data.js';
-
-import find, { bindFind } from '../util/find.js';
-import { findFiles } from '../util/io.js';
+import find, { bindFind } from "../util/find.js";
+import { findFiles } from "../util/io.js";
 
 // --> General supporting stuff
 
 function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
+  return nodeInspect(value, { colors: ENABLE_COLOR });
 }
 
 // --> YAML data repository structure constants
 
-export const WIKI_INFO_FILE = 'wiki-info.yaml';
-export const BUILD_DIRECTIVE_DATA_FILE = 'build-directives.yaml';
-export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml';
-export const ARTIST_DATA_FILE = 'artists.yaml';
-export const FLASH_DATA_FILE = 'flashes.yaml';
-export const NEWS_DATA_FILE = 'news.yaml';
-export const ART_TAG_DATA_FILE = 'tags.yaml';
-export const GROUP_DATA_FILE = 'groups.yaml';
-export const STATIC_PAGE_DATA_FILE = 'static-pages.yaml';
+export const WIKI_INFO_FILE = "wiki-info.yaml";
+export const BUILD_DIRECTIVE_DATA_FILE = "build-directives.yaml";
+export const HOMEPAGE_LAYOUT_DATA_FILE = "homepage.yaml";
+export const ARTIST_DATA_FILE = "artists.yaml";
+export const FLASH_DATA_FILE = "flashes.yaml";
+export const NEWS_DATA_FILE = "news.yaml";
+export const ART_TAG_DATA_FILE = "tags.yaml";
+export const GROUP_DATA_FILE = "groups.yaml";
+export const STATIC_PAGE_DATA_FILE = "static-pages.yaml";
 
-export const DATA_ALBUM_DIRECTORY = 'album';
+export const DATA_ALBUM_DIRECTORY = "album";
 
 // --> Document processing functions
 
@@ -78,7 +73,9 @@ export const DATA_ALBUM_DIRECTORY = 'album';
 // makeProcessDocument is a factory function: the returned function will take a
 // document and apply the configuration passed to makeProcessDocument in order
 // to construct a Thing subclass.
-function makeProcessDocument(thingClass, {
+function makeProcessDocument(
+  thingClass,
+  {
     // Optional early step for transforming field values before providing them
     // to the Thing's update() method. This is useful when the input format
     // (i.e. values in the document) differ from the format the actual Thing
@@ -101,454 +98,479 @@ function makeProcessDocument(thingClass, {
     // they're present in a document, but they won't be used for Thing property
     // generation, either. Useful for stuff that's present in data files but not
     // yet implemented as part of a Thing's data model!
-    ignoredFields = []
-}) {
-    if (!propertyFieldMapping) {
-        throw new Error(`Expected propertyFieldMapping to be provided`);
-    }
-
-    const knownFields = Object.values(propertyFieldMapping);
-
-    // Invert the property-field mapping, since it'll come in handy for
-    // assigning update() source values later.
-    const fieldPropertyMapping = Object.fromEntries(
-        (Object.entries(propertyFieldMapping)
-            .map(([ property, field ]) => [field, property])));
-
-    const decorateErrorWithName = fn => {
-        const nameField = propertyFieldMapping['name'];
-        if (!nameField) return fn;
-
-        return document => {
-            try {
-                return fn(document);
-            } catch (error) {
-                const name = document[nameField];
-                error.message = (name
-                    ? `(name: ${inspect(name)}) ${error.message}`
-                    : `(${color.dim(`no name found`)}) ${error.message}`);
-                throw error;
-            }
-        };
+    ignoredFields = [],
+  }
+) {
+  if (!propertyFieldMapping) {
+    throw new Error(`Expected propertyFieldMapping to be provided`);
+  }
+
+  const knownFields = Object.values(propertyFieldMapping);
+
+  // Invert the property-field mapping, since it'll come in handy for
+  // assigning update() source values later.
+  const fieldPropertyMapping = Object.fromEntries(
+    Object.entries(propertyFieldMapping).map(([property, field]) => [
+      field,
+      property,
+    ])
+  );
+
+  const decorateErrorWithName = (fn) => {
+    const nameField = propertyFieldMapping["name"];
+    if (!nameField) return fn;
+
+    return (document) => {
+      try {
+        return fn(document);
+      } catch (error) {
+        const name = document[nameField];
+        error.message = name
+          ? `(name: ${inspect(name)}) ${error.message}`
+          : `(${color.dim(`no name found`)}) ${error.message}`;
+        throw error;
+      }
     };
+  };
 
-    return decorateErrorWithName(document => {
-        const documentEntries = Object.entries(document)
-            .filter(([ field ]) => !ignoredFields.includes(field));
+  return decorateErrorWithName((document) => {
+    const documentEntries = Object.entries(document).filter(
+      ([field]) => !ignoredFields.includes(field)
+    );
 
-        const unknownFields = documentEntries
-            .map(([ field ]) => field)
-            .filter(field => !knownFields.includes(field));
+    const unknownFields = documentEntries
+      .map(([field]) => field)
+      .filter((field) => !knownFields.includes(field));
 
-        if (unknownFields.length) {
-            throw new makeProcessDocument.UnknownFieldsError(unknownFields);
-        }
+    if (unknownFields.length) {
+      throw new makeProcessDocument.UnknownFieldsError(unknownFields);
+    }
 
-        const fieldValues = {};
+    const fieldValues = {};
 
-        for (const [ field, value ] of documentEntries) {
-            if (Object.hasOwn(fieldTransformations, field)) {
-                fieldValues[field] = fieldTransformations[field](value);
-            } else {
-                fieldValues[field] = value;
-            }
-        }
+    for (const [field, value] of documentEntries) {
+      if (Object.hasOwn(fieldTransformations, field)) {
+        fieldValues[field] = fieldTransformations[field](value);
+      } else {
+        fieldValues[field] = value;
+      }
+    }
 
-        const sourceProperties = {};
+    const sourceProperties = {};
 
-        for (const [ field, value ] of Object.entries(fieldValues)) {
-            const property = fieldPropertyMapping[field];
-            sourceProperties[property] = value;
-        }
+    for (const [field, value] of Object.entries(fieldValues)) {
+      const property = fieldPropertyMapping[field];
+      sourceProperties[property] = value;
+    }
 
-        const thing = Reflect.construct(thingClass, []);
+    const thing = Reflect.construct(thingClass, []);
 
-        withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({ call }) => {
-            for (const [ property, value ] of Object.entries(sourceProperties)) {
-                call(() => (thing[property] = value));
-            }
-        });
+    withAggregate(
+      { message: `Errors applying ${color.green(thingClass.name)} properties` },
+      ({ call }) => {
+        for (const [property, value] of Object.entries(sourceProperties)) {
+          call(() => (thing[property] = value));
+        }
+      }
+    );
 
-        return thing;
-    });
+    return thing;
+  });
 }
 
-makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error {
-    constructor(fields) {
-        super(`Unknown fields present: ${fields.join(', ')}`);
-        this.fields = fields;
-    }
+makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends (
+  Error
+) {
+  constructor(fields) {
+    super(`Unknown fields present: ${fields.join(", ")}`);
+    this.fields = fields;
+  }
 };
 
 export const processAlbumDocument = makeProcessDocument(Album, {
-    fieldTransformations: {
-        'Artists': parseContributors,
-        'Cover Artists': parseContributors,
-        'Default Track Cover Artists': parseContributors,
-        'Wallpaper Artists': parseContributors,
-        'Banner Artists': parseContributors,
-
-        'Date': value => new Date(value),
-        'Date Added': value => new Date(value),
-        'Cover Art Date': value => new Date(value),
-        'Default Track Cover Art Date': value => new Date(value),
-
-        'Banner Dimensions': parseDimensions,
-
-        'Additional Files': parseAdditionalFiles,
-    },
-
-    propertyFieldMapping: {
-        name: 'Album',
-
-        color: 'Color',
-        directory: 'Directory',
-        urls: 'URLs',
-
-        artistContribsByRef: 'Artists',
-        coverArtistContribsByRef: 'Cover Artists',
-        trackCoverArtistContribsByRef: 'Default Track Cover Artists',
-
-        coverArtFileExtension: 'Cover Art File Extension',
-        trackCoverArtFileExtension: 'Track Art File Extension',
-
-        wallpaperArtistContribsByRef: 'Wallpaper Artists',
-        wallpaperStyle: 'Wallpaper Style',
-        wallpaperFileExtension: 'Wallpaper File Extension',
-
-        bannerArtistContribsByRef: 'Banner Artists',
-        bannerStyle: 'Banner Style',
-        bannerFileExtension: 'Banner File Extension',
-        bannerDimensions: 'Banner Dimensions',
-
-        date: 'Date',
-        trackArtDate: 'Default Track Cover Art Date',
-        coverArtDate: 'Cover Art Date',
-        dateAddedToWiki: 'Date Added',
-
-        hasCoverArt: 'Has Cover Art',
-        hasTrackArt: 'Has Track Art',
-        hasTrackNumbers: 'Has Track Numbers',
-        isMajorRelease: 'Major Release',
-        isListedOnHomepage: 'Listed on Homepage',
-
-        groupsByRef: 'Groups',
-        artTagsByRef: 'Art Tags',
-        commentary: 'Commentary',
-
-        additionalFiles: 'Additional Files',
-    }
+  fieldTransformations: {
+    Artists: parseContributors,
+    "Cover Artists": parseContributors,
+    "Default Track Cover Artists": parseContributors,
+    "Wallpaper Artists": parseContributors,
+    "Banner Artists": parseContributors,
+
+    Date: (value) => new Date(value),
+    "Date Added": (value) => new Date(value),
+    "Cover Art Date": (value) => new Date(value),
+    "Default Track Cover Art Date": (value) => new Date(value),
+
+    "Banner Dimensions": parseDimensions,
+
+    "Additional Files": parseAdditionalFiles,
+  },
+
+  propertyFieldMapping: {
+    name: "Album",
+
+    color: "Color",
+    directory: "Directory",
+    urls: "URLs",
+
+    artistContribsByRef: "Artists",
+    coverArtistContribsByRef: "Cover Artists",
+    trackCoverArtistContribsByRef: "Default Track Cover Artists",
+
+    coverArtFileExtension: "Cover Art File Extension",
+    trackCoverArtFileExtension: "Track Art File Extension",
+
+    wallpaperArtistContribsByRef: "Wallpaper Artists",
+    wallpaperStyle: "Wallpaper Style",
+    wallpaperFileExtension: "Wallpaper File Extension",
+
+    bannerArtistContribsByRef: "Banner Artists",
+    bannerStyle: "Banner Style",
+    bannerFileExtension: "Banner File Extension",
+    bannerDimensions: "Banner Dimensions",
+
+    date: "Date",
+    trackArtDate: "Default Track Cover Art Date",
+    coverArtDate: "Cover Art Date",
+    dateAddedToWiki: "Date Added",
+
+    hasCoverArt: "Has Cover Art",
+    hasTrackArt: "Has Track Art",
+    hasTrackNumbers: "Has Track Numbers",
+    isMajorRelease: "Major Release",
+    isListedOnHomepage: "Listed on Homepage",
+
+    groupsByRef: "Groups",
+    artTagsByRef: "Art Tags",
+    commentary: "Commentary",
+
+    additionalFiles: "Additional Files",
+  },
 });
 
 export const processTrackGroupDocument = makeProcessDocument(TrackGroup, {
-    fieldTransformations: {
-        'Date Originally Released': value => new Date(value),
-    },
-
-    propertyFieldMapping: {
-        name: 'Group',
-        color: 'Color',
-        dateOriginallyReleased: 'Date Originally Released',
-    }
+  fieldTransformations: {
+    "Date Originally Released": (value) => new Date(value),
+  },
+
+  propertyFieldMapping: {
+    name: "Group",
+    color: "Color",
+    dateOriginallyReleased: "Date Originally Released",
+  },
 });
 
 export const processTrackDocument = makeProcessDocument(Track, {
-    fieldTransformations: {
-        'Duration': getDurationInSeconds,
+  fieldTransformations: {
+    Duration: getDurationInSeconds,
 
-        'Date First Released': value => new Date(value),
-        'Cover Art Date': value => new Date(value),
+    "Date First Released": (value) => new Date(value),
+    "Cover Art Date": (value) => new Date(value),
 
-        'Artists': parseContributors,
-        'Contributors': parseContributors,
-        'Cover Artists': parseContributors,
+    Artists: parseContributors,
+    Contributors: parseContributors,
+    "Cover Artists": parseContributors,
 
-        'Additional Files': parseAdditionalFiles,
-    },
+    "Additional Files": parseAdditionalFiles,
+  },
 
-    propertyFieldMapping: {
-        name: 'Track',
+  propertyFieldMapping: {
+    name: "Track",
 
-        directory: 'Directory',
-        duration: 'Duration',
-        urls: 'URLs',
+    directory: "Directory",
+    duration: "Duration",
+    urls: "URLs",
 
-        coverArtDate: 'Cover Art Date',
-        coverArtFileExtension: 'Cover Art File Extension',
-        dateFirstReleased: 'Date First Released',
-        hasCoverArt: 'Has Cover Art',
-        hasURLs: 'Has URLs',
+    coverArtDate: "Cover Art Date",
+    coverArtFileExtension: "Cover Art File Extension",
+    dateFirstReleased: "Date First Released",
+    hasCoverArt: "Has Cover Art",
+    hasURLs: "Has URLs",
 
-        referencedTracksByRef: 'Referenced Tracks',
-        artistContribsByRef: 'Artists',
-        contributorContribsByRef: 'Contributors',
-        coverArtistContribsByRef: 'Cover Artists',
-        artTagsByRef: 'Art Tags',
-        originalReleaseTrackByRef: 'Originally Released As',
+    referencedTracksByRef: "Referenced Tracks",
+    artistContribsByRef: "Artists",
+    contributorContribsByRef: "Contributors",
+    coverArtistContribsByRef: "Cover Artists",
+    artTagsByRef: "Art Tags",
+    originalReleaseTrackByRef: "Originally Released As",
 
-        commentary: 'Commentary',
-        lyrics: 'Lyrics',
+    commentary: "Commentary",
+    lyrics: "Lyrics",
 
-        additionalFiles: 'Additional Files',
-    },
+    additionalFiles: "Additional Files",
+  },
 
-    ignoredFields: ['Sampled Tracks']
+  ignoredFields: ["Sampled Tracks"],
 });
 
 export const processArtistDocument = makeProcessDocument(Artist, {
-    propertyFieldMapping: {
-        name: 'Artist',
+  propertyFieldMapping: {
+    name: "Artist",
 
-        directory: 'Directory',
-        urls: 'URLs',
-        hasAvatar: 'Has Avatar',
-        avatarFileExtension: 'Avatar File Extension',
+    directory: "Directory",
+    urls: "URLs",
+    hasAvatar: "Has Avatar",
+    avatarFileExtension: "Avatar File Extension",
 
-        aliasNames: 'Aliases',
+    aliasNames: "Aliases",
 
-        contextNotes: 'Context Notes'
-    },
+    contextNotes: "Context Notes",
+  },
 
-    ignoredFields: ['Dead URLs']
+  ignoredFields: ["Dead URLs"],
 });
 
 export const processFlashDocument = makeProcessDocument(Flash, {
-    fieldTransformations: {
-        'Date': value => new Date(value),
+  fieldTransformations: {
+    Date: (value) => new Date(value),
 
-        'Contributors': parseContributors,
-    },
+    Contributors: parseContributors,
+  },
 
-    propertyFieldMapping: {
-        name: 'Flash',
+  propertyFieldMapping: {
+    name: "Flash",
 
-        directory: 'Directory',
-        page: 'Page',
-        date: 'Date',
-        coverArtFileExtension: 'Cover Art File Extension',
+    directory: "Directory",
+    page: "Page",
+    date: "Date",
+    coverArtFileExtension: "Cover Art File Extension",
 
-        featuredTracksByRef: 'Featured Tracks',
-        contributorContribsByRef: 'Contributors',
-        urls: 'URLs'
-    },
+    featuredTracksByRef: "Featured Tracks",
+    contributorContribsByRef: "Contributors",
+    urls: "URLs",
+  },
 });
 
 export const processFlashActDocument = makeProcessDocument(FlashAct, {
-    propertyFieldMapping: {
-        name: 'Act',
-        color: 'Color',
-        anchor: 'Anchor',
-        jump: 'Jump',
-        jumpColor: 'Jump Color'
-    }
+  propertyFieldMapping: {
+    name: "Act",
+    color: "Color",
+    anchor: "Anchor",
+    jump: "Jump",
+    jumpColor: "Jump Color",
+  },
 });
 
 export const processNewsEntryDocument = makeProcessDocument(NewsEntry, {
-    fieldTransformations: {
-        'Date': value => new Date(value)
-    },
-
-    propertyFieldMapping: {
-        name: 'Name',
-        directory: 'Directory',
-        date: 'Date',
-        content: 'Content',
-    }
+  fieldTransformations: {
+    Date: (value) => new Date(value),
+  },
+
+  propertyFieldMapping: {
+    name: "Name",
+    directory: "Directory",
+    date: "Date",
+    content: "Content",
+  },
 });
 
 export const processArtTagDocument = makeProcessDocument(ArtTag, {
-    propertyFieldMapping: {
-        name: 'Tag',
-        directory: 'Directory',
-        color: 'Color',
-        isContentWarning: 'Is CW'
-    }
+  propertyFieldMapping: {
+    name: "Tag",
+    directory: "Directory",
+    color: "Color",
+    isContentWarning: "Is CW",
+  },
 });
 
 export const processGroupDocument = makeProcessDocument(Group, {
-    propertyFieldMapping: {
-        name: 'Group',
-        directory: 'Directory',
-        description: 'Description',
-        urls: 'URLs',
-    }
+  propertyFieldMapping: {
+    name: "Group",
+    directory: "Directory",
+    description: "Description",
+    urls: "URLs",
+  },
 });
 
 export const processGroupCategoryDocument = makeProcessDocument(GroupCategory, {
-    propertyFieldMapping: {
-        name: 'Category',
-        color: 'Color',
-    }
+  propertyFieldMapping: {
+    name: "Category",
+    color: "Color",
+  },
 });
 
 export const processStaticPageDocument = makeProcessDocument(StaticPage, {
-    propertyFieldMapping: {
-        name: 'Name',
-        nameShort: 'Short Name',
-        directory: 'Directory',
+  propertyFieldMapping: {
+    name: "Name",
+    nameShort: "Short Name",
+    directory: "Directory",
 
-        content: 'Content',
-        stylesheet: 'Style',
+    content: "Content",
+    stylesheet: "Style",
 
-        showInNavigationBar: 'Show in Navigation Bar'
-    }
+    showInNavigationBar: "Show in Navigation Bar",
+  },
 });
 
 export const processWikiInfoDocument = makeProcessDocument(WikiInfo, {
-    propertyFieldMapping: {
-        name: 'Name',
-        nameShort: 'Short Name',
-        color: 'Color',
-        description: 'Description',
-        footerContent: 'Footer Content',
-        defaultLanguage: 'Default Language',
-        canonicalBase: 'Canonical Base',
-        divideTrackListsByGroupsByRef: 'Divide Track Lists By Groups',
-        enableFlashesAndGames: 'Enable Flashes & Games',
-        enableListings: 'Enable Listings',
-        enableNews: 'Enable News',
-        enableArtTagUI: 'Enable Art Tag UI',
-        enableGroupUI: 'Enable Group UI',
-    }
+  propertyFieldMapping: {
+    name: "Name",
+    nameShort: "Short Name",
+    color: "Color",
+    description: "Description",
+    footerContent: "Footer Content",
+    defaultLanguage: "Default Language",
+    canonicalBase: "Canonical Base",
+    divideTrackListsByGroupsByRef: "Divide Track Lists By Groups",
+    enableFlashesAndGames: "Enable Flashes & Games",
+    enableListings: "Enable Listings",
+    enableNews: "Enable News",
+    enableArtTagUI: "Enable Art Tag UI",
+    enableGroupUI: "Enable Group UI",
+  },
 });
 
-export const processHomepageLayoutDocument = makeProcessDocument(HomepageLayout, {
+export const processHomepageLayoutDocument = makeProcessDocument(
+  HomepageLayout,
+  {
     propertyFieldMapping: {
-        sidebarContent: 'Sidebar Content'
+      sidebarContent: "Sidebar Content",
     },
 
-    ignoredFields: ['Homepage']
-});
+    ignoredFields: ["Homepage"],
+  }
+);
 
 export function makeProcessHomepageLayoutRowDocument(rowClass, spec) {
-    return makeProcessDocument(rowClass, {
-        ...spec,
-
-        propertyFieldMapping: {
-            name: 'Row',
-            color: 'Color',
-            type: 'Type',
-            ...spec.propertyFieldMapping,
-        }
-    });
+  return makeProcessDocument(rowClass, {
+    ...spec,
+
+    propertyFieldMapping: {
+      name: "Row",
+      color: "Color",
+      type: "Type",
+      ...spec.propertyFieldMapping,
+    },
+  });
 }
 
 export const homepageLayoutRowTypeProcessMapping = {
-    albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, {
-        propertyFieldMapping: {
-            sourceGroupByRef: 'Group',
-            countAlbumsFromGroup: 'Count',
-            sourceAlbumsByRef: 'Albums',
-            actionLinks: 'Actions'
-        }
-    })
+  albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, {
+    propertyFieldMapping: {
+      sourceGroupByRef: "Group",
+      countAlbumsFromGroup: "Count",
+      sourceAlbumsByRef: "Albums",
+      actionLinks: "Actions",
+    },
+  }),
 };
 
 export function processHomepageLayoutRowDocument(document) {
-    const type = document['Type'];
+  const type = document["Type"];
 
-    const match = Object.entries(homepageLayoutRowTypeProcessMapping)
-        .find(([ key ]) => key === type);
+  const match = Object.entries(homepageLayoutRowTypeProcessMapping).find(
+    ([key]) => key === type
+  );
 
-    if (!match) {
-        throw new TypeError(`No processDocument function for row type ${type}!`);
-    }
+  if (!match) {
+    throw new TypeError(`No processDocument function for row type ${type}!`);
+  }
 
-    return match[1](document);
+  return match[1](document);
 }
 
 // --> Utilities shared across document parsing functions
 
 export function getDurationInSeconds(string) {
-    if (typeof string === 'number') {
-        return string;
-    }
-
-    if (typeof string !== 'string') {
-        throw new TypeError(`Expected a string or number, got ${string}`);
-    }
-
-    const parts = string.split(':').map(n => parseInt(n))
-    if (parts.length === 3) {
-        return parts[0] * 3600 + parts[1] * 60 + parts[2]
-    } else if (parts.length === 2) {
-        return parts[0] * 60 + parts[1]
-    } else {
-        return 0
-    }
+  if (typeof string === "number") {
+    return string;
+  }
+
+  if (typeof string !== "string") {
+    throw new TypeError(`Expected a string or number, got ${string}`);
+  }
+
+  const parts = string.split(":").map((n) => parseInt(n));
+  if (parts.length === 3) {
+    return parts[0] * 3600 + parts[1] * 60 + parts[2];
+  } else if (parts.length === 2) {
+    return parts[0] * 60 + parts[1];
+  } else {
+    return 0;
+  }
 }
 
 export function parseAdditionalFiles(array) {
-    if (!array) return null;
-    if (!Array.isArray(array)) {
-        // Error will be caught when validating against whatever this value is
-        return array;
-    }
-
-    return array.map(item => ({
-        title: item['Title'],
-        description: item['Description'] ?? null,
-        files: item['Files']
-    }));
+  if (!array) return null;
+  if (!Array.isArray(array)) {
+    // Error will be caught when validating against whatever this value is
+    return array;
+  }
+
+  return array.map((item) => ({
+    title: item["Title"],
+    description: item["Description"] ?? null,
+    files: item["Files"],
+  }));
 }
 
 export function parseCommentary(text) {
-    if (text) {
-        const lines = String(text).split('\n');
-        if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) {
-            return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`};
-        }
-        return text;
-    } else {
-        return null;
+  if (text) {
+    const lines = String(text).split("\n");
+    if (!lines[0].replace(/<\/b>/g, "").includes(":</i>")) {
+      return {
+        error: `An entry is missing commentary citation: "${lines[0].slice(
+          0,
+          40
+        )}..."`,
+      };
     }
+    return text;
+  } else {
+    return null;
+  }
 }
 
 export function parseContributors(contributors) {
-    if (!contributors) {
-        return null;
-    }
-
-    if (contributors.length === 1 && contributors[0].startsWith('<i>')) {
-        const arr = [];
-        arr.textContent = contributors[0];
-        return arr;
+  if (!contributors) {
+    return null;
+  }
+
+  if (contributors.length === 1 && contributors[0].startsWith("<i>")) {
+    const arr = [];
+    arr.textContent = contributors[0];
+    return arr;
+  }
+
+  contributors = contributors.map((contrib) => {
+    // 8asically, the format is "Who (What)", or just "Who". 8e sure to
+    // keep in mind that "what" doesn't necessarily have a value!
+    const match = contrib.match(/^(.*?)( \((.*)\))?$/);
+    if (!match) {
+      return contrib;
     }
+    const who = match[1];
+    const what = match[3] || null;
+    return { who, what };
+  });
 
-    contributors = contributors.map(contrib => {
-        // 8asically, the format is "Who (What)", or just "Who". 8e sure to
-        // keep in mind that "what" doesn't necessarily have a value!
-        const match = contrib.match(/^(.*?)( \((.*)\))?$/);
-        if (!match) {
-            return contrib;
-        }
-        const who = match[1];
-        const what = match[3] || null;
-        return {who, what};
-    });
-
-    const badContributor = contributors.find(val => typeof val === 'string');
-    if (badContributor) {
-        return {error: `An entry has an incorrectly formatted contributor, "${badContributor}".`};
-    }
+  const badContributor = contributors.find((val) => typeof val === "string");
+  if (badContributor) {
+    return {
+      error: `An entry has an incorrectly formatted contributor, "${badContributor}".`,
+    };
+  }
 
-    if (contributors.length === 1 && contributors[0].who === 'none') {
-        return null;
-    }
+  if (contributors.length === 1 && contributors[0].who === "none") {
+    return null;
+  }
 
-    return contributors;
+  return contributors;
 }
 
 function parseDimensions(string) {
-    if (!string) {
-        return null;
-    }
-
-    const parts = string.split(/[x,* ]+/g);
-    if (parts.length !== 2) throw new Error(`Invalid dimensions: ${string} (expected width & height)`);
-    const nums = parts.map(part => Number(part.trim()));
-    if (nums.includes(NaN)) throw new Error(`Invalid dimensions: ${string} (couldn't parse as numbers)`);
-    return nums;
+  if (!string) {
+    return null;
+  }
+
+  const parts = string.split(/[x,* ]+/g);
+  if (parts.length !== 2)
+    throw new Error(`Invalid dimensions: ${string} (expected width & height)`);
+  const nums = parts.map((part) => Number(part.trim()));
+  if (nums.includes(NaN))
+    throw new Error(
+      `Invalid dimensions: ${string} (couldn't parse as numbers)`
+    );
+  return nums;
 }
 
 // --> Data repository loading functions and descriptors
@@ -556,41 +578,41 @@ function parseDimensions(string) {
 // documentModes: Symbols indicating sets of behavior for loading and processing
 // data files.
 export const documentModes = {
-    // onePerFile: One document per file. Expects files array (or function) and
-    // processDocument function. Obviously, each specified data file should only
-    // contain one YAML document (an error will be thrown otherwise). Calls save
-    // with an array of processed documents (wiki objects).
-    onePerFile: Symbol('Document mode: onePerFile'),
-
-    // headerAndEntries: One or more documents per file; the first document is
-    // treated as a "header" and represents data which pertains to all following
-    // "entry" documents. Expects files array (or function) and
-    // processHeaderDocument and processEntryDocument functions. Calls save with
-    // an array of {header, entries} objects.
-    //
-    // Please note that the final results loaded from each file may be "missing"
-    // data objects corresponding to entry documents if the processEntryDocument
-    // function throws on any entries, resulting in partial data provided to
-    // save() - errors will be caught and thrown in the final buildSteps
-    // aggregate. However, if the processHeaderDocument function fails, all
-    // following documents in the same file will be ignored as well (i.e. an
-    // entire file will be excempt from the save() function's input).
-    headerAndEntries: Symbol('Document mode: headerAndEntries'),
-
-    // allInOne: One or more documents, all contained in one file. Expects file
-    // string (or function) and processDocument function. Calls save with an
-    // array of processed documents (wiki objects).
-    allInOne: Symbol('Document mode: allInOne'),
-
-    // oneDocumentTotal: Just a single document, represented in one file.
-    // Expects file string (or function) and processDocument function. Calls
-    // save with the single processed wiki document (data object).
-    //
-    // Please note that if the single document fails to process, the save()
-    // function won't be called at all, generally resulting in an altogether
-    // missing property from the global wikiData object. This should be caught
-    // and handled externally.
-    oneDocumentTotal: Symbol('Document mode: oneDocumentTotal'),
+  // onePerFile: One document per file. Expects files array (or function) and
+  // processDocument function. Obviously, each specified data file should only
+  // contain one YAML document (an error will be thrown otherwise). Calls save
+  // with an array of processed documents (wiki objects).
+  onePerFile: Symbol("Document mode: onePerFile"),
+
+  // headerAndEntries: One or more documents per file; the first document is
+  // treated as a "header" and represents data which pertains to all following
+  // "entry" documents. Expects files array (or function) and
+  // processHeaderDocument and processEntryDocument functions. Calls save with
+  // an array of {header, entries} objects.
+  //
+  // Please note that the final results loaded from each file may be "missing"
+  // data objects corresponding to entry documents if the processEntryDocument
+  // function throws on any entries, resulting in partial data provided to
+  // save() - errors will be caught and thrown in the final buildSteps
+  // aggregate. However, if the processHeaderDocument function fails, all
+  // following documents in the same file will be ignored as well (i.e. an
+  // entire file will be excempt from the save() function's input).
+  headerAndEntries: Symbol("Document mode: headerAndEntries"),
+
+  // allInOne: One or more documents, all contained in one file. Expects file
+  // string (or function) and processDocument function. Calls save with an
+  // array of processed documents (wiki objects).
+  allInOne: Symbol("Document mode: allInOne"),
+
+  // oneDocumentTotal: Just a single document, represented in one file.
+  // Expects file string (or function) and processDocument function. Calls
+  // save with the single processed wiki document (data object).
+  //
+  // Please note that if the single document fails to process, the save()
+  // function won't be called at all, generally resulting in an altogether
+  // missing property from the global wikiData object. This should be caught
+  // and handled externally.
+  oneDocumentTotal: Symbol("Document mode: oneDocumentTotal"),
 };
 
 // dataSteps: Top-level array of "steps" for loading YAML document files.
@@ -626,499 +648,559 @@ export const documentModes = {
 //   format depends on documentMode.
 //
 export const dataSteps = [
-    {
-        title: `Process wiki info file`,
-        file: WIKI_INFO_FILE,
+  {
+    title: `Process wiki info file`,
+    file: WIKI_INFO_FILE,
 
-        documentMode: documentModes.oneDocumentTotal,
-        processDocument: processWikiInfoDocument,
+    documentMode: documentModes.oneDocumentTotal,
+    processDocument: processWikiInfoDocument,
 
-        save(wikiInfo) {
-            if (!wikiInfo) {
-                return;
-            }
+    save(wikiInfo) {
+      if (!wikiInfo) {
+        return;
+      }
 
-            return {wikiInfo};
-        }
+      return { wikiInfo };
+    },
+  },
+
+  {
+    title: `Process album files`,
+    files: async (dataPath) =>
+      (
+        await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY), {
+          filter: (f) => path.extname(f) === ".yaml",
+          joinParentDirectory: false,
+        })
+      ).map((file) => path.join(DATA_ALBUM_DIRECTORY, file)),
+
+    documentMode: documentModes.headerAndEntries,
+    processHeaderDocument: processAlbumDocument,
+    processEntryDocument(document) {
+      return "Group" in document
+        ? processTrackGroupDocument(document)
+        : processTrackDocument(document);
     },
 
-    {
-        title: `Process album files`,
-        files: async dataPath => (
-            (await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY), {
-                filter: f => path.extname(f) === '.yaml',
-                joinParentDirectory: false
-            })).map(file => path.join(DATA_ALBUM_DIRECTORY, file))),
-
-        documentMode: documentModes.headerAndEntries,
-        processHeaderDocument: processAlbumDocument,
-        processEntryDocument(document) {
-            return ('Group' in document
-                ? processTrackGroupDocument(document)
-                : processTrackDocument(document));
-        },
-
-        save(results) {
-            const albumData = [];
-            const trackData = [];
-
-            for (const { header: album, entries } of results) {
-                // We can't mutate an array once it's set as a property
-                // value, so prepare the tracks and track groups that will
-                // show up in a track list all the way before actually
-                // applying them.
-                const trackGroups = [];
-                let currentTracksByRef = null;
-                let currentTrackGroup = null;
-
-                const albumRef = Thing.getReference(album);
-
-                function closeCurrentTrackGroup() {
-                    if (currentTracksByRef) {
-                        let trackGroup;
-
-                        if (currentTrackGroup) {
-                            trackGroup = currentTrackGroup;
-                        } else {
-                            trackGroup = new TrackGroup();
-                            trackGroup.name = `Default Track Group`;
-                            trackGroup.isDefaultTrackGroup = true;
-                        }
-
-                        trackGroup.album = album;
-                        trackGroup.tracksByRef = currentTracksByRef;
-                        trackGroups.push(trackGroup);
-                    }
-                }
+    save(results) {
+      const albumData = [];
+      const trackData = [];
 
-                for (const entry of entries) {
-                    if (entry instanceof TrackGroup) {
-                        closeCurrentTrackGroup();
-                        currentTracksByRef = [];
-                        currentTrackGroup = entry;
-                        continue;
-                    }
+      for (const { header: album, entries } of results) {
+        // We can't mutate an array once it's set as a property
+        // value, so prepare the tracks and track groups that will
+        // show up in a track list all the way before actually
+        // applying them.
+        const trackGroups = [];
+        let currentTracksByRef = null;
+        let currentTrackGroup = null;
 
-                    trackData.push(entry);
+        const albumRef = Thing.getReference(album);
 
-                    entry.dataSourceAlbumByRef = albumRef;
+        function closeCurrentTrackGroup() {
+          if (currentTracksByRef) {
+            let trackGroup;
 
-                    const trackRef = Thing.getReference(entry);
-                    if (currentTracksByRef) {
-                        currentTracksByRef.push(trackRef);
-                    } else {
-                        currentTracksByRef = [trackRef];
-                    }
-                }
-
-                closeCurrentTrackGroup();
-
-                album.trackGroups = trackGroups;
-                albumData.push(album);
+            if (currentTrackGroup) {
+              trackGroup = currentTrackGroup;
+            } else {
+              trackGroup = new TrackGroup();
+              trackGroup.name = `Default Track Group`;
+              trackGroup.isDefaultTrackGroup = true;
             }
 
-            return {albumData, trackData};
+            trackGroup.album = album;
+            trackGroup.tracksByRef = currentTracksByRef;
+            trackGroups.push(trackGroup);
+          }
         }
-    },
 
-    {
-        title: `Process artists file`,
-        file: ARTIST_DATA_FILE,
-
-        documentMode: documentModes.allInOne,
-        processDocument: processArtistDocument,
-
-        save(results) {
-            const artistData = results;
-
-            const artistAliasData = results.flatMap(artist => {
-                const origRef = Thing.getReference(artist);
-                return (artist.aliasNames?.map(name => {
-                    const alias = new Artist();
-                    alias.name = name;
-                    alias.isAlias = true;
-                    alias.aliasedArtistRef = origRef;
-                    alias.artistData = artistData;
-                    return alias;
-                }) ?? []);
-            });
+        for (const entry of entries) {
+          if (entry instanceof TrackGroup) {
+            closeCurrentTrackGroup();
+            currentTracksByRef = [];
+            currentTrackGroup = entry;
+            continue;
+          }
 
-            return {artistData, artistAliasData};
-        }
-    },
+          trackData.push(entry);
 
-    // TODO: WD.wikiInfo.enableFlashesAndGames &&
-    {
-        title: `Process flashes file`,
-        file: FLASH_DATA_FILE,
+          entry.dataSourceAlbumByRef = albumRef;
 
-        documentMode: documentModes.allInOne,
-        processDocument(document) {
-            return ('Act' in document
-                ? processFlashActDocument(document)
-                : processFlashDocument(document));
-        },
+          const trackRef = Thing.getReference(entry);
+          if (currentTracksByRef) {
+            currentTracksByRef.push(trackRef);
+          } else {
+            currentTracksByRef = [trackRef];
+          }
+        }
 
-        save(results) {
-            let flashAct;
-            let flashesByRef = [];
+        closeCurrentTrackGroup();
 
-            if (results[0] && !(results[0] instanceof FlashAct)) {
-                throw new Error(`Expected an act at top of flash data file`);
-            }
+        album.trackGroups = trackGroups;
+        albumData.push(album);
+      }
 
-            for (const thing of results) {
-                if (thing instanceof FlashAct) {
-                    if (flashAct) {
-                        Object.assign(flashAct, {flashesByRef});
-                    }
+      return { albumData, trackData };
+    },
+  },
+
+  {
+    title: `Process artists file`,
+    file: ARTIST_DATA_FILE,
+
+    documentMode: documentModes.allInOne,
+    processDocument: processArtistDocument,
+
+    save(results) {
+      const artistData = results;
+
+      const artistAliasData = results.flatMap((artist) => {
+        const origRef = Thing.getReference(artist);
+        return (
+          artist.aliasNames?.map((name) => {
+            const alias = new Artist();
+            alias.name = name;
+            alias.isAlias = true;
+            alias.aliasedArtistRef = origRef;
+            alias.artistData = artistData;
+            return alias;
+          }) ?? []
+        );
+      });
+
+      return { artistData, artistAliasData };
+    },
+  },
+
+  // TODO: WD.wikiInfo.enableFlashesAndGames &&
+  {
+    title: `Process flashes file`,
+    file: FLASH_DATA_FILE,
+
+    documentMode: documentModes.allInOne,
+    processDocument(document) {
+      return "Act" in document
+        ? processFlashActDocument(document)
+        : processFlashDocument(document);
+    },
 
-                    flashAct = thing;
-                    flashesByRef = [];
-                } else {
-                    flashesByRef.push(Thing.getReference(thing));
-                }
-            }
+    save(results) {
+      let flashAct;
+      let flashesByRef = [];
 
-            if (flashAct) {
-                Object.assign(flashAct, {flashesByRef});
-            }
+      if (results[0] && !(results[0] instanceof FlashAct)) {
+        throw new Error(`Expected an act at top of flash data file`);
+      }
 
-            const flashData = results.filter(x => x instanceof Flash);
-            const flashActData = results.filter(x => x instanceof FlashAct);
+      for (const thing of results) {
+        if (thing instanceof FlashAct) {
+          if (flashAct) {
+            Object.assign(flashAct, { flashesByRef });
+          }
 
-            return {flashData, flashActData};
+          flashAct = thing;
+          flashesByRef = [];
+        } else {
+          flashesByRef.push(Thing.getReference(thing));
         }
-    },
+      }
 
-    {
-        title: `Process groups file`,
-        file: GROUP_DATA_FILE,
+      if (flashAct) {
+        Object.assign(flashAct, { flashesByRef });
+      }
 
-        documentMode: documentModes.allInOne,
-        processDocument(document) {
-            return ('Category' in document
-                ? processGroupCategoryDocument(document)
-                : processGroupDocument(document));
-        },
+      const flashData = results.filter((x) => x instanceof Flash);
+      const flashActData = results.filter((x) => x instanceof FlashAct);
 
-        save(results) {
-            let groupCategory;
-            let groupsByRef = [];
+      return { flashData, flashActData };
+    },
+  },
 
-            if (results[0] && !(results[0] instanceof GroupCategory)) {
-                throw new Error(`Expected a category at top of group data file`);
-            }
+  {
+    title: `Process groups file`,
+    file: GROUP_DATA_FILE,
 
-            for (const thing of results) {
-                if (thing instanceof GroupCategory) {
-                    if (groupCategory) {
-                        Object.assign(groupCategory, {groupsByRef});
-                    }
+    documentMode: documentModes.allInOne,
+    processDocument(document) {
+      return "Category" in document
+        ? processGroupCategoryDocument(document)
+        : processGroupDocument(document);
+    },
 
-                    groupCategory = thing;
-                    groupsByRef = [];
-                } else {
-                    groupsByRef.push(Thing.getReference(thing));
-                }
-            }
+    save(results) {
+      let groupCategory;
+      let groupsByRef = [];
 
-            if (groupCategory) {
-                Object.assign(groupCategory, {groupsByRef});
-            }
+      if (results[0] && !(results[0] instanceof GroupCategory)) {
+        throw new Error(`Expected a category at top of group data file`);
+      }
 
-            const groupData = results.filter(x => x instanceof Group);
-            const groupCategoryData = results.filter(x => x instanceof GroupCategory);
+      for (const thing of results) {
+        if (thing instanceof GroupCategory) {
+          if (groupCategory) {
+            Object.assign(groupCategory, { groupsByRef });
+          }
 
-            return {groupData, groupCategoryData};
+          groupCategory = thing;
+          groupsByRef = [];
+        } else {
+          groupsByRef.push(Thing.getReference(thing));
         }
+      }
+
+      if (groupCategory) {
+        Object.assign(groupCategory, { groupsByRef });
+      }
+
+      const groupData = results.filter((x) => x instanceof Group);
+      const groupCategoryData = results.filter(
+        (x) => x instanceof GroupCategory
+      );
+
+      return { groupData, groupCategoryData };
     },
+  },
 
-    {
-        title: `Process homepage layout file`,
-        files: [HOMEPAGE_LAYOUT_DATA_FILE],
+  {
+    title: `Process homepage layout file`,
+    files: [HOMEPAGE_LAYOUT_DATA_FILE],
 
-        documentMode: documentModes.headerAndEntries,
-        processHeaderDocument: processHomepageLayoutDocument,
-        processEntryDocument: processHomepageLayoutRowDocument,
+    documentMode: documentModes.headerAndEntries,
+    processHeaderDocument: processHomepageLayoutDocument,
+    processEntryDocument: processHomepageLayoutRowDocument,
 
-        save(results) {
-            if (!results[0]) {
-                return;
-            }
+    save(results) {
+      if (!results[0]) {
+        return;
+      }
 
-            const { header: homepageLayout, entries: rows } = results[0];
-            Object.assign(homepageLayout, {rows});
-            return {homepageLayout};
-        }
+      const { header: homepageLayout, entries: rows } = results[0];
+      Object.assign(homepageLayout, { rows });
+      return { homepageLayout };
     },
+  },
 
-    // TODO: WD.wikiInfo.enableNews &&
-    {
-        title: `Process news data file`,
-        file: NEWS_DATA_FILE,
+  // TODO: WD.wikiInfo.enableNews &&
+  {
+    title: `Process news data file`,
+    file: NEWS_DATA_FILE,
 
-        documentMode: documentModes.allInOne,
-        processDocument: processNewsEntryDocument,
+    documentMode: documentModes.allInOne,
+    processDocument: processNewsEntryDocument,
 
-        save(newsData) {
-            sortChronologically(newsData);
-            newsData.reverse();
+    save(newsData) {
+      sortChronologically(newsData);
+      newsData.reverse();
 
-            return {newsData};
-        }
+      return { newsData };
     },
+  },
 
-    {
-        title: `Process art tags file`,
-        file: ART_TAG_DATA_FILE,
+  {
+    title: `Process art tags file`,
+    file: ART_TAG_DATA_FILE,
 
-        documentMode: documentModes.allInOne,
-        processDocument: processArtTagDocument,
+    documentMode: documentModes.allInOne,
+    processDocument: processArtTagDocument,
 
-        save(artTagData) {
-            sortAlphabetically(artTagData);
+    save(artTagData) {
+      sortAlphabetically(artTagData);
 
-            return {artTagData};
-        }
+      return { artTagData };
     },
+  },
 
-    {
-        title: `Process static pages file`,
-        file: STATIC_PAGE_DATA_FILE,
+  {
+    title: `Process static pages file`,
+    file: STATIC_PAGE_DATA_FILE,
 
-        documentMode: documentModes.allInOne,
-        processDocument: processStaticPageDocument,
+    documentMode: documentModes.allInOne,
+    processDocument: processStaticPageDocument,
 
-        save(staticPageData) {
-            return {staticPageData};
-        }
+    save(staticPageData) {
+      return { staticPageData };
     },
+  },
 ];
 
-export async function loadAndProcessDataDocuments({
-    dataPath,
-}) {
-    const processDataAggregate = openAggregate({message: `Errors processing data files`});
-    const wikiDataResult = {};
-
-    function decorateErrorWithFile(fn) {
-        return (x, index, array) => {
-            try {
-                return fn(x, index, array);
-            } catch (error) {
-                error.message += (
-                    (error.message.includes('\n') ? '\n' : ' ') +
-                    `(file: ${color.bright(color.blue(path.relative(dataPath, x.file)))})`
-                );
-                throw error;
-            }
-        };
-    }
+export async function loadAndProcessDataDocuments({ dataPath }) {
+  const processDataAggregate = openAggregate({
+    message: `Errors processing data files`,
+  });
+  const wikiDataResult = {};
+
+  function decorateErrorWithFile(fn) {
+    return (x, index, array) => {
+      try {
+        return fn(x, index, array);
+      } catch (error) {
+        error.message +=
+          (error.message.includes("\n") ? "\n" : " ") +
+          `(file: ${color.bright(
+            color.blue(path.relative(dataPath, x.file))
+          )})`;
+        throw error;
+      }
+    };
+  }
 
-    for (const dataStep of dataSteps) {
-        await processDataAggregate.nestAsync(
-            {message: `Errors during data step: ${dataStep.title}`},
-            async ({call, callAsync, map, mapAsync, nest}) => {
-                const { documentMode } = dataStep;
+  for (const dataStep of dataSteps) {
+    await processDataAggregate.nestAsync(
+      { message: `Errors during data step: ${dataStep.title}` },
+      async ({ call, callAsync, map, mapAsync, nest }) => {
+        const { documentMode } = dataStep;
 
-                if (!(Object.values(documentModes).includes(documentMode))) {
-                    throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
-                }
+        if (!Object.values(documentModes).includes(documentMode)) {
+          throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
+        }
 
-                if (documentMode === documentModes.allInOne || documentMode === documentModes.oneDocumentTotal) {
-                    if (!dataStep.file) {
-                        throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
-                    }
+        if (
+          documentMode === documentModes.allInOne ||
+          documentMode === documentModes.oneDocumentTotal
+        ) {
+          if (!dataStep.file) {
+            throw new Error(
+              `Expected 'file' property for ${documentMode.toString()}`
+            );
+          }
+
+          const file = path.join(
+            dataPath,
+            typeof dataStep.file === "function"
+              ? await callAsync(dataStep.file, dataPath)
+              : dataStep.file
+          );
 
-                    const file = path.join(dataPath,
-                        (typeof dataStep.file === 'function'
-                            ? await callAsync(dataStep.file, dataPath)
-                            : dataStep.file));
+          const readResult = await callAsync(readFile, file, "utf-8");
 
-                    const readResult = await callAsync(readFile, file, 'utf-8');
+          if (!readResult) {
+            return;
+          }
 
-                    if (!readResult) {
-                        return;
-                    }
+          const yamlResult =
+            documentMode === documentModes.oneDocumentTotal
+              ? call(yaml.load, readResult)
+              : call(yaml.loadAll, readResult);
 
-                    const yamlResult = (documentMode === documentModes.oneDocumentTotal
-                        ? call(yaml.load, readResult)
-                        : call(yaml.loadAll, readResult));
+          if (!yamlResult) {
+            return;
+          }
 
-                    if (!yamlResult) {
-                        return;
-                    }
+          let processResults;
 
-                    let processResults;
-
-                    if (documentMode === documentModes.oneDocumentTotal) {
-                        nest({message: `Errors processing document`}, ({ call }) => {
-                            processResults = call(dataStep.processDocument, yamlResult);
-                        });
-                    } else {
-                        const { result, aggregate } = mapAggregate(
-                            yamlResult,
-                            decorateErrorWithIndex(dataStep.processDocument),
-                            {message: `Errors processing documents`}
-                        );
-                        processResults = result;
-                        call(aggregate.close);
-                    }
+          if (documentMode === documentModes.oneDocumentTotal) {
+            nest({ message: `Errors processing document` }, ({ call }) => {
+              processResults = call(dataStep.processDocument, yamlResult);
+            });
+          } else {
+            const { result, aggregate } = mapAggregate(
+              yamlResult,
+              decorateErrorWithIndex(dataStep.processDocument),
+              { message: `Errors processing documents` }
+            );
+            processResults = result;
+            call(aggregate.close);
+          }
 
-                    if (!processResults) return;
+          if (!processResults) return;
 
-                    const saveResult = call(dataStep.save, processResults);
+          const saveResult = call(dataStep.save, processResults);
 
-                    if (!saveResult) return;
+          if (!saveResult) return;
 
-                    Object.assign(wikiDataResult, saveResult);
+          Object.assign(wikiDataResult, saveResult);
 
-                    return;
-                }
+          return;
+        }
+
+        if (!dataStep.files) {
+          throw new Error(
+            `Expected 'files' property for ${documentMode.toString()}`
+          );
+        }
 
-                if (!dataStep.files) {
-                    throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
+        const files = (
+          typeof dataStep.files === "function"
+            ? await callAsync(dataStep.files, dataPath)
+            : dataStep.files
+        ).map((file) => path.join(dataPath, file));
+
+        const readResults = await mapAsync(
+          files,
+          (file) =>
+            readFile(file, "utf-8").then((contents) => ({ file, contents })),
+          { message: `Errors reading data files` }
+        );
+
+        const yamlResults = map(
+          readResults,
+          decorateErrorWithFile(({ file, contents }) => ({
+            file,
+            documents: yaml.loadAll(contents),
+          })),
+          { message: `Errors parsing data files as valid YAML` }
+        );
+
+        let processResults;
+
+        if (documentMode === documentModes.headerAndEntries) {
+          nest(
+            { message: `Errors processing data files as valid documents` },
+            ({ call, map }) => {
+              processResults = [];
+
+              yamlResults.forEach(({ file, documents }) => {
+                const [headerDocument, ...entryDocuments] = documents;
+
+                const header = call(
+                  decorateErrorWithFile(({ document }) =>
+                    dataStep.processHeaderDocument(document)
+                  ),
+                  { file, document: headerDocument }
+                );
+
+                // Don't continue processing files whose header
+                // document is invalid - the entire file is excempt
+                // from data in this case.
+                if (!header) {
+                  return;
                 }
 
-                const files = (
-                    (typeof dataStep.files === 'function'
-                        ? await callAsync(dataStep.files, dataPath)
-                        : dataStep.files)
-                    .map(file => path.join(dataPath, file)));
-
-                const readResults = await mapAsync(
-                    files,
-                    file => (readFile(file, 'utf-8')
-                        .then(contents => ({file, contents}))),
-                    {message: `Errors reading data files`});
-
-                const yamlResults = map(
-                    readResults,
-                    decorateErrorWithFile(
-                        ({ file, contents }) => ({file, documents: yaml.loadAll(contents)})),
-                    {message: `Errors parsing data files as valid YAML`});
-
-                let processResults;
-
-                if (documentMode === documentModes.headerAndEntries) {
-                    nest({message: `Errors processing data files as valid documents`}, ({ call, map }) => {
-                        processResults = [];
-
-                        yamlResults.forEach(({ file, documents }) => {
-                            const [ headerDocument, ...entryDocuments ] = documents;
-
-                            const header = call(
-                                decorateErrorWithFile(
-                                    ({ document }) => dataStep.processHeaderDocument(document)),
-                                {file, document: headerDocument});
-
-                            // Don't continue processing files whose header
-                            // document is invalid - the entire file is excempt
-                            // from data in this case.
-                            if (!header) {
-                                return;
-                            }
-
-                            const entries = map(
-                                entryDocuments.map(document => ({file, document})),
-                                decorateErrorWithFile(
-                                    decorateErrorWithIndex(
-                                        ({ document }) => dataStep.processEntryDocument(document))),
-                                {message: `Errors processing entry documents`});
-
-                            // Entries may be incomplete (i.e. any errored
-                            // documents won't have a processed output
-                            // represented here) - this is intentional! By
-                            // principle, partial output is preferred over
-                            // erroring an entire file.
-                            processResults.push({header, entries});
-                        });
-                    });
+                const entries = map(
+                  entryDocuments.map((document) => ({ file, document })),
+                  decorateErrorWithFile(
+                    decorateErrorWithIndex(({ document }) =>
+                      dataStep.processEntryDocument(document)
+                    )
+                  ),
+                  { message: `Errors processing entry documents` }
+                );
+
+                // Entries may be incomplete (i.e. any errored
+                // documents won't have a processed output
+                // represented here) - this is intentional! By
+                // principle, partial output is preferred over
+                // erroring an entire file.
+                processResults.push({ header, entries });
+              });
+            }
+          );
+        }
+
+        if (documentMode === documentModes.onePerFile) {
+          nest(
+            { message: `Errors processing data files as valid documents` },
+            ({ call, map }) => {
+              processResults = [];
+
+              yamlResults.forEach(({ file, documents }) => {
+                if (documents.length > 1) {
+                  call(
+                    decorateErrorWithFile(() => {
+                      throw new Error(
+                        `Only expected one document to be present per file`
+                      );
+                    })
+                  );
+                  return;
                 }
 
-                if (documentMode === documentModes.onePerFile) {
-                    nest({message: `Errors processing data files as valid documents`}, ({ call, map }) => {
-                        processResults = [];
-
-                        yamlResults.forEach(({ file, documents }) => {
-                            if (documents.length > 1) {
-                                call(decorateErrorWithFile(() => {
-                                    throw new Error(`Only expected one document to be present per file`);
-                                }));
-                                return;
-                            }
-
-                            const result = call(
-                                decorateErrorWithFile(
-                                    ({ document }) => dataStep.processDocument(document)),
-                                {file, document: documents[0]});
-
-                            if (!result) {
-                                return;
-                            }
-
-                            processResults.push(result);
-                        });
-                    });
+                const result = call(
+                  decorateErrorWithFile(({ document }) =>
+                    dataStep.processDocument(document)
+                  ),
+                  { file, document: documents[0] }
+                );
+
+                if (!result) {
+                  return;
                 }
 
-                const saveResult = call(dataStep.save, processResults);
+                processResults.push(result);
+              });
+            }
+          );
+        }
 
-                if (!saveResult) return;
+        const saveResult = call(dataStep.save, processResults);
 
-                Object.assign(wikiDataResult, saveResult);
-            });
-    }
+        if (!saveResult) return;
 
-    return {
-        aggregate: processDataAggregate,
-        result: wikiDataResult
-    };
+        Object.assign(wikiDataResult, saveResult);
+      }
+    );
+  }
+
+  return {
+    aggregate: processDataAggregate,
+    result: wikiDataResult,
+  };
 }
 
 // Data linking! Basically, provide (portions of) wikiData to the Things which
 // require it - they'll expose dynamically computed properties as a result (many
 // of which are required for page HTML generation).
 export function linkWikiDataArrays(wikiData) {
-    function assignWikiData(things, ...keys) {
-        for (let i = 0; i < things.length; i++) {
-            for (let j = 0; j < keys.length; j++) {
-                const key = keys[j];
-                things[i][key] = wikiData[key];
-            }
-        }
+  function assignWikiData(things, ...keys) {
+    for (let i = 0; i < things.length; i++) {
+      for (let j = 0; j < keys.length; j++) {
+        const key = keys[j];
+        things[i][key] = wikiData[key];
+      }
     }
-
-    const WD = wikiData;
-
-    assignWikiData([WD.wikiInfo], 'groupData');
-
-    assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData', 'trackData');
-    WD.albumData.forEach(album => assignWikiData(album.trackGroups, 'trackData'));
-
-    assignWikiData(WD.trackData, 'albumData', 'artistData', 'artTagData', 'flashData', 'trackData');
-    assignWikiData(WD.artistData, 'albumData', 'artistData', 'flashData', 'trackData');
-    assignWikiData(WD.groupData, 'albumData', 'groupCategoryData');
-    assignWikiData(WD.groupCategoryData, 'groupData');
-    assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData');
-    assignWikiData(WD.flashActData, 'flashData');
-    assignWikiData(WD.artTagData, 'albumData', 'trackData');
-    assignWikiData(WD.homepageLayout.rows, 'albumData', 'groupData');
+  }
+
+  const WD = wikiData;
+
+  assignWikiData([WD.wikiInfo], "groupData");
+
+  assignWikiData(
+    WD.albumData,
+    "artistData",
+    "artTagData",
+    "groupData",
+    "trackData"
+  );
+  WD.albumData.forEach((album) =>
+    assignWikiData(album.trackGroups, "trackData")
+  );
+
+  assignWikiData(
+    WD.trackData,
+    "albumData",
+    "artistData",
+    "artTagData",
+    "flashData",
+    "trackData"
+  );
+  assignWikiData(
+    WD.artistData,
+    "albumData",
+    "artistData",
+    "flashData",
+    "trackData"
+  );
+  assignWikiData(WD.groupData, "albumData", "groupCategoryData");
+  assignWikiData(WD.groupCategoryData, "groupData");
+  assignWikiData(WD.flashData, "artistData", "flashActData", "trackData");
+  assignWikiData(WD.flashActData, "flashData");
+  assignWikiData(WD.artTagData, "albumData", "trackData");
+  assignWikiData(WD.homepageLayout.rows, "albumData", "groupData");
 }
 
 export function sortWikiDataArrays(wikiData) {
-    Object.assign(wikiData, {
-        albumData: sortChronologically(wikiData.albumData.slice()),
-        trackData: sortAlbumsTracksChronologically(wikiData.trackData.slice()),
-    });
-
-    // Re-link data arrays, so that every object has the new, sorted versions.
-    // Note that the sorting step deliberately creates new arrays (mutating
-    // slices instead of the original arrays) - this is so that the object
-    // caching system understands that it's working with a new ordering.
-    // We still need to actually provide those updated arrays over again!
-    linkWikiDataArrays(wikiData);
+  Object.assign(wikiData, {
+    albumData: sortChronologically(wikiData.albumData.slice()),
+    trackData: sortAlbumsTracksChronologically(wikiData.trackData.slice()),
+  });
+
+  // Re-link data arrays, so that every object has the new, sorted versions.
+  // Note that the sorting step deliberately creates new arrays (mutating
+  // slices instead of the original arrays) - this is so that the object
+  // caching system understands that it's working with a new ordering.
+  // We still need to actually provide those updated arrays over again!
+  linkWikiDataArrays(wikiData);
 }
 
 // Warn about directories which are reused across more than one of the same type
@@ -1128,63 +1210,76 @@ export function sortWikiDataArrays(wikiData) {
 // two tracks share the directory "megalovania", they'll both be skipped for the
 // build, for example).
 export function filterDuplicateDirectories(wikiData) {
-    const deduplicateSpec = [
-        'albumData',
-        'artTagData',
-        'flashData',
-        'groupData',
-        'newsData',
-        'trackData',
-    ];
-
-    const aggregate = openAggregate({message: `Duplicate directories found`});
-    for (const thingDataProp of deduplicateSpec) {
-        const thingData = wikiData[thingDataProp];
-        aggregate.nest({message: `Duplicate directories found in ${color.green('wikiData.' + thingDataProp)}`}, ({ call }) => {
-            const directoryPlaces = Object.create(null);
-            const duplicateDirectories = [];
-            for (const thing of thingData) {
-                const { directory } = thing;
-                if (directory in directoryPlaces) {
-                    directoryPlaces[directory].push(thing);
-                    duplicateDirectories.push(directory);
-                } else {
-                    directoryPlaces[directory] = [thing];
-                }
-            }
-            if (!duplicateDirectories.length) return;
-            duplicateDirectories.sort((a, b) => {
-                const aL = a.toLowerCase();
-                const bL = b.toLowerCase();
-                return aL < bL ? -1 : aL > bL ? 1 : 0;
-            });
-            for (const directory of duplicateDirectories) {
-                const places = directoryPlaces[directory];
-                call(() => {
-                    throw new Error(`Duplicate directory ${color.green(directory)}:\n` +
-                        places.map(thing => ` - ` + inspect(thing)).join('\n'));
-                });
-            }
-            const allDuplicatedThings = Object.values(directoryPlaces).filter(arr => arr.length > 1).flat();
-            const filteredThings = thingData.filter(thing => !allDuplicatedThings.includes(thing));
-            wikiData[thingDataProp] = filteredThings;
+  const deduplicateSpec = [
+    "albumData",
+    "artTagData",
+    "flashData",
+    "groupData",
+    "newsData",
+    "trackData",
+  ];
+
+  const aggregate = openAggregate({ message: `Duplicate directories found` });
+  for (const thingDataProp of deduplicateSpec) {
+    const thingData = wikiData[thingDataProp];
+    aggregate.nest(
+      {
+        message: `Duplicate directories found in ${color.green(
+          "wikiData." + thingDataProp
+        )}`,
+      },
+      ({ call }) => {
+        const directoryPlaces = Object.create(null);
+        const duplicateDirectories = [];
+        for (const thing of thingData) {
+          const { directory } = thing;
+          if (directory in directoryPlaces) {
+            directoryPlaces[directory].push(thing);
+            duplicateDirectories.push(directory);
+          } else {
+            directoryPlaces[directory] = [thing];
+          }
+        }
+        if (!duplicateDirectories.length) return;
+        duplicateDirectories.sort((a, b) => {
+          const aL = a.toLowerCase();
+          const bL = b.toLowerCase();
+          return aL < bL ? -1 : aL > bL ? 1 : 0;
         });
-    }
-
-    // TODO: This code closes the aggregate but it generally gets closed again
-    // by the caller. This works but it might be weird to assume closing an
-    // aggregate twice is okay, maybe there's a better solution? Expose a new
-    // function on aggregates for checking if it *would* error?
-    // (i.e: errors.length > 0)
-    try {
-        aggregate.close();
-    } catch (error) {
-        // Duplicate entries were found and filtered out, resulting in altered
-        // wikiData arrays. These must be re-linked so objects receive the new
-        // data.
-        linkWikiDataArrays(wikiData);
-    }
-    return aggregate;
+        for (const directory of duplicateDirectories) {
+          const places = directoryPlaces[directory];
+          call(() => {
+            throw new Error(
+              `Duplicate directory ${color.green(directory)}:\n` +
+                places.map((thing) => ` - ` + inspect(thing)).join("\n")
+            );
+          });
+        }
+        const allDuplicatedThings = Object.values(directoryPlaces)
+          .filter((arr) => arr.length > 1)
+          .flat();
+        const filteredThings = thingData.filter(
+          (thing) => !allDuplicatedThings.includes(thing)
+        );
+        wikiData[thingDataProp] = filteredThings;
+      }
+    );
+  }
+
+  // TODO: This code closes the aggregate but it generally gets closed again
+  // by the caller. This works but it might be weird to assume closing an
+  // aggregate twice is okay, maybe there's a better solution? Expose a new
+  // function on aggregates for checking if it *would* error?
+  // (i.e: errors.length > 0)
+  try {
+    aggregate.close();
+  } catch (error) {
+    // Duplicate entries were found and filtered out, resulting in altered
+    // wikiData arrays. These must be re-linked so objects receive the new
+    // data.
+    linkWikiDataArrays(wikiData);
+  }
+  return aggregate;
 }
 
 // Warn about references across data which don't match anything.  This involves
@@ -1193,102 +1288,166 @@ export function filterDuplicateDirectories(wikiData) {
 // any errors). At the same time, we remove errored references from the thing's
 // data array.
 export function filterReferenceErrors(wikiData) {
-    const referenceSpec = [
-        ['wikiInfo', {
-            divideTrackListsByGroupsByRef: 'group',
-        }],
-
-        ['albumData', {
-            artistContribsByRef: '_contrib',
-            coverArtistContribsByRef: '_contrib',
-            trackCoverArtistContribsByRef: '_contrib',
-            wallpaperArtistContribsByRef: '_contrib',
-            bannerArtistContribsByRef: '_contrib',
-            groupsByRef: 'group',
-            artTagsByRef: 'artTag',
-        }],
-
-        ['trackData', {
-            artistContribsByRef: '_contrib',
-            contributorContribsByRef: '_contrib',
-            coverArtistContribsByRef: '_contrib',
-            referencedTracksByRef: 'track',
-            artTagsByRef: 'artTag',
-            originalReleaseTrackByRef: 'track',
-        }],
-
-        ['groupCategoryData', {
-            groupsByRef: 'group',
-        }],
-
-        ['homepageLayout.rows', {
-            sourceGroupsByRef: 'group',
-            sourceAlbumsByRef: 'album',
-        }],
-
-        ['flashData', {
-            contributorContribsByRef: '_contrib',
-            featuredTracksByRef: 'track',
-        }],
-
-        ['flashActData', {
-            flashesByRef: 'flash',
-        }],
-    ];
-
-    function getNestedProp(obj, key) {
-        const recursive = (o, k) => (k.length === 1
-            ? o[k[0]]
-            : recursive(o[k[0]], k.slice(1)));
-        const keys = key.split(/(?<=(?<!\\)(?:\\\\)*)\./);
-        return recursive(obj, keys);
-    }
-
-    const aggregate = openAggregate({message: `Errors validating between-thing references in data`});
-    const boundFind = bindFind(wikiData, {mode: 'error'});
-    for (const [ thingDataProp, propSpec ] of referenceSpec) {
-        const thingData = getNestedProp(wikiData, thingDataProp);
-        aggregate.nest({message: `Reference errors in ${color.green('wikiData.' + thingDataProp)}`}, ({ nest }) => {
-            const things = Array.isArray(thingData) ? thingData : [thingData];
-            for (const thing of things) {
-                nest({message: `Reference errors in ${inspect(thing)}`}, ({ filter }) => {
-                    for (const [ property, findFnKey ] of Object.entries(propSpec)) {
-                        if (!thing[property]) continue;
-                        if (findFnKey === '_contrib') {
-                            thing[property] = filter(thing[property],
-                                decorateErrorWithIndex(({ who }) => {
-                                    const alias = find.artist(who, wikiData.artistAliasData, {mode: 'quiet'});
-                                    if (alias) {
-                                        const original = find.artist(alias.aliasedArtistRef, wikiData.artistData, {mode: 'quiet'});
-                                        throw new Error(`Reference ${color.red(who)} is to an alias, should be ${color.green(original.name)}`);
-                                    }
-                                    return boundFind.artist(who);
-                                }),
-                                {message: `Reference errors in contributions ${color.green(property)} (${color.green('find.artist')})`});
-                            continue;
-                        }
-                        const findFn = boundFind[findFnKey];
-                        const value = thing[property];
-                        if (Array.isArray(value)) {
-                            thing[property] = filter(value, decorateErrorWithIndex(findFn),
-                                {message: `Reference errors in property ${color.green(property)} (${color.green('find.' + findFnKey)})`});
-                        } else {
-                            nest({message: `Reference error in property ${color.green(property)} (${color.green('find.' + findFnKey)})`}, ({ call }) => {
-                                try {
-                                    call(findFn, value);
-                                } catch (error) {
-                                    thing[property] = null;
-                                    throw error;
-                                }
-                            });
-                        }
+  const referenceSpec = [
+    [
+      "wikiInfo",
+      {
+        divideTrackListsByGroupsByRef: "group",
+      },
+    ],
+
+    [
+      "albumData",
+      {
+        artistContribsByRef: "_contrib",
+        coverArtistContribsByRef: "_contrib",
+        trackCoverArtistContribsByRef: "_contrib",
+        wallpaperArtistContribsByRef: "_contrib",
+        bannerArtistContribsByRef: "_contrib",
+        groupsByRef: "group",
+        artTagsByRef: "artTag",
+      },
+    ],
+
+    [
+      "trackData",
+      {
+        artistContribsByRef: "_contrib",
+        contributorContribsByRef: "_contrib",
+        coverArtistContribsByRef: "_contrib",
+        referencedTracksByRef: "track",
+        artTagsByRef: "artTag",
+        originalReleaseTrackByRef: "track",
+      },
+    ],
+
+    [
+      "groupCategoryData",
+      {
+        groupsByRef: "group",
+      },
+    ],
+
+    [
+      "homepageLayout.rows",
+      {
+        sourceGroupsByRef: "group",
+        sourceAlbumsByRef: "album",
+      },
+    ],
+
+    [
+      "flashData",
+      {
+        contributorContribsByRef: "_contrib",
+        featuredTracksByRef: "track",
+      },
+    ],
+
+    [
+      "flashActData",
+      {
+        flashesByRef: "flash",
+      },
+    ],
+  ];
+
+  function getNestedProp(obj, key) {
+    const recursive = (o, k) =>
+      k.length === 1 ? o[k[0]] : recursive(o[k[0]], k.slice(1));
+    const keys = key.split(/(?<=(?<!\\)(?:\\\\)*)\./);
+    return recursive(obj, keys);
+  }
+
+  const aggregate = openAggregate({
+    message: `Errors validating between-thing references in data`,
+  });
+  const boundFind = bindFind(wikiData, { mode: "error" });
+  for (const [thingDataProp, propSpec] of referenceSpec) {
+    const thingData = getNestedProp(wikiData, thingDataProp);
+    aggregate.nest(
+      {
+        message: `Reference errors in ${color.green(
+          "wikiData." + thingDataProp
+        )}`,
+      },
+      ({ nest }) => {
+        const things = Array.isArray(thingData) ? thingData : [thingData];
+        for (const thing of things) {
+          nest(
+            { message: `Reference errors in ${inspect(thing)}` },
+            ({ filter }) => {
+              for (const [property, findFnKey] of Object.entries(propSpec)) {
+                if (!thing[property]) continue;
+                if (findFnKey === "_contrib") {
+                  thing[property] = filter(
+                    thing[property],
+                    decorateErrorWithIndex(({ who }) => {
+                      const alias = find.artist(who, wikiData.artistAliasData, {
+                        mode: "quiet",
+                      });
+                      if (alias) {
+                        const original = find.artist(
+                          alias.aliasedArtistRef,
+                          wikiData.artistData,
+                          { mode: "quiet" }
+                        );
+                        throw new Error(
+                          `Reference ${color.red(
+                            who
+                          )} is to an alias, should be ${color.green(
+                            original.name
+                          )}`
+                        );
+                      }
+                      return boundFind.artist(who);
+                    }),
+                    {
+                      message: `Reference errors in contributions ${color.green(
+                        property
+                      )} (${color.green("find.artist")})`,
+                    }
+                  );
+                  continue;
+                }
+                const findFn = boundFind[findFnKey];
+                const value = thing[property];
+                if (Array.isArray(value)) {
+                  thing[property] = filter(
+                    value,
+                    decorateErrorWithIndex(findFn),
+                    {
+                      message: `Reference errors in property ${color.green(
+                        property
+                      )} (${color.green("find." + findFnKey)})`,
+                    }
+                  );
+                } else {
+                  nest(
+                    {
+                      message: `Reference error in property ${color.green(
+                        property
+                      )} (${color.green("find." + findFnKey)})`,
+                    },
+                    ({ call }) => {
+                      try {
+                        call(findFn, value);
+                      } catch (error) {
+                        thing[property] = null;
+                        throw error;
+                      }
                     }
-                });
+                  );
+                }
+              }
             }
-        });
-    }
+          );
+        }
+      }
+    );
+  }
 
-    return aggregate;
+  return aggregate;
 }
 
 // Utility function for loading all wiki data from the provided YAML data
@@ -1297,48 +1456,49 @@ export function filterReferenceErrors(wikiData) {
 // a boilerplate for more specialized output, or as a quick start in utilities
 // where reporting info about data loading isn't as relevant as during the
 // main wiki build process.
-export async function quickLoadAllFromYAML(dataPath, {
-    showAggregate: customShowAggregate = showAggregate,
-} = {}) {
-    const showAggregate = customShowAggregate;
+export async function quickLoadAllFromYAML(
+  dataPath,
+  { showAggregate: customShowAggregate = showAggregate } = {}
+) {
+  const showAggregate = customShowAggregate;
 
-    let wikiData;
+  let wikiData;
 
-    {
-        const { aggregate, result } = await loadAndProcessDataDocuments({
-            dataPath,
-        });
+  {
+    const { aggregate, result } = await loadAndProcessDataDocuments({
+      dataPath,
+    });
 
-        wikiData = result;
-
-        try {
-            aggregate.close();
-            logInfo`Loaded data without errors. (complete data)`;
-        } catch (error) {
-            showAggregate(error);
-            logWarn`Loaded data with errors. (partial data)`;
-        }
-    }
-
-    linkWikiDataArrays(wikiData);
+    wikiData = result;
 
     try {
-        filterDuplicateDirectories(wikiData).close();
-        logInfo`No duplicate directories found. (complete data)`;
+      aggregate.close();
+      logInfo`Loaded data without errors. (complete data)`;
     } catch (error) {
-        showAggregate(error);
-        logWarn`Duplicate directories found. (partial data)`;
+      showAggregate(error);
+      logWarn`Loaded data with errors. (partial data)`;
     }
+  }
 
-    try {
-        filterReferenceErrors(wikiData).close();
-        logInfo`No reference errors found. (complete data)`;
-    } catch (error) {
-        showAggregate(error);
-        logWarn`Duplicate directories found. (partial data)`;
-    }
+  linkWikiDataArrays(wikiData);
+
+  try {
+    filterDuplicateDirectories(wikiData).close();
+    logInfo`No duplicate directories found. (complete data)`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Duplicate directories found. (partial data)`;
+  }
+
+  try {
+    filterReferenceErrors(wikiData).close();
+    logInfo`No reference errors found. (complete data)`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Duplicate directories found. (partial data)`;
+  }
 
-    sortWikiDataArrays(wikiData);
+  sortWikiDataArrays(wikiData);
 
-    return wikiData;
+  return wikiData;
 }
diff --git a/src/file-size-preloader.js b/src/file-size-preloader.js
index d0807cc3..d179e569 100644
--- a/src/file-size-preloader.js
+++ b/src/file-size-preloader.js
@@ -17,84 +17,84 @@
 // This only processes files one at a time because I'm lazy and stat calls
 // are very, very fast.
 
-import { stat } from 'fs/promises';
-import { logWarn } from './util/cli.js';
+import { stat } from "fs/promises";
+import { logWarn } from "./util/cli.js";
 
 export default class FileSizePreloader {
-    #paths = [];
-    #sizes = [];
-    #loadedPathIndex = -1;
+  #paths = [];
+  #sizes = [];
+  #loadedPathIndex = -1;
 
-    #loadingPromise = null;
-    #resolveLoadingPromise = null;
+  #loadingPromise = null;
+  #resolveLoadingPromise = null;
 
-    loadPaths(...paths) {
-        this.#paths.push(...paths.filter(p => !this.#paths.includes(p)));
-        return this.#startLoadingPaths();
-    }
-
-    waitUntilDoneLoading() {
-        return this.#loadingPromise ?? Promise.resolve();
-    }
-
-    #startLoadingPaths() {
-        if (this.#loadingPromise) {
-            return this.#loadingPromise;
-        }
-
-        this.#loadingPromise = new Promise((resolve => {
-            this.#resolveLoadingPromise = resolve;
-        }));
+  loadPaths(...paths) {
+    this.#paths.push(...paths.filter((p) => !this.#paths.includes(p)));
+    return this.#startLoadingPaths();
+  }
 
-        this.#loadNextPath();
+  waitUntilDoneLoading() {
+    return this.#loadingPromise ?? Promise.resolve();
+  }
 
-        return this.#loadingPromise;
+  #startLoadingPaths() {
+    if (this.#loadingPromise) {
+      return this.#loadingPromise;
     }
 
-    async #loadNextPath() {
-        if (this.#loadedPathIndex === this.#paths.length - 1) {
-            return this.#doneLoadingPaths();
-        }
+    this.#loadingPromise = new Promise((resolve) => {
+      this.#resolveLoadingPromise = resolve;
+    });
 
-        let size;
+    this.#loadNextPath();
 
-        const path = this.#paths[this.#loadedPathIndex + 1];
+    return this.#loadingPromise;
+  }
 
-        try {
-            size = await this.readFileSize(path);
-        } catch (error) {
-            // Oops! Discard that path, and don't increment the index before
-            // moving on, since the next path will now be in its place.
-            this.#paths.splice(this.#loadedPathIndex + 1, 1);
-            logWarn`Failed to process file size for ${path}: ${error.message}`;
-            return this.#loadNextPath();
-        }
-
-        this.#sizes.push(size);
-        this.#loadedPathIndex++;
-        return this.#loadNextPath();
+  async #loadNextPath() {
+    if (this.#loadedPathIndex === this.#paths.length - 1) {
+      return this.#doneLoadingPaths();
     }
 
-    #doneLoadingPaths() {
-        this.#resolveLoadingPromise();
-        this.#loadingPromise = null;
-        this.#resolveLoadingPromise = null;
-    }
+    let size;
 
-    // Override me if you want?
-    // The rest of the code here is literally just a queue system, so you could
-    // pretty much repurpose it for anything... but there are probably cleaner
-    // ways than making an instance or subclass of this and overriding this one
-    // method!
-    async readFileSize(path) {
-        const stats = await stat(path);
-        return stats.size;
-    }
+    const path = this.#paths[this.#loadedPathIndex + 1];
 
-    getSizeOfPath(path) {
-        const index = this.#paths.indexOf(path);
-        if (index === -1) return null;
-        if (index > this.#loadedPathIndex) return null;
-        return this.#sizes[index];
+    try {
+      size = await this.readFileSize(path);
+    } catch (error) {
+      // Oops! Discard that path, and don't increment the index before
+      // moving on, since the next path will now be in its place.
+      this.#paths.splice(this.#loadedPathIndex + 1, 1);
+      logWarn`Failed to process file size for ${path}: ${error.message}`;
+      return this.#loadNextPath();
     }
+
+    this.#sizes.push(size);
+    this.#loadedPathIndex++;
+    return this.#loadNextPath();
+  }
+
+  #doneLoadingPaths() {
+    this.#resolveLoadingPromise();
+    this.#loadingPromise = null;
+    this.#resolveLoadingPromise = null;
+  }
+
+  // Override me if you want?
+  // The rest of the code here is literally just a queue system, so you could
+  // pretty much repurpose it for anything... but there are probably cleaner
+  // ways than making an instance or subclass of this and overriding this one
+  // method!
+  async readFileSize(path) {
+    const stats = await stat(path);
+    return stats.size;
+  }
+
+  getSizeOfPath(path) {
+    const index = this.#paths.indexOf(path);
+    if (index === -1) return null;
+    if (index > this.#loadedPathIndex) return null;
+    return this.#sizes[index];
+  }
 }
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 839c1d42..9e78d38d 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -72,321 +72,336 @@
 // unused). This is just to make the code more porta8le and sta8le, long-term,
 // since it avoids a lot of otherwise implic8ted maintenance.
 
-'use strict';
+"use strict";
 
-const CACHE_FILE = 'thumbnail-cache.json';
+const CACHE_FILE = "thumbnail-cache.json";
 const WARNING_DELAY_TIME = 10000;
 
-import { spawn } from 'child_process';
-import { createHash } from 'crypto';
-import * as path from 'path';
+import { spawn } from "child_process";
+import { createHash } from "crypto";
+import * as path from "path";
 
-import {
-    readdir,
-    readFile,
-    writeFile
-} from 'fs/promises'; // Whatcha know! Nice.
-
-import {
-    createReadStream
-} from 'fs'; // Still gotta import from 8oth tho, for createReadStream.
+import { readdir, readFile, writeFile } from "fs/promises"; // Whatcha know! Nice.
 
-import {
-    logError,
-    logInfo,
-    logWarn,
-    parseOptions,
-    progressPromiseAll
-} from './util/cli.js';
+import { createReadStream } from "fs"; // Still gotta import from 8oth tho, for createReadStream.
 
 import {
-    commandExists,
-    isMain,
-    promisifyProcess,
-} from './util/node-utils.js';
+  logError,
+  logInfo,
+  logWarn,
+  parseOptions,
+  progressPromiseAll,
+} from "./util/cli.js";
+
+import { commandExists, isMain, promisifyProcess } from "./util/node-utils.js";
+
+import { delay, queue } from "./util/sugar.js";
+
+function traverse(
+  startDirPath,
+  { filterFile = () => true, filterDir = () => true } = {}
+) {
+  const recursive = (names, subDirPath) =>
+    Promise.all(
+      names.map((name) =>
+        readdir(path.join(startDirPath, subDirPath, name)).then(
+          (names) =>
+            filterDir(name)
+              ? recursive(names, path.join(subDirPath, name))
+              : [],
+          (err) => (filterFile(name) ? [path.join(subDirPath, name)] : [])
+        )
+      )
+    ).then((pathArrays) => pathArrays.flatMap((x) => x));
 
-import {
-    delay,
-    queue,
-} from './util/sugar.js';
-
-function traverse(startDirPath, {
-    filterFile = () => true,
-    filterDir = () => true
-} = {}) {
-    const recursive = (names, subDirPath) => Promise
-        .all(names.map(name => readdir(path.join(startDirPath, subDirPath, name)).then(
-            names => filterDir(name) ? recursive(names, path.join(subDirPath, name)) : [],
-            err => filterFile(name) ? [path.join(subDirPath, name)] : [])))
-        .then(pathArrays => pathArrays.flatMap(x => x));
-
-    return readdir(startDirPath)
-        .then(names => recursive(names, ''));
+  return readdir(startDirPath).then((names) => recursive(names, ""));
 }
 
 function readFileMD5(filePath) {
-    return new Promise((resolve, reject) => {
-        const md5 = createHash('md5');
-        const stream = createReadStream(filePath);
-        stream.on('data', data => md5.update(data));
-        stream.on('end', data => resolve(md5.digest('hex')));
-        stream.on('error', err => reject(err));
-    });
+  return new Promise((resolve, reject) => {
+    const md5 = createHash("md5");
+    const stream = createReadStream(filePath);
+    stream.on("data", (data) => md5.update(data));
+    stream.on("end", (data) => resolve(md5.digest("hex")));
+    stream.on("error", (err) => reject(err));
+  });
 }
 
 async function getImageMagickVersion(spawnConvert) {
-    const proc = spawnConvert(['--version'], false);
+  const proc = spawnConvert(["--version"], false);
 
-    let allData = '';
-    proc.stdout.on('data', data => {
-        allData += data.toString();
-    });
+  let allData = "";
+  proc.stdout.on("data", (data) => {
+    allData += data.toString();
+  });
 
-    await promisifyProcess(proc, false);
+  await promisifyProcess(proc, false);
 
-    if (!allData.match(/ImageMagick/i)) {
-        return null;
-    }
+  if (!allData.match(/ImageMagick/i)) {
+    return null;
+  }
 
-    const match = allData.match(/Version: (.*)/i);
-    if (!match) {
-        return 'unknown version';
-    }
+  const match = allData.match(/Version: (.*)/i);
+  if (!match) {
+    return "unknown version";
+  }
 
-    return match[1];
+  return match[1];
 }
 
 async function getSpawnConvert() {
-    let fn, description, version;
-    if (await commandExists('convert')) {
-        fn = args => spawn('convert', args);
-        description = 'convert';
-    } else if (await commandExists('magick')) {
-        fn = (args, prefix = true) => spawn('magick',
-            (prefix ? ['convert', ...args] : args));
-        description = 'magick convert';
-    } else {
-        return [`no convert or magick binary`, null];
-    }
+  let fn, description, version;
+  if (await commandExists("convert")) {
+    fn = (args) => spawn("convert", args);
+    description = "convert";
+  } else if (await commandExists("magick")) {
+    fn = (args, prefix = true) =>
+      spawn("magick", prefix ? ["convert", ...args] : args);
+    description = "magick convert";
+  } else {
+    return [`no convert or magick binary`, null];
+  }
+
+  version = await getImageMagickVersion(fn);
+
+  if (version === null) {
+    return [`binary --version output didn't indicate it's ImageMagick`];
+  }
+
+  return [`${description} (${version})`, fn];
+}
+
+function generateImageThumbnails(filePath, { spawnConvert }) {
+  const dirname = path.dirname(filePath);
+  const extname = path.extname(filePath);
+  const basename = path.basename(filePath, extname);
+  const output = (name) => path.join(dirname, basename + name + ".jpg");
+
+  const convert = (name, { size, quality }) =>
+    spawnConvert([
+      filePath,
+      "-strip",
+      "-resize",
+      `${size}x${size}>`,
+      "-interlace",
+      "Plane",
+      "-quality",
+      `${quality}%`,
+      output(name),
+    ]);
 
-    version = await getImageMagickVersion(fn);
+  return Promise.all([
+    promisifyProcess(convert(".medium", { size: 400, quality: 95 }), false),
+    promisifyProcess(convert(".small", { size: 250, quality: 85 }), false),
+  ]);
 
-    if (version === null) {
-        return [`binary --version output didn't indicate it's ImageMagick`];
+  return new Promise((resolve, reject) => {
+    if (Math.random() < 0.2) {
+      reject(new Error(`Them's the 8r8ks, kiddo!`));
+    } else {
+      resolve();
     }
-
-    return [`${description} (${version})`, fn];
+  });
 }
 
-function generateImageThumbnails(filePath, {spawnConvert}) {
-    const dirname = path.dirname(filePath);
-    const extname = path.extname(filePath);
-    const basename = path.basename(filePath, extname);
-    const output = name => path.join(dirname, basename + name + '.jpg');
-
-    const convert = (name, {size, quality}) => spawnConvert([
-        filePath,
-        '-strip',
-        '-resize', `${size}x${size}>`,
-        '-interlace', 'Plane',
-        '-quality', `${quality}%`,
-        output(name)
-    ]);
+export default async function genThumbs(
+  mediaPath,
+  { queueSize = 0, quiet = false } = {}
+) {
+  if (!mediaPath) {
+    throw new Error("Expected mediaPath to be passed");
+  }
 
-    return Promise.all([
-        promisifyProcess(convert('.medium', {size: 400, quality: 95}), false),
-        promisifyProcess(convert('.small', {size: 250, quality: 85}), false)
-    ]);
+  const quietInfo = quiet ? () => null : logInfo;
 
-    return new Promise((resolve, reject) => {
-        if (Math.random() < 0.2) {
-            reject(new Error(`Them's the 8r8ks, kiddo!`));
-        } else {
-            resolve();
-        }
-    });
-}
+  const filterFile = (name) => {
+    // TODO: Why is this not working????????
+    // thumbnail-cache.json is 8eing passed through, for some reason.
 
-export default async function genThumbs(mediaPath, {
-    queueSize = 0,
-    quiet = false
-} = {}) {
-    if (!mediaPath) {
-        throw new Error('Expected mediaPath to be passed');
-    }
+    const ext = path.extname(name);
+    if (ext !== ".jpg" && ext !== ".png") return false;
 
-    const quietInfo = (quiet
-        ? () => null
-        : logInfo);
-
-    const filterFile = name => {
-        // TODO: Why is this not working????????
-        // thumbnail-cache.json is 8eing passed through, for some reason.
-
-        const ext = path.extname(name);
-        if (ext !== '.jpg' && ext !== '.png') return false;
-
-        const rest = path.basename(name, ext);
-        if (rest.endsWith('.medium') || rest.endsWith('.small')) return false;
-
-        return true;
-    };
-
-    const filterDir = name => {
-        if (name === '.git') return false;
-        return true;
-    };
-
-    const [convertInfo, spawnConvert] = await getSpawnConvert() ?? [];
-    if (!spawnConvert) {
-        logError`${`It looks like you don't have ImageMagick installed.`}`;
-        logError`ImageMagick is required to generate thumbnails for display on the wiki.`;
-        logError`(Error message: ${convertInfo})`;
-        logInfo`You can find info to help install ImageMagick on Linux, Windows, or macOS`;
-        logInfo`from its official website: ${`https://imagemagick.org/script/download.php`}`;
-        logInfo`If you have trouble working ImageMagick and would like some help, feel free`;
-        logInfo`to drop a message in the HSMusic Discord server! ${'https://hsmusic.wiki/discord/'}`;
-        return false;
+    const rest = path.basename(name, ext);
+    if (rest.endsWith(".medium") || rest.endsWith(".small")) return false;
+
+    return true;
+  };
+
+  const filterDir = (name) => {
+    if (name === ".git") return false;
+    return true;
+  };
+
+  const [convertInfo, spawnConvert] = (await getSpawnConvert()) ?? [];
+  if (!spawnConvert) {
+    logError`${`It looks like you don't have ImageMagick installed.`}`;
+    logError`ImageMagick is required to generate thumbnails for display on the wiki.`;
+    logError`(Error message: ${convertInfo})`;
+    logInfo`You can find info to help install ImageMagick on Linux, Windows, or macOS`;
+    logInfo`from its official website: ${`https://imagemagick.org/script/download.php`}`;
+    logInfo`If you have trouble working ImageMagick and would like some help, feel free`;
+    logInfo`to drop a message in the HSMusic Discord server! ${"https://hsmusic.wiki/discord/"}`;
+    return false;
+  } else {
+    logInfo`Found ImageMagick binary: ${convertInfo}`;
+  }
+
+  let cache,
+    firstRun = false,
+    failedReadingCache = false;
+  try {
+    cache = JSON.parse(await readFile(path.join(mediaPath, CACHE_FILE)));
+    quietInfo`Cache file successfully read.`;
+  } catch (error) {
+    cache = {};
+    if (error.code === "ENOENT") {
+      firstRun = true;
     } else {
-        logInfo`Found ImageMagick binary: ${convertInfo}`;
+      failedReadingCache = true;
+      logWarn`Malformed or unreadable cache file: ${error}`;
+      logWarn`You may want to cancel and investigate this!`;
+      logWarn`All-new thumbnails and cache will be generated for this run.`;
+      await delay(WARNING_DELAY_TIME);
     }
-
-    let cache, firstRun = false, failedReadingCache = false;
-    try {
-        cache = JSON.parse(await readFile(path.join(mediaPath, CACHE_FILE)));
-        quietInfo`Cache file successfully read.`;
-    } catch (error) {
-        cache = {};
-        if (error.code === 'ENOENT') {
-            firstRun = true;
-        } else {
-            failedReadingCache = true;
-            logWarn`Malformed or unreadable cache file: ${error}`;
-            logWarn`You may want to cancel and investigate this!`;
-            logWarn`All-new thumbnails and cache will be generated for this run.`;
-            await delay(WARNING_DELAY_TIME);
-        }
+  }
+
+  try {
+    await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache));
+    quietInfo`Writing to cache file appears to be working.`;
+  } catch (error) {
+    logWarn`Test of cache file writing failed: ${error}`;
+    if (cache) {
+      logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`;
+    } else if (firstRun) {
+      logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`;
+    } else {
+      logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`;
     }
-
-    try {
-        await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache));
-        quietInfo`Writing to cache file appears to be working.`;
-    } catch (error) {
-        logWarn`Test of cache file writing failed: ${error}`;
-        if (cache) {
-            logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`;
-        } else if (firstRun) {
-            logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`;
-        } else {
-            logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`;
-        }
-        logWarn`You may want to cancel and investigate this!`;
-        await delay(WARNING_DELAY_TIME);
+    logWarn`You may want to cancel and investigate this!`;
+    await delay(WARNING_DELAY_TIME);
+  }
+
+  const imagePaths = await traverse(mediaPath, { filterFile, filterDir });
+
+  const imageToMD5Entries = await progressPromiseAll(
+    `Generating MD5s of image files`,
+    queue(
+      imagePaths.map(
+        (imagePath) => () =>
+          readFileMD5(path.join(mediaPath, imagePath)).then(
+            (md5) => [imagePath, md5],
+            (error) => [imagePath, { error }]
+          )
+      ),
+      queueSize
+    )
+  );
+
+  {
+    let error = false;
+    for (const entry of imageToMD5Entries) {
+      if (entry[1].error) {
+        logError`Failed to read ${entry[0]}: ${entry[1].error}`;
+        error = true;
+      }
     }
-
-    const imagePaths = await traverse(mediaPath, {filterFile, filterDir});
-
-    const imageToMD5Entries = await progressPromiseAll(`Generating MD5s of image files`, queue(
-        imagePaths.map(imagePath => () => readFileMD5(path.join(mediaPath, imagePath)).then(
-            md5 => [imagePath, md5],
-            error => [imagePath, {error}]
-        )),
-        queueSize
-    ));
-
-    {
-        let error = false;
-        for (const entry of imageToMD5Entries) {
-            if (entry[1].error) {
-                logError`Failed to read ${entry[0]}: ${entry[1].error}`;
-                error = true;
-            }
-        }
-        if (error) {
-            logError`Failed to read at least one image file!`;
-            logError`This implies a thumbnail probably won't be generatable.`;
-            logError`So, exiting early.`;
-            return false;
-        } else {
-            quietInfo`All image files successfully read.`;
-        }
+    if (error) {
+      logError`Failed to read at least one image file!`;
+      logError`This implies a thumbnail probably won't be generatable.`;
+      logError`So, exiting early.`;
+      return false;
+    } else {
+      quietInfo`All image files successfully read.`;
     }
+  }
 
-    // Technically we could pro8a8ly mut8te the cache varia8le in-place?
-    // 8ut that seems kinda iffy.
-    const updatedCache = Object.assign({}, cache);
+  // Technically we could pro8a8ly mut8te the cache varia8le in-place?
+  // 8ut that seems kinda iffy.
+  const updatedCache = Object.assign({}, cache);
 
-    const entriesToGenerate = imageToMD5Entries
-        .filter(([filePath, md5]) => md5 !== cache[filePath]);
-
-    if (entriesToGenerate.length === 0) {
-        logInfo`All image thumbnails are already up-to-date - nice!`;
-        return true;
-    }
+  const entriesToGenerate = imageToMD5Entries.filter(
+    ([filePath, md5]) => md5 !== cache[filePath]
+  );
 
-    const failed = [];
-    const succeeded = [];
-    const writeMessageFn = () => `Writing image thumbnails. [failed: ${failed.length}]`;
-
-    // This is actually sort of a lie, 8ecause we aren't doing synchronicity.
-    // (We pass queueSize = 1 to queue().) 8ut we still use progressPromiseAll,
-    // 'cuz the progress indic8tor is very cool and good.
-    await progressPromiseAll(writeMessageFn, queue(entriesToGenerate.map(([filePath, md5]) =>
-        () => generateImageThumbnails(path.join(mediaPath, filePath)).then(
-            () => {
+  if (entriesToGenerate.length === 0) {
+    logInfo`All image thumbnails are already up-to-date - nice!`;
+    return true;
+  }
+
+  const failed = [];
+  const succeeded = [];
+  const writeMessageFn = () =>
+    `Writing image thumbnails. [failed: ${failed.length}]`;
+
+  // This is actually sort of a lie, 8ecause we aren't doing synchronicity.
+  // (We pass queueSize = 1 to queue().) 8ut we still use progressPromiseAll,
+  // 'cuz the progress indic8tor is very cool and good.
+  await progressPromiseAll(
+    writeMessageFn,
+    queue(
+      entriesToGenerate.map(
+        ([filePath, md5]) =>
+          () =>
+            generateImageThumbnails(path.join(mediaPath, filePath)).then(
+              () => {
                 updatedCache[filePath] = md5;
                 succeeded.push(filePath);
-            },
-            error => {
+              },
+              (error) => {
                 failed.push([filePath, error]);
-            }
-        )
-    )));
-
-    if (failed.length > 0) {
-        for (const [path, error] of failed) {
-            logError`Thumbnails failed to generate for ${path} - ${error}`;
-        }
-        logWarn`Result is incomplete - the above ${failed.length} thumbnails should be checked for errors.`;
-        logWarn`${succeeded.length} successfully generated images won't be regenerated next run, though!`;
-    } else {
-        logInfo`Generated all (updated) thumbnails successfully!`;
-    }
-
-    try {
-        await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(updatedCache));
-        quietInfo`Updated cache file successfully written!`;
-    } catch (error) {
-        logWarn`Failed to write updated cache file: ${error}`;
-        logWarn`Any newly (re)generated thumbnails will be regenerated next run.`;
-        logWarn`Sorry about that!`;
+              }
+            )
+      )
+    )
+  );
+
+  if (failed.length > 0) {
+    for (const [path, error] of failed) {
+      logError`Thumbnails failed to generate for ${path} - ${error}`;
     }
-
-    return true;
+    logWarn`Result is incomplete - the above ${failed.length} thumbnails should be checked for errors.`;
+    logWarn`${succeeded.length} successfully generated images won't be regenerated next run, though!`;
+  } else {
+    logInfo`Generated all (updated) thumbnails successfully!`;
+  }
+
+  try {
+    await writeFile(
+      path.join(mediaPath, CACHE_FILE),
+      JSON.stringify(updatedCache)
+    );
+    quietInfo`Updated cache file successfully written!`;
+  } catch (error) {
+    logWarn`Failed to write updated cache file: ${error}`;
+    logWarn`Any newly (re)generated thumbnails will be regenerated next run.`;
+    logWarn`Sorry about that!`;
+  }
+
+  return true;
 }
 
 if (isMain(import.meta.url)) {
-    (async function() {
-        const miscOptions = await parseOptions(process.argv.slice(2), {
-            'media-path': {
-                type: 'value'
-            },
-            'queue-size': {
-                type: 'value',
-                validate(size) {
-                    if (parseInt(size) !== parseFloat(size)) return 'an integer';
-                    if (parseInt(size) < 0) return 'a counting number or zero';
-                    return true;
-                }
-            },
-            queue: {alias: 'queue-size'},
-        });
-
-        const mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA;
-        const queueSize = +(miscOptions['queue-size'] ?? 0);
-
-        await genThumbs(mediaPath, {queueSize});
-    })().catch(err => {
-        console.error(err);
+  (async function () {
+    const miscOptions = await parseOptions(process.argv.slice(2), {
+      "media-path": {
+        type: "value",
+      },
+      "queue-size": {
+        type: "value",
+        validate(size) {
+          if (parseInt(size) !== parseFloat(size)) return "an integer";
+          if (parseInt(size) < 0) return "a counting number or zero";
+          return true;
+        },
+      },
+      queue: { alias: "queue-size" },
     });
+
+    const mediaPath = miscOptions["media-path"] || process.env.HSMUSIC_MEDIA;
+    const queueSize = +(miscOptions["queue-size"] ?? 0);
+
+    await genThumbs(mediaPath, { queueSize });
+  })().catch((err) => {
+    console.error(err);
+  });
 }
diff --git a/src/listing-spec.js b/src/listing-spec.js
index df2b038e..92b9d9db 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -1,771 +1,968 @@
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
 import {
-    chunkByProperties,
-    getArtistNumContributions,
-    getTotalDuration,
-    sortAlphabetically,
-    sortChronologically,
-} from './util/wiki-data.js';
+  chunkByProperties,
+  getArtistNumContributions,
+  getTotalDuration,
+  sortAlphabetically,
+  sortChronologically,
+} from "./util/wiki-data.js";
 
 const listingSpec = [
-    {
-        directory: 'albums/by-name',
-        stringsKey: 'listAlbums.byName',
-
-        data({wikiData}) {
-            return sortAlphabetically(wikiData.albumData.slice());
-        },
-
-        row(album, {link, language}) {
-            return language.$('listingPage.listAlbums.byName.item', {
-                album: link.album(album),
-                tracks: language.countTracks(album.tracks.length, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'albums/by-tracks',
-        stringsKey: 'listAlbums.byTracks',
-
-        data({wikiData}) {
-            return wikiData.albumData.slice()
-                .sort((a, b) => b.tracks.length - a.tracks.length);
-        },
-
-        row(album, {link, language}) {
-            return language.$('listingPage.listAlbums.byTracks.item', {
-                album: link.album(album),
-                tracks: language.countTracks(album.tracks.length, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'albums/by-duration',
-        stringsKey: 'listAlbums.byDuration',
-
-        data({wikiData}) {
-            return wikiData.albumData
-                .map(album => ({album, duration: getTotalDuration(album.tracks)}))
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({album, duration}, {link, language}) {
-            return language.$('listingPage.listAlbums.byDuration.item', {
-                album: link.album(album),
-                duration: language.formatDuration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'albums/by-date',
-        stringsKey: 'listAlbums.byDate',
-
-        data({wikiData}) {
-            return sortChronologically(wikiData.albumData.filter(album => album.date));
-        },
-
-        row(album, {link, language}) {
-            return language.$('listingPage.listAlbums.byDate.item', {
-                album: link.album(album),
-                date: language.formatDate(album.date)
-            });
-        }
-    },
-
-    {
-        directory: 'albums/by-date-added',
-        stringsKey: 'listAlbums.byDateAdded',
-
-        data({wikiData}) {
-            return chunkByProperties(wikiData.albumData.filter(a => a.dateAddedToWiki).sort((a, b) => {
-                if (a.dateAddedToWiki < b.dateAddedToWiki) return -1;
-                if (a.dateAddedToWiki > b.dateAddedToWiki) return 1;
-            }), ['dateAddedToWiki']);
-        },
-
-        html(chunks, {link, language}) {
-            return fixWS`
+  {
+    directory: "albums/by-name",
+    stringsKey: "listAlbums.byName",
+
+    data({ wikiData }) {
+      return sortAlphabetically(wikiData.albumData.slice());
+    },
+
+    row(album, { link, language }) {
+      return language.$("listingPage.listAlbums.byName.item", {
+        album: link.album(album),
+        tracks: language.countTracks(album.tracks.length, { unit: true }),
+      });
+    },
+  },
+
+  {
+    directory: "albums/by-tracks",
+    stringsKey: "listAlbums.byTracks",
+
+    data({ wikiData }) {
+      return wikiData.albumData
+        .slice()
+        .sort((a, b) => b.tracks.length - a.tracks.length);
+    },
+
+    row(album, { link, language }) {
+      return language.$("listingPage.listAlbums.byTracks.item", {
+        album: link.album(album),
+        tracks: language.countTracks(album.tracks.length, { unit: true }),
+      });
+    },
+  },
+
+  {
+    directory: "albums/by-duration",
+    stringsKey: "listAlbums.byDuration",
+
+    data({ wikiData }) {
+      return wikiData.albumData
+        .map((album) => ({ album, duration: getTotalDuration(album.tracks) }))
+        .sort((a, b) => b.duration - a.duration);
+    },
+
+    row({ album, duration }, { link, language }) {
+      return language.$("listingPage.listAlbums.byDuration.item", {
+        album: link.album(album),
+        duration: language.formatDuration(duration),
+      });
+    },
+  },
+
+  {
+    directory: "albums/by-date",
+    stringsKey: "listAlbums.byDate",
+
+    data({ wikiData }) {
+      return sortChronologically(
+        wikiData.albumData.filter((album) => album.date)
+      );
+    },
+
+    row(album, { link, language }) {
+      return language.$("listingPage.listAlbums.byDate.item", {
+        album: link.album(album),
+        date: language.formatDate(album.date),
+      });
+    },
+  },
+
+  {
+    directory: "albums/by-date-added",
+    stringsKey: "listAlbums.byDateAdded",
+
+    data({ wikiData }) {
+      return chunkByProperties(
+        wikiData.albumData
+          .filter((a) => a.dateAddedToWiki)
+          .sort((a, b) => {
+            if (a.dateAddedToWiki < b.dateAddedToWiki) return -1;
+            if (a.dateAddedToWiki > b.dateAddedToWiki) return 1;
+          }),
+        ["dateAddedToWiki"]
+      );
+    },
+
+    html(chunks, { link, language }) {
+      return fixWS`
                 <dl>
-                    ${chunks.map(({dateAddedToWiki, chunk: albums}) => fixWS`
-                        <dt>${language.$('listingPage.listAlbums.byDateAdded.date', {
-                            date: language.formatDate(dateAddedToWiki)
-                        })}</dt>
+                    ${chunks
+                      .map(
+                        ({ dateAddedToWiki, chunk: albums }) => fixWS`
+                        <dt>${language.$(
+                          "listingPage.listAlbums.byDateAdded.date",
+                          {
+                            date: language.formatDate(dateAddedToWiki),
+                          }
+                        )}</dt>
                         <dd><ul>
-                            ${(albums
-                                .map(album => language.$('listingPage.listAlbums.byDateAdded.album', {
-                                    album: link.album(album)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                            ${albums
+                              .map((album) =>
+                                language.$(
+                                  "listingPage.listAlbums.byDateAdded.album",
+                                  {
+                                    album: link.album(album),
+                                  }
+                                )
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                         </ul></dd>
-                    `).join('\n')}
+                    `
+                      )
+                      .join("\n")}
                 </dl>
             `;
-        }
-    },
-
-    {
-        directory: 'artists/by-name',
-        stringsKey: 'listArtists.byName',
-
-        data({wikiData}) {
-            return sortAlphabetically(wikiData.artistData.slice())
-                .map(artist => ({artist, contributions: getArtistNumContributions(artist)}));
-        },
-
-        row({artist, contributions}, {link, language}) {
-            return language.$('listingPage.listArtists.byName.item', {
-                artist: link.artist(artist),
-                contributions: language.countContributions(contributions, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-contribs',
-        stringsKey: 'listArtists.byContribs',
-
-        data({wikiData}) {
-            return {
-                toTracks: (wikiData.artistData
-                    .map(artist => ({
-                        artist,
-                        contributions: (
-                            (artist.tracksAsContributor?.length ?? 0) +
-                            (artist.tracksAsArtist?.length ?? 0)
-                        )
-                    }))
-                    .sort((a, b) => b.contributions - a.contributions)
-                    .filter(({ contributions }) => contributions)),
-
-                toArtAndFlashes: (wikiData.artistData
-                    .map(artist => ({
-                        artist,
-                        contributions: (
-                            (artist.tracksAsCoverArtist?.length ?? 0) +
-                            (artist.albumsAsCoverArtist?.length ?? 0) +
-                            (artist.albumsAsWallpaperArtist?.length ?? 0) +
-                            (artist.albumsAsBannerArtist?.length ?? 0) +
-                            (wikiData.wikiInfo.enableFlashesAndGames
-                                ? (artist.flashesAsContributor?.length ?? 0)
-                                : 0)
-                        )
-                    }))
-                    .sort((a, b) => b.contributions - a.contributions)
-                    .filter(({ contributions }) => contributions)),
-
-                // This is a kinda naughty hack, 8ut like, it's the only place
-                // we'd 8e passing wikiData to html() otherwise, so like....
-                // (Ok we do do this again once later.)
-                showAsFlashes: wikiData.wikiInfo.enableFlashesAndGames
-            };
-        },
-
-        html({toTracks, toArtAndFlashes, showAsFlashes}, {link, language}) {
-            return fixWS`
+    },
+  },
+
+  {
+    directory: "artists/by-name",
+    stringsKey: "listArtists.byName",
+
+    data({ wikiData }) {
+      return sortAlphabetically(wikiData.artistData.slice()).map((artist) => ({
+        artist,
+        contributions: getArtistNumContributions(artist),
+      }));
+    },
+
+    row({ artist, contributions }, { link, language }) {
+      return language.$("listingPage.listArtists.byName.item", {
+        artist: link.artist(artist),
+        contributions: language.countContributions(contributions, {
+          unit: true,
+        }),
+      });
+    },
+  },
+
+  {
+    directory: "artists/by-contribs",
+    stringsKey: "listArtists.byContribs",
+
+    data({ wikiData }) {
+      return {
+        toTracks: wikiData.artistData
+          .map((artist) => ({
+            artist,
+            contributions:
+              (artist.tracksAsContributor?.length ?? 0) +
+              (artist.tracksAsArtist?.length ?? 0),
+          }))
+          .sort((a, b) => b.contributions - a.contributions)
+          .filter(({ contributions }) => contributions),
+
+        toArtAndFlashes: wikiData.artistData
+          .map((artist) => ({
+            artist,
+            contributions:
+              (artist.tracksAsCoverArtist?.length ?? 0) +
+              (artist.albumsAsCoverArtist?.length ?? 0) +
+              (artist.albumsAsWallpaperArtist?.length ?? 0) +
+              (artist.albumsAsBannerArtist?.length ?? 0) +
+              (wikiData.wikiInfo.enableFlashesAndGames
+                ? artist.flashesAsContributor?.length ?? 0
+                : 0),
+          }))
+          .sort((a, b) => b.contributions - a.contributions)
+          .filter(({ contributions }) => contributions),
+
+        // This is a kinda naughty hack, 8ut like, it's the only place
+        // we'd 8e passing wikiData to html() otherwise, so like....
+        // (Ok we do do this again once later.)
+        showAsFlashes: wikiData.wikiInfo.enableFlashesAndGames,
+      };
+    },
+
+    html({ toTracks, toArtAndFlashes, showAsFlashes }, { link, language }) {
+      return fixWS`
                 <div class="content-columns">
                     <div class="column">
-                        <h2>${language.$('listingPage.misc.trackContributors')}</h2>
+                        <h2>${language.$(
+                          "listingPage.misc.trackContributors"
+                        )}</h2>
                         <ul>
-                            ${(toTracks
-                                .map(({ artist, contributions }) => language.$('listingPage.listArtists.byContribs.item', {
+                            ${toTracks
+                              .map(({ artist, contributions }) =>
+                                language.$(
+                                  "listingPage.listArtists.byContribs.item",
+                                  {
                                     artist: link.artist(artist),
-                                    contributions: language.countContributions(contributions, {unit: true})
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                                    contributions: language.countContributions(
+                                      contributions,
+                                      { unit: true }
+                                    ),
+                                  }
+                                )
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                          </ul>
                     </div>
                     <div class="column">
-                        <h2>${language.$('listingPage.misc' +
+                        <h2>${language.$(
+                          "listingPage.misc" +
                             (showAsFlashes
-                                ? '.artAndFlashContributors'
-                                : '.artContributors'))}</h2>
+                              ? ".artAndFlashContributors"
+                              : ".artContributors")
+                        )}</h2>
                         <ul>
-                            ${(toArtAndFlashes
-                                .map(({ artist, contributions }) => language.$('listingPage.listArtists.byContribs.item', {
+                            ${toArtAndFlashes
+                              .map(({ artist, contributions }) =>
+                                language.$(
+                                  "listingPage.listArtists.byContribs.item",
+                                  {
                                     artist: link.artist(artist),
-                                    contributions: language.countContributions(contributions, {unit: true})
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                                    contributions: language.countContributions(
+                                      contributions,
+                                      { unit: true }
+                                    ),
+                                  }
+                                )
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                         </ul>
                     </div>
                 </div>
             `;
-        }
-    },
-
-    {
-        directory: 'artists/by-commentary',
-        stringsKey: 'listArtists.byCommentary',
-
-        data({wikiData}) {
-            return wikiData.artistData
-                .map(artist => ({artist, entries: (
-                    (artist.tracksAsCommentator?.length ?? 0) +
-                    (artist.albumsAsCommentator?.length ?? 0)
-                )}))
-                .filter(({ entries }) => entries)
-                .sort((a, b) => b.entries - a.entries);
-        },
-
-        row({artist, entries}, {link, language}) {
-            return language.$('listingPage.listArtists.byCommentary.item', {
-                artist: link.artist(artist),
-                entries: language.countCommentaryEntries(entries, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-duration',
-        stringsKey: 'listArtists.byDuration',
-
-        data({wikiData}) {
-            return wikiData.artistData
-                .map(artist => ({
-                    artist,
-                    duration: getTotalDuration([
-                        ...artist.tracksAsArtist ?? [],
-                        ...artist.tracksAsContributor ?? []
-                    ])
-                }))
-                .filter(({ duration }) => duration > 0)
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({artist, duration}, {link, language}) {
-            return language.$('listingPage.listArtists.byDuration.item', {
-                artist: link.artist(artist),
-                duration: language.formatDuration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-latest',
-        stringsKey: 'listArtists.byLatest',
-
-        data({wikiData}) {
-            const reversedTracks = sortChronologically(wikiData.trackData.filter(t => t.date)).reverse();
-            const reversedArtThings = sortChronologically([...wikiData.trackData, ...wikiData.albumData].filter(t => t.coverArtDate)).reverse();
-
-            return {
-                toTracks: sortChronologically(wikiData.artistData
-                    .map(artist => ({
-                        artist,
-                        directory: artist.directory,
-                        name: artist.name,
-                        date: reversedTracks.find(track => ([
-                            ...track.artistContribs ?? [],
-                            ...track.contributorContribs ?? []
-                        ].some(({ who }) => who === artist)))?.date
-                    }))
-                    .filter(({ date }) => date)).reverse(),
-
-                toArtAndFlashes: sortChronologically(wikiData.artistData
-                    .map(artist => {
-                        const thing = reversedArtThings.find(thing => ([
-                            ...thing.coverArtistContribs ?? [],
-                            ...!thing.album && thing.contributorContribs || []
-                        ].some(({ who }) => who === artist)));
-                        return thing && {
-                            artist,
-                            directory: artist.directory,
-                            name: artist.name,
-                            date: (thing.coverArtistContribs?.some(({ who }) => who === artist)
-                                ? thing.coverArtDate
-                                : thing.date)
-                        };
-                    })
-                    .filter(Boolean)
-                    .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0)
-                ).reverse(),
-
-                // (Ok we did it again.)
-                // This is a kinda naughty hack, 8ut like, it's the only place
-                // we'd 8e passing wikiData to html() otherwise, so like....
-                showAsFlashes: wikiData.wikiInfo.enableFlashesAndGames
-            };
-        },
-
-        html({toTracks, toArtAndFlashes, showAsFlashes}, {link, language}) {
-            return fixWS`
+    },
+  },
+
+  {
+    directory: "artists/by-commentary",
+    stringsKey: "listArtists.byCommentary",
+
+    data({ wikiData }) {
+      return wikiData.artistData
+        .map((artist) => ({
+          artist,
+          entries:
+            (artist.tracksAsCommentator?.length ?? 0) +
+            (artist.albumsAsCommentator?.length ?? 0),
+        }))
+        .filter(({ entries }) => entries)
+        .sort((a, b) => b.entries - a.entries);
+    },
+
+    row({ artist, entries }, { link, language }) {
+      return language.$("listingPage.listArtists.byCommentary.item", {
+        artist: link.artist(artist),
+        entries: language.countCommentaryEntries(entries, { unit: true }),
+      });
+    },
+  },
+
+  {
+    directory: "artists/by-duration",
+    stringsKey: "listArtists.byDuration",
+
+    data({ wikiData }) {
+      return wikiData.artistData
+        .map((artist) => ({
+          artist,
+          duration: getTotalDuration([
+            ...(artist.tracksAsArtist ?? []),
+            ...(artist.tracksAsContributor ?? []),
+          ]),
+        }))
+        .filter(({ duration }) => duration > 0)
+        .sort((a, b) => b.duration - a.duration);
+    },
+
+    row({ artist, duration }, { link, language }) {
+      return language.$("listingPage.listArtists.byDuration.item", {
+        artist: link.artist(artist),
+        duration: language.formatDuration(duration),
+      });
+    },
+  },
+
+  {
+    directory: "artists/by-latest",
+    stringsKey: "listArtists.byLatest",
+
+    data({ wikiData }) {
+      const reversedTracks = sortChronologically(
+        wikiData.trackData.filter((t) => t.date)
+      ).reverse();
+      const reversedArtThings = sortChronologically(
+        [...wikiData.trackData, ...wikiData.albumData].filter(
+          (t) => t.coverArtDate
+        )
+      ).reverse();
+
+      return {
+        toTracks: sortChronologically(
+          wikiData.artistData
+            .map((artist) => ({
+              artist,
+              directory: artist.directory,
+              name: artist.name,
+              date: reversedTracks.find((track) =>
+                [
+                  ...(track.artistContribs ?? []),
+                  ...(track.contributorContribs ?? []),
+                ].some(({ who }) => who === artist)
+              )?.date,
+            }))
+            .filter(({ date }) => date)
+        ).reverse(),
+
+        toArtAndFlashes: sortChronologically(
+          wikiData.artistData
+            .map((artist) => {
+              const thing = reversedArtThings.find((thing) =>
+                [
+                  ...(thing.coverArtistContribs ?? []),
+                  ...((!thing.album && thing.contributorContribs) || []),
+                ].some(({ who }) => who === artist)
+              );
+              return (
+                thing && {
+                  artist,
+                  directory: artist.directory,
+                  name: artist.name,
+                  date: thing.coverArtistContribs?.some(
+                    ({ who }) => who === artist
+                  )
+                    ? thing.coverArtDate
+                    : thing.date,
+                }
+              );
+            })
+            .filter(Boolean)
+            .sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0))
+        ).reverse(),
+
+        // (Ok we did it again.)
+        // This is a kinda naughty hack, 8ut like, it's the only place
+        // we'd 8e passing wikiData to html() otherwise, so like....
+        showAsFlashes: wikiData.wikiInfo.enableFlashesAndGames,
+      };
+    },
+
+    html({ toTracks, toArtAndFlashes, showAsFlashes }, { link, language }) {
+      return fixWS`
                 <div class="content-columns">
                     <div class="column">
-                        <h2>${language.$('listingPage.misc.trackContributors')}</h2>
+                        <h2>${language.$(
+                          "listingPage.misc.trackContributors"
+                        )}</h2>
                         <ul>
-                            ${(toTracks
-                                .map(({ artist, date }) => language.$('listingPage.listArtists.byLatest.item', {
+                            ${toTracks
+                              .map(({ artist, date }) =>
+                                language.$(
+                                  "listingPage.listArtists.byLatest.item",
+                                  {
                                     artist: link.artist(artist),
-                                    date: language.formatDate(date)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                                    date: language.formatDate(date),
+                                  }
+                                )
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                         </ul>
                     </div>
                     <div class="column">
-                        <h2>${language.$('listingPage.misc' +
+                        <h2>${language.$(
+                          "listingPage.misc" +
                             (showAsFlashes
-                                ? '.artAndFlashContributors'
-                                : '.artContributors'))}</h2>
+                              ? ".artAndFlashContributors"
+                              : ".artContributors")
+                        )}</h2>
                         <ul>
-                            ${(toArtAndFlashes
-                                .map(({ artist, date }) => language.$('listingPage.listArtists.byLatest.item', {
+                            ${toArtAndFlashes
+                              .map(({ artist, date }) =>
+                                language.$(
+                                  "listingPage.listArtists.byLatest.item",
+                                  {
                                     artist: link.artist(artist),
-                                    date: language.formatDate(date)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                                    date: language.formatDate(date),
+                                  }
+                                )
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                         </ul>
                     </div>
                 </div>
             `;
-        }
     },
-
-    {
-        directory: 'groups/by-name',
-        stringsKey: 'listGroups.byName',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-        data: ({wikiData}) => sortAlphabetically(wikiData.groupData.slice()),
-
-        row(group, {link, language}) {
-            return language.$('listingPage.listGroups.byCategory.group', {
-                group: link.groupInfo(group),
-                gallery: link.groupGallery(group, {
-                    text: language.$('listingPage.listGroups.byCategory.group.gallery')
-                })
-            });
-        }
+  },
+
+  {
+    directory: "groups/by-name",
+    stringsKey: "listGroups.byName",
+    condition: ({ wikiData }) => wikiData.wikiInfo.enableGroupUI,
+    data: ({ wikiData }) => sortAlphabetically(wikiData.groupData.slice()),
+
+    row(group, { link, language }) {
+      return language.$("listingPage.listGroups.byCategory.group", {
+        group: link.groupInfo(group),
+        gallery: link.groupGallery(group, {
+          text: language.$("listingPage.listGroups.byCategory.group.gallery"),
+        }),
+      });
     },
+  },
 
-    {
-        directory: 'groups/by-category',
-        stringsKey: 'listGroups.byCategory',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-        data: ({wikiData}) => wikiData.groupCategoryData,
+  {
+    directory: "groups/by-category",
+    stringsKey: "listGroups.byCategory",
+    condition: ({ wikiData }) => wikiData.wikiInfo.enableGroupUI,
+    data: ({ wikiData }) => wikiData.groupCategoryData,
 
-        html(groupCategoryData, {link, language}) {
-            return fixWS`
+    html(groupCategoryData, { link, language }) {
+      return fixWS`
                 <dl>
-                    ${groupCategoryData.map(category => fixWS`
-                        <dt>${language.$('listingPage.listGroups.byCategory.category', {
-                            category: link.groupInfo(category.groups[0], {text: category.name})
-                        })}</dt>
+                    ${groupCategoryData
+                      .map(
+                        (category) => fixWS`
+                        <dt>${language.$(
+                          "listingPage.listGroups.byCategory.category",
+                          {
+                            category: link.groupInfo(category.groups[0], {
+                              text: category.name,
+                            }),
+                          }
+                        )}</dt>
                         <dd><ul>
-                            ${(category.groups
-                                .map(group => language.$('listingPage.listGroups.byCategory.group', {
+                            ${category.groups
+                              .map((group) =>
+                                language.$(
+                                  "listingPage.listGroups.byCategory.group",
+                                  {
                                     group: link.groupInfo(group),
                                     gallery: link.groupGallery(group, {
-                                        text: language.$('listingPage.listGroups.byCategory.group.gallery')
-                                    })
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                                      text: language.$(
+                                        "listingPage.listGroups.byCategory.group.gallery"
+                                      ),
+                                    }),
+                                  }
+                                )
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                         </ul></dd>
-                    `).join('\n')}
+                    `
+                      )
+                      .join("\n")}
                 </dl>
             `;
-        }
-    },
-
-    {
-        directory: 'groups/by-albums',
-        stringsKey: 'listGroups.byAlbums',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-
-        data({wikiData}) {
-            return wikiData.groupData
-                .map(group => ({group, albums: group.albums.length}))
-                .sort((a, b) => b.albums - a.albums);
-        },
-
-        row({group, albums}, {link, language}) {
-            return language.$('listingPage.listGroups.byAlbums.item', {
-                group: link.groupInfo(group),
-                albums: language.countAlbums(albums, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-tracks',
-        stringsKey: 'listGroups.byTracks',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-
-        data({wikiData}) {
-            return wikiData.groupData
-                .map(group => ({group, tracks: group.albums.reduce((acc, album) => acc + album.tracks.length, 0)}))
-                .sort((a, b) => b.tracks - a.tracks);
-        },
-
-        row({group, tracks}, {link, language}) {
-            return language.$('listingPage.listGroups.byTracks.item', {
-                group: link.groupInfo(group),
-                tracks: language.countTracks(tracks, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-duration',
-        stringsKey: 'listGroups.byDuration',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-
-        data({wikiData}) {
-            return wikiData.groupData
-                .map(group => ({group, duration: getTotalDuration(group.albums.flatMap(album => album.tracks))}))
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({group, duration}, {link, language}) {
-            return language.$('listingPage.listGroups.byDuration.item', {
-                group: link.groupInfo(group),
-                duration: language.formatDuration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-latest-album',
-        stringsKey: 'listGroups.byLatest',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-
-        data({wikiData}) {
-            return sortChronologically(wikiData.groupData
-                .map(group => {
-                    const albums = group.albums.filter(a => a.date);
-                    return albums.length && {
-                        group,
-                        directory: group.directory,
-                        name: group.name,
-                        date: albums[albums.length - 1].date
-                    };
-                })
-                .filter(Boolean)
-                // So this is kinda tough to explain, 8ut 8asically, when we
-                // reverse the list after sorting it 8y d8te (so that the latest
-                // d8tes come first), it also flips the order of groups which
-                // share the same d8te.  This happens mostly when a single al8um
-                // is the l8test in two groups. So, say one such al8um is in the
-                // groups "Fandom" and "UMSPAF". Per category order, Fandom is
-                // meant to show up 8efore UMSPAF, 8ut when we do the reverse
-                // l8ter, that flips them, and UMSPAF ends up displaying 8efore
-                // Fandom. So we do an extra reverse here, which will fix that
-                // and only affect groups that share the same d8te (8ecause
-                // groups that don't will 8e moved 8y the sortChronologically
-                // call surrounding this).
-                .reverse()).reverse()
-        },
-
-        row({group, date}, {link, language}) {
-            return language.$('listingPage.listGroups.byLatest.item', {
-                group: link.groupInfo(group),
-                date: language.formatDate(date)
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/by-name',
-        stringsKey: 'listTracks.byName',
-
-        data({wikiData}) {
-            return sortAlphabetically(wikiData.trackData.slice());
-        },
-
-        row(track, {link, language}) {
-            return language.$('listingPage.listTracks.byName.item', {
-                track: link.track(track)
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/by-album',
-        stringsKey: 'listTracks.byAlbum',
-        data: ({wikiData}) => wikiData.albumData,
-
-        html(albumData, {link, language}) {
-            return fixWS`
+    },
+  },
+
+  {
+    directory: "groups/by-albums",
+    stringsKey: "listGroups.byAlbums",
+    condition: ({ wikiData }) => wikiData.wikiInfo.enableGroupUI,
+
+    data({ wikiData }) {
+      return wikiData.groupData
+        .map((group) => ({ group, albums: group.albums.length }))
+        .sort((a, b) => b.albums - a.albums);
+    },
+
+    row({ group, albums }, { link, language }) {
+      return language.$("listingPage.listGroups.byAlbums.item", {
+        group: link.groupInfo(group),
+        albums: language.countAlbums(albums, { unit: true }),
+      });
+    },
+  },
+
+  {
+    directory: "groups/by-tracks",
+    stringsKey: "listGroups.byTracks",
+    condition: ({ wikiData }) => wikiData.wikiInfo.enableGroupUI,
+
+    data({ wikiData }) {
+      return wikiData.groupData
+        .map((group) => ({
+          group,
+          tracks: group.albums.reduce(
+            (acc, album) => acc + album.tracks.length,
+            0
+          ),
+        }))
+        .sort((a, b) => b.tracks - a.tracks);
+    },
+
+    row({ group, tracks }, { link, language }) {
+      return language.$("listingPage.listGroups.byTracks.item", {
+        group: link.groupInfo(group),
+        tracks: language.countTracks(tracks, { unit: true }),
+      });
+    },
+  },
+
+  {
+    directory: "groups/by-duration",
+    stringsKey: "listGroups.byDuration",
+    condition: ({ wikiData }) => wikiData.wikiInfo.enableGroupUI,
+
+    data({ wikiData }) {
+      return wikiData.groupData
+        .map((group) => ({
+          group,
+          duration: getTotalDuration(
+            group.albums.flatMap((album) => album.tracks)
+          ),
+        }))
+        .sort((a, b) => b.duration - a.duration);
+    },
+
+    row({ group, duration }, { link, language }) {
+      return language.$("listingPage.listGroups.byDuration.item", {
+        group: link.groupInfo(group),
+        duration: language.formatDuration(duration),
+      });
+    },
+  },
+
+  {
+    directory: "groups/by-latest-album",
+    stringsKey: "listGroups.byLatest",
+    condition: ({ wikiData }) => wikiData.wikiInfo.enableGroupUI,
+
+    data({ wikiData }) {
+      return sortChronologically(
+        wikiData.groupData
+          .map((group) => {
+            const albums = group.albums.filter((a) => a.date);
+            return (
+              albums.length && {
+                group,
+                directory: group.directory,
+                name: group.name,
+                date: albums[albums.length - 1].date,
+              }
+            );
+          })
+          .filter(Boolean)
+          // So this is kinda tough to explain, 8ut 8asically, when we
+          // reverse the list after sorting it 8y d8te (so that the latest
+          // d8tes come first), it also flips the order of groups which
+          // share the same d8te.  This happens mostly when a single al8um
+          // is the l8test in two groups. So, say one such al8um is in the
+          // groups "Fandom" and "UMSPAF". Per category order, Fandom is
+          // meant to show up 8efore UMSPAF, 8ut when we do the reverse
+          // l8ter, that flips them, and UMSPAF ends up displaying 8efore
+          // Fandom. So we do an extra reverse here, which will fix that
+          // and only affect groups that share the same d8te (8ecause
+          // groups that don't will 8e moved 8y the sortChronologically
+          // call surrounding this).
+          .reverse()
+      ).reverse();
+    },
+
+    row({ group, date }, { link, language }) {
+      return language.$("listingPage.listGroups.byLatest.item", {
+        group: link.groupInfo(group),
+        date: language.formatDate(date),
+      });
+    },
+  },
+
+  {
+    directory: "tracks/by-name",
+    stringsKey: "listTracks.byName",
+
+    data({ wikiData }) {
+      return sortAlphabetically(wikiData.trackData.slice());
+    },
+
+    row(track, { link, language }) {
+      return language.$("listingPage.listTracks.byName.item", {
+        track: link.track(track),
+      });
+    },
+  },
+
+  {
+    directory: "tracks/by-album",
+    stringsKey: "listTracks.byAlbum",
+    data: ({ wikiData }) => wikiData.albumData,
+
+    html(albumData, { link, language }) {
+      return fixWS`
                 <dl>
-                    ${albumData.map(album => fixWS`
-                        <dt>${language.$('listingPage.listTracks.byAlbum.album', {
-                            album: link.album(album)
-                        })}</dt>
+                    ${albumData
+                      .map(
+                        (album) => fixWS`
+                        <dt>${language.$(
+                          "listingPage.listTracks.byAlbum.album",
+                          {
+                            album: link.album(album),
+                          }
+                        )}</dt>
                         <dd><ol>
-                            ${(album.tracks
-                                .map(track => language.$('listingPage.listTracks.byAlbum.track', {
-                                    track: link.track(track)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                            ${album.tracks
+                              .map((track) =>
+                                language.$(
+                                  "listingPage.listTracks.byAlbum.track",
+                                  {
+                                    track: link.track(track),
+                                  }
+                                )
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                         </ol></dd>
-                    `).join('\n')}
+                    `
+                      )
+                      .join("\n")}
                 </dl>
             `;
-        }
     },
+  },
 
-    {
-        directory: 'tracks/by-date',
-        stringsKey: 'listTracks.byDate',
+  {
+    directory: "tracks/by-date",
+    stringsKey: "listTracks.byDate",
 
-        data({wikiData}) {
-            return chunkByProperties(
-                sortChronologically(wikiData.trackData.filter(t => t.date)),
-                ['album', 'date']
-            );
-        },
+    data({ wikiData }) {
+      return chunkByProperties(
+        sortChronologically(wikiData.trackData.filter((t) => t.date)),
+        ["album", "date"]
+      );
+    },
 
-        html(chunks, {link, language}) {
-            return fixWS`
+    html(chunks, { link, language }) {
+      return fixWS`
                 <dl>
-                    ${chunks.map(({album, date, chunk: tracks}) => fixWS`
-                        <dt>${language.$('listingPage.listTracks.byDate.album', {
+                    ${chunks
+                      .map(
+                        ({ album, date, chunk: tracks }) => fixWS`
+                        <dt>${language.$(
+                          "listingPage.listTracks.byDate.album",
+                          {
                             album: link.album(album),
-                            date: language.formatDate(date)
-                        })}</dt>
+                            date: language.formatDate(date),
+                          }
+                        )}</dt>
                         <dd><ul>
-                            ${(tracks
-                                .map(track => track.aka
-                                    ? `<li class="rerelease">${language.$('listingPage.listTracks.byDate.track.rerelease', {
-                                        track: link.track(track)
-                                    })}</li>`
-                                    : `<li>${language.$('listingPage.listTracks.byDate.track', {
-                                        track: link.track(track)
-                                    })}</li>`)
-                                .join('\n'))}
+                            ${tracks
+                              .map((track) =>
+                                track.aka
+                                  ? `<li class="rerelease">${language.$(
+                                      "listingPage.listTracks.byDate.track.rerelease",
+                                      {
+                                        track: link.track(track),
+                                      }
+                                    )}</li>`
+                                  : `<li>${language.$(
+                                      "listingPage.listTracks.byDate.track",
+                                      {
+                                        track: link.track(track),
+                                      }
+                                    )}</li>`
+                              )
+                              .join("\n")}
                         </ul></dd>
-                    `).join('\n')}
+                    `
+                      )
+                      .join("\n")}
                 </dl>
             `;
-        }
     },
+  },
 
-    {
-        directory: 'tracks/by-duration',
-        stringsKey: 'listTracks.byDuration',
+  {
+    directory: "tracks/by-duration",
+    stringsKey: "listTracks.byDuration",
 
-        data({wikiData}) {
-            return wikiData.trackData
-                .map(track => ({track, duration: track.duration}))
-                .filter(({ duration }) => duration > 0)
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({track, duration}, {link, language}) {
-            return language.$('listingPage.listTracks.byDuration.item', {
-                track: link.track(track),
-                duration: language.formatDuration(duration)
-            });
-        }
+    data({ wikiData }) {
+      return wikiData.trackData
+        .map((track) => ({ track, duration: track.duration }))
+        .filter(({ duration }) => duration > 0)
+        .sort((a, b) => b.duration - a.duration);
     },
 
-    {
-        directory: 'tracks/by-duration-in-album',
-        stringsKey: 'listTracks.byDurationInAlbum',
-
-        data({wikiData}) {
-            return wikiData.albumData.map(album => ({
-                album,
-                tracks: album.tracks.slice().sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0))
-            }));
-        },
+    row({ track, duration }, { link, language }) {
+      return language.$("listingPage.listTracks.byDuration.item", {
+        track: link.track(track),
+        duration: language.formatDuration(duration),
+      });
+    },
+  },
+
+  {
+    directory: "tracks/by-duration-in-album",
+    stringsKey: "listTracks.byDurationInAlbum",
+
+    data({ wikiData }) {
+      return wikiData.albumData.map((album) => ({
+        album,
+        tracks: album.tracks
+          .slice()
+          .sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0)),
+      }));
+    },
 
-        html(albums, {link, language}) {
-            return fixWS`
+    html(albums, { link, language }) {
+      return fixWS`
                 <dl>
-                    ${albums.map(({album, tracks}) => fixWS`
-                        <dt>${language.$('listingPage.listTracks.byDurationInAlbum.album', {
-                            album: link.album(album)
-                        })}</dt>
+                    ${albums
+                      .map(
+                        ({ album, tracks }) => fixWS`
+                        <dt>${language.$(
+                          "listingPage.listTracks.byDurationInAlbum.album",
+                          {
+                            album: link.album(album),
+                          }
+                        )}</dt>
                         <dd><ul>
-                            ${(tracks
-                                .map(track => language.$('listingPage.listTracks.byDurationInAlbum.track', {
+                            ${tracks
+                              .map((track) =>
+                                language.$(
+                                  "listingPage.listTracks.byDurationInAlbum.track",
+                                  {
                                     track: link.track(track),
-                                    duration: language.formatDuration(track.duration ?? 0)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                                    duration: language.formatDuration(
+                                      track.duration ?? 0
+                                    ),
+                                  }
+                                )
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                         </dd></ul>
-                    `).join('\n')}
+                    `
+                      )
+                      .join("\n")}
                 </dl>
             `;
-        }
     },
-
-    {
-        directory: 'tracks/by-times-referenced',
-        stringsKey: 'listTracks.byTimesReferenced',
-
-        data({wikiData}) {
-            return wikiData.trackData
-                .map(track => ({track, timesReferenced: track.referencedByTracks.length}))
-                .filter(({ timesReferenced }) => timesReferenced > 0)
-                .sort((a, b) => b.timesReferenced - a.timesReferenced);
-        },
-
-        row({track, timesReferenced}, {link, language}) {
-            return language.$('listingPage.listTracks.byTimesReferenced.item', {
-                track: link.track(track),
-                timesReferenced: language.countTimesReferenced(timesReferenced, {unit: true})
-            });
-        }
+  },
+
+  {
+    directory: "tracks/by-times-referenced",
+    stringsKey: "listTracks.byTimesReferenced",
+
+    data({ wikiData }) {
+      return wikiData.trackData
+        .map((track) => ({
+          track,
+          timesReferenced: track.referencedByTracks.length,
+        }))
+        .filter(({ timesReferenced }) => timesReferenced > 0)
+        .sort((a, b) => b.timesReferenced - a.timesReferenced);
     },
 
-    {
-        directory: 'tracks/in-flashes/by-album',
-        stringsKey: 'listTracks.inFlashes.byAlbum',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableFlashesAndGames,
-
-        data({wikiData}) {
-            return chunkByProperties(wikiData.trackData
-                .filter(t => t.featuredInFlashes?.length > 0), ['album']);
-        },
+    row({ track, timesReferenced }, { link, language }) {
+      return language.$("listingPage.listTracks.byTimesReferenced.item", {
+        track: link.track(track),
+        timesReferenced: language.countTimesReferenced(timesReferenced, {
+          unit: true,
+        }),
+      });
+    },
+  },
+
+  {
+    directory: "tracks/in-flashes/by-album",
+    stringsKey: "listTracks.inFlashes.byAlbum",
+    condition: ({ wikiData }) => wikiData.wikiInfo.enableFlashesAndGames,
+
+    data({ wikiData }) {
+      return chunkByProperties(
+        wikiData.trackData.filter((t) => t.featuredInFlashes?.length > 0),
+        ["album"]
+      );
+    },
 
-        html(chunks, {link, language}) {
-            return fixWS`
+    html(chunks, { link, language }) {
+      return fixWS`
                 <dl>
-                    ${chunks.map(({album, chunk: tracks}) => fixWS`
-                        <dt>${language.$('listingPage.listTracks.inFlashes.byAlbum.album', {
+                    ${chunks
+                      .map(
+                        ({ album, chunk: tracks }) => fixWS`
+                        <dt>${language.$(
+                          "listingPage.listTracks.inFlashes.byAlbum.album",
+                          {
                             album: link.album(album),
-                            date: language.formatDate(album.date)
-                        })}</dt>
+                            date: language.formatDate(album.date),
+                          }
+                        )}</dt>
                         <dd><ul>
-                            ${(tracks
-                                .map(track => language.$('listingPage.listTracks.inFlashes.byAlbum.track', {
+                            ${tracks
+                              .map((track) =>
+                                language.$(
+                                  "listingPage.listTracks.inFlashes.byAlbum.track",
+                                  {
                                     track: link.track(track),
-                                    flashes: language.formatConjunctionList(track.featuredInFlashes.map(link.flash))
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                                    flashes: language.formatConjunctionList(
+                                      track.featuredInFlashes.map(link.flash)
+                                    ),
+                                  }
+                                )
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                         </dd></ul>
-                    `).join('\n')}
+                    `
+                      )
+                      .join("\n")}
                 </dl>
             `;
-        }
     },
+  },
 
-    {
-        directory: 'tracks/in-flashes/by-flash',
-        stringsKey: 'listTracks.inFlashes.byFlash',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableFlashesAndGames,
-        data: ({wikiData}) => wikiData.flashData,
+  {
+    directory: "tracks/in-flashes/by-flash",
+    stringsKey: "listTracks.inFlashes.byFlash",
+    condition: ({ wikiData }) => wikiData.wikiInfo.enableFlashesAndGames,
+    data: ({ wikiData }) => wikiData.flashData,
 
-        html(flashData, {link, language}) {
-            return fixWS`
+    html(flashData, { link, language }) {
+      return fixWS`
                 <dl>
-                    ${sortChronologically(flashData.slice()).map(flash => fixWS`
-                        <dt>${language.$('listingPage.listTracks.inFlashes.byFlash.flash', {
+                    ${sortChronologically(flashData.slice())
+                      .map(
+                        (flash) => fixWS`
+                        <dt>${language.$(
+                          "listingPage.listTracks.inFlashes.byFlash.flash",
+                          {
                             flash: link.flash(flash),
-                            date: language.formatDate(flash.date)
-                        })}</dt>
+                            date: language.formatDate(flash.date),
+                          }
+                        )}</dt>
                         <dd><ul>
-                            ${(flash.featuredTracks
-                                .map(track => language.$('listingPage.listTracks.inFlashes.byFlash.track', {
+                            ${flash.featuredTracks
+                              .map((track) =>
+                                language.$(
+                                  "listingPage.listTracks.inFlashes.byFlash.track",
+                                  {
                                     track: link.track(track),
-                                    album: link.album(track.album)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                                    album: link.album(track.album),
+                                  }
+                                )
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                         </ul></dd>
-                    `).join('\n')}
+                    `
+                      )
+                      .join("\n")}
                 </dl>
             `;
-        }
+    },
+  },
+
+  {
+    directory: "tracks/with-lyrics",
+    stringsKey: "listTracks.withLyrics",
+
+    data({ wikiData }) {
+      return wikiData.albumData
+        .map((album) => ({
+          album,
+          tracks: album.tracks.filter((t) => t.lyrics),
+        }))
+        .filter(({ tracks }) => tracks.length > 0);
     },
 
-    {
-        directory: 'tracks/with-lyrics',
-        stringsKey: 'listTracks.withLyrics',
-
-        data({wikiData}) {
-            return wikiData.albumData.map(album => ({
-                album,
-                tracks: album.tracks.filter(t => t.lyrics)
-            })).filter(({ tracks }) => tracks.length > 0);
-        },
-
-        html(chunks, {link, language}) {
-            return fixWS`
+    html(chunks, { link, language }) {
+      return fixWS`
                 <dl>
-                    ${chunks.map(({album, tracks}) => fixWS`
-                        <dt>${language.$('listingPage.listTracks.withLyrics.album', {
+                    ${chunks
+                      .map(
+                        ({ album, tracks }) => fixWS`
+                        <dt>${language.$(
+                          "listingPage.listTracks.withLyrics.album",
+                          {
                             album: link.album(album),
-                            date: language.formatDate(album.date)
-                        })}</dt>
+                            date: language.formatDate(album.date),
+                          }
+                        )}</dt>
                         <dd><ul>
-                            ${(tracks
-                                .map(track => language.$('listingPage.listTracks.withLyrics.track', {
+                            ${tracks
+                              .map((track) =>
+                                language.$(
+                                  "listingPage.listTracks.withLyrics.track",
+                                  {
                                     track: link.track(track),
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                                  }
+                                )
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                         </dd></ul>
-                    `).join('\n')}
+                    `
+                      )
+                      .join("\n")}
                 </dl>
             `;
-        }
-    },
-
-    {
-        directory: 'tags/by-name',
-        stringsKey: 'listTags.byName',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableArtTagUI,
-
-        data({wikiData}) {
-            return sortAlphabetically(wikiData.artTagData.filter(tag => !tag.isContentWarning))
-                .map(tag => ({tag, timesUsed: tag.taggedInThings?.length}));
-        },
-
-        row({tag, timesUsed}, {link, language}) {
-            return language.$('listingPage.listTags.byName.item', {
-                tag: link.tag(tag),
-                timesUsed: language.countTimesUsed(timesUsed, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'tags/by-uses',
-        stringsKey: 'listTags.byUses',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableArtTagUI,
-
-        data({wikiData}) {
-            return wikiData.artTagData
-                .filter(tag => !tag.isContentWarning)
-                .map(tag => ({tag, timesUsed: tag.taggedInThings?.length}))
-                .sort((a, b) => b.timesUsed - a.timesUsed);
-        },
-
-        row({tag, timesUsed}, {link, language}) {
-            return language.$('listingPage.listTags.byUses.item', {
-                tag: link.tag(tag),
-                timesUsed: language.countTimesUsed(timesUsed, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'random',
-        stringsKey: 'other.randomPages',
-
-        data: ({wikiData}) => ({
-            officialAlbumData: wikiData.officialAlbumData,
-            fandomAlbumData: wikiData.fandomAlbumData
-        }),
+    },
+  },
+
+  {
+    directory: "tags/by-name",
+    stringsKey: "listTags.byName",
+    condition: ({ wikiData }) => wikiData.wikiInfo.enableArtTagUI,
+
+    data({ wikiData }) {
+      return sortAlphabetically(
+        wikiData.artTagData.filter((tag) => !tag.isContentWarning)
+      ).map((tag) => ({ tag, timesUsed: tag.taggedInThings?.length }));
+    },
+
+    row({ tag, timesUsed }, { link, language }) {
+      return language.$("listingPage.listTags.byName.item", {
+        tag: link.tag(tag),
+        timesUsed: language.countTimesUsed(timesUsed, { unit: true }),
+      });
+    },
+  },
+
+  {
+    directory: "tags/by-uses",
+    stringsKey: "listTags.byUses",
+    condition: ({ wikiData }) => wikiData.wikiInfo.enableArtTagUI,
+
+    data({ wikiData }) {
+      return wikiData.artTagData
+        .filter((tag) => !tag.isContentWarning)
+        .map((tag) => ({ tag, timesUsed: tag.taggedInThings?.length }))
+        .sort((a, b) => b.timesUsed - a.timesUsed);
+    },
+
+    row({ tag, timesUsed }, { link, language }) {
+      return language.$("listingPage.listTags.byUses.item", {
+        tag: link.tag(tag),
+        timesUsed: language.countTimesUsed(timesUsed, { unit: true }),
+      });
+    },
+  },
+
+  {
+    directory: "random",
+    stringsKey: "other.randomPages",
+
+    data: ({ wikiData }) => ({
+      officialAlbumData: wikiData.officialAlbumData,
+      fandomAlbumData: wikiData.fandomAlbumData,
+    }),
 
-        html: ({officialAlbumData, fandomAlbumData}, {
-            getLinkThemeString,
-            language
-        }) => fixWS`
+    html: (
+      { officialAlbumData, fandomAlbumData },
+      { getLinkThemeString, language }
+    ) => fixWS`
             <p>Choose a link to go to a random page in that category or album! If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.</p>
             <p class="js-hide-once-data">(Data files are downloading in the background! Please wait for data to load.)</p>
             <p class="js-show-once-data">(Data files have finished being downloaded. The links should work!)</p>
@@ -780,49 +977,73 @@ const listingSpec = [
                     <li><a href="#" data-random="track">Random Track (whole site)</a></li>
                 </ul></dd>
                 ${[
-                    {name: 'Official', albumData: officialAlbumData, code: 'official'},
-                    {name: 'Fandom', albumData: fandomAlbumData, code: 'fandom'}
-                ].map(category => fixWS`
-                    <dt>${category.name}: (<a href="#" data-random="album-in-${category.code}">Random Album</a>, <a href="#" data-random="track-in-${category.code}">Random Track</a>)</dt>
-                    <dd><ul>${category.albumData.map(album => fixWS`
-                        <li><a style="${getLinkThemeString(album.color)}; --album-directory: ${album.directory}" href="#" data-random="track-in-album">${album.name}</a></li>
-                    `).join('\n')}</ul></dd>
-                `).join('\n')}
+                  {
+                    name: "Official",
+                    albumData: officialAlbumData,
+                    code: "official",
+                  },
+                  {
+                    name: "Fandom",
+                    albumData: fandomAlbumData,
+                    code: "fandom",
+                  },
+                ]
+                  .map(
+                    (category) => fixWS`
+                    <dt>${category.name}: (<a href="#" data-random="album-in-${
+                      category.code
+                    }">Random Album</a>, <a href="#" data-random="track-in-${
+                      category.code
+                    }">Random Track</a>)</dt>
+                    <dd><ul>${category.albumData
+                      .map(
+                        (album) => fixWS`
+                        <li><a style="${getLinkThemeString(
+                          album.color
+                        )}; --album-directory: ${
+                          album.directory
+                        }" href="#" data-random="track-in-album">${
+                          album.name
+                        }</a></li>
+                    `
+                      )
+                      .join("\n")}</ul></dd>
+                `
+                  )
+                  .join("\n")}
             </dl>
-        `
-    }
+        `,
+  },
 ];
 
-const filterListings = directoryPrefix => listingSpec
-    .filter(l => l.directory.startsWith(directoryPrefix));
+const filterListings = (directoryPrefix) =>
+  listingSpec.filter((l) => l.directory.startsWith(directoryPrefix));
 
 const listingTargetSpec = [
-    {
-        title: ({language}) => language.$('listingPage.target.album'),
-        listings: filterListings('album')
-    },
-    {
-        title: ({language}) => language.$('listingPage.target.artist'),
-        listings: filterListings('artist')
-    },
-    {
-        title: ({language}) => language.$('listingPage.target.group'),
-        listings: filterListings('group')
-    },
-    {
-        title: ({language}) => language.$('listingPage.target.track'),
-        listings: filterListings('track')
-    },
-    {
-        title: ({language}) => language.$('listingPage.target.tag'),
-        listings: filterListings('tag')
-    },
-    {
-        title: ({language}) => language.$('listingPage.target.other'),
-        listings: [
-            listingSpec.find(l => l.directory === 'random')
-        ]
-    }
+  {
+    title: ({ language }) => language.$("listingPage.target.album"),
+    listings: filterListings("album"),
+  },
+  {
+    title: ({ language }) => language.$("listingPage.target.artist"),
+    listings: filterListings("artist"),
+  },
+  {
+    title: ({ language }) => language.$("listingPage.target.group"),
+    listings: filterListings("group"),
+  },
+  {
+    title: ({ language }) => language.$("listingPage.target.track"),
+    listings: filterListings("track"),
+  },
+  {
+    title: ({ language }) => language.$("listingPage.target.tag"),
+    listings: filterListings("tag"),
+  },
+  {
+    title: ({ language }) => language.$("listingPage.target.other"),
+    listings: [listingSpec.find((l) => l.directory === "random")],
+  },
 ];
 
-export {listingSpec, listingTargetSpec};
+export { listingSpec, listingTargetSpec };
diff --git a/src/misc-templates.js b/src/misc-templates.js
index 61afa710..4f3a75d0 100644
--- a/src/misc-templates.js
+++ b/src/misc-templates.js
@@ -2,220 +2,283 @@
 // These are made available right on a page spec's ({wikiData, language, ...})
 // args object!
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
-import * as html from './util/html.js';
+import * as html from "./util/html.js";
 
-import {
-    Track,
-    Album,
-} from './data/things.js';
+import { Track, Album } from "./data/things.js";
 
-import {
-    getColors
-} from './util/colors.js';
+import { getColors } from "./util/colors.js";
 
-import {
-    unique
-} from './util/sugar.js';
+import { unique } from "./util/sugar.js";
 
 import {
-    getTotalDuration,
-    sortAlbumsTracksChronologically,
-    sortChronologically,
-} from './util/wiki-data.js';
+  getTotalDuration,
+  sortAlbumsTracksChronologically,
+  sortChronologically,
+} from "./util/wiki-data.js";
 
-const BANDCAMP_DOMAINS = [
-    'bc.s3m.us',
-    'music.solatrux.com',
-];
+const BANDCAMP_DOMAINS = ["bc.s3m.us", "music.solatrux.com"];
 
-const MASTODON_DOMAINS = [
-    'types.pl',
-];
+const MASTODON_DOMAINS = ["types.pl"];
 
 // "Additional Files" listing
 
-export function generateAdditionalFilesShortcut(additionalFiles, {language}) {
-    if (!additionalFiles?.length) return '';
+export function generateAdditionalFilesShortcut(additionalFiles, { language }) {
+  if (!additionalFiles?.length) return "";
 
-    return language.$('releaseInfo.additionalFiles.shortcut', {
-        anchorLink: `<a href="#additional-files">${language.$('releaseInfo.additionalFiles.shortcut.anchorLink')}</a>`,
-        titles: language.formatUnitList(additionalFiles.map(g => g.title))
-    });
+  return language.$("releaseInfo.additionalFiles.shortcut", {
+    anchorLink: `<a href="#additional-files">${language.$(
+      "releaseInfo.additionalFiles.shortcut.anchorLink"
+    )}</a>`,
+    titles: language.formatUnitList(additionalFiles.map((g) => g.title)),
+  });
 }
 
-export function generateAdditionalFilesList(additionalFiles, {language, getFileSize, linkFile}) {
-    if (!additionalFiles?.length) return '';
+export function generateAdditionalFilesList(
+  additionalFiles,
+  { language, getFileSize, linkFile }
+) {
+  if (!additionalFiles?.length) return "";
 
-    const fileCount = additionalFiles.flatMap(g => g.files).length;
+  const fileCount = additionalFiles.flatMap((g) => g.files).length;
 
-    return fixWS`
-        <p id="additional-files">${language.$('releaseInfo.additionalFiles.heading', {
-            additionalFiles: language.countAdditionalFiles(fileCount, {unit: true})
-        })}</p>
+  return fixWS`
+        <p id="additional-files">${language.$(
+          "releaseInfo.additionalFiles.heading",
+          {
+            additionalFiles: language.countAdditionalFiles(fileCount, {
+              unit: true,
+            }),
+          }
+        )}</p>
         <dl>
-            ${additionalFiles.map(({ title, description, files }) => fixWS`
-                <dt>${(description
-                    ? language.$('releaseInfo.additionalFiles.entry.withDescription', {title, description})
-                    : language.$('releaseInfo.additionalFiles.entry', {title}))}</dt>
+            ${additionalFiles
+              .map(
+                ({ title, description, files }) => fixWS`
+                <dt>${
+                  description
+                    ? language.$(
+                        "releaseInfo.additionalFiles.entry.withDescription",
+                        { title, description }
+                      )
+                    : language.$("releaseInfo.additionalFiles.entry", { title })
+                }</dt>
                 <dd><ul>
-                    ${files.map(file => {
+                    ${files
+                      .map((file) => {
                         const size = getFileSize(file);
-                        return (size
-                            ? `<li>${language.$('releaseInfo.additionalFiles.file.withSize', {
+                        return size
+                          ? `<li>${language.$(
+                              "releaseInfo.additionalFiles.file.withSize",
+                              {
+                                file: linkFile(file),
+                                size: language.formatFileSize(
+                                  getFileSize(file)
+                                ),
+                              }
+                            )}</li>`
+                          : `<li>${language.$(
+                              "releaseInfo.additionalFiles.file",
+                              {
                                 file: linkFile(file),
-                                size: language.formatFileSize(getFileSize(file))
-                            })}</li>`
-                            : `<li>${language.$('releaseInfo.additionalFiles.file', {
-                                file: linkFile(file)
-                            })}</li>`);
-                    }).join('\n')}
+                              }
+                            )}</li>`;
+                      })
+                      .join("\n")}
                 </ul></dd>
-            `).join('\n')}
+            `
+              )
+              .join("\n")}
         </dl>
     `;
 }
 
 // Artist strings
 
-export function getArtistString(artists, {
-    iconifyURL, link, language,
-    showIcons = false,
-    showContrib = false
-}) {
-    return language.formatConjunctionList(artists.map(({ who, what }) => {
-        const { urls, directory, name } = who;
-        return [
-            link.artist(who),
-            showContrib && what && `(${what})`,
-            showIcons && urls?.length && `<span class="icons">(${
-                language.formatUnitList(urls.map(url => iconifyURL(url, {language})))
-            })</span>`
-        ].filter(Boolean).join(' ');
-    }));
+export function getArtistString(
+  artists,
+  { iconifyURL, link, language, showIcons = false, showContrib = false }
+) {
+  return language.formatConjunctionList(
+    artists.map(({ who, what }) => {
+      const { urls, directory, name } = who;
+      return [
+        link.artist(who),
+        showContrib && what && `(${what})`,
+        showIcons &&
+          urls?.length &&
+          `<span class="icons">(${language.formatUnitList(
+            urls.map((url) => iconifyURL(url, { language }))
+          )})</span>`,
+      ]
+        .filter(Boolean)
+        .join(" ");
+    })
+  );
 }
 
 // Chronology links
 
-export function generateChronologyLinks(currentThing, {
-    dateKey = 'date',
+export function generateChronologyLinks(
+  currentThing,
+  {
+    dateKey = "date",
     contribKey,
     getThings,
     headingString,
     link,
     linkAnythingMan,
     language,
-    wikiData
-}) {
-    const { albumData } = wikiData;
-
-    const contributions = currentThing[contribKey];
-    if (!contributions) {
-        return '';
-    }
-
-    if (contributions.length > 8) {
-        return `<div class="chronology">${language.$('misc.chronology.seeArtistPages')}</div>`;
-    }
-
-    return contributions.map(({ who: artist }) => {
-        const thingsUnsorted = unique(getThings(artist)).filter(t => t[dateKey]);
-
-        // Kinda a hack, but we automatically detect which is (probably) the
-        // right function to use here.
-        const args = [thingsUnsorted, {getDate: t => t[dateKey]}];
-        const things = (thingsUnsorted.every(t => t instanceof Album || t instanceof Track)
-            ? sortAlbumsTracksChronologically(...args)
-            : sortChronologically(...args));
-
-        const index = things.indexOf(currentThing);
-
-        if (index === -1) return '';
-
-        // TODO: This can pro8a8ly 8e made to use generatePreviousNextLinks?
-        // We'd need to make generatePreviousNextLinks use toAnythingMan tho.
-        const previous = things[index - 1];
-        const next = things[index + 1];
-        const parts = [
-            previous && linkAnythingMan(previous, {
-                color: false,
-                text: language.$('misc.nav.previous')
-            }),
-            next && linkAnythingMan(next, {
-                color: false,
-                text: language.$('misc.nav.next')
-            })
-        ].filter(Boolean);
-
-        if (!parts.length) {
-            return '';
-        }
-
-        const stringOpts = {
-            index: language.formatIndex(index + 1, {language}),
-            artist: link.artist(artist)
-        };
-
-        return fixWS`
+    wikiData,
+  }
+) {
+  const { albumData } = wikiData;
+
+  const contributions = currentThing[contribKey];
+  if (!contributions) {
+    return "";
+  }
+
+  if (contributions.length > 8) {
+    return `<div class="chronology">${language.$(
+      "misc.chronology.seeArtistPages"
+    )}</div>`;
+  }
+
+  return contributions
+    .map(({ who: artist }) => {
+      const thingsUnsorted = unique(getThings(artist)).filter(
+        (t) => t[dateKey]
+      );
+
+      // Kinda a hack, but we automatically detect which is (probably) the
+      // right function to use here.
+      const args = [thingsUnsorted, { getDate: (t) => t[dateKey] }];
+      const things = thingsUnsorted.every(
+        (t) => t instanceof Album || t instanceof Track
+      )
+        ? sortAlbumsTracksChronologically(...args)
+        : sortChronologically(...args);
+
+      const index = things.indexOf(currentThing);
+
+      if (index === -1) return "";
+
+      // TODO: This can pro8a8ly 8e made to use generatePreviousNextLinks?
+      // We'd need to make generatePreviousNextLinks use toAnythingMan tho.
+      const previous = things[index - 1];
+      const next = things[index + 1];
+      const parts = [
+        previous &&
+          linkAnythingMan(previous, {
+            color: false,
+            text: language.$("misc.nav.previous"),
+          }),
+        next &&
+          linkAnythingMan(next, {
+            color: false,
+            text: language.$("misc.nav.next"),
+          }),
+      ].filter(Boolean);
+
+      if (!parts.length) {
+        return "";
+      }
+
+      const stringOpts = {
+        index: language.formatIndex(index + 1, { language }),
+        artist: link.artist(artist),
+      };
+
+      return fixWS`
             <div class="chronology">
-                <span class="heading">${language.$(headingString, stringOpts)}</span>
-                ${parts.length && `<span class="buttons">(${parts.join(', ')})</span>`}
+                <span class="heading">${language.$(
+                  headingString,
+                  stringOpts
+                )}</span>
+                ${
+                  parts.length &&
+                  `<span class="buttons">(${parts.join(", ")})</span>`
+                }
             </div>
         `;
-    }).filter(Boolean).join('\n');
+    })
+    .filter(Boolean)
+    .join("\n");
 }
 
 // Content warning tags
 
-export function getRevealStringFromWarnings(warnings, {language}) {
-    return language.$('misc.contentWarnings', {warnings}) + `<br><span class="reveal-interaction">${language.$('misc.contentWarnings.reveal')}</span>`
+export function getRevealStringFromWarnings(warnings, { language }) {
+  return (
+    language.$("misc.contentWarnings", { warnings }) +
+    `<br><span class="reveal-interaction">${language.$(
+      "misc.contentWarnings.reveal"
+    )}</span>`
+  );
 }
 
-export function getRevealStringFromTags(tags, {language}) {
-    return tags && tags.some(tag => tag.isContentWarning) && (
-        getRevealStringFromWarnings(language.formatUnitList(tags.filter(tag => tag.isContentWarning).map(tag => tag.name)), {language}));
+export function getRevealStringFromTags(tags, { language }) {
+  return (
+    tags &&
+    tags.some((tag) => tag.isContentWarning) &&
+    getRevealStringFromWarnings(
+      language.formatUnitList(
+        tags.filter((tag) => tag.isContentWarning).map((tag) => tag.name)
+      ),
+      { language }
+    )
+  );
 }
 
 // Cover art links
 
 export function generateCoverLink({
-    img, link, language, to, wikiData,
-    src,
-    path,
-    alt,
-    tags = []
+  img,
+  link,
+  language,
+  to,
+  wikiData,
+  src,
+  path,
+  alt,
+  tags = [],
 }) {
-    const { wikiInfo } = wikiData;
+  const { wikiInfo } = wikiData;
 
-    if (!src && path) {
-        src = to(...path);
-    }
+  if (!src && path) {
+    src = to(...path);
+  }
 
-    if (!src) {
-        throw new Error(`Expected src or path`);
-    }
+  if (!src) {
+    throw new Error(`Expected src or path`);
+  }
 
-    return fixWS`
+  return fixWS`
         <div id="cover-art-container">
             ${img({
-                src,
-                alt,
-                thumb: 'medium',
-                id: 'cover-art',
-                link: true,
-                square: true,
-                reveal: getRevealStringFromTags(tags, {language})
+              src,
+              alt,
+              thumb: "medium",
+              id: "cover-art",
+              link: true,
+              square: true,
+              reveal: getRevealStringFromTags(tags, { language }),
             })}
-            ${wikiInfo.enableArtTagUI && tags.filter(tag => !tag.isContentWarning).length && fixWS`
+            ${
+              wikiInfo.enableArtTagUI &&
+              tags.filter((tag) => !tag.isContentWarning).length &&
+              fixWS`
                 <p class="tags">
-                    ${language.$('releaseInfo.artTags')}
-                    ${(tags
-                        .filter(tag => !tag.isContentWarning)
-                        .map(link.tag)
-                        .join(',\n'))}
+                    ${language.$("releaseInfo.artTags")}
+                    ${tags
+                      .filter((tag) => !tag.isContentWarning)
+                      .map(link.tag)
+                      .join(",\n")}
                 </p>
-            `}
+            `
+            }
         </div>
     `;
 }
@@ -223,288 +286,364 @@ export function generateCoverLink({
 // CSS & color shenanigans
 
 export function getThemeString(color, additionalVariables = []) {
-    if (!color) return '';
+  if (!color) return "";
 
-    const { primary, dim, bg } = getColors(color);
+  const { primary, dim, bg } = getColors(color);
 
-    const variables = [
-        `--primary-color: ${primary}`,
-        `--dim-color: ${dim}`,
-        `--bg-color: ${bg}`,
-        ...additionalVariables
-    ].filter(Boolean);
+  const variables = [
+    `--primary-color: ${primary}`,
+    `--dim-color: ${dim}`,
+    `--bg-color: ${bg}`,
+    ...additionalVariables,
+  ].filter(Boolean);
 
-    if (!variables.length) return '';
+  if (!variables.length) return "";
 
-    return (
-        `:root {\n` +
-        variables.map(line => `    ` + line + ';\n').join('') +
-        `}`
-    );
+  return (
+    `:root {\n` + variables.map((line) => `    ` + line + ";\n").join("") + `}`
+  );
 }
-export function getAlbumStylesheet(album, {to}) {
-    return [
-        album.wallpaperArtistContribs.length && fixWS`
+export function getAlbumStylesheet(album, { to }) {
+  return [
+    album.wallpaperArtistContribs.length &&
+      fixWS`
             body::before {
-                background-image: url("${to('media.albumWallpaper', album.directory, album.wallpaperFileExtension)}");
+                background-image: url("${to(
+                  "media.albumWallpaper",
+                  album.directory,
+                  album.wallpaperFileExtension
+                )}");
                 ${album.wallpaperStyle}
             }
         `,
-        album.bannerStyle && fixWS`
+    album.bannerStyle &&
+      fixWS`
             #banner img {
                 ${album.bannerStyle}
             }
-        `
-    ].filter(Boolean).join('\n');
+        `,
+  ]
+    .filter(Boolean)
+    .join("\n");
 }
 
 // Divided track lists
 
-export function generateTrackListDividedByGroups(tracks, {
-    getTrackItem,
-    language,
-    wikiData,
-}) {
-    const { divideTrackListsByGroups: groups } = wikiData.wikiInfo;
+export function generateTrackListDividedByGroups(
+  tracks,
+  { getTrackItem, language, wikiData }
+) {
+  const { divideTrackListsByGroups: groups } = wikiData.wikiInfo;
 
-    if (!groups?.length) {
-        return html.tag('ul', tracks.map(t => getTrackItem(t)));
-    }
-
-    const lists = Object.fromEntries(groups.map(group => [group.directory, {group, tracks: []}]));
-    const other = [];
-
-    for (const track of tracks) {
-        const { album } = track;
-        const group = groups.find(g => g.albums.includes(album));
-        if (group) {
-            lists[group.directory].tracks.push(track);
-        } else {
-            other.push(track);
-        }
+  if (!groups?.length) {
+    return html.tag(
+      "ul",
+      tracks.map((t) => getTrackItem(t))
+    );
+  }
+
+  const lists = Object.fromEntries(
+    groups.map((group) => [group.directory, { group, tracks: [] }])
+  );
+  const other = [];
+
+  for (const track of tracks) {
+    const { album } = track;
+    const group = groups.find((g) => g.albums.includes(album));
+    if (group) {
+      lists[group.directory].tracks.push(track);
+    } else {
+      other.push(track);
     }
+  }
 
-    const ddul = tracks => fixWS`
+  const ddul = (tracks) => fixWS`
         <dd><ul>
-            ${tracks.map(t => getTrackItem(t)).join('\n')}
+            ${tracks.map((t) => getTrackItem(t)).join("\n")}
         </ul></dd>
     `;
 
-    return html.tag('dl', Object.values(lists)
-        .filter(({ tracks }) => tracks.length)
-        .flatMap(({ group, tracks }) => [
-            html.tag('dt', language.formatString('trackList.group', {group: group.name})),
-            ddul(tracks)
-        ])
-        .concat(other.length ? [
-            `<dt>${language.formatString('trackList.group', {
-                group: language.formatString('trackList.group.other')
-            })}</dt>`,
-            ddul(other)
-        ] : []));
+  return html.tag(
+    "dl",
+    Object.values(lists)
+      .filter(({ tracks }) => tracks.length)
+      .flatMap(({ group, tracks }) => [
+        html.tag(
+          "dt",
+          language.formatString("trackList.group", { group: group.name })
+        ),
+        ddul(tracks),
+      ])
+      .concat(
+        other.length
+          ? [
+              `<dt>${language.formatString("trackList.group", {
+                group: language.formatString("trackList.group.other"),
+              })}</dt>`,
+              ddul(other),
+            ]
+          : []
+      )
+  );
 }
 
 // Fancy lookin' links
 
-export function fancifyURL(url, {language, album = false} = {}) {
-    let local = Symbol();
-    let domain;
-    try {
-        domain = new URL(url).hostname;
-    } catch (error) {
-        // No support for relative local URLs yet, sorry! (I.e, local URLs must
-        // be absolute relative to the domain name in order to work.)
-        domain = local;
-    }
-    return fixWS`<a href="${url}" class="nowrap">${
-        domain === local ? language.$('misc.external.local') :
-        domain.includes('bandcamp.com') ? language.$('misc.external.bandcamp') :
-        BANDCAMP_DOMAINS.includes(domain) ? language.$('misc.external.bandcamp.domain', {domain}) :
-        MASTODON_DOMAINS.includes(domain) ? language.$('misc.external.mastodon.domain', {domain}) :
-        domain.includes('youtu') ? (album
-            ? (url.includes('list=')
-                ? language.$('misc.external.youtube.playlist')
-                : language.$('misc.external.youtube.fullAlbum'))
-            : language.$('misc.external.youtube')) :
-        domain.includes('soundcloud') ? language.$('misc.external.soundcloud') :
-        domain.includes('tumblr.com') ? language.$('misc.external.tumblr') :
-        domain.includes('twitter.com') ? language.$('misc.external.twitter') :
-        domain.includes('deviantart.com') ? language.$('misc.external.deviantart') :
-        domain.includes('wikipedia.org') ? language.$('misc.external.wikipedia') :
-        domain.includes('poetryfoundation.org') ? language.$('misc.external.poetryFoundation') :
-        domain.includes('instagram.com') ? language.$('misc.external.instagram') :
-        domain.includes('patreon.com') ? language.$('misc.external.patreon') :
-        domain
-    }</a>`;
+export function fancifyURL(url, { language, album = false } = {}) {
+  let local = Symbol();
+  let domain;
+  try {
+    domain = new URL(url).hostname;
+  } catch (error) {
+    // No support for relative local URLs yet, sorry! (I.e, local URLs must
+    // be absolute relative to the domain name in order to work.)
+    domain = local;
+  }
+  return fixWS`<a href="${url}" class="nowrap">${
+    domain === local
+      ? language.$("misc.external.local")
+      : domain.includes("bandcamp.com")
+      ? language.$("misc.external.bandcamp")
+      : BANDCAMP_DOMAINS.includes(domain)
+      ? language.$("misc.external.bandcamp.domain", { domain })
+      : MASTODON_DOMAINS.includes(domain)
+      ? language.$("misc.external.mastodon.domain", { domain })
+      : domain.includes("youtu")
+      ? album
+        ? url.includes("list=")
+          ? language.$("misc.external.youtube.playlist")
+          : language.$("misc.external.youtube.fullAlbum")
+        : language.$("misc.external.youtube")
+      : domain.includes("soundcloud")
+      ? language.$("misc.external.soundcloud")
+      : domain.includes("tumblr.com")
+      ? language.$("misc.external.tumblr")
+      : domain.includes("twitter.com")
+      ? language.$("misc.external.twitter")
+      : domain.includes("deviantart.com")
+      ? language.$("misc.external.deviantart")
+      : domain.includes("wikipedia.org")
+      ? language.$("misc.external.wikipedia")
+      : domain.includes("poetryfoundation.org")
+      ? language.$("misc.external.poetryFoundation")
+      : domain.includes("instagram.com")
+      ? language.$("misc.external.instagram")
+      : domain.includes("patreon.com")
+      ? language.$("misc.external.patreon")
+      : domain
+  }</a>`;
 }
 
-export function fancifyFlashURL(url, flash, {language}) {
-    const link = fancifyURL(url, {language});
-    return `<span class="nowrap">${
-        url.includes('homestuck.com') ? (isNaN(Number(flash.page))
-            ? language.$('misc.external.flash.homestuck.secret', {link})
-            : language.$('misc.external.flash.homestuck.page', {link, page: flash.page})) :
-        url.includes('bgreco.net') ? language.$('misc.external.flash.bgreco', {link}) :
-        url.includes('youtu') ? language.$('misc.external.flash.youtube', {link}) :
-        link
-    }</span>`;
+export function fancifyFlashURL(url, flash, { language }) {
+  const link = fancifyURL(url, { language });
+  return `<span class="nowrap">${
+    url.includes("homestuck.com")
+      ? isNaN(Number(flash.page))
+        ? language.$("misc.external.flash.homestuck.secret", { link })
+        : language.$("misc.external.flash.homestuck.page", {
+            link,
+            page: flash.page,
+          })
+      : url.includes("bgreco.net")
+      ? language.$("misc.external.flash.bgreco", { link })
+      : url.includes("youtu")
+      ? language.$("misc.external.flash.youtube", { link })
+      : link
+  }</span>`;
 }
 
-export function iconifyURL(url, {language, to}) {
-    const domain = new URL(url).hostname;
-    const [ id, msg ] = (
-        domain.includes('bandcamp.com') ? ['bandcamp', language.$('misc.external.bandcamp')] :
-        BANDCAMP_DOMAINS.includes(domain) ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})] :
-        MASTODON_DOMAINS.includes(domain) ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})] :
-        domain.includes('youtu') ? ['youtube', language.$('misc.external.youtube')] :
-        domain.includes('soundcloud') ? ['soundcloud', language.$('misc.external.soundcloud')] :
-        domain.includes('tumblr.com') ? ['tumblr', language.$('misc.external.tumblr')] :
-        domain.includes('twitter.com') ? ['twitter', language.$('misc.external.twitter')] :
-        domain.includes('deviantart.com') ? ['deviantart', language.$('misc.external.deviantart')] :
-        domain.includes('instagram.com') ? ['instagram', language.$('misc.external.bandcamp')] :
-        ['globe', language.$('misc.external.domain', {domain})]
-    );
-    return fixWS`<a href="${url}" class="icon"><svg><title>${msg}</title><use href="${to('shared.staticFile', `icons.svg#icon-${id}`)}"></use></svg></a>`;
+export function iconifyURL(url, { language, to }) {
+  const domain = new URL(url).hostname;
+  const [id, msg] = domain.includes("bandcamp.com")
+    ? ["bandcamp", language.$("misc.external.bandcamp")]
+    : BANDCAMP_DOMAINS.includes(domain)
+    ? ["bandcamp", language.$("misc.external.bandcamp.domain", { domain })]
+    : MASTODON_DOMAINS.includes(domain)
+    ? ["mastodon", language.$("misc.external.mastodon.domain", { domain })]
+    : domain.includes("youtu")
+    ? ["youtube", language.$("misc.external.youtube")]
+    : domain.includes("soundcloud")
+    ? ["soundcloud", language.$("misc.external.soundcloud")]
+    : domain.includes("tumblr.com")
+    ? ["tumblr", language.$("misc.external.tumblr")]
+    : domain.includes("twitter.com")
+    ? ["twitter", language.$("misc.external.twitter")]
+    : domain.includes("deviantart.com")
+    ? ["deviantart", language.$("misc.external.deviantart")]
+    : domain.includes("instagram.com")
+    ? ["instagram", language.$("misc.external.bandcamp")]
+    : ["globe", language.$("misc.external.domain", { domain })];
+  return fixWS`<a href="${url}" class="icon"><svg><title>${msg}</title><use href="${to(
+    "shared.staticFile",
+    `icons.svg#icon-${id}`
+  )}"></use></svg></a>`;
 }
 
 // Grids
 
 export function getGridHTML({
-    img,
-    language,
-
-    entries,
-    srcFn,
-    linkFn,
-    noSrcTextFn = () => '',
-    altFn = () => '',
-    detailsFn = null,
-    lazy = true
+  img,
+  language,
+
+  entries,
+  srcFn,
+  linkFn,
+  noSrcTextFn = () => "",
+  altFn = () => "",
+  detailsFn = null,
+  lazy = true,
 }) {
-    return entries.map(({ large, item }, i) => linkFn(item,
-        {
-            class: ['grid-item', 'box', large && 'large-grid-item'],
-            text: fixWS`
+  return entries
+    .map(({ large, item }, i) =>
+      linkFn(item, {
+        class: ["grid-item", "box", large && "large-grid-item"],
+        text: fixWS`
                 ${img({
-                    src: srcFn(item),
-                    alt: altFn(item),
-                    thumb: 'small',
-                    lazy: (typeof lazy === 'number' ? i >= lazy : lazy),
-                    square: true,
-                    reveal: getRevealStringFromTags(item.artTags, {language}),
-                    noSrcText: noSrcTextFn(item)
+                  src: srcFn(item),
+                  alt: altFn(item),
+                  thumb: "small",
+                  lazy: typeof lazy === "number" ? i >= lazy : lazy,
+                  square: true,
+                  reveal: getRevealStringFromTags(item.artTags, { language }),
+                  noSrcText: noSrcTextFn(item),
                 })}
                 <span>${item.name}</span>
                 ${detailsFn && `<span>${detailsFn(item)}</span>`}
-            `
-        })).join('\n');
+            `,
+      })
+    )
+    .join("\n");
 }
 
 export function getAlbumGridHTML({
-    getAlbumCover, getGridHTML, link, language,
-    details = false,
-    ...props
+  getAlbumCover,
+  getGridHTML,
+  link,
+  language,
+  details = false,
+  ...props
 }) {
-    return getGridHTML({
-        srcFn: getAlbumCover,
-        linkFn: link.album,
-        detailsFn: details && (album => language.$('misc.albumGrid.details', {
-            tracks: language.countTracks(album.tracks.length, {unit: true}),
-            time: language.formatDuration(getTotalDuration(album.tracks))
+  return getGridHTML({
+    srcFn: getAlbumCover,
+    linkFn: link.album,
+    detailsFn:
+      details &&
+      ((album) =>
+        language.$("misc.albumGrid.details", {
+          tracks: language.countTracks(album.tracks.length, { unit: true }),
+          time: language.formatDuration(getTotalDuration(album.tracks)),
         })),
-        noSrcTextFn: album => language.$('misc.albumGrid.noCoverArt', {
-            album: album.name
-        }),
-        ...props
-    });
+    noSrcTextFn: (album) =>
+      language.$("misc.albumGrid.noCoverArt", {
+        album: album.name,
+      }),
+    ...props,
+  });
 }
 
 export function getFlashGridHTML({
-    getFlashCover, getGridHTML, link,
-    ...props
+  getFlashCover,
+  getGridHTML,
+  link,
+  ...props
 }) {
-    return getGridHTML({
-        srcFn: getFlashCover,
-        linkFn: link.flash,
-        ...props
-    });
+  return getGridHTML({
+    srcFn: getFlashCover,
+    linkFn: link.flash,
+    ...props,
+  });
 }
 
 // Nav-bar links
 
-export function generateInfoGalleryLinks(currentThing, isGallery, {
-    link, language,
-    linkKeyGallery,
-    linkKeyInfo
-}) {
-    return [
-        link[linkKeyInfo](currentThing, {
-            class: isGallery ? '' : 'current',
-            text: language.$('misc.nav.info')
-        }),
-        link[linkKeyGallery](currentThing, {
-            class: isGallery ? 'current' : '',
-            text: language.$('misc.nav.gallery')
-        })
-    ].join(', ');
+export function generateInfoGalleryLinks(
+  currentThing,
+  isGallery,
+  { link, language, linkKeyGallery, linkKeyInfo }
+) {
+  return [
+    link[linkKeyInfo](currentThing, {
+      class: isGallery ? "" : "current",
+      text: language.$("misc.nav.info"),
+    }),
+    link[linkKeyGallery](currentThing, {
+      class: isGallery ? "current" : "",
+      text: language.$("misc.nav.gallery"),
+    }),
+  ].join(", ");
 }
 
-export function generatePreviousNextLinks(current, {
-    data,
-    link,
-    linkKey,
-    language
-}) {
-    const linkFn = link[linkKey];
-
-    const index = data.indexOf(current);
-    const previous = data[index - 1];
-    const next = data[index + 1];
-
-    return [
-        previous && linkFn(previous, {
-            attributes: {
-                id: 'previous-button',
-                title: previous.name
-            },
-            text: language.$('misc.nav.previous'),
-            color: false
-        }),
-        next && linkFn(next, {
-            attributes: {
-                id: 'next-button',
-                title: next.name
-            },
-            text: language.$('misc.nav.next'),
-            color: false
-        })
-    ].filter(Boolean).join(', ');
+export function generatePreviousNextLinks(
+  current,
+  { data, link, linkKey, language }
+) {
+  const linkFn = link[linkKey];
+
+  const index = data.indexOf(current);
+  const previous = data[index - 1];
+  const next = data[index + 1];
+
+  return [
+    previous &&
+      linkFn(previous, {
+        attributes: {
+          id: "previous-button",
+          title: previous.name,
+        },
+        text: language.$("misc.nav.previous"),
+        color: false,
+      }),
+    next &&
+      linkFn(next, {
+        attributes: {
+          id: "next-button",
+          title: next.name,
+        },
+        text: language.$("misc.nav.next"),
+        color: false,
+      }),
+  ]
+    .filter(Boolean)
+    .join(", ");
 }
 
 // Footer stuff
 
-export function getFooterLocalizationLinks(pathname, {
-    defaultLanguage,
-    languages,
-    paths,
-    language,
-    to
-}) {
-    const { toPath } = paths;
-    const keySuffix = toPath[0].replace(/^localized\./, '.');
-    const toArgs = toPath.slice(1);
-
-    const links = Object.entries(languages)
-        .filter(([ code, language ]) => code !== 'default' && !language.hidden)
-        .map(([ code, language ]) => language)
-        .sort(({ name: a }, { name: b }) => a < b ? -1 : a > b ? 1 : 0)
-        .map(language => html.tag('span', html.tag('a', {
-            href: (language === defaultLanguage
-                ? to('localizedDefaultLanguage' + keySuffix, ...toArgs)
-                : to('localizedWithBaseDirectory' + keySuffix, language.code, ...toArgs))
-        }, language.name)));
-
-    return html.tag('div',
-        {class: 'footer-localization-links'},
-        language.$('misc.uiLanguage', {languages: links.join('\n')}));
+export function getFooterLocalizationLinks(
+  pathname,
+  { defaultLanguage, languages, paths, language, to }
+) {
+  const { toPath } = paths;
+  const keySuffix = toPath[0].replace(/^localized\./, ".");
+  const toArgs = toPath.slice(1);
+
+  const links = Object.entries(languages)
+    .filter(([code, language]) => code !== "default" && !language.hidden)
+    .map(([code, language]) => language)
+    .sort(({ name: a }, { name: b }) => (a < b ? -1 : a > b ? 1 : 0))
+    .map((language) =>
+      html.tag(
+        "span",
+        html.tag(
+          "a",
+          {
+            href:
+              language === defaultLanguage
+                ? to("localizedDefaultLanguage" + keySuffix, ...toArgs)
+                : to(
+                    "localizedWithBaseDirectory" + keySuffix,
+                    language.code,
+                    ...toArgs
+                  ),
+          },
+          language.name
+        )
+      )
+    );
+
+  return html.tag(
+    "div",
+    { class: "footer-localization-links" },
+    language.$("misc.uiLanguage", { languages: links.join("\n") })
+  );
 }
diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js
index 57135a4a..3c197239 100644
--- a/src/page/album-commentary.js
+++ b/src/page/album-commentary.js
@@ -2,143 +2,182 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
-import {
-    filterAlbumsByCommentary
-} from '../util/wiki-data.js';
+import { filterAlbumsByCommentary } from "../util/wiki-data.js";
 
 // Page exports
 
-export function condition({wikiData}) {
-    return filterAlbumsByCommentary(wikiData.albumData).length;
+export function condition({ wikiData }) {
+  return filterAlbumsByCommentary(wikiData.albumData).length;
 }
 
-export function targets({wikiData}) {
-    return filterAlbumsByCommentary(wikiData.albumData);
+export function targets({ wikiData }) {
+  return filterAlbumsByCommentary(wikiData.albumData);
 }
 
-export function write(album, {wikiData}) {
-    const { wikiInfo } = wikiData;
-
-    const entries = [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary);
-    const words = entries.join(' ').split(' ').length;
-
-    const page = {
-        type: 'page',
-        path: ['albumCommentary', album.directory],
-        page: ({
-            getAlbumStylesheet,
-            getLinkThemeString,
-            getThemeString,
-            link,
-            language,
-            to,
-            transformMultiline
-        }) => ({
-            title: language.$('albumCommentaryPage.title', {album: album.name}),
-            stylesheet: getAlbumStylesheet(album),
-            theme: getThemeString(album.color),
-
-            main: {
-                content: fixWS`
+export function write(album, { wikiData }) {
+  const { wikiInfo } = wikiData;
+
+  const entries = [album, ...album.tracks]
+    .filter((x) => x.commentary)
+    .map((x) => x.commentary);
+  const words = entries.join(" ").split(" ").length;
+
+  const page = {
+    type: "page",
+    path: ["albumCommentary", album.directory],
+    page: ({
+      getAlbumStylesheet,
+      getLinkThemeString,
+      getThemeString,
+      link,
+      language,
+      to,
+      transformMultiline,
+    }) => ({
+      title: language.$("albumCommentaryPage.title", { album: album.name }),
+      stylesheet: getAlbumStylesheet(album),
+      theme: getThemeString(album.color),
+
+      main: {
+        content: fixWS`
                     <div class="long-content">
-                        <h1>${language.$('albumCommentaryPage.title', {
-                            album: link.album(album)
+                        <h1>${language.$("albumCommentaryPage.title", {
+                          album: link.album(album),
                         })}</h1>
-                        <p>${language.$('albumCommentaryPage.infoLine', {
-                            words: `<b>${language.formatWordCount(words, {unit: true})}</b>`,
-                            entries: `<b>${language.countCommentaryEntries(entries.length, {unit: true})}</b>`
+                        <p>${language.$("albumCommentaryPage.infoLine", {
+                          words: `<b>${language.formatWordCount(words, {
+                            unit: true,
+                          })}</b>`,
+                          entries: `<b>${language.countCommentaryEntries(
+                            entries.length,
+                            { unit: true }
+                          )}</b>`,
                         })}</p>
-                        ${album.commentary && fixWS`
-                            <h3>${language.$('albumCommentaryPage.entry.title.albumCommentary')}</h3>
+                        ${
+                          album.commentary &&
+                          fixWS`
+                            <h3>${language.$(
+                              "albumCommentaryPage.entry.title.albumCommentary"
+                            )}</h3>
                             <blockquote>
                                 ${transformMultiline(album.commentary)}
                             </blockquote>
-                        `}
-                        ${album.tracks.filter(t => t.commentary).map(track => fixWS`
-                            <h3 id="${track.directory}">${language.$('albumCommentaryPage.entry.title.trackCommentary', {
-                                track: link.track(track)
-                            })}</h3>
-                            <blockquote style="${getLinkThemeString(track.color)}">
+                        `
+                        }
+                        ${album.tracks
+                          .filter((t) => t.commentary)
+                          .map(
+                            (track) => fixWS`
+                            <h3 id="${track.directory}">${language.$(
+                              "albumCommentaryPage.entry.title.trackCommentary",
+                              {
+                                track: link.track(track),
+                              }
+                            )}</h3>
+                            <blockquote style="${getLinkThemeString(
+                              track.color
+                            )}">
                                 ${transformMultiline(track.commentary)}
                             </blockquote>
-                        `).join('\n')}
+                        `
+                          )
+                          .join("\n")}
                     </div>
-                `
-            },
-
-            nav: {
-                linkContainerClasses: ['nav-links-hierarchy'],
-                links: [
-                    {toHome: true},
-                    {
-                        path: ['localized.commentaryIndex'],
-                        title: language.$('commentaryIndex.title')
-                    },
-                    {
-                        html: language.$('albumCommentaryPage.nav.album', {
-                            album: link.albumCommentary(album, {class: 'current'})
-                        })
-                    }
-                ]
-            }
-        })
-    };
-
-    return [page];
+                `,
+      },
+
+      nav: {
+        linkContainerClasses: ["nav-links-hierarchy"],
+        links: [
+          { toHome: true },
+          {
+            path: ["localized.commentaryIndex"],
+            title: language.$("commentaryIndex.title"),
+          },
+          {
+            html: language.$("albumCommentaryPage.nav.album", {
+              album: link.albumCommentary(album, { class: "current" }),
+            }),
+          },
+        ],
+      },
+    }),
+  };
+
+  return [page];
 }
 
-export function writeTargetless({wikiData}) {
-    const data = filterAlbumsByCommentary(wikiData.albumData)
-        .map(album => ({
-            album,
-            entries: [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary)
-        }))
-        .map(({ album, entries }) => ({
-            album, entries,
-            words: entries.join(' ').split(' ').length
-        }));
-
-    const totalEntries = data.reduce((acc, {entries}) => acc + entries.length, 0);
-    const totalWords = data.reduce((acc, {words}) => acc + words, 0);
-
-    const page = {
-        type: 'page',
-        path: ['commentaryIndex'],
-        page: ({
-            link,
-            language
-        }) => ({
-            title: language.$('commentaryIndex.title'),
-
-            main: {
-                content: fixWS`
+export function writeTargetless({ wikiData }) {
+  const data = filterAlbumsByCommentary(wikiData.albumData)
+    .map((album) => ({
+      album,
+      entries: [album, ...album.tracks]
+        .filter((x) => x.commentary)
+        .map((x) => x.commentary),
+    }))
+    .map(({ album, entries }) => ({
+      album,
+      entries,
+      words: entries.join(" ").split(" ").length,
+    }));
+
+  const totalEntries = data.reduce(
+    (acc, { entries }) => acc + entries.length,
+    0
+  );
+  const totalWords = data.reduce((acc, { words }) => acc + words, 0);
+
+  const page = {
+    type: "page",
+    path: ["commentaryIndex"],
+    page: ({ link, language }) => ({
+      title: language.$("commentaryIndex.title"),
+
+      main: {
+        content: fixWS`
                     <div class="long-content">
-                        <h1>${language.$('commentaryIndex.title')}</h1>
-                        <p>${language.$('commentaryIndex.infoLine', {
-                            words: `<b>${language.formatWordCount(totalWords, {unit: true})}</b>`,
-                            entries: `<b>${language.countCommentaryEntries(totalEntries, {unit: true})}</b>`
+                        <h1>${language.$("commentaryIndex.title")}</h1>
+                        <p>${language.$("commentaryIndex.infoLine", {
+                          words: `<b>${language.formatWordCount(totalWords, {
+                            unit: true,
+                          })}</b>`,
+                          entries: `<b>${language.countCommentaryEntries(
+                            totalEntries,
+                            { unit: true }
+                          )}</b>`,
                         })}</p>
-                        <p>${language.$('commentaryIndex.albumList.title')}</p>
+                        <p>${language.$("commentaryIndex.albumList.title")}</p>
                         <ul>
                             ${data
-                                .map(({ album, entries, words }) => fixWS`
-                                    <li>${language.$('commentaryIndex.albumList.item', {
+                              .map(
+                                ({ album, entries, words }) => fixWS`
+                                    <li>${language.$(
+                                      "commentaryIndex.albumList.item",
+                                      {
                                         album: link.albumCommentary(album),
-                                        words: language.formatWordCount(words, {unit: true}),
-                                        entries: language.countCommentaryEntries(entries.length, {unit: true})
-                                    })}</li>
-                                `)
-                                .join('\n')}
+                                        words: language.formatWordCount(words, {
+                                          unit: true,
+                                        }),
+                                        entries:
+                                          language.countCommentaryEntries(
+                                            entries.length,
+                                            { unit: true }
+                                          ),
+                                      }
+                                    )}</li>
+                                `
+                              )
+                              .join("\n")}
                         </ul>
                     </div>
-                `
-            },
+                `,
+      },
 
-            nav: {simple: true}
-        })
-    };
+      nav: { simple: true },
+    }),
+  };
 
-    return [page];
+  return [page];
 }
diff --git a/src/page/album.js b/src/page/album.js
index c265fdc6..48747d7f 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -2,297 +2,394 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
-import * as html from '../util/html.js';
+import * as html from "../util/html.js";
 
-import {
-    bindOpts,
-    compareArrays,
-} from '../util/sugar.js';
+import { bindOpts, compareArrays } from "../util/sugar.js";
 
 import {
-    getAlbumCover,
-    getAlbumListTag,
-    getTotalDuration,
-} from '../util/wiki-data.js';
+  getAlbumCover,
+  getAlbumListTag,
+  getTotalDuration,
+} from "../util/wiki-data.js";
 
 // Page exports
 
-export function targets({wikiData}) {
-    return wikiData.albumData;
+export function targets({ wikiData }) {
+  return wikiData.albumData;
 }
 
-export function write(album, {wikiData}) {
-    const { wikiInfo } = wikiData;
+export function write(album, { wikiData }) {
+  const { wikiInfo } = wikiData;
 
-    const unbound_trackToListItem = (track, {
+  const unbound_trackToListItem = (
+    track,
+    { getArtistString, getLinkThemeString, link, language }
+  ) => {
+    const itemOpts = {
+      duration: language.formatDuration(track.duration ?? 0),
+      track: link.track(track),
+    };
+    return `<li style="${getLinkThemeString(track.color)}">${
+      compareArrays(
+        track.artistContribs.map((c) => c.who),
+        album.artistContribs.map((c) => c.who),
+        { checkOrder: false }
+      )
+        ? language.$("trackList.item.withDuration", itemOpts)
+        : language.$("trackList.item.withDuration.withArtists", {
+            ...itemOpts,
+            by: `<span class="by">${language.$(
+              "trackList.item.withArtists.by",
+              {
+                artists: getArtistString(track.artistContribs),
+              }
+            )}</span>`,
+          })
+    }</li>`;
+  };
+
+  const hasCommentaryEntries =
+    [album, ...album.tracks].filter((x) => x.commentary).length > 0;
+  const hasAdditionalFiles = album.additionalFiles?.length > 0;
+  const albumDuration = getTotalDuration(album.tracks);
+
+  const listTag = getAlbumListTag(album);
+
+  const data = {
+    type: "data",
+    path: ["album", album.directory],
+    data: ({
+      serializeContribs,
+      serializeCover,
+      serializeGroupsForAlbum,
+      serializeLink,
+    }) => ({
+      name: album.name,
+      directory: album.directory,
+      dates: {
+        released: album.date,
+        trackArtAdded: album.trackArtDate,
+        coverArtAdded: album.coverArtDate,
+        addedToWiki: album.dateAddedToWiki,
+      },
+      duration: albumDuration,
+      color: album.color,
+      cover: serializeCover(album, getAlbumCover),
+      artistContribs: serializeContribs(album.artistContribs),
+      coverArtistContribs: serializeContribs(album.coverArtistContribs),
+      wallpaperArtistContribs: serializeContribs(album.wallpaperArtistContribs),
+      bannerArtistContribs: serializeContribs(album.bannerArtistContribs),
+      groups: serializeGroupsForAlbum(album),
+      trackGroups: album.trackGroups?.map((trackGroup) => ({
+        name: trackGroup.name,
+        color: trackGroup.color,
+        tracks: trackGroup.tracks.map((track) => track.directory),
+      })),
+      tracks: album.tracks.map((track) => ({
+        link: serializeLink(track),
+        duration: track.duration,
+      })),
+    }),
+  };
+
+  const page = {
+    type: "page",
+    path: ["album", album.directory],
+    page: ({
+      fancifyURL,
+      generateAdditionalFilesShortcut,
+      generateAdditionalFilesList,
+      generateChronologyLinks,
+      generateCoverLink,
+      getAlbumCover,
+      getAlbumStylesheet,
+      getArtistString,
+      getLinkThemeString,
+      getSizeOfAdditionalFile,
+      getThemeString,
+      link,
+      language,
+      transformMultiline,
+      urls,
+    }) => {
+      const trackToListItem = bindOpts(unbound_trackToListItem, {
         getArtistString,
         getLinkThemeString,
         link,
-        language
-    }) => {
-        const itemOpts = {
-            duration: language.formatDuration(track.duration ?? 0),
-            track: link.track(track)
-        };
-        return `<li style="${getLinkThemeString(track.color)}">${
-            (compareArrays(
-                track.artistContribs.map(c => c.who),
-                album.artistContribs.map(c => c.who),
-                {checkOrder: false})
-                ? language.$('trackList.item.withDuration', itemOpts)
-                : language.$('trackList.item.withDuration.withArtists', {
-                    ...itemOpts,
-                    by: `<span class="by">${
-                        language.$('trackList.item.withArtists.by', {
-                            artists: getArtistString(track.artistContribs)
-                        })
-                    }</span>`
-                }))
-        }</li>`;
-    };
-
-    const hasCommentaryEntries = ([album, ...album.tracks].filter(x => x.commentary).length > 0);
-    const hasAdditionalFiles = (album.additionalFiles?.length > 0);
-    const albumDuration = getTotalDuration(album.tracks);
-
-    const listTag = getAlbumListTag(album);
-
-    const data = {
-        type: 'data',
-        path: ['album', album.directory],
-        data: ({
-            serializeContribs,
-            serializeCover,
-            serializeGroupsForAlbum,
-            serializeLink
-        }) => ({
-            name: album.name,
-            directory: album.directory,
-            dates: {
-                released: album.date,
-                trackArtAdded: album.trackArtDate,
-                coverArtAdded: album.coverArtDate,
-                addedToWiki: album.dateAddedToWiki
-            },
-            duration: albumDuration,
-            color: album.color,
-            cover: serializeCover(album, getAlbumCover),
-            artistContribs: serializeContribs(album.artistContribs),
-            coverArtistContribs: serializeContribs(album.coverArtistContribs),
-            wallpaperArtistContribs: serializeContribs(album.wallpaperArtistContribs),
-            bannerArtistContribs: serializeContribs(album.bannerArtistContribs),
-            groups: serializeGroupsForAlbum(album),
-            trackGroups: album.trackGroups?.map(trackGroup => ({
-                name: trackGroup.name,
-                color: trackGroup.color,
-                tracks: trackGroup.tracks.map(track => track.directory)
-            })),
-            tracks: album.tracks.map(track => ({
-                link: serializeLink(track),
-                duration: track.duration
-            }))
-        })
-    };
-
-    const page = {
-        type: 'page',
-        path: ['album', album.directory],
-        page: ({
-            fancifyURL,
-            generateAdditionalFilesShortcut,
-            generateAdditionalFilesList,
-            generateChronologyLinks,
-            generateCoverLink,
-            getAlbumCover,
-            getAlbumStylesheet,
-            getArtistString,
-            getLinkThemeString,
-            getSizeOfAdditionalFile,
-            getThemeString,
-            link,
-            language,
-            transformMultiline,
-            urls,
-        }) => {
-            const trackToListItem = bindOpts(unbound_trackToListItem, {
-                getArtistString,
-                getLinkThemeString,
-                link,
-                language
-            });
-
-            const cover = getAlbumCover(album);
-
-            return {
-                title: language.$('albumPage.title', {album: album.name}),
-                stylesheet: getAlbumStylesheet(album),
-                theme: getThemeString(album.color, [
-                    `--album-directory: ${album.directory}`
-                ]),
-
-                banner: album.bannerArtistContribs.length && {
-                    dimensions: album.bannerDimensions,
-                    path: ['media.albumBanner', album.directory, album.bannerFileExtension],
-                    alt: language.$('misc.alt.albumBanner'),
-                    position: 'top'
-                },
-
-                main: {
-                    content: fixWS`
-                        ${cover && generateCoverLink({
+        language,
+      });
+
+      const cover = getAlbumCover(album);
+
+      return {
+        title: language.$("albumPage.title", { album: album.name }),
+        stylesheet: getAlbumStylesheet(album),
+        theme: getThemeString(album.color, [
+          `--album-directory: ${album.directory}`,
+        ]),
+
+        banner: album.bannerArtistContribs.length && {
+          dimensions: album.bannerDimensions,
+          path: [
+            "media.albumBanner",
+            album.directory,
+            album.bannerFileExtension,
+          ],
+          alt: language.$("misc.alt.albumBanner"),
+          position: "top",
+        },
+
+        main: {
+          content: fixWS`
+                        ${
+                          cover &&
+                          generateCoverLink({
                             src: cover,
-                            alt: language.$('misc.alt.albumCover'),
-                            tags: album.artTags
-                        })}
-                        <h1>${language.$('albumPage.title', {album: album.name})}</h1>
+                            alt: language.$("misc.alt.albumCover"),
+                            tags: album.artTags,
+                          })
+                        }
+                        <h1>${language.$("albumPage.title", {
+                          album: album.name,
+                        })}</h1>
                         <p>
                             ${[
-                                album.artistContribs.length && language.$('releaseInfo.by', {
-                                    artists: getArtistString(album.artistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
+                              album.artistContribs.length &&
+                                language.$("releaseInfo.by", {
+                                  artists: getArtistString(
+                                    album.artistContribs,
+                                    {
+                                      showContrib: true,
+                                      showIcons: true,
+                                    }
+                                  ),
                                 }),
-                                album.coverArtistContribs.length && language.$('releaseInfo.coverArtBy', {
-                                    artists: getArtistString(album.coverArtistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
+                              album.coverArtistContribs.length &&
+                                language.$("releaseInfo.coverArtBy", {
+                                  artists: getArtistString(
+                                    album.coverArtistContribs,
+                                    {
+                                      showContrib: true,
+                                      showIcons: true,
+                                    }
+                                  ),
                                 }),
-                                album.wallpaperArtistContribs.length && language.$('releaseInfo.wallpaperArtBy', {
-                                    artists: getArtistString(album.wallpaperArtistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
+                              album.wallpaperArtistContribs.length &&
+                                language.$("releaseInfo.wallpaperArtBy", {
+                                  artists: getArtistString(
+                                    album.wallpaperArtistContribs,
+                                    {
+                                      showContrib: true,
+                                      showIcons: true,
+                                    }
+                                  ),
                                 }),
-                                album.bannerArtistContribs.length && language.$('releaseInfo.bannerArtBy', {
-                                    artists: getArtistString(album.bannerArtistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
+                              album.bannerArtistContribs.length &&
+                                language.$("releaseInfo.bannerArtBy", {
+                                  artists: getArtistString(
+                                    album.bannerArtistContribs,
+                                    {
+                                      showContrib: true,
+                                      showIcons: true,
+                                    }
+                                  ),
                                 }),
-                                album.date && language.$('releaseInfo.released', {
-                                    date: language.formatDate(album.date)
+                              album.date &&
+                                language.$("releaseInfo.released", {
+                                  date: language.formatDate(album.date),
                                 }),
-                                (album.coverArtDate &&
-                                    +album.coverArtDate !== +album.date &&
-                                    language.$('releaseInfo.artReleased', {
-                                        date: language.formatDate(album.coverArtDate)
-                                    })),
-                                language.$('releaseInfo.duration', {
-                                    duration: language.formatDuration(albumDuration, {approximate: album.tracks.length > 1})
-                                })
-                            ].filter(Boolean).join('<br>\n')}
+                              album.coverArtDate &&
+                                +album.coverArtDate !== +album.date &&
+                                language.$("releaseInfo.artReleased", {
+                                  date: language.formatDate(album.coverArtDate),
+                                }),
+                              language.$("releaseInfo.duration", {
+                                duration: language.formatDuration(
+                                  albumDuration,
+                                  { approximate: album.tracks.length > 1 }
+                                ),
+                              }),
+                            ]
+                              .filter(Boolean)
+                              .join("<br>\n")}
                         </p>
-                        ${(hasAdditionalFiles || hasCommentaryEntries) && fixWS`<p>
+                        ${
+                          (hasAdditionalFiles || hasCommentaryEntries) &&
+                          fixWS`<p>
                             ${[
-                                hasAdditionalFiles && generateAdditionalFilesShortcut(album.additionalFiles, {language}),
-                                hasCommentaryEntries && language.$('releaseInfo.viewCommentary', {
-                                    link: link.albumCommentary(album, {
-                                        text: language.$('releaseInfo.viewCommentary.link')
-                                    })
-                                })
-                            ].filter(Boolean).join('<br>\n')
-                        }</p>`}
-                        ${album.urls?.length && `<p>${
-                            language.$('releaseInfo.listenOn', {
-                                links: language.formatDisjunctionList(album.urls.map(url => fancifyURL(url, {album: true})))
-                            })
-                        }</p>`}
-                        ${album.trackGroups && (album.trackGroups.length > 1 || !album.trackGroups[0].isDefaultTrackGroup) ? fixWS`
+                              hasAdditionalFiles &&
+                                generateAdditionalFilesShortcut(
+                                  album.additionalFiles,
+                                  { language }
+                                ),
+                              hasCommentaryEntries &&
+                                language.$("releaseInfo.viewCommentary", {
+                                  link: link.albumCommentary(album, {
+                                    text: language.$(
+                                      "releaseInfo.viewCommentary.link"
+                                    ),
+                                  }),
+                                }),
+                            ]
+                              .filter(Boolean)
+                              .join("<br>\n")}</p>`
+                        }
+                        ${
+                          album.urls?.length &&
+                          `<p>${language.$("releaseInfo.listenOn", {
+                            links: language.formatDisjunctionList(
+                              album.urls.map((url) =>
+                                fancifyURL(url, { album: true })
+                              )
+                            ),
+                          })}</p>`
+                        }
+                        ${
+                          album.trackGroups &&
+                          (album.trackGroups.length > 1 ||
+                            !album.trackGroups[0].isDefaultTrackGroup)
+                            ? fixWS`
                             <dl class="album-group-list">
-                                ${album.trackGroups.map(({ name, color, startIndex, tracks }) => fixWS`
-                                    <dt>${
-                                        language.$('trackList.section.withDuration', {
-                                            duration: language.formatDuration(getTotalDuration(tracks), {approximate: tracks.length > 1}),
-                                            section: name
-                                        })
-                                    }</dt>
-                                    <dd><${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}>
-                                        ${tracks.map(trackToListItem).join('\n')}
+                                ${album.trackGroups
+                                  .map(
+                                    ({
+                                      name,
+                                      color,
+                                      startIndex,
+                                      tracks,
+                                    }) => fixWS`
+                                    <dt>${language.$(
+                                      "trackList.section.withDuration",
+                                      {
+                                        duration: language.formatDuration(
+                                          getTotalDuration(tracks),
+                                          { approximate: tracks.length > 1 }
+                                        ),
+                                        section: name,
+                                      }
+                                    )}</dt>
+                                    <dd><${
+                                      listTag === "ol"
+                                        ? `ol start="${startIndex + 1}"`
+                                        : listTag
+                                    }>
+                                        ${tracks
+                                          .map(trackToListItem)
+                                          .join("\n")}
                                     </${listTag}></dd>
-                                `).join('\n')}
+                                `
+                                  )
+                                  .join("\n")}
                             </dl>
-                        ` : fixWS`
+                        `
+                            : fixWS`
                             <${listTag}>
-                                ${album.tracks.map(trackToListItem).join('\n')}
+                                ${album.tracks.map(trackToListItem).join("\n")}
                             </${listTag}>
-                        `}
-                        ${album.dateAddedToWiki && fixWS`
+                        `
+                        }
+                        ${
+                          album.dateAddedToWiki &&
+                          fixWS`
                             <p>
                                 ${[
-                                    language.$('releaseInfo.addedToWiki', {
-                                        date: language.formatDate(album.dateAddedToWiki)
-                                    })
-                                ].filter(Boolean).join('<br>\n')}
+                                  language.$("releaseInfo.addedToWiki", {
+                                    date: language.formatDate(
+                                      album.dateAddedToWiki
+                                    ),
+                                  }),
+                                ]
+                                  .filter(Boolean)
+                                  .join("<br>\n")}
                             </p>
-                        `}
-                        ${hasAdditionalFiles && generateAdditionalFilesList(album.additionalFiles, {
+                        `
+                        }
+                        ${
+                          hasAdditionalFiles &&
+                          generateAdditionalFilesList(album.additionalFiles, {
                             // TODO: Kinda near the metal here...
-                            getFileSize: file => getSizeOfAdditionalFile(urls
-                                .from('media.root')
-                                .to('media.albumAdditionalFile', album.directory, file)),
-                            linkFile: file => link.albumAdditionalFile({album, file}),
-                        })}
-                        ${album.commentary && fixWS`
-                            <p>${language.$('releaseInfo.artistCommentary')}</p>
+                            getFileSize: (file) =>
+                              getSizeOfAdditionalFile(
+                                urls
+                                  .from("media.root")
+                                  .to(
+                                    "media.albumAdditionalFile",
+                                    album.directory,
+                                    file
+                                  )
+                              ),
+                            linkFile: (file) =>
+                              link.albumAdditionalFile({ album, file }),
+                          })
+                        }
+                        ${
+                          album.commentary &&
+                          fixWS`
+                            <p>${language.$("releaseInfo.artistCommentary")}</p>
                             <blockquote>
                                 ${transformMultiline(album.commentary)}
                             </blockquote>
-                        `}
-                    `
-                },
-
-                sidebarLeft: generateAlbumSidebar(album, null, {
-                    fancifyURL,
-                    getLinkThemeString,
-                    link,
-                    language,
-                    transformMultiline,
-                    wikiData
-                }),
-
-                nav: {
-                    linkContainerClasses: ['nav-links-hierarchy'],
-                    links: [
-                        {toHome: true},
-                        {
-                            html: language.$('albumPage.nav.album', {
-                                album: link.album(album, {class: 'current'})
-                            })
-                        },
-                    ],
-                    bottomRowContent: generateAlbumNavLinks(album, null, {language}),
-                    content: generateAlbumChronologyLinks(album, null, {generateChronologyLinks}),
-                },
-
-                secondaryNav: generateAlbumSecondaryNav(album, null, {
-                    language,
-                    link,
-                    getLinkThemeString,
-                }),
-            };
-        }
-    };
+                        `
+                        }
+                    `,
+        },
+
+        sidebarLeft: generateAlbumSidebar(album, null, {
+          fancifyURL,
+          getLinkThemeString,
+          link,
+          language,
+          transformMultiline,
+          wikiData,
+        }),
 
-    return [page, data];
+        nav: {
+          linkContainerClasses: ["nav-links-hierarchy"],
+          links: [
+            { toHome: true },
+            {
+              html: language.$("albumPage.nav.album", {
+                album: link.album(album, { class: "current" }),
+              }),
+            },
+          ],
+          bottomRowContent: generateAlbumNavLinks(album, null, { language }),
+          content: generateAlbumChronologyLinks(album, null, {
+            generateChronologyLinks,
+          }),
+        },
+
+        secondaryNav: generateAlbumSecondaryNav(album, null, {
+          language,
+          link,
+          getLinkThemeString,
+        }),
+      };
+    },
+  };
+
+  return [page, data];
 }
 
 // Utility functions
 
-export function generateAlbumSidebar(album, currentTrack, {
+export function generateAlbumSidebar(
+  album,
+  currentTrack,
+  {
     fancifyURL,
     getLinkThemeString,
     link,
     language,
     transformMultiline,
-    wikiData
-}) {
-    const listTag = getAlbumListTag(album);
+    wikiData,
+  }
+) {
+  const listTag = getAlbumListTag(album);
 
-    /*
+  /*
     const trackGroups = album.trackGroups || [{
         name: language.$('albumSidebar.trackList.fallbackGroupName'),
         color: album.color,
@@ -301,185 +398,254 @@ export function generateAlbumSidebar(album, currentTrack, {
     }];
     */
 
-    const { trackGroups } = album;
+  const { trackGroups } = album;
 
-    const trackToListItem = track => html.tag('li',
-        {class: track === currentTrack && 'current'},
-        language.$('albumSidebar.trackList.item', {
-            track: link.track(track)
-        }));
+  const trackToListItem = (track) =>
+    html.tag(
+      "li",
+      { class: track === currentTrack && "current" },
+      language.$("albumSidebar.trackList.item", {
+        track: link.track(track),
+      })
+    );
 
-    const nameOrDefault = (isDefaultTrackGroup, name) =>
-        (isDefaultTrackGroup
-            ? language.$('albumSidebar.trackList.fallbackGroupName')
-            : name);
+  const nameOrDefault = (isDefaultTrackGroup, name) =>
+    isDefaultTrackGroup
+      ? language.$("albumSidebar.trackList.fallbackGroupName")
+      : name;
 
-    const trackListPart = fixWS`
+  const trackListPart = fixWS`
         <h1>${link.album(album)}</h1>
-        ${trackGroups.map(({ name, color, startIndex, tracks, isDefaultTrackGroup }) =>
-            html.tag('details', {
+        ${trackGroups
+          .map(({ name, color, startIndex, tracks, isDefaultTrackGroup }) =>
+            html.tag(
+              "details",
+              {
                 // Leave side8ar track groups collapsed on al8um homepage,
                 // since there's already a view of all the groups expanded
                 // in the main content area.
                 open: currentTrack && tracks.includes(currentTrack),
-                class: tracks.includes(currentTrack) && 'current'
-            }, [
-                html.tag('summary',
-                    {style: getLinkThemeString(color)},
-                    (listTag === 'ol'
-                        ? language.$('albumSidebar.trackList.group.withRange', {
-                            group: `<span class="group-name">${nameOrDefault(isDefaultTrackGroup, name)}</span>`,
-                            range: `${startIndex + 1}&ndash;${startIndex + tracks.length}`
-                        })
-                        : language.$('albumSidebar.trackList.group', {
-                            group: `<span class="group-name">${nameOrDefault(isDefaultTrackGroup, name)}</span>`
-                        }))
+                class: tracks.includes(currentTrack) && "current",
+              },
+              [
+                html.tag(
+                  "summary",
+                  { style: getLinkThemeString(color) },
+                  listTag === "ol"
+                    ? language.$("albumSidebar.trackList.group.withRange", {
+                        group: `<span class="group-name">${nameOrDefault(
+                          isDefaultTrackGroup,
+                          name
+                        )}</span>`,
+                        range: `${startIndex + 1}&ndash;${
+                          startIndex + tracks.length
+                        }`,
+                      })
+                    : language.$("albumSidebar.trackList.group", {
+                        group: `<span class="group-name">${nameOrDefault(
+                          isDefaultTrackGroup,
+                          name
+                        )}</span>`,
+                      })
                 ),
                 fixWS`
-                    <${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}>
-                        ${tracks.map(trackToListItem).join('\n')}
+                    <${
+                      listTag === "ol"
+                        ? `ol start="${startIndex + 1}"`
+                        : listTag
+                    }>
+                        ${tracks.map(trackToListItem).join("\n")}
                     </${listTag}>
-                `
-            ])).join('\n')}
+                `,
+              ]
+            )
+          )
+          .join("\n")}
     `;
 
-    const { groups } = album;
-
-    const groupParts = groups.map(group => {
-        const albums = group.albums.filter(album => album.date);
-        const index = albums.indexOf(album);
-        const next = index >= 0 && albums[index + 1];
-        const previous = index > 0 && albums[index - 1];
-        return {group, next, previous};
-    }).map(({group, next, previous}) => fixWS`
-        <h1>${
-            language.$('albumSidebar.groupBox.title', {
-                group: link.groupInfo(group)
-            })
-        }</h1>
+  const { groups } = album;
+
+  const groupParts = groups
+    .map((group) => {
+      const albums = group.albums.filter((album) => album.date);
+      const index = albums.indexOf(album);
+      const next = index >= 0 && albums[index + 1];
+      const previous = index > 0 && albums[index - 1];
+      return { group, next, previous };
+    })
+    .map(
+      ({ group, next, previous }) => fixWS`
+        <h1>${language.$("albumSidebar.groupBox.title", {
+          group: link.groupInfo(group),
+        })}</h1>
         ${!currentTrack && transformMultiline(group.descriptionShort)}
-        ${group.urls?.length && `<p>${
-            language.$('releaseInfo.visitOn', {
-                links: language.formatDisjunctionList(group.urls.map(url => fancifyURL(url)))
-            })
-        }</p>`}
-        ${!currentTrack && fixWS`
-            ${next && `<p class="group-chronology-link">${
-                language.$('albumSidebar.groupBox.next', {
-                    album: link.album(next)
-                })
-            }</p>`}
-            ${previous && `<p class="group-chronology-link">${
-                language.$('albumSidebar.groupBox.previous', {
-                    album: link.album(previous)
-                })
-            }</p>`}
-        `}
-    `);
-
-    if (groupParts.length) {
-        if (currentTrack) {
-            const combinedGroupPart = groupParts.join('\n<hr>\n');
-            return {
-                multiple: [
-                    trackListPart,
-                    combinedGroupPart
-                ]
-            };
-        } else {
-            return {
-                multiple: [
-                    ...groupParts,
-                    trackListPart
-                ]
-            };
+        ${
+          group.urls?.length &&
+          `<p>${language.$("releaseInfo.visitOn", {
+            links: language.formatDisjunctionList(
+              group.urls.map((url) => fancifyURL(url))
+            ),
+          })}</p>`
+        }
+        ${
+          !currentTrack &&
+          fixWS`
+            ${
+              next &&
+              `<p class="group-chronology-link">${language.$(
+                "albumSidebar.groupBox.next",
+                {
+                  album: link.album(next),
+                }
+              )}</p>`
+            }
+            ${
+              previous &&
+              `<p class="group-chronology-link">${language.$(
+                "albumSidebar.groupBox.previous",
+                {
+                  album: link.album(previous),
+                }
+              )}</p>`
+            }
+        `
         }
+    `
+    );
+
+  if (groupParts.length) {
+    if (currentTrack) {
+      const combinedGroupPart = groupParts.join("\n<hr>\n");
+      return {
+        multiple: [trackListPart, combinedGroupPart],
+      };
     } else {
-        return {
-            content: trackListPart
-        };
+      return {
+        multiple: [...groupParts, trackListPart],
+      };
     }
+  } else {
+    return {
+      content: trackListPart,
+    };
+  }
 }
 
-export function generateAlbumSecondaryNav(album, currentTrack, {
-    link,
-    language,
-    getLinkThemeString,
-}) {
-    const { groups } = album;
-
-    if (!groups.length) {
-        return null;
-    }
-
-    const groupParts = groups.map(group => {
-        const albums = group.albums.filter(album => album.date);
-        const index = albums.indexOf(album);
-        const next = index >= 0 && albums[index + 1];
-        const previous = index > 0 && albums[index - 1];
-        return {group, next, previous};
-    }).map(({group, next, previous}) => {
-        const previousNext = !currentTrack && [
-            previous && link.album(previous, {color: false, text: language.$('misc.nav.previous')}),
-            next && link.album(next, {color: false, text: language.$('misc.nav.next')})
-        ].filter(Boolean);
-        return html.tag('span', {style: getLinkThemeString(group.color)}, [
-            language.$('albumSidebar.groupBox.title', {
-                group: link.groupInfo(group)
+export function generateAlbumSecondaryNav(
+  album,
+  currentTrack,
+  { link, language, getLinkThemeString }
+) {
+  const { groups } = album;
+
+  if (!groups.length) {
+    return null;
+  }
+
+  const groupParts = groups
+    .map((group) => {
+      const albums = group.albums.filter((album) => album.date);
+      const index = albums.indexOf(album);
+      const next = index >= 0 && albums[index + 1];
+      const previous = index > 0 && albums[index - 1];
+      return { group, next, previous };
+    })
+    .map(({ group, next, previous }) => {
+      const previousNext =
+        !currentTrack &&
+        [
+          previous &&
+            link.album(previous, {
+              color: false,
+              text: language.$("misc.nav.previous"),
+            }),
+          next &&
+            link.album(next, {
+              color: false,
+              text: language.$("misc.nav.next"),
             }),
-            previousNext?.length && `(${previousNext.join(',\n')})`
-        ]);
+        ].filter(Boolean);
+      return html.tag("span", { style: getLinkThemeString(group.color) }, [
+        language.$("albumSidebar.groupBox.title", {
+          group: link.groupInfo(group),
+        }),
+        previousNext?.length && `(${previousNext.join(",\n")})`,
+      ]);
     });
 
-    return {
-        classes: ['dot-between-spans'],
-        content: groupParts.join('\n'),
-    };
+  return {
+    classes: ["dot-between-spans"],
+    content: groupParts.join("\n"),
+  };
 }
 
-export function generateAlbumNavLinks(album, currentTrack, {
-    generatePreviousNextLinks,
-    language
-}) {
-    if (album.tracks.length <= 1) {
-        return '';
-    }
-
-    const previousNextLinks = currentTrack && generatePreviousNextLinks(currentTrack, {
-        data: album.tracks,
-        linkKey: 'track'
+export function generateAlbumNavLinks(
+  album,
+  currentTrack,
+  { generatePreviousNextLinks, language }
+) {
+  if (album.tracks.length <= 1) {
+    return "";
+  }
+
+  const previousNextLinks =
+    currentTrack &&
+    generatePreviousNextLinks(currentTrack, {
+      data: album.tracks,
+      linkKey: "track",
     });
-    const randomLink = `<a href="#" data-random="track-in-album" id="random-button">${
-        (currentTrack
-            ? language.$('trackPage.nav.random')
-            : language.$('albumPage.nav.randomTrack'))
-    }</a>`;
-
-    return (previousNextLinks
-        ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)`
-        : `<span class="js-hide-until-data">(${randomLink})</span>`);
+  const randomLink = `<a href="#" data-random="track-in-album" id="random-button">${
+    currentTrack
+      ? language.$("trackPage.nav.random")
+      : language.$("albumPage.nav.randomTrack")
+  }</a>`;
+
+  return previousNextLinks
+    ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)`
+    : `<span class="js-hide-until-data">(${randomLink})</span>`;
 }
 
-export function generateAlbumChronologyLinks(album, currentTrack, {generateChronologyLinks}) {
-    return html.tag('div', {
-        [html.onlyIfContent]: true,
-        class: 'nav-chronology-links',
-    }, [
-        currentTrack && generateChronologyLinks(currentTrack, {
-            contribKey: 'artistContribs',
-            getThings: artist => [...artist.tracksAsArtist, ...artist.tracksAsContributor],
-            headingString: 'misc.chronology.heading.track'
+export function generateAlbumChronologyLinks(
+  album,
+  currentTrack,
+  { generateChronologyLinks }
+) {
+  return html.tag(
+    "div",
+    {
+      [html.onlyIfContent]: true,
+      class: "nav-chronology-links",
+    },
+    [
+      currentTrack &&
+        generateChronologyLinks(currentTrack, {
+          contribKey: "artistContribs",
+          getThings: (artist) => [
+            ...artist.tracksAsArtist,
+            ...artist.tracksAsContributor,
+          ],
+          headingString: "misc.chronology.heading.track",
         }),
-        currentTrack && generateChronologyLinks(currentTrack, {
-            contribKey: 'contributorContribs',
-            getThings: artist => [...artist.tracksAsArtist, ...artist.tracksAsContributor],
-            headingString: 'misc.chronology.heading.track'
+      currentTrack &&
+        generateChronologyLinks(currentTrack, {
+          contribKey: "contributorContribs",
+          getThings: (artist) => [
+            ...artist.tracksAsArtist,
+            ...artist.tracksAsContributor,
+          ],
+          headingString: "misc.chronology.heading.track",
         }),
-        generateChronologyLinks(currentTrack || album, {
-            contribKey: 'coverArtistContribs',
-            dateKey: 'coverArtDate',
-            getThings: artist => [...artist.albumsAsCoverArtist, ...artist.tracksAsCoverArtist],
-            headingString: 'misc.chronology.heading.coverArt'
-        })
-    ].filter(Boolean).join('\n'));
+      generateChronologyLinks(currentTrack || album, {
+        contribKey: "coverArtistContribs",
+        dateKey: "coverArtDate",
+        getThings: (artist) => [
+          ...artist.albumsAsCoverArtist,
+          ...artist.tracksAsCoverArtist,
+        ],
+        headingString: "misc.chronology.heading.coverArt",
+      }),
+    ]
+      .filter(Boolean)
+      .join("\n")
+  );
 }
diff --git a/src/page/artist-alias.js b/src/page/artist-alias.js
index ac23e902..4fc129e9 100644
--- a/src/page/artist-alias.js
+++ b/src/page/artist-alias.js
@@ -1,22 +1,21 @@
 // Artist alias redirect pages.
 // (Makes old permalinks bring visitors to the up-to-date page.)
 
-export function targets({wikiData}) {
-    return wikiData.artistAliasData;
+export function targets({ wikiData }) {
+  return wikiData.artistAliasData;
 }
 
-export function write(aliasArtist, {wikiData}) {
-    // This function doesn't actually use wikiData, 8ut, um, consistency?
+export function write(aliasArtist, { wikiData }) {
+  // This function doesn't actually use wikiData, 8ut, um, consistency?
 
-    const { aliasedArtist } = aliasArtist;
+  const { aliasedArtist } = aliasArtist;
 
-    const redirect = {
-        type: 'redirect',
-        fromPath: ['artist', aliasArtist.directory],
-        toPath: ['artist', aliasedArtist.directory],
-        title: () => aliasedArtist.name
-    };
+  const redirect = {
+    type: "redirect",
+    fromPath: ["artist", aliasArtist.directory],
+    toPath: ["artist", aliasedArtist.directory],
+    title: () => aliasedArtist.name,
+  };
 
-    return [redirect];
+  return [redirect];
 }
-
diff --git a/src/page/artist.js b/src/page/artist.js
index 6c31a010..8be20106 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -4,515 +4,753 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
-import * as html from '../util/html.js';
+import * as html from "../util/html.js";
 
-import {
-    bindOpts,
-    unique
-} from '../util/sugar.js';
+import { bindOpts, unique } from "../util/sugar.js";
 
 import {
-    chunkByProperties,
-    getTotalDuration,
-    sortAlbumsTracksChronologically,
-    sortByDate,
-    sortByDirectory,
-    sortChronologically,
-} from '../util/wiki-data.js';
+  chunkByProperties,
+  getTotalDuration,
+  sortAlbumsTracksChronologically,
+  sortByDate,
+  sortByDirectory,
+  sortChronologically,
+} from "../util/wiki-data.js";
 
 // Page exports
 
-export function targets({wikiData}) {
-    return wikiData.artistData;
+export function targets({ wikiData }) {
+  return wikiData.artistData;
 }
 
-export function write(artist, {wikiData}) {
-    const { groupData, wikiInfo } = wikiData;
-
-    const { name, urls, contextNotes } = artist;
-
-    const artThingsAll = sortAlbumsTracksChronologically(unique([
-        ...artist.albumsAsCoverArtist ?? [],
-        ...artist.albumsAsWallpaperArtist ?? [],
-        ...artist.albumsAsBannerArtist ?? [],
-        ...artist.tracksAsCoverArtist ?? []
-    ]), {getDate: o => o.coverArtDate});
-
-    const artThingsGallery = sortAlbumsTracksChronologically([
-        ...artist.albumsAsCoverArtist ?? [],
-        ...artist.tracksAsCoverArtist ?? []
-    ], {getDate: o => o.coverArtDate});
-
-    const commentaryThings = sortAlbumsTracksChronologically([
-        ...artist.albumsAsCommentator ?? [],
-        ...artist.tracksAsCommentator ?? []
-    ]);
-
-    const hasGallery = artThingsGallery.length > 0;
-
-    const getArtistsAndContrib = (thing, key) => ({
-        artists: thing[key]?.filter(({ who }) => who !== artist),
-        contrib: thing[key]?.find(({ who }) => who === artist),
-        thing,
-        key
-    });
-
-    const artListChunks = chunkByProperties(artThingsAll.flatMap(thing =>
-        (['coverArtistContribs', 'wallpaperArtistContribs', 'bannerArtistContribs']
-            .map(key => getArtistsAndContrib(thing, key))
-            .filter(({ contrib }) => contrib)
-            .map(props => ({
-                album: thing.album || thing,
-                track: thing.album ? thing : null,
-                date: thing.date,
-                ...props
-            })))
-    ), ['date', 'album']);
-
-    const commentaryListChunks = chunkByProperties(commentaryThings.map(thing => ({
-        album: thing.album || thing,
-        track: thing.album ? thing : null
-    })), ['album']);
-
-    const allTracks = sortAlbumsTracksChronologically(unique([
-        ...artist.tracksAsArtist ?? [],
-        ...artist.tracksAsContributor ?? []
-    ]));
-
-    const chunkTracks = tracks => (
-        chunkByProperties(tracks.map(track => ({
-            track,
-            date: +track.date,
-            album: track.album,
-            duration: track.duration,
-            artists: (track.artistContribs.some(({ who }) => who === artist)
-                ? track.artistContribs.filter(({ who }) => who !== artist)
-                : track.contributorContribs.filter(({ who }) => who !== artist)),
-            contrib: {
-                who: artist,
-                whatArray: [
-                    track.artistContribs.find(({ who }) => who === artist)?.what,
-                    track.contributorContribs.find(({ who }) => who === artist)?.what
-                ].filter(Boolean)
+export function write(artist, { wikiData }) {
+  const { groupData, wikiInfo } = wikiData;
+
+  const { name, urls, contextNotes } = artist;
+
+  const artThingsAll = sortAlbumsTracksChronologically(
+    unique([
+      ...(artist.albumsAsCoverArtist ?? []),
+      ...(artist.albumsAsWallpaperArtist ?? []),
+      ...(artist.albumsAsBannerArtist ?? []),
+      ...(artist.tracksAsCoverArtist ?? []),
+    ]),
+    { getDate: (o) => o.coverArtDate }
+  );
+
+  const artThingsGallery = sortAlbumsTracksChronologically(
+    [
+      ...(artist.albumsAsCoverArtist ?? []),
+      ...(artist.tracksAsCoverArtist ?? []),
+    ],
+    { getDate: (o) => o.coverArtDate }
+  );
+
+  const commentaryThings = sortAlbumsTracksChronologically([
+    ...(artist.albumsAsCommentator ?? []),
+    ...(artist.tracksAsCommentator ?? []),
+  ]);
+
+  const hasGallery = artThingsGallery.length > 0;
+
+  const getArtistsAndContrib = (thing, key) => ({
+    artists: thing[key]?.filter(({ who }) => who !== artist),
+    contrib: thing[key]?.find(({ who }) => who === artist),
+    thing,
+    key,
+  });
+
+  const artListChunks = chunkByProperties(
+    artThingsAll.flatMap((thing) =>
+      ["coverArtistContribs", "wallpaperArtistContribs", "bannerArtistContribs"]
+        .map((key) => getArtistsAndContrib(thing, key))
+        .filter(({ contrib }) => contrib)
+        .map((props) => ({
+          album: thing.album || thing,
+          track: thing.album ? thing : null,
+          date: thing.date,
+          ...props,
+        }))
+    ),
+    ["date", "album"]
+  );
+
+  const commentaryListChunks = chunkByProperties(
+    commentaryThings.map((thing) => ({
+      album: thing.album || thing,
+      track: thing.album ? thing : null,
+    })),
+    ["album"]
+  );
+
+  const allTracks = sortAlbumsTracksChronologically(
+    unique([
+      ...(artist.tracksAsArtist ?? []),
+      ...(artist.tracksAsContributor ?? []),
+    ])
+  );
+
+  const chunkTracks = (tracks) =>
+    chunkByProperties(
+      tracks.map((track) => ({
+        track,
+        date: +track.date,
+        album: track.album,
+        duration: track.duration,
+        artists: track.artistContribs.some(({ who }) => who === artist)
+          ? track.artistContribs.filter(({ who }) => who !== artist)
+          : track.contributorContribs.filter(({ who }) => who !== artist),
+        contrib: {
+          who: artist,
+          whatArray: [
+            track.artistContribs.find(({ who }) => who === artist)?.what,
+            track.contributorContribs.find(({ who }) => who === artist)?.what,
+          ].filter(Boolean),
+        },
+      })),
+      ["date", "album"]
+    ).map(({ date, album, chunk }) => ({
+      date,
+      album,
+      chunk,
+      duration: getTotalDuration(chunk),
+    }));
+
+  const trackListChunks = chunkTracks(allTracks);
+  const totalDuration = getTotalDuration(allTracks);
+
+  const countGroups = (things) => {
+    const usedGroups = things.flatMap(
+      (thing) => thing.groups || thing.album?.groups || []
+    );
+    return groupData
+      .map((group) => ({
+        group,
+        contributions: usedGroups.filter((g) => g === group).length,
+      }))
+      .filter(({ contributions }) => contributions > 0)
+      .sort((a, b) => b.contributions - a.contributions);
+  };
+
+  const musicGroups = countGroups(allTracks);
+  const artGroups = countGroups(artThingsAll);
+
+  let flashes, flashListChunks;
+  if (wikiInfo.enableFlashesAndGames) {
+    flashes = sortChronologically(artist.flashesAsContributor?.slice() ?? []);
+    flashListChunks = chunkByProperties(
+      flashes.map((flash) => ({
+        act: flash.act,
+        flash,
+        date: flash.date,
+        // Manual artists/contrib properties here, 8ecause we don't
+        // want to show the full list of other contri8utors inline.
+        // (It can often 8e very, very large!)
+        artists: [],
+        contrib: flash.contributorContribs.find(({ who }) => who === artist),
+      })),
+      ["act"]
+    ).map(({ act, chunk }) => ({
+      act,
+      chunk,
+      dateFirst: chunk[0].date,
+      dateLast: chunk[chunk.length - 1].date,
+    }));
+  }
+
+  const generateEntryAccents = ({
+    getArtistString,
+    language,
+    original,
+    entry,
+    artists,
+    contrib,
+  }) =>
+    original
+      ? language.$("artistPage.creditList.entry.rerelease", { entry })
+      : artists.length
+      ? contrib.what || contrib.whatArray?.length
+        ? language.$(
+            "artistPage.creditList.entry.withArtists.withContribution",
+            {
+              entry,
+              artists: getArtistString(artists),
+              contribution: contrib.whatArray
+                ? language.formatUnitList(contrib.whatArray)
+                : contrib.what,
             }
-        })), ['date', 'album'])
-        .map(({date, album, chunk}) => ({
-            date, album, chunk,
-            duration: getTotalDuration(chunk),
-        })));
-
-    const trackListChunks = chunkTracks(allTracks);
-    const totalDuration = getTotalDuration(allTracks);
-
-    const countGroups = things => {
-        const usedGroups = things.flatMap(thing => thing.groups || thing.album?.groups || []);
-        return groupData
-            .map(group => ({
-                group,
-                contributions: usedGroups.filter(g => g === group).length
-            }))
-            .filter(({ contributions }) => contributions > 0)
-            .sort((a, b) => b.contributions - a.contributions);
-    };
+          )
+        : language.$("artistPage.creditList.entry.withArtists", {
+            entry,
+            artists: getArtistString(artists),
+          })
+      : contrib.what || contrib.whatArray?.length
+      ? language.$("artistPage.creditList.entry.withContribution", {
+          entry,
+          contribution: contrib.whatArray
+            ? language.formatUnitList(contrib.whatArray)
+            : contrib.what,
+        })
+      : entry;
 
-    const musicGroups = countGroups(allTracks);
-    const artGroups = countGroups(artThingsAll);
-
-    let flashes, flashListChunks;
-    if (wikiInfo.enableFlashesAndGames) {
-        flashes = sortChronologically(artist.flashesAsContributor?.slice() ?? []);
-        flashListChunks = (
-            chunkByProperties(flashes.map(flash => ({
-                act: flash.act,
-                flash,
-                date: flash.date,
-                // Manual artists/contrib properties here, 8ecause we don't
-                // want to show the full list of other contri8utors inline.
-                // (It can often 8e very, very large!)
-                artists: [],
-                contrib: flash.contributorContribs.find(({ who }) => who === artist)
-            })), ['act'])
-            .map(({ act, chunk }) => ({
-                act, chunk,
-                dateFirst: chunk[0].date,
-                dateLast: chunk[chunk.length - 1].date
-            })));
-    }
-
-    const generateEntryAccents = ({
-        getArtistString, language,
-        original, entry, artists, contrib
-    }) =>
-        (original
-            ? language.$('artistPage.creditList.entry.rerelease', {entry})
-            : (artists.length
-                ? ((contrib.what || contrib.whatArray?.length)
-                    ? language.$('artistPage.creditList.entry.withArtists.withContribution', {
-                        entry,
-                        artists: getArtistString(artists),
-                        contribution: (contrib.whatArray ? language.formatUnitList(contrib.whatArray) : contrib.what)
-                    })
-                    : language.$('artistPage.creditList.entry.withArtists', {
-                        entry,
-                        artists: getArtistString(artists)
-                    }))
-                : ((contrib.what || contrib.whatArray?.length)
-                    ? language.$('artistPage.creditList.entry.withContribution', {
-                        entry,
-                        contribution: (contrib.whatArray ? language.formatUnitList(contrib.whatArray) : contrib.what)
-                    })
-                    : entry)));
-
-    const unbound_generateTrackList = (chunks, {
-        getArtistString, link, language
-    }) => fixWS`
+  const unbound_generateTrackList = (
+    chunks,
+    { getArtistString, link, language }
+  ) => fixWS`
         <dl>
-            ${chunks.map(({date, album, chunk, duration}) => fixWS`
+            ${chunks
+              .map(
+                ({ date, album, chunk, duration }) => fixWS`
                 <dt>${
-                    (date && duration) ? language.$('artistPage.creditList.album.withDate.withDuration', {
+                  date && duration
+                    ? language.$(
+                        "artistPage.creditList.album.withDate.withDuration",
+                        {
+                          album: link.album(album),
+                          date: language.formatDate(date),
+                          duration: language.formatDuration(duration, {
+                            approximate: true,
+                          }),
+                        }
+                      )
+                    : date
+                    ? language.$("artistPage.creditList.album.withDate", {
                         album: link.album(album),
                         date: language.formatDate(date),
-                        duration: language.formatDuration(duration, {approximate: true})
-                    }) : date ? language.$('artistPage.creditList.album.withDate', {
+                      })
+                    : duration
+                    ? language.$("artistPage.creditList.album.withDuration", {
                         album: link.album(album),
-                        date: language.formatDate(date)
-                    }) : duration ? language.$('artistPage.creditList.album.withDuration', {
+                        duration: language.formatDuration(duration, {
+                          approximate: true,
+                        }),
+                      })
+                    : language.$("artistPage.creditList.album", {
                         album: link.album(album),
-                        duration: language.formatDuration(duration, {approximate: true})
-                    }) : language.$('artistPage.creditList.album', {
-                        album: link.album(album)
-                    })}</dt>
+                      })
+                }</dt>
                 <dd><ul>
-                    ${(chunk
-                        .map(({track, ...props}) => ({
-                            original: track.originalReleaseTrack,
-                            entry: language.$('artistPage.creditList.entry.track.withDuration', {
-                                track: link.track(track),
-                                duration: language.formatDuration(track.duration ?? 0)
-                            }),
-                            ...props
-                        }))
-                        .map(({original, ...opts}) => html.tag('li',
-                            {class: original && 'rerelease'},
-                            generateEntryAccents({getArtistString, language, original, ...opts})))
-                        .join('\n'))}
+                    ${chunk
+                      .map(({ track, ...props }) => ({
+                        original: track.originalReleaseTrack,
+                        entry: language.$(
+                          "artistPage.creditList.entry.track.withDuration",
+                          {
+                            track: link.track(track),
+                            duration: language.formatDuration(
+                              track.duration ?? 0
+                            ),
+                          }
+                        ),
+                        ...props,
+                      }))
+                      .map(({ original, ...opts }) =>
+                        html.tag(
+                          "li",
+                          { class: original && "rerelease" },
+                          generateEntryAccents({
+                            getArtistString,
+                            language,
+                            original,
+                            ...opts,
+                          })
+                        )
+                      )
+                      .join("\n")}
                 </ul></dd>
-            `).join('\n')}
+            `
+              )
+              .join("\n")}
         </dl>
     `;
 
-    const unbound_serializeArtistsAndContrib = (key, {
-        serializeContribs,
-        serializeLink
-    }) => thing => {
-        const { artists, contrib } = getArtistsAndContrib(thing, key);
-        const ret = {};
-        ret.link = serializeLink(thing);
-        if (contrib.what) ret.contribution = contrib.what;
-        if (artists.length) ret.otherArtists = serializeContribs(artists);
-        return ret;
+  const unbound_serializeArtistsAndContrib =
+    (key, { serializeContribs, serializeLink }) =>
+    (thing) => {
+      const { artists, contrib } = getArtistsAndContrib(thing, key);
+      const ret = {};
+      ret.link = serializeLink(thing);
+      if (contrib.what) ret.contribution = contrib.what;
+      if (artists.length) ret.otherArtists = serializeContribs(artists);
+      return ret;
     };
 
-    const unbound_serializeTrackListChunks = (chunks, {serializeLink}) =>
-        chunks.map(({date, album, chunk, duration}) => ({
-            album: serializeLink(album),
-            date,
-            duration,
-            tracks: chunk.map(({ track }) => ({
-                link: serializeLink(track),
-                duration: track.duration
-            }))
-        }));
-
-    const data = {
-        type: 'data',
-        path: ['artist', artist.directory],
-        data: ({
-            serializeContribs,
-            serializeLink
-        }) => {
-            const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, {
-                serializeContribs,
-                serializeLink
-            });
-
-            const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, {
-                serializeLink
-            });
-
-            return {
-                albums: {
-                    asCoverArtist: artist.albumsAsCoverArtist?.map(serializeArtistsAndContrib('coverArtistContribs')),
-                    asWallpaperArtist: artist.albumsAsWallpaperArtist?.map(serializeArtistsAndContrib('wallpaperArtistContribs')),
-                    asBannerArtist: artist.albumsAsBannerArtist?.map(serializeArtistsAndContrib('bannerArtistContribs'))
-                },
-                flashes: wikiInfo.enableFlashesAndGames ? {
-                    asContributor: (artist.flashesAsContributor
-                        ?.map(flash => getArtistsAndContrib(flash, 'contributorContribs'))
-                        .map(({ contrib, thing: flash }) => ({
-                            link: serializeLink(flash),
-                            contribution: contrib.what
-                        })))
-                } : null,
-                tracks: {
-                    asArtist: artist.tracksAsArtist.map(serializeArtistsAndContrib('artistContribs')),
-                    asContributor: artist.tracksAsContributor.map(serializeArtistsAndContrib('contributorContribs')),
-                    chunked: serializeTrackListChunks(trackListChunks)
-                }
-            };
+  const unbound_serializeTrackListChunks = (chunks, { serializeLink }) =>
+    chunks.map(({ date, album, chunk, duration }) => ({
+      album: serializeLink(album),
+      date,
+      duration,
+      tracks: chunk.map(({ track }) => ({
+        link: serializeLink(track),
+        duration: track.duration,
+      })),
+    }));
+
+  const data = {
+    type: "data",
+    path: ["artist", artist.directory],
+    data: ({ serializeContribs, serializeLink }) => {
+      const serializeArtistsAndContrib = bindOpts(
+        unbound_serializeArtistsAndContrib,
+        {
+          serializeContribs,
+          serializeLink,
         }
-    };
+      );
 
-    const infoPage = {
-        type: 'page',
-        path: ['artist', artist.directory],
-        page: ({
-            fancifyURL,
-            generateCoverLink,
-            generateInfoGalleryLinks,
-            getArtistAvatar,
-            getArtistString,
-            link,
-            language,
-            to,
-            transformMultiline
-        }) => {
-            const generateTrackList = bindOpts(unbound_generateTrackList, {
-                getArtistString,
-                link,
-                language
-            });
-
-            return {
-                title: language.$('artistPage.title', {artist: name}),
-
-                main: {
-                    content: fixWS`
-                        ${artist.hasAvatar && generateCoverLink({
+      const serializeTrackListChunks = bindOpts(
+        unbound_serializeTrackListChunks,
+        {
+          serializeLink,
+        }
+      );
+
+      return {
+        albums: {
+          asCoverArtist: artist.albumsAsCoverArtist?.map(
+            serializeArtistsAndContrib("coverArtistContribs")
+          ),
+          asWallpaperArtist: artist.albumsAsWallpaperArtist?.map(
+            serializeArtistsAndContrib("wallpaperArtistContribs")
+          ),
+          asBannerArtist: artist.albumsAsBannerArtist?.map(
+            serializeArtistsAndContrib("bannerArtistContribs")
+          ),
+        },
+        flashes: wikiInfo.enableFlashesAndGames
+          ? {
+              asContributor: artist.flashesAsContributor
+                ?.map((flash) =>
+                  getArtistsAndContrib(flash, "contributorContribs")
+                )
+                .map(({ contrib, thing: flash }) => ({
+                  link: serializeLink(flash),
+                  contribution: contrib.what,
+                })),
+            }
+          : null,
+        tracks: {
+          asArtist: artist.tracksAsArtist.map(
+            serializeArtistsAndContrib("artistContribs")
+          ),
+          asContributor: artist.tracksAsContributor.map(
+            serializeArtistsAndContrib("contributorContribs")
+          ),
+          chunked: serializeTrackListChunks(trackListChunks),
+        },
+      };
+    },
+  };
+
+  const infoPage = {
+    type: "page",
+    path: ["artist", artist.directory],
+    page: ({
+      fancifyURL,
+      generateCoverLink,
+      generateInfoGalleryLinks,
+      getArtistAvatar,
+      getArtistString,
+      link,
+      language,
+      to,
+      transformMultiline,
+    }) => {
+      const generateTrackList = bindOpts(unbound_generateTrackList, {
+        getArtistString,
+        link,
+        language,
+      });
+
+      return {
+        title: language.$("artistPage.title", { artist: name }),
+
+        main: {
+          content: fixWS`
+                        ${
+                          artist.hasAvatar &&
+                          generateCoverLink({
                             src: getArtistAvatar(artist),
-                            alt: language.$('misc.alt.artistAvatar')
-                        })}
-                        <h1>${language.$('artistPage.title', {artist: name})}</h1>
-                        ${contextNotes && fixWS`
-                            <p>${language.$('releaseInfo.note')}</p>
+                            alt: language.$("misc.alt.artistAvatar"),
+                          })
+                        }
+                        <h1>${language.$("artistPage.title", {
+                          artist: name,
+                        })}</h1>
+                        ${
+                          contextNotes &&
+                          fixWS`
+                            <p>${language.$("releaseInfo.note")}</p>
                             <blockquote>
                                 ${transformMultiline(contextNotes)}
                             </blockquote>
                             <hr>
-                        `}
-                        ${urls?.length && `<p>${language.$('releaseInfo.visitOn', {
-                            links: language.formatDisjunctionList(urls.map(url => fancifyURL(url, {language})))
-                        })}</p>`}
-                        ${hasGallery && `<p>${language.$('artistPage.viewArtGallery', {
+                        `
+                        }
+                        ${
+                          urls?.length &&
+                          `<p>${language.$("releaseInfo.visitOn", {
+                            links: language.formatDisjunctionList(
+                              urls.map((url) => fancifyURL(url, { language }))
+                            ),
+                          })}</p>`
+                        }
+                        ${
+                          hasGallery &&
+                          `<p>${language.$("artistPage.viewArtGallery", {
                             link: link.artistGallery(artist, {
-                                text: language.$('artistPage.viewArtGallery.link')
-                            })
-                        })}</p>`}
-                        <p>${language.$('misc.jumpTo.withLinks', {
-                            links: language.formatUnitList([
-                                allTracks.length && `<a href="#tracks">${language.$('artistPage.trackList.title')}</a>`,
-                                artThingsAll.length && `<a href="#art">${language.$('artistPage.artList.title')}</a>`,
-                                wikiInfo.enableFlashesAndGames && flashes.length && `<a href="#flashes">${language.$('artistPage.flashList.title')}</a>`,
-                                commentaryThings.length && `<a href="#commentary">${language.$('artistPage.commentaryList.title')}</a>`
-                            ].filter(Boolean))
+                              text: language.$(
+                                "artistPage.viewArtGallery.link"
+                              ),
+                            }),
+                          })}</p>`
+                        }
+                        <p>${language.$("misc.jumpTo.withLinks", {
+                          links: language.formatUnitList(
+                            [
+                              allTracks.length &&
+                                `<a href="#tracks">${language.$(
+                                  "artistPage.trackList.title"
+                                )}</a>`,
+                              artThingsAll.length &&
+                                `<a href="#art">${language.$(
+                                  "artistPage.artList.title"
+                                )}</a>`,
+                              wikiInfo.enableFlashesAndGames &&
+                                flashes.length &&
+                                `<a href="#flashes">${language.$(
+                                  "artistPage.flashList.title"
+                                )}</a>`,
+                              commentaryThings.length &&
+                                `<a href="#commentary">${language.$(
+                                  "artistPage.commentaryList.title"
+                                )}</a>`,
+                            ].filter(Boolean)
+                          ),
                         })}</p>
-                        ${allTracks.length && fixWS`
-                            <h2 id="tracks">${language.$('artistPage.trackList.title')}</h2>
-                            <p>${language.$('artistPage.contributedDurationLine', {
+                        ${
+                          allTracks.length &&
+                          fixWS`
+                            <h2 id="tracks">${language.$(
+                              "artistPage.trackList.title"
+                            )}</h2>
+                            <p>${language.$(
+                              "artistPage.contributedDurationLine",
+                              {
                                 artist: artist.name,
-                                duration: language.formatDuration(totalDuration, {approximate: true, unit: true})
-                            })}</p>
-                            <p>${language.$('artistPage.musicGroupsLine', {
-                                groups: language.formatUnitList(musicGroups
-                                    .map(({ group, contributions }) => language.$('artistPage.groupsLine.item', {
-                                        group: link.groupInfo(group),
-                                        contributions: language.countContributions(contributions)
-                                    })))
+                                duration: language.formatDuration(
+                                  totalDuration,
+                                  { approximate: true, unit: true }
+                                ),
+                              }
+                            )}</p>
+                            <p>${language.$("artistPage.musicGroupsLine", {
+                              groups: language.formatUnitList(
+                                musicGroups.map(({ group, contributions }) =>
+                                  language.$("artistPage.groupsLine.item", {
+                                    group: link.groupInfo(group),
+                                    contributions:
+                                      language.countContributions(
+                                        contributions
+                                      ),
+                                  })
+                                )
+                              ),
                             })}</p>
                             ${generateTrackList(trackListChunks)}
-                        `}
-                        ${artThingsAll.length && fixWS`
-                            <h2 id="art">${language.$('artistPage.artList.title')}</h2>
-                            ${hasGallery && `<p>${language.$('artistPage.viewArtGallery.orBrowseList', {
-                                link: link.artistGallery(artist, {
-                                    text: language.$('artistPage.viewArtGallery.link')
-                                })
-                            })}</p>`}
-                            <p>${language.$('artistPage.artGroupsLine', {
-                                groups: language.formatUnitList(artGroups
-                                    .map(({ group, contributions }) => language.$('artistPage.groupsLine.item', {
-                                        group: link.groupInfo(group),
-                                        contributions: language.countContributions(contributions)
-                                    })))
+                        `
+                        }
+                        ${
+                          artThingsAll.length &&
+                          fixWS`
+                            <h2 id="art">${language.$(
+                              "artistPage.artList.title"
+                            )}</h2>
+                            ${
+                              hasGallery &&
+                              `<p>${language.$(
+                                "artistPage.viewArtGallery.orBrowseList",
+                                {
+                                  link: link.artistGallery(artist, {
+                                    text: language.$(
+                                      "artistPage.viewArtGallery.link"
+                                    ),
+                                  }),
+                                }
+                              )}</p>`
+                            }
+                            <p>${language.$("artistPage.artGroupsLine", {
+                              groups: language.formatUnitList(
+                                artGroups.map(({ group, contributions }) =>
+                                  language.$("artistPage.groupsLine.item", {
+                                    group: link.groupInfo(group),
+                                    contributions:
+                                      language.countContributions(
+                                        contributions
+                                      ),
+                                  })
+                                )
+                              ),
                             })}</p>
                             <dl>
-                                ${artListChunks.map(({date, album, chunk}) => fixWS`
-                                    <dt>${language.$('artistPage.creditList.album.withDate', {
+                                ${artListChunks
+                                  .map(
+                                    ({ date, album, chunk }) => fixWS`
+                                    <dt>${language.$(
+                                      "artistPage.creditList.album.withDate",
+                                      {
                                         album: link.album(album),
-                                        date: language.formatDate(date)
-                                    })}</dt>
+                                        date: language.formatDate(date),
+                                      }
+                                    )}</dt>
                                     <dd><ul>
-                                        ${(chunk
-                                            .map(({album, track, key, ...props}) => ({
-                                                entry: (track
-                                                    ? language.$('artistPage.creditList.entry.track', {
-                                                        track: link.track(track)
-                                                    })
-                                                    : `<i>${language.$('artistPage.creditList.entry.album.' + {
-                                                        wallpaperArtistContribs: 'wallpaperArt',
-                                                        bannerArtistContribs: 'bannerArt',
-                                                        coverArtistContribs: 'coverArt'
-                                                    }[key])}</i>`),
-                                                ...props
-                                            }))
-                                            .map(opts => generateEntryAccents({getArtistString, language, ...opts}))
-                                            .map(row => `<li>${row}</li>`)
-                                            .join('\n'))}
+                                        ${chunk
+                                          .map(
+                                            ({
+                                              album,
+                                              track,
+                                              key,
+                                              ...props
+                                            }) => ({
+                                              entry: track
+                                                ? language.$(
+                                                    "artistPage.creditList.entry.track",
+                                                    {
+                                                      track: link.track(track),
+                                                    }
+                                                  )
+                                                : `<i>${language.$(
+                                                    "artistPage.creditList.entry.album." +
+                                                      {
+                                                        wallpaperArtistContribs:
+                                                          "wallpaperArt",
+                                                        bannerArtistContribs:
+                                                          "bannerArt",
+                                                        coverArtistContribs:
+                                                          "coverArt",
+                                                      }[key]
+                                                  )}</i>`,
+                                              ...props,
+                                            })
+                                          )
+                                          .map((opts) =>
+                                            generateEntryAccents({
+                                              getArtistString,
+                                              language,
+                                              ...opts,
+                                            })
+                                          )
+                                          .map((row) => `<li>${row}</li>`)
+                                          .join("\n")}
                                     </ul></dd>
-                                `).join('\n')}
+                                `
+                                  )
+                                  .join("\n")}
                             </dl>
-                        `}
-                        ${wikiInfo.enableFlashesAndGames && flashes.length && fixWS`
-                            <h2 id="flashes">${language.$('artistPage.flashList.title')}</h2>
+                        `
+                        }
+                        ${
+                          wikiInfo.enableFlashesAndGames &&
+                          flashes.length &&
+                          fixWS`
+                            <h2 id="flashes">${language.$(
+                              "artistPage.flashList.title"
+                            )}</h2>
                             <dl>
-                                ${flashListChunks.map(({act, chunk, dateFirst, dateLast}) => fixWS`
-                                    <dt>${language.$('artistPage.creditList.flashAct.withDateRange', {
-                                        act: link.flash(chunk[0].flash, {text: act.name}),
-                                        dateRange: language.formatDateRange(dateFirst, dateLast)
-                                    })}</dt>
+                                ${flashListChunks
+                                  .map(
+                                    ({
+                                      act,
+                                      chunk,
+                                      dateFirst,
+                                      dateLast,
+                                    }) => fixWS`
+                                    <dt>${language.$(
+                                      "artistPage.creditList.flashAct.withDateRange",
+                                      {
+                                        act: link.flash(chunk[0].flash, {
+                                          text: act.name,
+                                        }),
+                                        dateRange: language.formatDateRange(
+                                          dateFirst,
+                                          dateLast
+                                        ),
+                                      }
+                                    )}</dt>
                                     <dd><ul>
-                                        ${(chunk
-                                            .map(({flash, ...props}) => ({
-                                                entry: language.$('artistPage.creditList.entry.flash', {
-                                                    flash: link.flash(flash)
-                                                }),
-                                                ...props
-                                            }))
-                                            .map(opts => generateEntryAccents({getArtistString, language, ...opts}))
-                                            .map(row => `<li>${row}</li>`)
-                                            .join('\n'))}
+                                        ${chunk
+                                          .map(({ flash, ...props }) => ({
+                                            entry: language.$(
+                                              "artistPage.creditList.entry.flash",
+                                              {
+                                                flash: link.flash(flash),
+                                              }
+                                            ),
+                                            ...props,
+                                          }))
+                                          .map((opts) =>
+                                            generateEntryAccents({
+                                              getArtistString,
+                                              language,
+                                              ...opts,
+                                            })
+                                          )
+                                          .map((row) => `<li>${row}</li>`)
+                                          .join("\n")}
                                     </ul></dd>
-                                `).join('\n')}
+                                `
+                                  )
+                                  .join("\n")}
                             </dl>
-                        `}
-                        ${commentaryThings.length && fixWS`
-                            <h2 id="commentary">${language.$('artistPage.commentaryList.title')}</h2>
+                        `
+                        }
+                        ${
+                          commentaryThings.length &&
+                          fixWS`
+                            <h2 id="commentary">${language.$(
+                              "artistPage.commentaryList.title"
+                            )}</h2>
                             <dl>
-                                ${commentaryListChunks.map(({album, chunk}) => fixWS`
-                                    <dt>${language.$('artistPage.creditList.album', {
-                                        album: link.album(album)
-                                    })}</dt>
+                                ${commentaryListChunks
+                                  .map(
+                                    ({ album, chunk }) => fixWS`
+                                    <dt>${language.$(
+                                      "artistPage.creditList.album",
+                                      {
+                                        album: link.album(album),
+                                      }
+                                    )}</dt>
                                     <dd><ul>
-                                        ${(chunk
-                                            .map(({album, track, ...props}) => track
-                                                ? language.$('artistPage.creditList.entry.track', {
-                                                    track: link.track(track)
-                                                })
-                                                : `<i>${language.$('artistPage.creditList.entry.album.commentary')}</i>`)
-                                            .map(row => `<li>${row}</li>`)
-                                            .join('\n'))}
+                                        ${chunk
+                                          .map(({ album, track, ...props }) =>
+                                            track
+                                              ? language.$(
+                                                  "artistPage.creditList.entry.track",
+                                                  {
+                                                    track: link.track(track),
+                                                  }
+                                                )
+                                              : `<i>${language.$(
+                                                  "artistPage.creditList.entry.album.commentary"
+                                                )}</i>`
+                                          )
+                                          .map((row) => `<li>${row}</li>`)
+                                          .join("\n")}
                                     </ul></dd>
-                                `).join('\n')}
+                                `
+                                  )
+                                  .join("\n")}
                             </dl>
-                        `}
-                    `
-                },
-
-                nav: generateNavForArtist(artist, false, hasGallery, {
-                    generateInfoGalleryLinks,
-                    link,
-                    language,
-                    wikiData
-                })
-            };
-        }
-    };
-
-    const galleryPage = hasGallery && {
-        type: 'page',
-        path: ['artistGallery', artist.directory],
-        page: ({
-            generateInfoGalleryLinks,
-            getAlbumCover,
-            getGridHTML,
-            getTrackCover,
-            link,
-            language,
-            to
-        }) => ({
-            title: language.$('artistGalleryPage.title', {artist: name}),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${language.$('artistGalleryPage.title', {artist: name})}</h1>
-                    <p class="quick-info">${language.$('artistGalleryPage.infoLine', {
-                        coverArts: language.countCoverArts(artThingsGallery.length, {unit: true})
-                    })}</p>
+                        `
+                        }
+                    `,
+        },
+
+        nav: generateNavForArtist(artist, false, hasGallery, {
+          generateInfoGalleryLinks,
+          link,
+          language,
+          wikiData,
+        }),
+      };
+    },
+  };
+
+  const galleryPage = hasGallery && {
+    type: "page",
+    path: ["artistGallery", artist.directory],
+    page: ({
+      generateInfoGalleryLinks,
+      getAlbumCover,
+      getGridHTML,
+      getTrackCover,
+      link,
+      language,
+      to,
+    }) => ({
+      title: language.$("artistGalleryPage.title", { artist: name }),
+
+      main: {
+        classes: ["top-index"],
+        content: fixWS`
+                    <h1>${language.$("artistGalleryPage.title", {
+                      artist: name,
+                    })}</h1>
+                    <p class="quick-info">${language.$(
+                      "artistGalleryPage.infoLine",
+                      {
+                        coverArts: language.countCoverArts(
+                          artThingsGallery.length,
+                          { unit: true }
+                        ),
+                      }
+                    )}</p>
                     <div class="grid-listing">
                         ${getGridHTML({
-                            entries: artThingsGallery.map(item => ({item})),
-                            srcFn: thing => (thing.album
-                                ? getTrackCover(thing)
-                                : getAlbumCover(thing)),
-                            linkFn: (thing, opts) => (thing.album
-                                ? link.track(thing, opts)
-                                : link.album(thing, opts))
+                          entries: artThingsGallery.map((item) => ({ item })),
+                          srcFn: (thing) =>
+                            thing.album
+                              ? getTrackCover(thing)
+                              : getAlbumCover(thing),
+                          linkFn: (thing, opts) =>
+                            thing.album
+                              ? link.track(thing, opts)
+                              : link.album(thing, opts),
                         })}
                     </div>
-                `
-            },
-
-            nav: generateNavForArtist(artist, true, hasGallery, {
-                generateInfoGalleryLinks,
-                link,
-                language,
-                wikiData
-            })
-        })
-    };
-
-    return [data, infoPage, galleryPage].filter(Boolean);
+                `,
+      },
+
+      nav: generateNavForArtist(artist, true, hasGallery, {
+        generateInfoGalleryLinks,
+        link,
+        language,
+        wikiData,
+      }),
+    }),
+  };
+
+  return [data, infoPage, galleryPage].filter(Boolean);
 }
 
 // Utility functions
 
-function generateNavForArtist(artist, isGallery, hasGallery, {
-    generateInfoGalleryLinks,
-    link,
-    language,
-    wikiData
-}) {
-    const { wikiInfo } = wikiData;
-
-    const infoGalleryLinks = (hasGallery &&
-        generateInfoGalleryLinks(artist, isGallery, {
-            link, language,
-            linkKeyGallery: 'artistGallery',
-            linkKeyInfo: 'artist'
-        }))
+function generateNavForArtist(
+  artist,
+  isGallery,
+  hasGallery,
+  { generateInfoGalleryLinks, link, language, wikiData }
+) {
+  const { wikiInfo } = wikiData;
+
+  const infoGalleryLinks =
+    hasGallery &&
+    generateInfoGalleryLinks(artist, isGallery, {
+      link,
+      language,
+      linkKeyGallery: "artistGallery",
+      linkKeyInfo: "artist",
+    });
 
-    return {
-        linkContainerClasses: ['nav-links-hierarchy'],
-        links: [
-            {toHome: true},
-            wikiInfo.enableListings &&
-            {
-                path: ['localized.listingIndex'],
-                title: language.$('listingIndex.title')
-            },
-            {
-                html: language.$('artistPage.nav.artist', {
-                    artist: link.artist(artist, {class: 'current'})
-                })
-            },
-            hasGallery &&
-            {
-                divider: false,
-                html: `(${infoGalleryLinks})`
-            }
-        ]
-    };
+  return {
+    linkContainerClasses: ["nav-links-hierarchy"],
+    links: [
+      { toHome: true },
+      wikiInfo.enableListings && {
+        path: ["localized.listingIndex"],
+        title: language.$("listingIndex.title"),
+      },
+      {
+        html: language.$("artistPage.nav.artist", {
+          artist: link.artist(artist, { class: "current" }),
+        }),
+      },
+      hasGallery && {
+        divider: false,
+        html: `(${infoGalleryLinks})`,
+      },
+    ],
+  };
 }
diff --git a/src/page/flash.js b/src/page/flash.js
index 21a22b94..4d8b9f11 100644
--- a/src/page/flash.js
+++ b/src/page/flash.js
@@ -2,251 +2,329 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
-import * as html from '../util/html.js';
+import * as html from "../util/html.js";
 
-import {
-    getFlashLink
-} from '../util/wiki-data.js';
+import { getFlashLink } from "../util/wiki-data.js";
 
 // Page exports
 
-export function condition({wikiData}) {
-    return wikiData.wikiInfo.enableFlashesAndGames;
+export function condition({ wikiData }) {
+  return wikiData.wikiInfo.enableFlashesAndGames;
 }
 
-export function targets({wikiData}) {
-    return wikiData.flashData;
+export function targets({ wikiData }) {
+  return wikiData.flashData;
 }
 
-export function write(flash, {wikiData}) {
-    const page = {
-        type: 'page',
-        path: ['flash', flash.directory],
-        page: ({
-            fancifyFlashURL,
-            generateChronologyLinks,
-            generateCoverLink,
-            generatePreviousNextLinks,
-            getArtistString,
-            getFlashCover,
-            getThemeString,
-            link,
-            language,
-            transformInline
-        }) => ({
-            title: language.$('flashPage.title', {flash: flash.name}),
-            theme: getThemeString(flash.color, [
-                `--flash-directory: ${flash.directory}`
-            ]),
-
-            main: {
-                content: fixWS`
-                    <h1>${language.$('flashPage.title', {flash: flash.name})}</h1>
+export function write(flash, { wikiData }) {
+  const page = {
+    type: "page",
+    path: ["flash", flash.directory],
+    page: ({
+      fancifyFlashURL,
+      generateChronologyLinks,
+      generateCoverLink,
+      generatePreviousNextLinks,
+      getArtistString,
+      getFlashCover,
+      getThemeString,
+      link,
+      language,
+      transformInline,
+    }) => ({
+      title: language.$("flashPage.title", { flash: flash.name }),
+      theme: getThemeString(flash.color, [
+        `--flash-directory: ${flash.directory}`,
+      ]),
+
+      main: {
+        content: fixWS`
+                    <h1>${language.$("flashPage.title", {
+                      flash: flash.name,
+                    })}</h1>
                     ${generateCoverLink({
-                        src: getFlashCover(flash),
-                        alt: language.$('misc.alt.flashArt')
+                      src: getFlashCover(flash),
+                      alt: language.$("misc.alt.flashArt"),
                     })}
-                    <p>${language.$('releaseInfo.released', {date: language.formatDate(flash.date)})}</p>
-                    ${(flash.page || flash.urls?.length) && `<p>${language.$('releaseInfo.playOn', {
-                        links: language.formatDisjunctionList([
+                    <p>${language.$("releaseInfo.released", {
+                      date: language.formatDate(flash.date),
+                    })}</p>
+                    ${
+                      (flash.page || flash.urls?.length) &&
+                      `<p>${language.$("releaseInfo.playOn", {
+                        links: language.formatDisjunctionList(
+                          [
                             flash.page && getFlashLink(flash),
-                            ...flash.urls ?? []
-                        ].map(url => fancifyFlashURL(url, flash)))
-                    })}</p>`}
-                    ${flash.featuredTracks && fixWS`
-                        <p>Tracks featured in <i>${flash.name.replace(/\.$/, '')}</i>:</p>
+                            ...(flash.urls ?? []),
+                          ].map((url) => fancifyFlashURL(url, flash))
+                        ),
+                      })}</p>`
+                    }
+                    ${
+                      flash.featuredTracks &&
+                      fixWS`
+                        <p>Tracks featured in <i>${flash.name.replace(
+                          /\.$/,
+                          ""
+                        )}</i>:</p>
                         <ul>
-                            ${(flash.featuredTracks
-                                .map(track => language.$('trackList.item.withArtists', {
-                                    track: link.track(track),
-                                    by: `<span class="by">${
-                                        language.$('trackList.item.withArtists.by', {
-                                            artists: getArtistString(track.artistContribs)
-                                        })
-                                    }</span>`
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
+                            ${flash.featuredTracks
+                              .map((track) =>
+                                language.$("trackList.item.withArtists", {
+                                  track: link.track(track),
+                                  by: `<span class="by">${language.$(
+                                    "trackList.item.withArtists.by",
+                                    {
+                                      artists: getArtistString(
+                                        track.artistContribs
+                                      ),
+                                    }
+                                  )}</span>`,
+                                })
+                              )
+                              .map((row) => `<li>${row}</li>`)
+                              .join("\n")}
                         </ul>
-                    `}
-                    ${flash.contributorContribs.length && fixWS`
-                        <p>${language.$('releaseInfo.contributors')}</p>
+                    `
+                    }
+                    ${
+                      flash.contributorContribs.length &&
+                      fixWS`
+                        <p>${language.$("releaseInfo.contributors")}</p>
                         <ul>
                             ${flash.contributorContribs
-                                .map(contrib => `<li>${getArtistString([contrib], {
+                              .map(
+                                (contrib) =>
+                                  `<li>${getArtistString([contrib], {
                                     showContrib: true,
-                                    showIcons: true
-                                })}</li>`)
-                                .join('\n')}
+                                    showIcons: true,
+                                  })}</li>`
+                              )
+                              .join("\n")}
                         </ul>
-                    `}
-                `
-            },
-
-            sidebarLeft: generateSidebarForFlash(flash, {link, language, wikiData}),
-            nav: generateNavForFlash(flash, {
-                generateChronologyLinks,
-                generatePreviousNextLinks,
-                link,
-                language,
-                wikiData
-            })
-        })
-    };
-
-    return [page];
+                    `
+                    }
+                `,
+      },
+
+      sidebarLeft: generateSidebarForFlash(flash, { link, language, wikiData }),
+      nav: generateNavForFlash(flash, {
+        generateChronologyLinks,
+        generatePreviousNextLinks,
+        link,
+        language,
+        wikiData,
+      }),
+    }),
+  };
+
+  return [page];
 }
 
-export function writeTargetless({wikiData}) {
-    const { flashActData } = wikiData;
-
-    const page = {
-        type: 'page',
-        path: ['flashIndex'],
-        page: ({
-            getFlashGridHTML,
-            getLinkThemeString,
-            link,
-            language
-        }) => ({
-            title: language.$('flashIndex.title'),
-
-            main: {
-                classes: ['flash-index'],
-                content: fixWS`
-                    <h1>${language.$('flashIndex.title')}</h1>
+export function writeTargetless({ wikiData }) {
+  const { flashActData } = wikiData;
+
+  const page = {
+    type: "page",
+    path: ["flashIndex"],
+    page: ({ getFlashGridHTML, getLinkThemeString, link, language }) => ({
+      title: language.$("flashIndex.title"),
+
+      main: {
+        classes: ["flash-index"],
+        content: fixWS`
+                    <h1>${language.$("flashIndex.title")}</h1>
                     <div class="long-content">
-                        <p class="quick-info">${language.$('misc.jumpTo')}</p>
+                        <p class="quick-info">${language.$("misc.jumpTo")}</p>
                         <ul class="quick-info">
-                            ${flashActData.filter(act => act.jump).map(({ anchor, jump, jumpColor }) => fixWS`
-                                <li><a href="#${anchor}" style="${getLinkThemeString(jumpColor)}">${jump}</a></li>
-                            `).join('\n')}
+                            ${flashActData
+                              .filter((act) => act.jump)
+                              .map(
+                                ({ anchor, jump, jumpColor }) => fixWS`
+                                <li><a href="#${anchor}" style="${getLinkThemeString(
+                                  jumpColor
+                                )}">${jump}</a></li>
+                            `
+                              )
+                              .join("\n")}
                         </ul>
                     </div>
-                    ${flashActData.map((act, i) => fixWS`
-                        <h2 id="${act.anchor}" style="${getLinkThemeString(act.color)}">${link.flash(act.flashes[0], {text: act.name})}</h2>
+                    ${flashActData
+                      .map(
+                        (act, i) => fixWS`
+                        <h2 id="${act.anchor}" style="${getLinkThemeString(
+                          act.color
+                        )}">${link.flash(act.flashes[0], {
+                          text: act.name,
+                        })}</h2>
                         <div class="grid-listing">
                             ${getFlashGridHTML({
-                                entries: act.flashes.map(flash => ({item: flash})),
-                                lazy: i === 0 ? 4 : true
+                              entries: act.flashes.map((flash) => ({
+                                item: flash,
+                              })),
+                              lazy: i === 0 ? 4 : true,
                             })}
                         </div>
-                    `).join('\n')}
-                `
-            },
+                    `
+                      )
+                      .join("\n")}
+                `,
+      },
 
-            nav: {simple: true}
-        })
-    };
+      nav: { simple: true },
+    }),
+  };
 
-    return [page];
+  return [page];
 }
 
 // Utility functions
 
-function generateNavForFlash(flash, {
+function generateNavForFlash(
+  flash,
+  {
     generateChronologyLinks,
     generatePreviousNextLinks,
     link,
     language,
-    wikiData
-}) {
-    const { flashData, wikiInfo } = wikiData;
-
-    const previousNextLinks = generatePreviousNextLinks(flash, {
-        data: flashData,
-        linkKey: 'flash'
-    });
-
-    return {
-        linkContainerClasses: ['nav-links-hierarchy'],
-        links: [
-            {toHome: true},
-            {
-                path: ['localized.flashIndex'],
-                title: language.$('flashIndex.title')
-            },
-            {
-                html: language.$('flashPage.nav.flash', {
-                    flash: link.flash(flash, {class: 'current'})
-                })
-            },
-        ],
-
-        bottomRowContent: previousNextLinks && `(${previousNextLinks})`,
+    wikiData,
+  }
+) {
+  const { flashData, wikiInfo } = wikiData;
 
-        content: fixWS`
+  const previousNextLinks = generatePreviousNextLinks(flash, {
+    data: flashData,
+    linkKey: "flash",
+  });
+
+  return {
+    linkContainerClasses: ["nav-links-hierarchy"],
+    links: [
+      { toHome: true },
+      {
+        path: ["localized.flashIndex"],
+        title: language.$("flashIndex.title"),
+      },
+      {
+        html: language.$("flashPage.nav.flash", {
+          flash: link.flash(flash, { class: "current" }),
+        }),
+      },
+    ],
+
+    bottomRowContent: previousNextLinks && `(${previousNextLinks})`,
+
+    content: fixWS`
             <div>
                 ${generateChronologyLinks(flash, {
-                    headingString: 'misc.chronology.heading.flash',
-                    contribKey: 'contributorContribs',
-                    getThings: artist => artist.flashesAsContributor
+                  headingString: "misc.chronology.heading.flash",
+                  contribKey: "contributorContribs",
+                  getThings: (artist) => artist.flashesAsContributor,
                 })}
             </div>
-        `
-    };
+        `,
+  };
 }
 
-function generateSidebarForFlash(flash, {link, language, wikiData}) {
-    // all hard-coded, sorry :(
-    // this doesnt have a super portable implementation/design...yet!!
-
-    const { flashActData } = wikiData;
-
-    const act6 = flashActData.findIndex(act => act.name.startsWith('Act 6'));
-    const postCanon = flashActData.findIndex(act => act.name.includes('Post Canon'));
-    const outsideCanon = postCanon + flashActData.slice(postCanon).findIndex(act => !act.name.includes('Post Canon'));
-    const actIndex = flashActData.indexOf(flash.act);
-    const side = (
-        (actIndex < 0) ? 0 :
-        (actIndex < act6) ? 1 :
-        (actIndex <= outsideCanon) ? 2 :
-        3
-    );
-    const currentAct = flash && flash.act;
-
-    return {
-        content: fixWS`
-            <h1>${link.flashIndex('', {text: language.$('flashIndex.title')})}</h1>
+function generateSidebarForFlash(flash, { link, language, wikiData }) {
+  // all hard-coded, sorry :(
+  // this doesnt have a super portable implementation/design...yet!!
+
+  const { flashActData } = wikiData;
+
+  const act6 = flashActData.findIndex((act) => act.name.startsWith("Act 6"));
+  const postCanon = flashActData.findIndex((act) =>
+    act.name.includes("Post Canon")
+  );
+  const outsideCanon =
+    postCanon +
+    flashActData
+      .slice(postCanon)
+      .findIndex((act) => !act.name.includes("Post Canon"));
+  const actIndex = flashActData.indexOf(flash.act);
+  const side =
+    actIndex < 0 ? 0 : actIndex < act6 ? 1 : actIndex <= outsideCanon ? 2 : 3;
+  const currentAct = flash && flash.act;
+
+  return {
+    content: fixWS`
+            <h1>${link.flashIndex("", {
+              text: language.$("flashIndex.title"),
+            })}</h1>
             <dl>
-                ${flashActData.filter(act =>
-                    act.name.startsWith('Act 1') ||
-                    act.name.startsWith('Act 6 Act 1') ||
-                    act.name.startsWith('Hiveswap') ||
-                    // Sorry not sorry -Yiffy
-                    (({index = flashActData.indexOf(act)} = {}) => (
-                        index < act6 ? side === 1 :
-                        index < outsideCanon ? side === 2 :
-                        true
-                    ))()
-                ).flatMap(act => [
-                    act.name.startsWith('Act 1') && html.tag('dt',
-                        {class: ['side', side === 1 && 'current']},
-                        link.flash(act.flashes[0], {color: '#4ac925', text: `Side 1 (Acts 1-5)`}))
-                    || act.name.startsWith('Act 6 Act 1') && html.tag('dt',
-                        {class: ['side', side === 2 && 'current']},
-                        link.flash(act.flashes[0], {color: '#1076a2', text: `Side 2 (Acts 6-7)`}))
-                    || act.name.startsWith('Hiveswap Act 1') && html.tag('dt',
-                        {class: ['side', side === 3 && 'current']},
-                        link.flash(act.flashes[0], {color: '#008282', text: `Outside Canon (Misc. Games)`})),
-                    (({index = flashActData.indexOf(act)} = {}) => (
-                        index < act6 ? side === 1 :
-                        index < outsideCanon ? side === 2 :
-                        true
-                    ))() && html.tag('dt',
-                        {class: act === currentAct && 'current'},
-                        link.flash(act.flashes[0], {text: act.name})),
-                    act === currentAct && fixWS`
+                ${flashActData
+                  .filter(
+                    (act) =>
+                      act.name.startsWith("Act 1") ||
+                      act.name.startsWith("Act 6 Act 1") ||
+                      act.name.startsWith("Hiveswap") ||
+                      // Sorry not sorry -Yiffy
+                      (({ index = flashActData.indexOf(act) } = {}) =>
+                        index < act6
+                          ? side === 1
+                          : index < outsideCanon
+                          ? side === 2
+                          : true)()
+                  )
+                  .flatMap((act) => [
+                    (act.name.startsWith("Act 1") &&
+                      html.tag(
+                        "dt",
+                        { class: ["side", side === 1 && "current"] },
+                        link.flash(act.flashes[0], {
+                          color: "#4ac925",
+                          text: `Side 1 (Acts 1-5)`,
+                        })
+                      )) ||
+                      (act.name.startsWith("Act 6 Act 1") &&
+                        html.tag(
+                          "dt",
+                          { class: ["side", side === 2 && "current"] },
+                          link.flash(act.flashes[0], {
+                            color: "#1076a2",
+                            text: `Side 2 (Acts 6-7)`,
+                          })
+                        )) ||
+                      (act.name.startsWith("Hiveswap Act 1") &&
+                        html.tag(
+                          "dt",
+                          { class: ["side", side === 3 && "current"] },
+                          link.flash(act.flashes[0], {
+                            color: "#008282",
+                            text: `Outside Canon (Misc. Games)`,
+                          })
+                        )),
+                    (({ index = flashActData.indexOf(act) } = {}) =>
+                      index < act6
+                        ? side === 1
+                        : index < outsideCanon
+                        ? side === 2
+                        : true)() &&
+                      html.tag(
+                        "dt",
+                        { class: act === currentAct && "current" },
+                        link.flash(act.flashes[0], { text: act.name })
+                      ),
+                    act === currentAct &&
+                      fixWS`
                         <dd><ul>
-                            ${act.flashes.map(f => html.tag('li',
-                                {class: f === flash && 'current'},
-                                link.flash(f))).join('\n')}
+                            ${act.flashes
+                              .map((f) =>
+                                html.tag(
+                                  "li",
+                                  { class: f === flash && "current" },
+                                  link.flash(f)
+                                )
+                              )
+                              .join("\n")}
                         </ul></dd>
-                    `
-                ]).filter(Boolean).join('\n')}
+                    `,
+                  ])
+                  .filter(Boolean)
+                  .join("\n")}
             </dl>
-        `
-    };
+        `,
+  };
 }
diff --git a/src/page/group.js b/src/page/group.js
index b83244a3..bea9ecc7 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -2,268 +2,326 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
-import * as html from '../util/html.js';
+import * as html from "../util/html.js";
 
-import {
-    getTotalDuration,
-    sortChronologically,
-} from '../util/wiki-data.js';
+import { getTotalDuration, sortChronologically } from "../util/wiki-data.js";
 
 // Page exports
 
-export function targets({wikiData}) {
-    return wikiData.groupData;
+export function targets({ wikiData }) {
+  return wikiData.groupData;
 }
 
-export function write(group, {wikiData}) {
-    const { listingSpec, wikiInfo } = wikiData;
+export function write(group, { wikiData }) {
+  const { listingSpec, wikiInfo } = wikiData;
 
-    const { albums } = group;
-    const tracks = albums.flatMap(album => album.tracks);
-    const totalDuration = getTotalDuration(tracks);
+  const { albums } = group;
+  const tracks = albums.flatMap((album) => album.tracks);
+  const totalDuration = getTotalDuration(tracks);
 
-    const albumLines = group.albums.map(album => ({
-        album,
-        otherGroup: album.groups.find(g => g !== group)
-    }));
+  const albumLines = group.albums.map((album) => ({
+    album,
+    otherGroup: album.groups.find((g) => g !== group),
+  }));
 
-    const infoPage = {
-        type: 'page',
-        path: ['groupInfo', group.directory],
-        page: ({
-            generateInfoGalleryLinks,
-            generatePreviousNextLinks,
-            getLinkThemeString,
-            getThemeString,
-            fancifyURL,
-            link,
-            language,
-            transformMultiline
-        }) => ({
-            title: language.$('groupInfoPage.title', {group: group.name}),
-            theme: getThemeString(group.color),
+  const infoPage = {
+    type: "page",
+    path: ["groupInfo", group.directory],
+    page: ({
+      generateInfoGalleryLinks,
+      generatePreviousNextLinks,
+      getLinkThemeString,
+      getThemeString,
+      fancifyURL,
+      link,
+      language,
+      transformMultiline,
+    }) => ({
+      title: language.$("groupInfoPage.title", { group: group.name }),
+      theme: getThemeString(group.color),
 
-            main: {
-                content: fixWS`
-                    <h1>${language.$('groupInfoPage.title', {group: group.name})}</h1>
-                    ${group.urls?.length && `<p>${
-                        language.$('releaseInfo.visitOn', {
-                            links: language.formatDisjunctionList(group.urls.map(url => fancifyURL(url, {language})))
-                        })
-                    }</p>`}
+      main: {
+        content: fixWS`
+                    <h1>${language.$("groupInfoPage.title", {
+                      group: group.name,
+                    })}</h1>
+                    ${
+                      group.urls?.length &&
+                      `<p>${language.$("releaseInfo.visitOn", {
+                        links: language.formatDisjunctionList(
+                          group.urls.map((url) => fancifyURL(url, { language }))
+                        ),
+                      })}</p>`
+                    }
                     <blockquote>
                         ${transformMultiline(group.description)}
                     </blockquote>
-                    <h2>${language.$('groupInfoPage.albumList.title')}</h2>
-                    <p>${
-                        language.$('groupInfoPage.viewAlbumGallery', {
-                            link: link.groupGallery(group, {
-                                text: language.$('groupInfoPage.viewAlbumGallery.link')
-                            })
-                        })
-                    }</p>
+                    <h2>${language.$("groupInfoPage.albumList.title")}</h2>
+                    <p>${language.$("groupInfoPage.viewAlbumGallery", {
+                      link: link.groupGallery(group, {
+                        text: language.$("groupInfoPage.viewAlbumGallery.link"),
+                      }),
+                    })}</p>
                     <ul>
-                        ${albumLines.map(({ album, otherGroup }) => {
-                            const item = (album.date
-                                ? language.$('groupInfoPage.albumList.item', {
-                                    year: album.date.getFullYear(),
-                                    album: link.album(album)
+                        ${albumLines
+                          .map(({ album, otherGroup }) => {
+                            const item = album.date
+                              ? language.$("groupInfoPage.albumList.item", {
+                                  year: album.date.getFullYear(),
+                                  album: link.album(album),
                                 })
-                                : language.$('groupInfoPage.albumList.item.withoutYear', {
-                                    album: link.album(album)
-                                }));
-                            return html.tag('li', (otherGroup
-                                ? language.$('groupInfoPage.albumList.item.withAccent', {
-                                    item,
-                                    accent: html.tag('span',
-                                        {class: 'other-group-accent'},
-                                        language.$('groupInfoPage.albumList.item.otherGroupAccent', {
-                                            group: link.groupInfo(otherGroup, {color: false})
-                                        }))
-                                })
-                                : item));
-                        }).join('\n')}
+                              : language.$(
+                                  "groupInfoPage.albumList.item.withoutYear",
+                                  {
+                                    album: link.album(album),
+                                  }
+                                );
+                            return html.tag(
+                              "li",
+                              otherGroup
+                                ? language.$(
+                                    "groupInfoPage.albumList.item.withAccent",
+                                    {
+                                      item,
+                                      accent: html.tag(
+                                        "span",
+                                        { class: "other-group-accent" },
+                                        language.$(
+                                          "groupInfoPage.albumList.item.otherGroupAccent",
+                                          {
+                                            group: link.groupInfo(otherGroup, {
+                                              color: false,
+                                            }),
+                                          }
+                                        )
+                                      ),
+                                    }
+                                  )
+                                : item
+                            );
+                          })
+                          .join("\n")}
                     </ul>
-                `
-            },
+                `,
+      },
 
-            sidebarLeft: generateGroupSidebar(group, false, {
-                getLinkThemeString,
-                link,
-                language,
-                wikiData
-            }),
+      sidebarLeft: generateGroupSidebar(group, false, {
+        getLinkThemeString,
+        link,
+        language,
+        wikiData,
+      }),
 
-            nav: generateGroupNav(group, false, {
-                generateInfoGalleryLinks,
-                generatePreviousNextLinks,
-                link,
-                language,
-                wikiData
-            })
-        })
-    };
+      nav: generateGroupNav(group, false, {
+        generateInfoGalleryLinks,
+        generatePreviousNextLinks,
+        link,
+        language,
+        wikiData,
+      }),
+    }),
+  };
 
-    const galleryPage = {
-        type: 'page',
-        path: ['groupGallery', group.directory],
-        page: ({
-            generateInfoGalleryLinks,
-            generatePreviousNextLinks,
-            getAlbumGridHTML,
-            getLinkThemeString,
-            getThemeString,
-            link,
-            language
-        }) => ({
-            title: language.$('groupGalleryPage.title', {group: group.name}),
-            theme: getThemeString(group.color),
+  const galleryPage = {
+    type: "page",
+    path: ["groupGallery", group.directory],
+    page: ({
+      generateInfoGalleryLinks,
+      generatePreviousNextLinks,
+      getAlbumGridHTML,
+      getLinkThemeString,
+      getThemeString,
+      link,
+      language,
+    }) => ({
+      title: language.$("groupGalleryPage.title", { group: group.name }),
+      theme: getThemeString(group.color),
 
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${language.$('groupGalleryPage.title', {group: group.name})}</h1>
-                    <p class="quick-info">${
-                        language.$('groupGalleryPage.infoLine', {
-                            tracks: `<b>${language.countTracks(tracks.length, {unit: true})}</b>`,
-                            albums: `<b>${language.countAlbums(albums.length, {unit: true})}</b>`,
-                            time: `<b>${language.formatDuration(totalDuration, {unit: true})}</b>`
-                        })
-                    }</p>
-                    ${wikiInfo.enableGroupUI && wikiInfo.enableListings && html.tag('p',
-                        {class: 'quick-info'},
-                        language.$('groupGalleryPage.anotherGroupLine', {
-                            link: link.listing(listingSpec.find(l => l.directory === 'groups/by-category'), {
-                                text: language.$('groupGalleryPage.anotherGroupLine.link')
-                            })
+      main: {
+        classes: ["top-index"],
+        content: fixWS`
+                    <h1>${language.$("groupGalleryPage.title", {
+                      group: group.name,
+                    })}</h1>
+                    <p class="quick-info">${language.$(
+                      "groupGalleryPage.infoLine",
+                      {
+                        tracks: `<b>${language.countTracks(tracks.length, {
+                          unit: true,
+                        })}</b>`,
+                        albums: `<b>${language.countAlbums(albums.length, {
+                          unit: true,
+                        })}</b>`,
+                        time: `<b>${language.formatDuration(totalDuration, {
+                          unit: true,
+                        })}</b>`,
+                      }
+                    )}</p>
+                    ${
+                      wikiInfo.enableGroupUI &&
+                      wikiInfo.enableListings &&
+                      html.tag(
+                        "p",
+                        { class: "quick-info" },
+                        language.$("groupGalleryPage.anotherGroupLine", {
+                          link: link.listing(
+                            listingSpec.find(
+                              (l) => l.directory === "groups/by-category"
+                            ),
+                            {
+                              text: language.$(
+                                "groupGalleryPage.anotherGroupLine.link"
+                              ),
+                            }
+                          ),
                         })
-                    )}
+                      )
+                    }
                     <div class="grid-listing">
                         ${getAlbumGridHTML({
-                            entries: sortChronologically(group.albums.map(album => ({
-                                item: album,
-                                directory: album.directory,
-                                name: album.name,
-                                date: album.date,
-                            }))).reverse(),
-                            details: true
+                          entries: sortChronologically(
+                            group.albums.map((album) => ({
+                              item: album,
+                              directory: album.directory,
+                              name: album.name,
+                              date: album.date,
+                            }))
+                          ).reverse(),
+                          details: true,
                         })}
                     </div>
-                `
-            },
+                `,
+      },
 
-            sidebarLeft: generateGroupSidebar(group, true, {
-                getLinkThemeString,
-                link,
-                language,
-                wikiData
-            }),
+      sidebarLeft: generateGroupSidebar(group, true, {
+        getLinkThemeString,
+        link,
+        language,
+        wikiData,
+      }),
 
-            nav: generateGroupNav(group, true, {
-                generateInfoGalleryLinks,
-                generatePreviousNextLinks,
-                link,
-                language,
-                wikiData
-            })
-        })
-    };
+      nav: generateGroupNav(group, true, {
+        generateInfoGalleryLinks,
+        generatePreviousNextLinks,
+        link,
+        language,
+        wikiData,
+      }),
+    }),
+  };
 
-    return [infoPage, galleryPage];
+  return [infoPage, galleryPage];
 }
 
 // Utility functions
 
-function generateGroupSidebar(currentGroup, isGallery, {
-    getLinkThemeString,
-    link,
-    language,
-    wikiData
-}) {
-    const { groupCategoryData, wikiInfo } = wikiData;
+function generateGroupSidebar(
+  currentGroup,
+  isGallery,
+  { getLinkThemeString, link, language, wikiData }
+) {
+  const { groupCategoryData, wikiInfo } = wikiData;
 
-    if (!wikiInfo.enableGroupUI) {
-        return null;
-    }
+  if (!wikiInfo.enableGroupUI) {
+    return null;
+  }
 
-    const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
+  const linkKey = isGallery ? "groupGallery" : "groupInfo";
 
-    return {
-        content: fixWS`
-            <h1>${language.$('groupSidebar.title')}</h1>
-            ${groupCategoryData.map(category =>
-                html.tag('details', {
+  return {
+    content: fixWS`
+            <h1>${language.$("groupSidebar.title")}</h1>
+            ${groupCategoryData
+              .map((category) =>
+                html.tag(
+                  "details",
+                  {
                     open: category === currentGroup.category,
-                    class: category === currentGroup.category && 'current'
-                }, [
-                    html.tag('summary',
-                        {style: getLinkThemeString(category.color)},
-                        language.$('groupSidebar.groupList.category', {
-                            category: `<span class="group-name">${category.name}</span>`
-                        })),
-                    html.tag('ul',
-                        category.groups.map(group => html.tag('li',
-                            {
-                                class: group === currentGroup && 'current',
-                                style: getLinkThemeString(group.color)
-                            },
-                            language.$('groupSidebar.groupList.item', {
-                                group: link[linkKey](group)
-                            }))))
-                ])).join('\n')}
+                    class: category === currentGroup.category && "current",
+                  },
+                  [
+                    html.tag(
+                      "summary",
+                      { style: getLinkThemeString(category.color) },
+                      language.$("groupSidebar.groupList.category", {
+                        category: `<span class="group-name">${category.name}</span>`,
+                      })
+                    ),
+                    html.tag(
+                      "ul",
+                      category.groups.map((group) =>
+                        html.tag(
+                          "li",
+                          {
+                            class: group === currentGroup && "current",
+                            style: getLinkThemeString(group.color),
+                          },
+                          language.$("groupSidebar.groupList.item", {
+                            group: link[linkKey](group),
+                          })
+                        )
+                      )
+                    ),
+                  ]
+                )
+              )
+              .join("\n")}
             </dl>
-        `
-    };
+        `,
+  };
 }
 
-function generateGroupNav(currentGroup, isGallery, {
+function generateGroupNav(
+  currentGroup,
+  isGallery,
+  {
     generateInfoGalleryLinks,
     generatePreviousNextLinks,
     link,
     language,
-    wikiData
-}) {
-    const { groupData, wikiInfo } = wikiData;
+    wikiData,
+  }
+) {
+  const { groupData, wikiInfo } = wikiData;
 
-    if (!wikiInfo.enableGroupUI) {
-        return {simple: true};
-    }
+  if (!wikiInfo.enableGroupUI) {
+    return { simple: true };
+  }
 
-    const urlKey = isGallery ? 'localized.groupGallery' : 'localized.groupInfo';
-    const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
+  const urlKey = isGallery ? "localized.groupGallery" : "localized.groupInfo";
+  const linkKey = isGallery ? "groupGallery" : "groupInfo";
 
-    const infoGalleryLinks = generateInfoGalleryLinks(currentGroup, isGallery, {
-        linkKeyGallery: 'groupGallery',
-        linkKeyInfo: 'groupInfo'
-    });
+  const infoGalleryLinks = generateInfoGalleryLinks(currentGroup, isGallery, {
+    linkKeyGallery: "groupGallery",
+    linkKeyInfo: "groupInfo",
+  });
 
-    const previousNextLinks = generatePreviousNextLinks(currentGroup, {
-        data: groupData,
-        linkKey
-    });
+  const previousNextLinks = generatePreviousNextLinks(currentGroup, {
+    data: groupData,
+    linkKey,
+  });
 
-    return {
-        linkContainerClasses: ['nav-links-hierarchy'],
-        links: [
-            {toHome: true},
-            wikiInfo.enableListings &&
-            {
-                path: ['localized.listingIndex'],
-                title: language.$('listingIndex.title')
-            },
-            {
-                html: language.$('groupPage.nav.group', {
-                    group: link[linkKey](currentGroup, {class: 'current'})
-                })
-            },
-            {
-                divider: false,
-                html: (previousNextLinks
-                    ? `(${infoGalleryLinks}; ${previousNextLinks})`
-                    : `(${previousNextLinks})`)
-            }
-        ]
-    };
+  return {
+    linkContainerClasses: ["nav-links-hierarchy"],
+    links: [
+      { toHome: true },
+      wikiInfo.enableListings && {
+        path: ["localized.listingIndex"],
+        title: language.$("listingIndex.title"),
+      },
+      {
+        html: language.$("groupPage.nav.group", {
+          group: link[linkKey](currentGroup, { class: "current" }),
+        }),
+      },
+      {
+        divider: false,
+        html: previousNextLinks
+          ? `(${infoGalleryLinks}; ${previousNextLinks})`
+          : `(${previousNextLinks})`,
+      },
+    ],
+  };
 }
diff --git a/src/page/homepage.js b/src/page/homepage.js
index a19df6cf..ebe3a8d3 100644
--- a/src/page/homepage.js
+++ b/src/page/homepage.js
@@ -2,123 +2,184 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
-import * as html from '../util/html.js';
+import * as html from "../util/html.js";
 
-import {
-    getNewAdditions,
-    getNewReleases
-} from '../util/wiki-data.js';
+import { getNewAdditions, getNewReleases } from "../util/wiki-data.js";
 
 // Page exports
 
-export function writeTargetless({wikiData}) {
-    const { newsData, staticPageData, homepageLayout, wikiInfo } = wikiData;
-
-    const page = {
-        type: 'page',
-        path: ['home'],
-        page: ({
-            getAlbumGridHTML,
-            getLinkThemeString,
-            link,
-            language,
-            to,
-            transformInline,
-            transformMultiline
-        }) => ({
-            title: wikiInfo.name,
-            showWikiNameInTitle: false,
-
-            meta: {
-                description: wikiInfo.description
-            },
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
+export function writeTargetless({ wikiData }) {
+  const { newsData, staticPageData, homepageLayout, wikiInfo } = wikiData;
+
+  const page = {
+    type: "page",
+    path: ["home"],
+    page: ({
+      getAlbumGridHTML,
+      getLinkThemeString,
+      link,
+      language,
+      to,
+      transformInline,
+      transformMultiline,
+    }) => ({
+      title: wikiInfo.name,
+      showWikiNameInTitle: false,
+
+      meta: {
+        description: wikiInfo.description,
+      },
+
+      main: {
+        classes: ["top-index"],
+        content: fixWS`
                     <h1>${wikiInfo.name}</h1>
-                    ${homepageLayout.rows?.map((row, i) => fixWS`
-                        <section class="row" style="${getLinkThemeString(row.color)}">
+                    ${homepageLayout.rows
+                      ?.map(
+                        (row, i) => fixWS`
+                        <section class="row" style="${getLinkThemeString(
+                          row.color
+                        )}">
                             <h2>${row.name}</h2>
-                            ${row.type === 'albums' && fixWS`
+                            ${
+                              row.type === "albums" &&
+                              fixWS`
                                 <div class="grid-listing">
                                     ${getAlbumGridHTML({
-                                        entries: (
-                                            row.sourceGroupByRef === 'new-releases' ? getNewReleases(row.countAlbumsFromGroup, {wikiData}) :
-                                            row.sourceGroupByRef === 'new-additions' ? getNewAdditions(row.countAlbumsFromGroup, {wikiData}) :
-                                            ((row.sourceGroup?.albums ?? [])
-                                                .slice()
-                                                .reverse()
-                                                .filter(album => album.isListedOnHomepage)
-                                                .slice(0, row.countAlbumsFromGroup)
-                                                .map(album => ({item: album})))
-                                        ).concat(row.sourceAlbums.map(album => ({item: album}))),
-                                        lazy: i > 0
+                                      entries: (row.sourceGroupByRef ===
+                                      "new-releases"
+                                        ? getNewReleases(
+                                            row.countAlbumsFromGroup,
+                                            { wikiData }
+                                          )
+                                        : row.sourceGroupByRef ===
+                                          "new-additions"
+                                        ? getNewAdditions(
+                                            row.countAlbumsFromGroup,
+                                            { wikiData }
+                                          )
+                                        : (row.sourceGroup?.albums ?? [])
+                                            .slice()
+                                            .reverse()
+                                            .filter(
+                                              (album) =>
+                                                album.isListedOnHomepage
+                                            )
+                                            .slice(0, row.countAlbumsFromGroup)
+                                            .map((album) => ({ item: album }))
+                                      ).concat(
+                                        row.sourceAlbums.map((album) => ({
+                                          item: album,
+                                        }))
+                                      ),
+                                      lazy: i > 0,
                                     })}
-                                    ${row.actionLinks?.length && fixWS`
+                                    ${
+                                      row.actionLinks?.length &&
+                                      fixWS`
                                         <div class="grid-actions">
-                                            ${row.actionLinks.map(action => transformInline(action)
-                                                .replace('<a', '<a class="box grid-item"')).join('\n')}
+                                            ${row.actionLinks
+                                              .map((action) =>
+                                                transformInline(action).replace(
+                                                  "<a",
+                                                  '<a class="box grid-item"'
+                                                )
+                                              )
+                                              .join("\n")}
                                         </div>
-                                    `}
+                                    `
+                                    }
                                 </div>
-                            `}
+                            `
+                            }
                         </section>
-                    `).join('\n')}
-                `
-            },
-
-            sidebarLeft: homepageLayout.sidebarContent && {
-                wide: true,
-                collapse: false,
-                // This is a pretty filthy hack! 8ut otherwise, the [[news]] part
-                // gets treated like it's a reference to the track named "news",
-                // which o8viously isn't what we're going for. Gotta catch that
-                // 8efore we pass it to transformMultiline, 'cuz otherwise it'll
-                // get repl8ced with just the word "news" (or anything else that
-                // transformMultiline does with references it can't match) -- and
-                // we can't match that for replacing it with the news column!
-                //
-                // And no, I will not make [[news]] into part of transformMultiline
-                // (even though that would 8e hilarious).
-                content: (transformMultiline(homepageLayout.sidebarContent.replace('[[news]]', '__GENERATE_NEWS__'))
-                    .replace('<p>__GENERATE_NEWS__</p>', wikiInfo.enableNews ? fixWS`
-                        <h1>${language.$('homepage.news.title')}</h1>
-                        ${newsData.slice(0, 3).map((entry, i) => html.tag('article',
-                            {class: ['news-entry', i === 0 && 'first-news-entry']},
-                            fixWS`
-                                <h2><time>${language.formatDate(entry.date)}</time> ${link.newsEntry(entry)}</h2>
+                    `
+                      )
+                      .join("\n")}
+                `,
+      },
+
+      sidebarLeft: homepageLayout.sidebarContent && {
+        wide: true,
+        collapse: false,
+        // This is a pretty filthy hack! 8ut otherwise, the [[news]] part
+        // gets treated like it's a reference to the track named "news",
+        // which o8viously isn't what we're going for. Gotta catch that
+        // 8efore we pass it to transformMultiline, 'cuz otherwise it'll
+        // get repl8ced with just the word "news" (or anything else that
+        // transformMultiline does with references it can't match) -- and
+        // we can't match that for replacing it with the news column!
+        //
+        // And no, I will not make [[news]] into part of transformMultiline
+        // (even though that would 8e hilarious).
+        content: transformMultiline(
+          homepageLayout.sidebarContent.replace("[[news]]", "__GENERATE_NEWS__")
+        ).replace(
+          "<p>__GENERATE_NEWS__</p>",
+          wikiInfo.enableNews
+            ? fixWS`
+                        <h1>${language.$("homepage.news.title")}</h1>
+                        ${newsData
+                          .slice(0, 3)
+                          .map((entry, i) =>
+                            html.tag(
+                              "article",
+                              {
+                                class: [
+                                  "news-entry",
+                                  i === 0 && "first-news-entry",
+                                ],
+                              },
+                              fixWS`
+                                <h2><time>${language.formatDate(
+                                  entry.date
+                                )}</time> ${link.newsEntry(entry)}</h2>
                                 ${transformMultiline(entry.contentShort)}
-                                ${entry.contentShort !== entry.content && link.newsEntry(entry, {
-                                    text: language.$('homepage.news.entry.viewRest')
-                                })}
-                            `)).join('\n')}
-                    ` : `<p><i>News requested in content description but this feature isn't enabled</i></p>`))
-            },
-
-            nav: {
-                linkContainerClasses: ['nav-links-index'],
-                links: [
-                    link.home('', {text: wikiInfo.nameShort, class: 'current', to}),
-
-                    wikiInfo.enableListings &&
-                    link.listingIndex('', {text: language.$('listingIndex.title'), to}),
-
-                    wikiInfo.enableNews &&
-                    link.newsIndex('', {text: language.$('newsIndex.title'), to}),
-
-                    wikiInfo.enableFlashesAndGames &&
-                    link.flashIndex('', {text: language.$('flashIndex.title'), to}),
-
-                    ...(staticPageData
-                        .filter(page => page.showInNavigationBar)
-                        .map(page => link.staticPage(page, {text: page.nameShort}))),
-                ].filter(Boolean).map(html => ({html})),
-            }
-        })
-    };
-
-    return [page];
+                                ${
+                                  entry.contentShort !== entry.content &&
+                                  link.newsEntry(entry, {
+                                    text: language.$(
+                                      "homepage.news.entry.viewRest"
+                                    ),
+                                  })
+                                }
+                            `
+                            )
+                          )
+                          .join("\n")}
+                    `
+            : `<p><i>News requested in content description but this feature isn't enabled</i></p>`
+        ),
+      },
+
+      nav: {
+        linkContainerClasses: ["nav-links-index"],
+        links: [
+          link.home("", { text: wikiInfo.nameShort, class: "current", to }),
+
+          wikiInfo.enableListings &&
+            link.listingIndex("", {
+              text: language.$("listingIndex.title"),
+              to,
+            }),
+
+          wikiInfo.enableNews &&
+            link.newsIndex("", { text: language.$("newsIndex.title"), to }),
+
+          wikiInfo.enableFlashesAndGames &&
+            link.flashIndex("", { text: language.$("flashIndex.title"), to }),
+
+          ...staticPageData
+            .filter((page) => page.showInNavigationBar)
+            .map((page) => link.staticPage(page, { text: page.nameShort })),
+        ]
+          .filter(Boolean)
+          .map((html) => ({ html })),
+      },
+    }),
+  };
+
+  return [page];
 }
diff --git a/src/page/index.js b/src/page/index.js
index f580cbea..50fbd7a4 100644
--- a/src/page/index.js
+++ b/src/page/index.js
@@ -39,15 +39,15 @@
 // These functions should be referenced only from adjacent modules, as they
 // pertain only to site page generation.
 
-export * as album from './album.js';
-export * as albumCommentary from './album-commentary.js';
-export * as artist from './artist.js';
-export * as artistAlias from './artist-alias.js';
-export * as flash from './flash.js';
-export * as group from './group.js';
-export * as homepage from './homepage.js';
-export * as listing from './listing.js';
-export * as news from './news.js';
-export * as static from './static.js';
-export * as tag from './tag.js';
-export * as track from './track.js';
+export * as album from "./album.js";
+export * as albumCommentary from "./album-commentary.js";
+export * as artist from "./artist.js";
+export * as artistAlias from "./artist-alias.js";
+export * as flash from "./flash.js";
+export * as group from "./group.js";
+export * as homepage from "./homepage.js";
+export * as listing from "./listing.js";
+export * as news from "./news.js";
+export * as static from "./static.js";
+export * as tag from "./tag.js";
+export * as track from "./track.js";
diff --git a/src/page/listing.js b/src/page/listing.js
index 447a0c8f..886c8a9d 100644
--- a/src/page/listing.js
+++ b/src/page/listing.js
@@ -10,193 +10,218 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
-import * as html from '../util/html.js';
+import * as html from "../util/html.js";
 
-import {
-    getTotalDuration
-} from '../util/wiki-data.js';
+import { getTotalDuration } from "../util/wiki-data.js";
 
 // Page exports
 
-export function condition({wikiData}) {
-    return wikiData.wikiInfo.enableListings;
+export function condition({ wikiData }) {
+  return wikiData.wikiInfo.enableListings;
 }
 
-export function targets({wikiData}) {
-    return wikiData.listingSpec;
+export function targets({ wikiData }) {
+  return wikiData.listingSpec;
 }
 
-export function write(listing, {wikiData}) {
-    if (listing.condition && !listing.condition({wikiData})) {
-        return null;
-    }
+export function write(listing, { wikiData }) {
+  if (listing.condition && !listing.condition({ wikiData })) {
+    return null;
+  }
 
-    const { wikiInfo } = wikiData;
+  const { wikiInfo } = wikiData;
 
-    const data = (listing.data
-        ? listing.data({wikiData})
-        : null);
+  const data = listing.data ? listing.data({ wikiData }) : null;
 
-    const page = {
-        type: 'page',
-        path: ['listing', listing.directory],
-        page: opts => {
-            const { getLinkThemeString, link, language } = opts;
-            const titleKey = `listingPage.${listing.stringsKey}.title`;
+  const page = {
+    type: "page",
+    path: ["listing", listing.directory],
+    page: (opts) => {
+      const { getLinkThemeString, link, language } = opts;
+      const titleKey = `listingPage.${listing.stringsKey}.title`;
 
-            return {
-                title: language.$(titleKey),
+      return {
+        title: language.$(titleKey),
 
-                main: {
-                    content: fixWS`
+        main: {
+          content: fixWS`
                         <h1>${language.$(titleKey)}</h1>
-                        ${listing.html && (listing.data
+                        ${
+                          listing.html &&
+                          (listing.data
                             ? listing.html(data, opts)
-                            : listing.html(opts))}
-                        ${listing.row && fixWS`
+                            : listing.html(opts))
+                        }
+                        ${
+                          listing.row &&
+                          fixWS`
                             <ul>
-                                ${(data
-                                    .map(item => listing.row(item, opts))
-                                    .map(row => `<li>${row}</li>`)
-                                    .join('\n'))}
+                                ${data
+                                  .map((item) => listing.row(item, opts))
+                                  .map((row) => `<li>${row}</li>`)
+                                  .join("\n")}
                             </ul>
-                        `}
-                    `
-                },
-
-                sidebarLeft: {
-                    content: generateSidebarForListings(listing, {
-                        getLinkThemeString,
-                        link,
-                        language,
-                        wikiData
-                    })
-                },
-
-                nav: {
-                    linkContainerClasses: ['nav-links-hierarchy'],
-                    links: [
-                        {toHome: true},
-                        {
-                            path: ['localized.listingIndex'],
-                            title: language.$('listingIndex.title')
-                        },
-                        {toCurrentPage: true}
-                    ]
-                }
-            };
-        }
-    };
-
-    return [page];
-}
-
-export function writeTargetless({wikiData}) {
-    const { albumData, trackData, wikiInfo } = wikiData;
+                        `
+                        }
+                    `,
+        },
 
-    const totalDuration = getTotalDuration(trackData);
-
-    const page = {
-        type: 'page',
-        path: ['listingIndex'],
-        page: ({
+        sidebarLeft: {
+          content: generateSidebarForListings(listing, {
             getLinkThemeString,
+            link,
             language,
-            link
-        }) => ({
-            title: language.$('listingIndex.title'),
-
-            main: {
-                content: fixWS`
-                    <h1>${language.$('listingIndex.title')}</h1>
-                    <p>${language.$('listingIndex.infoLine', {
-                        wiki: wikiInfo.name,
-                        tracks: `<b>${language.countTracks(trackData.length, {unit: true})}</b>`,
-                        albums: `<b>${language.countAlbums(albumData.length, {unit: true})}</b>`,
-                        duration: `<b>${language.formatDuration(totalDuration, {approximate: true, unit: true})}</b>`
-                    })}</p>
-                    <hr>
-                    <p>${language.$('listingIndex.exploreList')}</p>
-                    ${generateLinkIndexForListings(null, false, {link, language, wikiData})}
-                `
-            },
-
-            sidebarLeft: {
-                content: generateSidebarForListings(null, {
-                    getLinkThemeString,
-                    link,
-                    language,
-                    wikiData
-                })
+            wikiData,
+          }),
+        },
+
+        nav: {
+          linkContainerClasses: ["nav-links-hierarchy"],
+          links: [
+            { toHome: true },
+            {
+              path: ["localized.listingIndex"],
+              title: language.$("listingIndex.title"),
             },
+            { toCurrentPage: true },
+          ],
+        },
+      };
+    },
+  };
+
+  return [page];
+}
 
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
-};
+export function writeTargetless({ wikiData }) {
+  const { albumData, trackData, wikiInfo } = wikiData;
+
+  const totalDuration = getTotalDuration(trackData);
+
+  const page = {
+    type: "page",
+    path: ["listingIndex"],
+    page: ({ getLinkThemeString, language, link }) => ({
+      title: language.$("listingIndex.title"),
+
+      main: {
+        content: fixWS`
+                    <h1>${language.$("listingIndex.title")}</h1>
+                    <p>${language.$("listingIndex.infoLine", {
+                      wiki: wikiInfo.name,
+                      tracks: `<b>${language.countTracks(trackData.length, {
+                        unit: true,
+                      })}</b>`,
+                      albums: `<b>${language.countAlbums(albumData.length, {
+                        unit: true,
+                      })}</b>`,
+                      duration: `<b>${language.formatDuration(totalDuration, {
+                        approximate: true,
+                        unit: true,
+                      })}</b>`,
+                    })}</p>
+                    <hr>
+                    <p>${language.$("listingIndex.exploreList")}</p>
+                    ${generateLinkIndexForListings(null, false, {
+                      link,
+                      language,
+                      wikiData,
+                    })}
+                `,
+      },
+
+      sidebarLeft: {
+        content: generateSidebarForListings(null, {
+          getLinkThemeString,
+          link,
+          language,
+          wikiData,
+        }),
+      },
+
+      nav: { simple: true },
+    }),
+  };
+
+  return [page];
+}
 
 // Utility functions
 
-function generateSidebarForListings(currentListing, {
-    getLinkThemeString,
-    link,
-    language,
-    wikiData
-}) {
-    return fixWS`
-        <h1>${link.listingIndex('', {text: language.$('listingIndex.title')})}</h1>
+function generateSidebarForListings(
+  currentListing,
+  { getLinkThemeString, link, language, wikiData }
+) {
+  return fixWS`
+        <h1>${link.listingIndex("", {
+          text: language.$("listingIndex.title"),
+        })}</h1>
         ${generateLinkIndexForListings(currentListing, true, {
-            getLinkThemeString,
-            link,
-            language,
-            wikiData
+          getLinkThemeString,
+          link,
+          language,
+          wikiData,
         })}
     `;
 }
 
-function generateLinkIndexForListings(currentListing, forSidebar, {
-    getLinkThemeString,
-    link,
-    language,
-    wikiData
-}) {
-    const { listingTargetSpec, wikiInfo } = wikiData;
-
-    const filteredByCondition = listingTargetSpec
-        .map(({ listings, ...rest }) => ({
-            ...rest,
-            listings: listings.filter(({ condition: c }) => !c || c({wikiData}))
-        }))
-        .filter(({ listings }) => listings.length > 0);
-
-    const genUL = listings => html.tag('ul',
-        listings.map(listing => html.tag('li',
-            {class: [listing === currentListing && 'current']},
-            link.listing(listing, {text: language.$(`listingPage.${listing.stringsKey}.title.short`)})
-        )));
-
-    if (forSidebar) {
-        return filteredByCondition.map(({ title, listings }) =>
-            html.tag('details', {
-                open: !forSidebar || listings.includes(currentListing),
-                class: listings.includes(currentListing) && 'current'
-            }, [
-                html.tag('summary',
-                    {style: getLinkThemeString(wikiInfo.color)},
-                    html.tag('span',
-                        {class: 'group-name'},
-                        title({language}))),
-                genUL(listings)
-            ])).join('\n');
-    } else {
-        return html.tag('dl',
-            filteredByCondition.flatMap(({ title, listings }) => [
-                html.tag('dt', title({language})),
-                html.tag('dd', genUL(listings))
-            ]));
-    }
+function generateLinkIndexForListings(
+  currentListing,
+  forSidebar,
+  { getLinkThemeString, link, language, wikiData }
+) {
+  const { listingTargetSpec, wikiInfo } = wikiData;
+
+  const filteredByCondition = listingTargetSpec
+    .map(({ listings, ...rest }) => ({
+      ...rest,
+      listings: listings.filter(({ condition: c }) => !c || c({ wikiData })),
+    }))
+    .filter(({ listings }) => listings.length > 0);
+
+  const genUL = (listings) =>
+    html.tag(
+      "ul",
+      listings.map((listing) =>
+        html.tag(
+          "li",
+          { class: [listing === currentListing && "current"] },
+          link.listing(listing, {
+            text: language.$(`listingPage.${listing.stringsKey}.title.short`),
+          })
+        )
+      )
+    );
+
+  if (forSidebar) {
+    return filteredByCondition
+      .map(({ title, listings }) =>
+        html.tag(
+          "details",
+          {
+            open: !forSidebar || listings.includes(currentListing),
+            class: listings.includes(currentListing) && "current",
+          },
+          [
+            html.tag(
+              "summary",
+              { style: getLinkThemeString(wikiInfo.color) },
+              html.tag("span", { class: "group-name" }, title({ language }))
+            ),
+            genUL(listings),
+          ]
+        )
+      )
+      .join("\n");
+  } else {
+    return html.tag(
+      "dl",
+      filteredByCondition.flatMap(({ title, listings }) => [
+        html.tag("dt", title({ language })),
+        html.tag("dd", genUL(listings)),
+      ])
+    );
+  }
 }
diff --git a/src/page/news.js b/src/page/news.js
index 9336506f..2fc5d7b0 100644
--- a/src/page/news.js
+++ b/src/page/news.js
@@ -2,126 +2,135 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
 // Page exports
 
-export function condition({wikiData}) {
-    return wikiData.wikiInfo.enableNews;
+export function condition({ wikiData }) {
+  return wikiData.wikiInfo.enableNews;
 }
 
-export function targets({wikiData}) {
-    return wikiData.newsData;
+export function targets({ wikiData }) {
+  return wikiData.newsData;
 }
 
-export function write(entry, {wikiData}) {
-    const page = {
-        type: 'page',
-        path: ['newsEntry', entry.directory],
-        page: ({
-            generatePreviousNextLinks,
-            link,
-            language,
-            transformMultiline,
-        }) => ({
-            title: language.$('newsEntryPage.title', {entry: entry.name}),
-
-            main: {
-                content: fixWS`
+export function write(entry, { wikiData }) {
+  const page = {
+    type: "page",
+    path: ["newsEntry", entry.directory],
+    page: ({
+      generatePreviousNextLinks,
+      link,
+      language,
+      transformMultiline,
+    }) => ({
+      title: language.$("newsEntryPage.title", { entry: entry.name }),
+
+      main: {
+        content: fixWS`
                     <div class="long-content">
-                        <h1>${language.$('newsEntryPage.title', {entry: entry.name})}</h1>
-                        <p>${language.$('newsEntryPage.published', {date: language.formatDate(entry.date)})}</p>
+                        <h1>${language.$("newsEntryPage.title", {
+                          entry: entry.name,
+                        })}</h1>
+                        <p>${language.$("newsEntryPage.published", {
+                          date: language.formatDate(entry.date),
+                        })}</p>
                         ${transformMultiline(entry.content)}
                     </div>
-                `
-            },
-
-            nav: generateNewsEntryNav(entry, {
-                generatePreviousNextLinks,
-                link,
-                language,
-                wikiData
-            })
-        })
-    };
-
-    return [page];
+                `,
+      },
+
+      nav: generateNewsEntryNav(entry, {
+        generatePreviousNextLinks,
+        link,
+        language,
+        wikiData,
+      }),
+    }),
+  };
+
+  return [page];
 }
 
-export function writeTargetless({wikiData}) {
-    const { newsData } = wikiData;
-
-    const page = {
-        type: 'page',
-        path: ['newsIndex'],
-        page: ({
-            link,
-            language,
-            transformMultiline
-        }) => ({
-            title: language.$('newsIndex.title'),
-
-            main: {
-                content: fixWS`
+export function writeTargetless({ wikiData }) {
+  const { newsData } = wikiData;
+
+  const page = {
+    type: "page",
+    path: ["newsIndex"],
+    page: ({ link, language, transformMultiline }) => ({
+      title: language.$("newsIndex.title"),
+
+      main: {
+        content: fixWS`
                     <div class="long-content news-index">
-                        <h1>${language.$('newsIndex.title')}</h1>
-                        ${newsData.map(entry => fixWS`
+                        <h1>${language.$("newsIndex.title")}</h1>
+                        ${newsData
+                          .map(
+                            (entry) => fixWS`
                             <article id="${entry.directory}">
-                                <h2><time>${language.formatDate(entry.date)}</time> ${link.newsEntry(entry)}</h2>
+                                <h2><time>${language.formatDate(
+                                  entry.date
+                                )}</time> ${link.newsEntry(entry)}</h2>
                                 ${transformMultiline(entry.contentShort)}
-                                ${entry.contentShort !== entry.content && `<p>${link.newsEntry(entry, {
-                                    text: language.$('newsIndex.entry.viewRest')
-                                })}</p>`}
+                                ${
+                                  entry.contentShort !== entry.content &&
+                                  `<p>${link.newsEntry(entry, {
+                                    text: language.$(
+                                      "newsIndex.entry.viewRest"
+                                    ),
+                                  })}</p>`
+                                }
                             </article>
-                        `).join('\n')}
+                        `
+                          )
+                          .join("\n")}
                     </div>
-                `
-            },
+                `,
+      },
 
-            nav: {simple: true}
-        })
-    };
+      nav: { simple: true },
+    }),
+  };
 
-    return [page];
+  return [page];
 }
 
 // Utility functions
 
-function generateNewsEntryNav(entry, {
-    generatePreviousNextLinks,
+function generateNewsEntryNav(
+  entry,
+  { generatePreviousNextLinks, link, language, wikiData }
+) {
+  const { wikiInfo, newsData } = wikiData;
+
+  // The newsData list is sorted reverse chronologically (newest ones first),
+  // so the way we find next/previous entries is flipped from normal.
+  const previousNextLinks = generatePreviousNextLinks(entry, {
     link,
     language,
-    wikiData
-}) {
-    const { wikiInfo, newsData } = wikiData;
-
-    // The newsData list is sorted reverse chronologically (newest ones first),
-    // so the way we find next/previous entries is flipped from normal.
-    const previousNextLinks = generatePreviousNextLinks(entry, {
-        link, language,
-        data: newsData.slice().reverse(),
-        linkKey: 'newsEntry'
-    });
-
-    return {
-        linkContainerClasses: ['nav-links-hierarchy'],
-        links: [
-            {toHome: true},
-            {
-                path: ['localized.newsIndex'],
-                title: language.$('newsEntryPage.nav.news')
-            },
-            {
-                html: language.$('newsEntryPage.nav.entry', {
-                    date: language.formatDate(entry.date),
-                    entry: link.newsEntry(entry, {class: 'current'})
-                })
-            },
-            previousNextLinks &&
-            {
-                divider: false,
-                html: `(${previousNextLinks})`
-            }
-        ]
-    };
+    data: newsData.slice().reverse(),
+    linkKey: "newsEntry",
+  });
+
+  return {
+    linkContainerClasses: ["nav-links-hierarchy"],
+    links: [
+      { toHome: true },
+      {
+        path: ["localized.newsIndex"],
+        title: language.$("newsEntryPage.nav.news"),
+      },
+      {
+        html: language.$("newsEntryPage.nav.entry", {
+          date: language.formatDate(entry.date),
+          entry: link.newsEntry(entry, { class: "current" }),
+        }),
+      },
+      previousNextLinks && {
+        divider: false,
+        html: `(${previousNextLinks})`,
+      },
+    ],
+  };
 }
diff --git a/src/page/static.js b/src/page/static.js
index e9b6a047..39acd64e 100644
--- a/src/page/static.js
+++ b/src/page/static.js
@@ -4,37 +4,34 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
 // Page exports
 
-export function targets({wikiData}) {
-    return wikiData.staticPageData;
+export function targets({ wikiData }) {
+  return wikiData.staticPageData;
 }
 
-export function write(staticPage, {wikiData}) {
-    const page = {
-        type: 'page',
-        path: ['staticPage', staticPage.directory],
-        page: ({
-            language,
-            transformMultiline
-        }) => ({
-            title: staticPage.name,
-            stylesheet: staticPage.stylesheet,
-
-            main: {
-                content: fixWS`
+export function write(staticPage, { wikiData }) {
+  const page = {
+    type: "page",
+    path: ["staticPage", staticPage.directory],
+    page: ({ language, transformMultiline }) => ({
+      title: staticPage.name,
+      stylesheet: staticPage.stylesheet,
+
+      main: {
+        content: fixWS`
                     <div class="long-content">
                         <h1>${staticPage.name}</h1>
                         ${transformMultiline(staticPage.content)}
                     </div>
-                `
-            },
+                `,
+      },
 
-            nav: {simple: true}
-        })
-    };
+      nav: { simple: true },
+    }),
+  };
 
-    return [page];
+  return [page];
 }
diff --git a/src/page/tag.js b/src/page/tag.js
index 471439da..98b552b3 100644
--- a/src/page/tag.js
+++ b/src/page/tag.js
@@ -2,110 +2,111 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
 // Page exports
 
-export function condition({wikiData}) {
-    return wikiData.wikiInfo.enableArtTagUI;
+export function condition({ wikiData }) {
+  return wikiData.wikiInfo.enableArtTagUI;
 }
 
-export function targets({wikiData}) {
-    return wikiData.artTagData.filter(tag => !tag.isContentWarning);
+export function targets({ wikiData }) {
+  return wikiData.artTagData.filter((tag) => !tag.isContentWarning);
 }
 
-export function write(tag, {wikiData}) {
-    const { wikiInfo } = wikiData;
-    const { taggedInThings: things } = tag;
+export function write(tag, { wikiData }) {
+  const { wikiInfo } = wikiData;
+  const { taggedInThings: things } = tag;
 
-    // Display things featuring this art tag in reverse chronological order,
-    // sticking the most recent additions near the top!
-    const thingsReversed = things.slice().reverse();
+  // Display things featuring this art tag in reverse chronological order,
+  // sticking the most recent additions near the top!
+  const thingsReversed = things.slice().reverse();
 
-    const entries = thingsReversed.map(item => ({item}));
+  const entries = thingsReversed.map((item) => ({ item }));
 
-    const page = {
-        type: 'page',
-        path: ['tag', tag.directory],
-        page: ({
-            generatePreviousNextLinks,
-            getAlbumCover,
-            getGridHTML,
-            getThemeString,
-            getTrackCover,
-            link,
-            language,
-            to
-        }) => ({
-            title: language.$('tagPage.title', {tag: tag.name}),
-            theme: getThemeString(tag.color),
+  const page = {
+    type: "page",
+    path: ["tag", tag.directory],
+    page: ({
+      generatePreviousNextLinks,
+      getAlbumCover,
+      getGridHTML,
+      getThemeString,
+      getTrackCover,
+      link,
+      language,
+      to,
+    }) => ({
+      title: language.$("tagPage.title", { tag: tag.name }),
+      theme: getThemeString(tag.color),
 
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${language.$('tagPage.title', {tag: tag.name})}</h1>
-                    <p class="quick-info">${language.$('tagPage.infoLine', {
-                        coverArts: language.countCoverArts(things.length, {unit: true})
+      main: {
+        classes: ["top-index"],
+        content: fixWS`
+                    <h1>${language.$("tagPage.title", { tag: tag.name })}</h1>
+                    <p class="quick-info">${language.$("tagPage.infoLine", {
+                      coverArts: language.countCoverArts(things.length, {
+                        unit: true,
+                      }),
                     })}</p>
                     <div class="grid-listing">
                         ${getGridHTML({
-                            entries,
-                            srcFn: thing => (thing.album
-                                ? getTrackCover(thing)
-                                : getAlbumCover(thing)),
-                            linkFn: (thing, opts) => (thing.album
-                                ? link.track(thing, opts)
-                                : link.album(thing, opts))
+                          entries,
+                          srcFn: (thing) =>
+                            thing.album
+                              ? getTrackCover(thing)
+                              : getAlbumCover(thing),
+                          linkFn: (thing, opts) =>
+                            thing.album
+                              ? link.track(thing, opts)
+                              : link.album(thing, opts),
                         })}
                     </div>
-                `
-            },
+                `,
+      },
 
-            nav: generateTagNav(tag, {
-                generatePreviousNextLinks,
-                link,
-                language,
-                wikiData
-            })
-        })
-    };
+      nav: generateTagNav(tag, {
+        generatePreviousNextLinks,
+        link,
+        language,
+        wikiData,
+      }),
+    }),
+  };
 
-    return [page];
+  return [page];
 }
 
 // Utility functions
 
-function generateTagNav(tag, {
-    generatePreviousNextLinks,
-    link,
-    language,
-    wikiData
-}) {
-    const previousNextLinks = generatePreviousNextLinks(tag, {
-        data: wikiData.artTagData.filter(tag => !tag.isContentWarning),
-        linkKey: 'tag'
-    });
+function generateTagNav(
+  tag,
+  { generatePreviousNextLinks, link, language, wikiData }
+) {
+  const previousNextLinks = generatePreviousNextLinks(tag, {
+    data: wikiData.artTagData.filter((tag) => !tag.isContentWarning),
+    linkKey: "tag",
+  });
 
-    return {
-        linkContainerClasses: ['nav-links-hierarchy'],
-        links: [
-            {toHome: true},
-            wikiData.wikiInfo.enableListings &&
-            {
-                path: ['localized.listingIndex'],
-                title: language.$('listingIndex.title')
-            },
-            {
-                html: language.$('tagPage.nav.tag', {
-                    tag: link.tag(tag, {class: 'current'})
-                })
-            },
-            /*
+  return {
+    linkContainerClasses: ["nav-links-hierarchy"],
+    links: [
+      { toHome: true },
+      wikiData.wikiInfo.enableListings && {
+        path: ["localized.listingIndex"],
+        title: language.$("listingIndex.title"),
+      },
+      {
+        html: language.$("tagPage.nav.tag", {
+          tag: link.tag(tag, { class: "current" }),
+        }),
+      },
+      /*
             previousNextLinks && {
                 divider: false,
                 html: `(${previousNextLinks})`
             }
             */
-        ]
-    };
+    ],
+  };
 }
diff --git a/src/page/track.js b/src/page/track.js
index c4ec6c59..15316e8f 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -2,186 +2,221 @@
 
 // Imports
 
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 
 import {
-    generateAlbumChronologyLinks,
-    generateAlbumNavLinks,
-    generateAlbumSecondaryNav,
-    generateAlbumSidebar
-} from './album.js';
+  generateAlbumChronologyLinks,
+  generateAlbumNavLinks,
+  generateAlbumSecondaryNav,
+  generateAlbumSidebar,
+} from "./album.js";
 
-import * as html from '../util/html.js';
+import * as html from "../util/html.js";
 
-import {
-    bindOpts
-} from '../util/sugar.js';
+import { bindOpts } from "../util/sugar.js";
 
 import {
-    getTrackCover,
-    getAlbumListTag,
-    sortChronologically,
-} from '../util/wiki-data.js';
+  getTrackCover,
+  getAlbumListTag,
+  sortChronologically,
+} from "../util/wiki-data.js";
 
 // Page exports
 
-export function targets({wikiData}) {
-    return wikiData.trackData;
+export function targets({ wikiData }) {
+  return wikiData.trackData;
 }
 
-export function write(track, {wikiData}) {
-    const { groupData, wikiInfo } = wikiData;
-    const { album, referencedByTracks, referencedTracks, otherReleases } = track;
+export function write(track, { wikiData }) {
+  const { groupData, wikiInfo } = wikiData;
+  const { album, referencedByTracks, referencedTracks, otherReleases } = track;
 
-    const listTag = getAlbumListTag(album);
+  const listTag = getAlbumListTag(album);
 
-    let flashesThatFeature;
-    if (wikiInfo.enableFlashesAndGames) {
-        flashesThatFeature = sortChronologically([track, ...otherReleases]
-            .flatMap(track => track.featuredInFlashes
-                .map(flash => ({
-                    flash,
-                    as: track,
-                    directory: flash.directory,
-                    name: flash.name,
-                    date: flash.date
-                }))));
-    }
+  let flashesThatFeature;
+  if (wikiInfo.enableFlashesAndGames) {
+    flashesThatFeature = sortChronologically(
+      [track, ...otherReleases].flatMap((track) =>
+        track.featuredInFlashes.map((flash) => ({
+          flash,
+          as: track,
+          directory: flash.directory,
+          name: flash.name,
+          date: flash.date,
+        }))
+      )
+    );
+  }
 
-    const unbound_getTrackItem = (track, {getArtistString, link, language}) => (
-        html.tag('li', language.$('trackList.item.withArtists', {
-            track: link.track(track),
-            by: `<span class="by">${language.$('trackList.item.withArtists.by', {
-                artists: getArtistString(track.artistContribs)
-            })}</span>`
-        })));
+  const unbound_getTrackItem = (track, { getArtistString, link, language }) =>
+    html.tag(
+      "li",
+      language.$("trackList.item.withArtists", {
+        track: link.track(track),
+        by: `<span class="by">${language.$("trackList.item.withArtists.by", {
+          artists: getArtistString(track.artistContribs),
+        })}</span>`,
+      })
+    );
 
-    const unbound_generateTrackList = (tracks, {getArtistString, link, language}) => html.tag('ul',
-        tracks.map(track => unbound_getTrackItem(track, {getArtistString, link, language}))
+  const unbound_generateTrackList = (
+    tracks,
+    { getArtistString, link, language }
+  ) =>
+    html.tag(
+      "ul",
+      tracks.map((track) =>
+        unbound_getTrackItem(track, { getArtistString, link, language })
+      )
     );
 
-    const hasCommentary = track.commentary || otherReleases.some(t => t.commentary);
-    const generateCommentary = ({
-        link,
-        language,
-        transformMultiline
-    }) => transformMultiline([
+  const hasCommentary =
+    track.commentary || otherReleases.some((t) => t.commentary);
+  const generateCommentary = ({ link, language, transformMultiline }) =>
+    transformMultiline(
+      [
         track.commentary,
-        ...otherReleases.map(track =>
-            (track.commentary?.split('\n')
-                .filter(line => line.replace(/<\/b>/g, '').includes(':</i>'))
-                .map(line => fixWS`
+        ...otherReleases.map((track) =>
+          track.commentary
+            ?.split("\n")
+            .filter((line) => line.replace(/<\/b>/g, "").includes(":</i>"))
+            .map(
+              (line) => fixWS`
                     ${line}
-                    ${language.$('releaseInfo.artistCommentary.seeOriginalRelease', {
-                        original: link.track(track)
-                    })}
-                `)
-                .join('\n')))
-    ].filter(Boolean).join('\n'));
+                    ${language.$(
+                      "releaseInfo.artistCommentary.seeOriginalRelease",
+                      {
+                        original: link.track(track),
+                      }
+                    )}
+                `
+            )
+            .join("\n")
+        ),
+      ]
+        .filter(Boolean)
+        .join("\n")
+    );
 
-    const data = {
-        type: 'data',
-        path: ['track', track.directory],
-        data: ({
-            serializeContribs,
-            serializeCover,
-            serializeGroupsForTrack,
-            serializeLink
-        }) => ({
-            name: track.name,
-            directory: track.directory,
-            dates: {
-                released: track.date,
-                originallyReleased: track.originalDate,
-                coverArtAdded: track.coverArtDate
-            },
-            duration: track.duration,
-            color: track.color,
-            cover: serializeCover(track, getTrackCover),
-            artistsContribs: serializeContribs(track.artistContribs),
-            contributorContribs: serializeContribs(track.contributorContribs),
-            coverArtistContribs: serializeContribs(track.coverArtistContribs || []),
-            album: serializeLink(track.album),
-            groups: serializeGroupsForTrack(track),
-            references: track.references.map(serializeLink),
-            referencedBy: track.referencedBy.map(serializeLink),
-            alsoReleasedAs: otherReleases.map(track => ({
-                track: serializeLink(track),
-                album: serializeLink(track.album)
-            }))
-        })
-    };
+  const data = {
+    type: "data",
+    path: ["track", track.directory],
+    data: ({
+      serializeContribs,
+      serializeCover,
+      serializeGroupsForTrack,
+      serializeLink,
+    }) => ({
+      name: track.name,
+      directory: track.directory,
+      dates: {
+        released: track.date,
+        originallyReleased: track.originalDate,
+        coverArtAdded: track.coverArtDate,
+      },
+      duration: track.duration,
+      color: track.color,
+      cover: serializeCover(track, getTrackCover),
+      artistsContribs: serializeContribs(track.artistContribs),
+      contributorContribs: serializeContribs(track.contributorContribs),
+      coverArtistContribs: serializeContribs(track.coverArtistContribs || []),
+      album: serializeLink(track.album),
+      groups: serializeGroupsForTrack(track),
+      references: track.references.map(serializeLink),
+      referencedBy: track.referencedBy.map(serializeLink),
+      alsoReleasedAs: otherReleases.map((track) => ({
+        track: serializeLink(track),
+        album: serializeLink(track.album),
+      })),
+    }),
+  };
 
-    const getSocialEmbedDescription = ({
-        getArtistString: _getArtistString,
-        language,
-    }) => {
-        const hasArtists = (track.artistContribs?.length > 0);
-        const hasCoverArtists = (track.coverArtistContribs?.length > 0);
-        const getArtistString = contribs => _getArtistString(contribs, {
-            // We don't want to put actual HTML tags in social embeds (sadly
-            // they don't get parsed and displayed, generally speaking), so
-            // override the link argument so that artist "links" just show
-            // their names.
-            link: {artist: artist => artist.name}
-        });
-        if (!hasArtists && !hasCoverArtists) return '';
-        return language.formatString(
-            'trackPage.socialEmbed.body' + [
-                hasArtists && '.withArtists',
-                hasCoverArtists && '.withCoverArtists',
-            ].filter(Boolean).join(''),
-            Object.fromEntries([
-                hasArtists && ['artists', getArtistString(track.artistContribs)],
-                hasCoverArtists && ['coverArtists', getArtistString(track.coverArtistContribs)],
-            ].filter(Boolean)))
-    };
+  const getSocialEmbedDescription = ({
+    getArtistString: _getArtistString,
+    language,
+  }) => {
+    const hasArtists = track.artistContribs?.length > 0;
+    const hasCoverArtists = track.coverArtistContribs?.length > 0;
+    const getArtistString = (contribs) =>
+      _getArtistString(contribs, {
+        // We don't want to put actual HTML tags in social embeds (sadly
+        // they don't get parsed and displayed, generally speaking), so
+        // override the link argument so that artist "links" just show
+        // their names.
+        link: { artist: (artist) => artist.name },
+      });
+    if (!hasArtists && !hasCoverArtists) return "";
+    return language.formatString(
+      "trackPage.socialEmbed.body" +
+        [hasArtists && ".withArtists", hasCoverArtists && ".withCoverArtists"]
+          .filter(Boolean)
+          .join(""),
+      Object.fromEntries(
+        [
+          hasArtists && ["artists", getArtistString(track.artistContribs)],
+          hasCoverArtists && [
+            "coverArtists",
+            getArtistString(track.coverArtistContribs),
+          ],
+        ].filter(Boolean)
+      )
+    );
+  };
 
-    const page = {
-        type: 'page',
-        path: ['track', track.directory],
-        page: ({
-            absoluteTo,
-            fancifyURL,
-            generateChronologyLinks,
-            generateCoverLink,
-            generatePreviousNextLinks,
-            generateTrackListDividedByGroups,
-            getAlbumStylesheet,
-            getArtistString,
-            getLinkThemeString,
-            getThemeString,
-            getTrackCover,
-            link,
-            language,
-            transformInline,
-            transformLyrics,
-            transformMultiline,
-            to,
-            urls,
-        }) => {
-            const getTrackItem = bindOpts(unbound_getTrackItem, {getArtistString, link, language});
-            const cover = getTrackCover(track);
+  const page = {
+    type: "page",
+    path: ["track", track.directory],
+    page: ({
+      absoluteTo,
+      fancifyURL,
+      generateChronologyLinks,
+      generateCoverLink,
+      generatePreviousNextLinks,
+      generateTrackListDividedByGroups,
+      getAlbumStylesheet,
+      getArtistString,
+      getLinkThemeString,
+      getThemeString,
+      getTrackCover,
+      link,
+      language,
+      transformInline,
+      transformLyrics,
+      transformMultiline,
+      to,
+      urls,
+    }) => {
+      const getTrackItem = bindOpts(unbound_getTrackItem, {
+        getArtistString,
+        link,
+        language,
+      });
+      const cover = getTrackCover(track);
 
-            return {
-                title: language.$('trackPage.title', {track: track.name}),
-                stylesheet: getAlbumStylesheet(album, {to}),
-                theme: getThemeString(track.color, [
-                    `--album-directory: ${album.directory}`,
-                    `--track-directory: ${track.directory}`
-                ]),
+      return {
+        title: language.$("trackPage.title", { track: track.name }),
+        stylesheet: getAlbumStylesheet(album, { to }),
+        theme: getThemeString(track.color, [
+          `--album-directory: ${album.directory}`,
+          `--track-directory: ${track.directory}`,
+        ]),
 
-                socialEmbed: {
-                    heading: language.$('trackPage.socialEmbed.heading', {album: track.album.name}),
-                    headingLink: absoluteTo('localized.album', album.directory),
-                    title: language.$('trackPage.socialEmbed.title', {track: track.name}),
-                    description: getSocialEmbedDescription({getArtistString, language}),
-                    image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}),
-                    color: track.color,
-                },
+        socialEmbed: {
+          heading: language.$("trackPage.socialEmbed.heading", {
+            album: track.album.name,
+          }),
+          headingLink: absoluteTo("localized.album", album.directory),
+          title: language.$("trackPage.socialEmbed.title", {
+            track: track.name,
+          }),
+          description: getSocialEmbedDescription({ getArtistString, language }),
+          image:
+            "/" + getTrackCover(track, { to: urls.from("shared.root").to }),
+          color: track.color,
+        },
 
-                // disabled for now! shifting banner position per height of page is disorienting
-                /*
+        // disabled for now! shifting banner position per height of page is disorienting
+        /*
                 banner: album.bannerArtistContribs.length && {
                     classes: ['dim'],
                     dimensions: album.bannerDimensions,
@@ -191,156 +226,239 @@ export function write(track, {wikiData}) {
                 },
                 */
 
-                main: {
-                    content: fixWS`
-                        ${cover && generateCoverLink({
+        main: {
+          content: fixWS`
+                        ${
+                          cover &&
+                          generateCoverLink({
                             src: cover,
-                            alt: language.$('misc.alt.trackCover'),
-                            tags: track.artTags
-                        })}
-                        <h1>${language.$('trackPage.title', {track: track.name})}</h1>
+                            alt: language.$("misc.alt.trackCover"),
+                            tags: track.artTags,
+                          })
+                        }
+                        <h1>${language.$("trackPage.title", {
+                          track: track.name,
+                        })}</h1>
                         <p>
                             ${[
-                                language.$('releaseInfo.by', {
-                                    artists: getArtistString(track.artistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
+                              language.$("releaseInfo.by", {
+                                artists: getArtistString(track.artistContribs, {
+                                  showContrib: true,
+                                  showIcons: true,
                                 }),
-                                track.coverArtistContribs.length && language.$('releaseInfo.coverArtBy', {
-                                    artists: getArtistString(track.coverArtistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
+                              }),
+                              track.coverArtistContribs.length &&
+                                language.$("releaseInfo.coverArtBy", {
+                                  artists: getArtistString(
+                                    track.coverArtistContribs,
+                                    {
+                                      showContrib: true,
+                                      showIcons: true,
+                                    }
+                                  ),
+                                }),
+                              track.date &&
+                                language.$("releaseInfo.released", {
+                                  date: language.formatDate(track.date),
                                 }),
-                                track.date && language.$('releaseInfo.released', {
-                                    date: language.formatDate(track.date)
+                              track.coverArtDate &&
+                                +track.coverArtDate !== +track.date &&
+                                language.$("releaseInfo.artReleased", {
+                                  date: language.formatDate(track.coverArtDate),
                                 }),
-                                (track.coverArtDate &&
-                                    +track.coverArtDate !== +track.date &&
-                                    language.$('releaseInfo.artReleased', {
-                                        date: language.formatDate(track.coverArtDate)
-                                    })),
-                                track.duration && language.$('releaseInfo.duration', {
-                                    duration: language.formatDuration(track.duration)
-                                })
-                            ].filter(Boolean).join('<br>\n')}
+                              track.duration &&
+                                language.$("releaseInfo.duration", {
+                                  duration: language.formatDuration(
+                                    track.duration
+                                  ),
+                                }),
+                            ]
+                              .filter(Boolean)
+                              .join("<br>\n")}
                         </p>
                         <p>${
-                            (track.urls?.length
-                                ? language.$('releaseInfo.listenOn', {
-                                    links: language.formatDisjunctionList(track.urls.map(url => fancifyURL(url, {language})))
-                                })
-                                : language.$('releaseInfo.listenOn.noLinks'))
+                          track.urls?.length
+                            ? language.$("releaseInfo.listenOn", {
+                                links: language.formatDisjunctionList(
+                                  track.urls.map((url) =>
+                                    fancifyURL(url, { language })
+                                  )
+                                ),
+                              })
+                            : language.$("releaseInfo.listenOn.noLinks")
                         }</p>
-                        ${otherReleases.length && fixWS`
-                            <p>${language.$('releaseInfo.alsoReleasedAs')}</p>
+                        ${
+                          otherReleases.length &&
+                          fixWS`
+                            <p>${language.$("releaseInfo.alsoReleasedAs")}</p>
                             <ul>
-                                ${otherReleases.map(track => fixWS`
-                                    <li>${language.$('releaseInfo.alsoReleasedAs.item', {
+                                ${otherReleases
+                                  .map(
+                                    (track) => fixWS`
+                                    <li>${language.$(
+                                      "releaseInfo.alsoReleasedAs.item",
+                                      {
                                         track: link.track(track),
-                                        album: link.album(track.album)
-                                    })}</li>
-                                `).join('\n')}
+                                        album: link.album(track.album),
+                                      }
+                                    )}</li>
+                                `
+                                  )
+                                  .join("\n")}
                             </ul>
-                        `}
-                        ${track.contributorContribs.length && fixWS`
-                            <p>${language.$('releaseInfo.contributors')}</p>
+                        `
+                        }
+                        ${
+                          track.contributorContribs.length &&
+                          fixWS`
+                            <p>${language.$("releaseInfo.contributors")}</p>
                             <ul>
-                                ${(track.contributorContribs
-                                    .map(contrib => `<li>${getArtistString([contrib], {
+                                ${track.contributorContribs
+                                  .map(
+                                    (contrib) =>
+                                      `<li>${getArtistString([contrib], {
                                         showContrib: true,
-                                        showIcons: true
-                                    })}</li>`)
-                                    .join('\n'))}
+                                        showIcons: true,
+                                      })}</li>`
+                                  )
+                                  .join("\n")}
                             </ul>
-                        `}
-                        ${referencedTracks.length && fixWS`
-                            <p>${language.$('releaseInfo.tracksReferenced', {track: `<i>${track.name}</i>`})}</p>
-                            ${html.tag('ul', referencedTracks.map(getTrackItem))}
-                        `}
-                        ${referencedByTracks.length && fixWS`
-                            <p>${language.$('releaseInfo.tracksThatReference', {track: `<i>${track.name}</i>`})}</p>
-                            ${generateTrackListDividedByGroups(referencedByTracks, {
+                        `
+                        }
+                        ${
+                          referencedTracks.length &&
+                          fixWS`
+                            <p>${language.$("releaseInfo.tracksReferenced", {
+                              track: `<i>${track.name}</i>`,
+                            })}</p>
+                            ${html.tag(
+                              "ul",
+                              referencedTracks.map(getTrackItem)
+                            )}
+                        `
+                        }
+                        ${
+                          referencedByTracks.length &&
+                          fixWS`
+                            <p>${language.$("releaseInfo.tracksThatReference", {
+                              track: `<i>${track.name}</i>`,
+                            })}</p>
+                            ${generateTrackListDividedByGroups(
+                              referencedByTracks,
+                              {
                                 getTrackItem,
                                 wikiData,
-                            })}
-                        `}
-                        ${wikiInfo.enableFlashesAndGames && flashesThatFeature.length && fixWS`
-                            <p>${language.$('releaseInfo.flashesThatFeature', {track: `<i>${track.name}</i>`})}</p>
+                              }
+                            )}
+                        `
+                        }
+                        ${
+                          wikiInfo.enableFlashesAndGames &&
+                          flashesThatFeature.length &&
+                          fixWS`
+                            <p>${language.$("releaseInfo.flashesThatFeature", {
+                              track: `<i>${track.name}</i>`,
+                            })}</p>
                             <ul>
-                                ${flashesThatFeature.map(({ flash, as }) => html.tag('li',
-                                    {class: as !== track && 'rerelease'},
-                                    (as === track
-                                        ? language.$('releaseInfo.flashesThatFeature.item', {
-                                            flash: link.flash(flash)
-                                        })
-                                        : language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
-                                            flash: link.flash(flash),
-                                            track: link.track(as)
-                                        })))).join('\n')}
+                                ${flashesThatFeature
+                                  .map(({ flash, as }) =>
+                                    html.tag(
+                                      "li",
+                                      { class: as !== track && "rerelease" },
+                                      as === track
+                                        ? language.$(
+                                            "releaseInfo.flashesThatFeature.item",
+                                            {
+                                              flash: link.flash(flash),
+                                            }
+                                          )
+                                        : language.$(
+                                            "releaseInfo.flashesThatFeature.item.asDifferentRelease",
+                                            {
+                                              flash: link.flash(flash),
+                                              track: link.track(as),
+                                            }
+                                          )
+                                    )
+                                  )
+                                  .join("\n")}
                             </ul>
-                        `}
-                        ${track.lyrics && fixWS`
-                            <p>${language.$('releaseInfo.lyrics')}</p>
+                        `
+                        }
+                        ${
+                          track.lyrics &&
+                          fixWS`
+                            <p>${language.$("releaseInfo.lyrics")}</p>
                             <blockquote>
                                 ${transformLyrics(track.lyrics)}
                             </blockquote>
-                        `}
-                        ${hasCommentary && fixWS`
-                            <p>${language.$('releaseInfo.artistCommentary')}</p>
+                        `
+                        }
+                        ${
+                          hasCommentary &&
+                          fixWS`
+                            <p>${language.$("releaseInfo.artistCommentary")}</p>
                             <blockquote>
-                                ${generateCommentary({link, language, transformMultiline})}
+                                ${generateCommentary({
+                                  link,
+                                  language,
+                                  transformMultiline,
+                                })}
                             </blockquote>
-                        `}
-                    `
-                },
+                        `
+                        }
+                    `,
+        },
 
-                sidebarLeft: generateAlbumSidebar(album, track, {
-                    fancifyURL,
-                    getLinkThemeString,
-                    link,
-                    language,
-                    transformMultiline,
-                    wikiData
-                }),
+        sidebarLeft: generateAlbumSidebar(album, track, {
+          fancifyURL,
+          getLinkThemeString,
+          link,
+          language,
+          transformMultiline,
+          wikiData,
+        }),
 
-                nav: {
-                    linkContainerClasses: ['nav-links-hierarchy'],
-                    links: [
-                        {toHome: true},
-                        {
-                            path: ['localized.album', album.directory],
-                            title: album.name
-                        },
-                        listTag === 'ol' ? {
-                            html: language.$('trackPage.nav.track.withNumber', {
-                                number: album.tracks.indexOf(track) + 1,
-                                track: link.track(track, {class: 'current', to})
-                            })
-                        } : {
-                            html: language.$('trackPage.nav.track', {
-                                track: link.track(track, {class: 'current', to})
-                            })
-                        },
-                    ].filter(Boolean),
-                    content: generateAlbumChronologyLinks(album, track, {generateChronologyLinks}),
-                    bottomRowContent: (album.tracks.length > 1 &&
-                        generateAlbumNavLinks(album, track, {
-                            generatePreviousNextLinks,
-                            language,
-                        })),
+        nav: {
+          linkContainerClasses: ["nav-links-hierarchy"],
+          links: [
+            { toHome: true },
+            {
+              path: ["localized.album", album.directory],
+              title: album.name,
+            },
+            listTag === "ol"
+              ? {
+                  html: language.$("trackPage.nav.track.withNumber", {
+                    number: album.tracks.indexOf(track) + 1,
+                    track: link.track(track, { class: "current", to }),
+                  }),
+                }
+              : {
+                  html: language.$("trackPage.nav.track", {
+                    track: link.track(track, { class: "current", to }),
+                  }),
                 },
+          ].filter(Boolean),
+          content: generateAlbumChronologyLinks(album, track, {
+            generateChronologyLinks,
+          }),
+          bottomRowContent:
+            album.tracks.length > 1 &&
+            generateAlbumNavLinks(album, track, {
+              generatePreviousNextLinks,
+              language,
+            }),
+        },
 
-                secondaryNav: generateAlbumSecondaryNav(album, track, {
-                    language,
-                    link,
-                    getLinkThemeString,
-                }),
-            };
-        }
-    };
+        secondaryNav: generateAlbumSecondaryNav(album, track, {
+          language,
+          link,
+          getLinkThemeString,
+        }),
+      };
+    },
+  };
 
-    return [data, page];
+  return [data, page];
 }
-
diff --git a/src/repl.js b/src/repl.js
index cd4c3212..1a694d7e 100644
--- a/src/repl.js
+++ b/src/repl.js
@@ -1,70 +1,70 @@
-import * as os from 'os';
-import * as path from 'path';
-import * as repl from 'repl';
-import { fileURLToPath } from 'url';
-import { promisify } from 'util';
+import * as os from "os";
+import * as path from "path";
+import * as repl from "repl";
+import { fileURLToPath } from "url";
+import { promisify } from "util";
 
-import { quickLoadAllFromYAML } from './data/yaml.js';
-import { logError, parseOptions } from './util/cli.js';
-import { showAggregate } from './util/sugar.js';
+import { quickLoadAllFromYAML } from "./data/yaml.js";
+import { logError, parseOptions } from "./util/cli.js";
+import { showAggregate } from "./util/sugar.js";
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 async function main() {
-    const miscOptions = await parseOptions(process.argv.slice(2), {
-        'data-path': {
-            type: 'value'
-        },
+  const miscOptions = await parseOptions(process.argv.slice(2), {
+    "data-path": {
+      type: "value",
+    },
 
-        'show-traces': {
-            type: 'flag'
-        },
+    "show-traces": {
+      type: "flag",
+    },
 
-        'no-history': {
-            type: 'flag'
-        },
-    });
+    "no-history": {
+      type: "flag",
+    },
+  });
 
-    const dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA;
-    const showAggregateTraces = miscOptions['show-traces'] ?? false;
-    const disableHistory = miscOptions['no-history'] ?? false;
+  const dataPath = miscOptions["data-path"] || process.env.HSMUSIC_DATA;
+  const showAggregateTraces = miscOptions["show-traces"] ?? false;
+  const disableHistory = miscOptions["no-history"] ?? false;
 
-    if (!dataPath) {
-        logError`Expected --data-path option or HSMUSIC_DATA to be set`;
-        return;
-    }
+  if (!dataPath) {
+    logError`Expected --data-path option or HSMUSIC_DATA to be set`;
+    return;
+  }
 
-    console.log('HSMusic data REPL');
+  console.log("HSMusic data REPL");
 
-    const wikiData = await quickLoadAllFromYAML(dataPath);
-    const replServer = repl.start();
+  const wikiData = await quickLoadAllFromYAML(dataPath);
+  const replServer = repl.start();
 
-    Object.assign(
-        replServer.context,
-        wikiData,
-        {wikiData, WD: wikiData}
-    );
+  Object.assign(replServer.context, wikiData, { wikiData, WD: wikiData });
 
-    if (disableHistory) {
-        console.log(`\rInput history disabled (--no-history provided)`);
-        replServer.displayPrompt(true);
-    } else {
-        const historyFile = path.join(os.homedir(), '.hsmusic_repl_history');
-        replServer.setupHistory(historyFile, err => {
-            if (err) {
-                console.error(`\rFailed to begin locally logging input history to ${historyFile} (provide --no-history to disable)`);
-            } else {
-                console.log(`\rLogging input history to ${historyFile} (provide --no-history to disable)`);
-            }
-            replServer.displayPrompt(true);
-        });
-    }
+  if (disableHistory) {
+    console.log(`\rInput history disabled (--no-history provided)`);
+    replServer.displayPrompt(true);
+  } else {
+    const historyFile = path.join(os.homedir(), ".hsmusic_repl_history");
+    replServer.setupHistory(historyFile, (err) => {
+      if (err) {
+        console.error(
+          `\rFailed to begin locally logging input history to ${historyFile} (provide --no-history to disable)`
+        );
+      } else {
+        console.log(
+          `\rLogging input history to ${historyFile} (provide --no-history to disable)`
+        );
+      }
+      replServer.displayPrompt(true);
+    });
+  }
 }
 
-main().catch(error => {
-    if (error instanceof AggregateError) {
-        showAggregate(error)
-    } else {
-        console.error(error);
-    }
+main().catch((error) => {
+  if (error instanceof AggregateError) {
+    showAggregate(error);
+  } else {
+    console.error(error);
+  }
 });
diff --git a/src/static/client.js b/src/static/client.js
index 7397735c..72fa9cc2 100644
--- a/src/static/client.js
+++ b/src/static/client.js
@@ -5,13 +5,9 @@
 //
 // Upd8: As of 04/02/2021, it's now used for info cards too! Nice.
 
-import {
-    getColors
-} from '../util/colors.js';
+import { getColors } from "../util/colors.js";
 
-import {
-    getArtistNumContributions
-} from '../util/wiki-data.js';
+import { getArtistNumContributions } from "../util/wiki-data.js";
 
 let albumData, artistData, flashData;
 let officialAlbumData, fandomAlbumData, artistNames;
@@ -20,190 +16,235 @@ let ready = false;
 
 // Localiz8tion nonsense ----------------------------------
 
-const language = document.documentElement.getAttribute('lang');
+const language = document.documentElement.getAttribute("lang");
 
 let list;
-if (
-    typeof Intl === 'object' &&
-    typeof Intl.ListFormat === 'function'
-) {
-    const getFormat = type => {
-        const formatter = new Intl.ListFormat(language, {type});
-        return formatter.format.bind(formatter);
-    };
-
-    list = {
-        conjunction: getFormat('conjunction'),
-        disjunction: getFormat('disjunction'),
-        unit: getFormat('unit')
-    };
+if (typeof Intl === "object" && typeof Intl.ListFormat === "function") {
+  const getFormat = (type) => {
+    const formatter = new Intl.ListFormat(language, { type });
+    return formatter.format.bind(formatter);
+  };
+
+  list = {
+    conjunction: getFormat("conjunction"),
+    disjunction: getFormat("disjunction"),
+    unit: getFormat("unit"),
+  };
 } else {
-    // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free.
-    // We use the same mock for every list 'cuz we don't have any of the
-    // necessary CLDR info to appropri8tely distinguish 8etween them.
-    const arbitraryMock = array => array.join(', ');
-
-    list = {
-        conjunction: arbitraryMock,
-        disjunction: arbitraryMock,
-        unit: arbitraryMock
-    };
+  // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free.
+  // We use the same mock for every list 'cuz we don't have any of the
+  // necessary CLDR info to appropri8tely distinguish 8etween them.
+  const arbitraryMock = (array) => array.join(", ");
+
+  list = {
+    conjunction: arbitraryMock,
+    disjunction: arbitraryMock,
+    unit: arbitraryMock,
+  };
 }
 
 // Miscellaneous helpers ----------------------------------
 
-function rebase(href, rebaseKey = 'rebaseLocalized') {
-    const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/';
-    if (relative) {
-        return relative + href;
-    } else {
-        return href;
-    }
+function rebase(href, rebaseKey = "rebaseLocalized") {
+  const relative = (document.documentElement.dataset[rebaseKey] || ".") + "/";
+  if (relative) {
+    return relative + href;
+  } else {
+    return href;
+  }
 }
 
 function pick(array) {
-    return array[Math.floor(Math.random() * array.length)];
+  return array[Math.floor(Math.random() * array.length)];
 }
 
 function cssProp(el, key) {
-    return getComputedStyle(el).getPropertyValue(key).trim();
+  return getComputedStyle(el).getPropertyValue(key).trim();
 }
 
 function getRefDirectory(ref) {
-    return ref.split(':')[1];
+  return ref.split(":")[1];
 }
 
 function getAlbum(el) {
-    const directory = cssProp(el, '--album-directory');
-    return albumData.find(album => album.directory === directory);
+  const directory = cssProp(el, "--album-directory");
+  return albumData.find((album) => album.directory === directory);
 }
 
 function getFlash(el) {
-    const directory = cssProp(el, '--flash-directory');
-    return flashData.find(flash => flash.directory === directory);
+  const directory = cssProp(el, "--flash-directory");
+  return flashData.find((flash) => flash.directory === directory);
 }
 
 // TODO: These should pro8a8ly access some shared urlSpec path. We'd need to
 // separ8te the tooling around that into common-shared code too.
 const getLinkHref = (type, directory) => rebase(`${type}/${directory}`);
-const openAlbum = d => rebase(`album/${d}`);
-const openTrack = d => rebase(`track/${d}`);
-const openArtist = d => rebase(`artist/${d}`);
-const openFlash = d => rebase(`flash/${d}`);
+const openAlbum = (d) => rebase(`album/${d}`);
+const openTrack = (d) => rebase(`track/${d}`);
+const openArtist = (d) => rebase(`artist/${d}`);
+const openFlash = (d) => rebase(`flash/${d}`);
 
 function getTrackListAndIndex() {
-    const album = getAlbum(document.body);
-    const directory = cssProp(document.body, '--track-directory');
-    if (!directory && !album) return {};
-    if (!directory) return {list: album.tracks};
-    const trackIndex = album.tracks.findIndex(track => track.directory === directory);
-    return {list: album.tracks, index: trackIndex};
+  const album = getAlbum(document.body);
+  const directory = cssProp(document.body, "--track-directory");
+  if (!directory && !album) return {};
+  if (!directory) return { list: album.tracks };
+  const trackIndex = album.tracks.findIndex(
+    (track) => track.directory === directory
+  );
+  return { list: album.tracks, index: trackIndex };
 }
 
 function openRandomTrack() {
-    const { list } = getTrackListAndIndex();
-    if (!list) return;
-    return openTrack(pick(list));
+  const { list } = getTrackListAndIndex();
+  if (!list) return;
+  return openTrack(pick(list));
 }
 
 function getFlashListAndIndex() {
-    const list = flashData.filter(flash => !flash.act8r8k)
-    const flash = getFlash(document.body);
-    if (!flash) return {list};
-    const flashIndex = list.indexOf(flash);
-    return {list, index: flashIndex};
+  const list = flashData.filter((flash) => !flash.act8r8k);
+  const flash = getFlash(document.body);
+  if (!flash) return { list };
+  const flashIndex = list.indexOf(flash);
+  return { list, index: flashIndex };
 }
 
 // TODO: This should also use urlSpec.
 function fetchData(type, directory) {
-    return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData'))
-        .then(res => res.json());
+  return fetch(rebase(`${type}/${directory}/data.json`, "rebaseData")).then(
+    (res) => res.json()
+  );
 }
 
 // JS-based links -----------------------------------------
 
-for (const a of document.body.querySelectorAll('[data-random]')) {
-    a.addEventListener('click', evt => {
-        if (!ready) {
-            evt.preventDefault();
-            return;
-        }
-
-        setTimeout(() => {
-            a.href = rebase('js-disabled');
-        });
-        switch (a.dataset.random) {
-            case 'album': return a.href = openAlbum(pick(albumData).directory);
-            case 'album-in-fandom': return a.href = openAlbum(pick(fandomAlbumData).directory);
-            case 'album-in-official': return a.href = openAlbum(pick(officialAlbumData).directory);
-            case 'track': return a.href = openTrack(getRefDirectory(pick(albumData.map(a => a.tracks).reduce((a, b) => a.concat(b), []))));
-            case 'track-in-album': return a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)));
-            case 'track-in-fandom': return a.href = openTrack(getRefDirectory(pick(fandomAlbumData.reduce((acc, album) => acc.concat(album.tracks), []))));
-            case 'track-in-official': return a.href = openTrack(getRefDirectory(pick(officialAlbumData.reduce((acc, album) => acc.concat(album.tracks), []))));
-            case 'artist': return a.href = openArtist(pick(artistData).directory);
-            case 'artist-more-than-one-contrib': return a.href = openArtist(pick(artistData.filter(artist => getArtistNumContributions(artist) > 1)).directory);
-        }
+for (const a of document.body.querySelectorAll("[data-random]")) {
+  a.addEventListener("click", (evt) => {
+    if (!ready) {
+      evt.preventDefault();
+      return;
+    }
+
+    setTimeout(() => {
+      a.href = rebase("js-disabled");
     });
+    switch (a.dataset.random) {
+      case "album":
+        return (a.href = openAlbum(pick(albumData).directory));
+      case "album-in-fandom":
+        return (a.href = openAlbum(pick(fandomAlbumData).directory));
+      case "album-in-official":
+        return (a.href = openAlbum(pick(officialAlbumData).directory));
+      case "track":
+        return (a.href = openTrack(
+          getRefDirectory(
+            pick(
+              albumData.map((a) => a.tracks).reduce((a, b) => a.concat(b), [])
+            )
+          )
+        ));
+      case "track-in-album":
+        return (a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))));
+      case "track-in-fandom":
+        return (a.href = openTrack(
+          getRefDirectory(
+            pick(
+              fandomAlbumData.reduce(
+                (acc, album) => acc.concat(album.tracks),
+                []
+              )
+            )
+          )
+        ));
+      case "track-in-official":
+        return (a.href = openTrack(
+          getRefDirectory(
+            pick(
+              officialAlbumData.reduce(
+                (acc, album) => acc.concat(album.tracks),
+                []
+              )
+            )
+          )
+        ));
+      case "artist":
+        return (a.href = openArtist(pick(artistData).directory));
+      case "artist-more-than-one-contrib":
+        return (a.href = openArtist(
+          pick(
+            artistData.filter((artist) => getArtistNumContributions(artist) > 1)
+          ).directory
+        ));
+    }
+  });
 }
 
-const next = document.getElementById('next-button');
-const previous = document.getElementById('previous-button');
-const random = document.getElementById('random-button');
+const next = document.getElementById("next-button");
+const previous = document.getElementById("previous-button");
+const random = document.getElementById("random-button");
 
 const prependTitle = (el, prepend) => {
-    const existing = el.getAttribute('title');
-    if (existing) {
-        el.setAttribute('title', prepend + ' ' + existing);
-    } else {
-        el.setAttribute('title', prepend);
-    }
+  const existing = el.getAttribute("title");
+  if (existing) {
+    el.setAttribute("title", prepend + " " + existing);
+  } else {
+    el.setAttribute("title", prepend);
+  }
 };
 
-if (next) prependTitle(next, '(Shift+N)');
-if (previous) prependTitle(previous, '(Shift+P)');
-if (random) prependTitle(random, '(Shift+R)');
-
-document.addEventListener('keypress', event => {
-    if (event.shiftKey) {
-        if (event.charCode === 'N'.charCodeAt(0)) {
-            if (next) next.click();
-        } else if (event.charCode === 'P'.charCodeAt(0)) {
-            if (previous) previous.click();
-        } else if (event.charCode === 'R'.charCodeAt(0)) {
-            if (random && ready) random.click();
-        }
+if (next) prependTitle(next, "(Shift+N)");
+if (previous) prependTitle(previous, "(Shift+P)");
+if (random) prependTitle(random, "(Shift+R)");
+
+document.addEventListener("keypress", (event) => {
+  if (event.shiftKey) {
+    if (event.charCode === "N".charCodeAt(0)) {
+      if (next) next.click();
+    } else if (event.charCode === "P".charCodeAt(0)) {
+      if (previous) previous.click();
+    } else if (event.charCode === "R".charCodeAt(0)) {
+      if (random && ready) random.click();
     }
+  }
 });
 
-for (const reveal of document.querySelectorAll('.reveal')) {
-    reveal.addEventListener('click', event => {
-        if (!reveal.classList.contains('revealed')) {
-            reveal.classList.add('revealed');
-            event.preventDefault();
-            event.stopPropagation();
-        }
-    });
+for (const reveal of document.querySelectorAll(".reveal")) {
+  reveal.addEventListener("click", (event) => {
+    if (!reveal.classList.contains("revealed")) {
+      reveal.classList.add("revealed");
+      event.preventDefault();
+      event.stopPropagation();
+    }
+  });
 }
 
-const elements1 = document.getElementsByClassName('js-hide-once-data');
-const elements2 = document.getElementsByClassName('js-show-once-data');
+const elements1 = document.getElementsByClassName("js-hide-once-data");
+const elements2 = document.getElementsByClassName("js-show-once-data");
 
-for (const element of elements1) element.style.display = 'block';
+for (const element of elements1) element.style.display = "block";
 
-fetch(rebase('data.json', 'rebaseShared')).then(data => data.json()).then(data => {
+fetch(rebase("data.json", "rebaseShared"))
+  .then((data) => data.json())
+  .then((data) => {
     albumData = data.albumData;
     artistData = data.artistData;
     flashData = data.flashData;
 
-    officialAlbumData = albumData.filter(album => album.groups.includes('group:official'));
-    fandomAlbumData = albumData.filter(album => !album.groups.includes('group:official'));
-    artistNames = artistData.filter(artist => !artist.alias).map(artist => artist.name);
+    officialAlbumData = albumData.filter((album) =>
+      album.groups.includes("group:official")
+    );
+    fandomAlbumData = albumData.filter(
+      (album) => !album.groups.includes("group:official")
+    );
+    artistNames = artistData
+      .filter((artist) => !artist.alias)
+      .map((artist) => artist.name);
 
-    for (const element of elements1) element.style.display = 'none';
-    for (const element of elements2) element.style.display = 'block';
+    for (const element of elements1) element.style.display = "none";
+    for (const element of elements2) element.style.display = "block";
 
     ready = true;
-});
+  });
 
 // Data & info card ---------------------------------------
 
@@ -216,197 +257,210 @@ let fastHover = false;
 let endFastHoverTimeout = null;
 
 function colorLink(a, color) {
-    if (color) {
-        const { primary, dim } = getColors(color);
-        a.style.setProperty('--primary-color', primary);
-        a.style.setProperty('--dim-color', dim);
-    }
+  if (color) {
+    const { primary, dim } = getColors(color);
+    a.style.setProperty("--primary-color", primary);
+    a.style.setProperty("--dim-color", dim);
+  }
 }
 
-function link(a, type, {name, directory, color}) {
-    colorLink(a, color);
-    a.innerText = name
-    a.href = getLinkHref(type, directory);
+function link(a, type, { name, directory, color }) {
+  colorLink(a, color);
+  a.innerText = name;
+  a.href = getLinkHref(type, directory);
 }
 
 function joinElements(type, elements) {
-    // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on
-    // strings. So instead, we'll pass the element's outer HTML's (which means
-    // the entire HTML of that element).
-    //
-    // That does mean this function returns a string, so always 8e sure to
-    // set innerHTML when using it (not appendChild).
-
-    return list[type](elements.map(el => el.outerHTML));
+  // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on
+  // strings. So instead, we'll pass the element's outer HTML's (which means
+  // the entire HTML of that element).
+  //
+  // That does mean this function returns a string, so always 8e sure to
+  // set innerHTML when using it (not appendChild).
+
+  return list[type](elements.map((el) => el.outerHTML));
 }
 
 const infoCard = (() => {
-    const container = document.getElementById('info-card-container');
-
-    let cancelShow = false;
-    let hideTimeout = null;
-    let showing = false;
-
-    container.addEventListener('mouseenter', cancelHide);
-    container.addEventListener('mouseleave', readyHide);
-
-    function show(type, target) {
-        cancelShow = false;
-
-        fetchData(type, target.dataset[type]).then(data => {
-            // Manual DOM 'cuz we're laaaazy.
-
-            if (cancelShow) {
-                return;
-            }
-
-            showing = true;
-
-            const rect = target.getBoundingClientRect();
-
-            container.style.setProperty('--primary-color', data.color);
-
-            container.style.top = window.scrollY + rect.bottom + 'px';
-            container.style.left = window.scrollX + rect.left + 'px';
-
-            // Use a short timeout to let a currently hidden (or not yet shown)
-            // info card teleport to the position set a8ove. (If it's currently
-            // shown, it'll transition to that position.)
-            setTimeout(() => {
-                container.classList.remove('hide');
-                container.classList.add('show');
-            }, 50);
-
-            // 8asic details.
-
-            const nameLink = container.querySelector('.info-card-name a');
-            link(nameLink, 'track', data);
-
-            const albumLink = container.querySelector('.info-card-album a');
-            link(albumLink, 'album', data.album);
-
-            const artistSpan = container.querySelector('.info-card-artists span');
-            artistSpan.innerHTML = joinElements('conjunction', data.artists.map(({ artist }) => {
-                const a = document.createElement('a');
-                a.href = getLinkHref('artist', artist.directory);
-                a.innerText = artist.name;
-                return a;
-            }));
-
-            const coverArtistParagraph = container.querySelector('.info-card-cover-artists');
-            const coverArtistSpan = coverArtistParagraph.querySelector('span');
-            if (data.coverArtists.length) {
-                coverArtistParagraph.style.display = 'block';
-                coverArtistSpan.innerHTML = joinElements('conjunction', data.coverArtists.map(({ artist }) => {
-                    const a = document.createElement('a');
-                    a.href = getLinkHref('artist', artist.directory);
-                    a.innerText = artist.name;
-                    return a;
-                }));
-            } else {
-                coverArtistParagraph.style.display = 'none';
-            }
-
-            // Cover art.
-
-            const [ containerNoReveal, containerReveal ] = [
-                container.querySelector('.info-card-art-container.no-reveal'),
-                container.querySelector('.info-card-art-container.reveal')
-            ];
-
-            const [ containerShow, containerHide ] = (data.cover.warnings.length
-                ? [containerReveal, containerNoReveal]
-                : [containerNoReveal, containerReveal]);
-
-            containerHide.style.display = 'none';
-            containerShow.style.display = 'block';
-
-            const img = containerShow.querySelector('.info-card-art');
-            img.src = rebase(data.cover.paths.small, 'rebaseMedia');
-
-            const imgLink = containerShow.querySelector('a');
-            colorLink(imgLink, data.color);
-            imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia');
-
-            if (containerShow === containerReveal) {
-                const cw = containerShow.querySelector('.info-card-art-warnings');
-                cw.innerText = list.unit(data.cover.warnings);
-
-                const reveal = containerShow.querySelector('.reveal');
-                reveal.classList.remove('revealed');
-            }
-        });
-    }
-
-    function hide() {
-        container.classList.remove('show');
-        container.classList.add('hide');
-        cancelShow = true;
-        showing = false;
-    }
-
-    function readyHide() {
-        if (!hideTimeout && showing) {
-            hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY);
-        }
+  const container = document.getElementById("info-card-container");
+
+  let cancelShow = false;
+  let hideTimeout = null;
+  let showing = false;
+
+  container.addEventListener("mouseenter", cancelHide);
+  container.addEventListener("mouseleave", readyHide);
+
+  function show(type, target) {
+    cancelShow = false;
+
+    fetchData(type, target.dataset[type]).then((data) => {
+      // Manual DOM 'cuz we're laaaazy.
+
+      if (cancelShow) {
+        return;
+      }
+
+      showing = true;
+
+      const rect = target.getBoundingClientRect();
+
+      container.style.setProperty("--primary-color", data.color);
+
+      container.style.top = window.scrollY + rect.bottom + "px";
+      container.style.left = window.scrollX + rect.left + "px";
+
+      // Use a short timeout to let a currently hidden (or not yet shown)
+      // info card teleport to the position set a8ove. (If it's currently
+      // shown, it'll transition to that position.)
+      setTimeout(() => {
+        container.classList.remove("hide");
+        container.classList.add("show");
+      }, 50);
+
+      // 8asic details.
+
+      const nameLink = container.querySelector(".info-card-name a");
+      link(nameLink, "track", data);
+
+      const albumLink = container.querySelector(".info-card-album a");
+      link(albumLink, "album", data.album);
+
+      const artistSpan = container.querySelector(".info-card-artists span");
+      artistSpan.innerHTML = joinElements(
+        "conjunction",
+        data.artists.map(({ artist }) => {
+          const a = document.createElement("a");
+          a.href = getLinkHref("artist", artist.directory);
+          a.innerText = artist.name;
+          return a;
+        })
+      );
+
+      const coverArtistParagraph = container.querySelector(
+        ".info-card-cover-artists"
+      );
+      const coverArtistSpan = coverArtistParagraph.querySelector("span");
+      if (data.coverArtists.length) {
+        coverArtistParagraph.style.display = "block";
+        coverArtistSpan.innerHTML = joinElements(
+          "conjunction",
+          data.coverArtists.map(({ artist }) => {
+            const a = document.createElement("a");
+            a.href = getLinkHref("artist", artist.directory);
+            a.innerText = artist.name;
+            return a;
+          })
+        );
+      } else {
+        coverArtistParagraph.style.display = "none";
+      }
+
+      // Cover art.
+
+      const [containerNoReveal, containerReveal] = [
+        container.querySelector(".info-card-art-container.no-reveal"),
+        container.querySelector(".info-card-art-container.reveal"),
+      ];
+
+      const [containerShow, containerHide] = data.cover.warnings.length
+        ? [containerReveal, containerNoReveal]
+        : [containerNoReveal, containerReveal];
+
+      containerHide.style.display = "none";
+      containerShow.style.display = "block";
+
+      const img = containerShow.querySelector(".info-card-art");
+      img.src = rebase(data.cover.paths.small, "rebaseMedia");
+
+      const imgLink = containerShow.querySelector("a");
+      colorLink(imgLink, data.color);
+      imgLink.href = rebase(data.cover.paths.original, "rebaseMedia");
+
+      if (containerShow === containerReveal) {
+        const cw = containerShow.querySelector(".info-card-art-warnings");
+        cw.innerText = list.unit(data.cover.warnings);
+
+        const reveal = containerShow.querySelector(".reveal");
+        reveal.classList.remove("revealed");
+      }
+    });
+  }
+
+  function hide() {
+    container.classList.remove("show");
+    container.classList.add("hide");
+    cancelShow = true;
+    showing = false;
+  }
+
+  function readyHide() {
+    if (!hideTimeout && showing) {
+      hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY);
     }
+  }
 
-    function cancelHide() {
-        if (hideTimeout) {
-            clearTimeout(hideTimeout);
-            hideTimeout = null;
-        }
+  function cancelHide() {
+    if (hideTimeout) {
+      clearTimeout(hideTimeout);
+      hideTimeout = null;
     }
-
-    return {
-        show,
-        hide,
-        readyHide,
-        cancelHide
-    };
+  }
+
+  return {
+    show,
+    hide,
+    readyHide,
+    cancelHide,
+  };
 })();
 
 function makeInfoCardLinkHandlers(type) {
-    let hoverTimeout = null;
-
-    return {
-        mouseenter(evt) {
-            hoverTimeout = setTimeout(() => {
-                fastHover = true;
-                infoCard.show(type, evt.target);
-            }, fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY);
+  let hoverTimeout = null;
+
+  return {
+    mouseenter(evt) {
+      hoverTimeout = setTimeout(
+        () => {
+          fastHover = true;
+          infoCard.show(type, evt.target);
+        },
+        fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY
+      );
 
-            clearTimeout(endFastHoverTimeout);
-            endFastHoverTimeout = null;
+      clearTimeout(endFastHoverTimeout);
+      endFastHoverTimeout = null;
 
-            infoCard.cancelHide();
-        },
+      infoCard.cancelHide();
+    },
 
-        mouseleave(evt) {
-            clearTimeout(hoverTimeout);
+    mouseleave(evt) {
+      clearTimeout(hoverTimeout);
 
-            if (fastHover && !endFastHoverTimeout) {
-                endFastHoverTimeout = setTimeout(() => {
-                    endFastHoverTimeout = null;
-                    fastHover = false;
-                }, END_FAST_HOVER_DELAY);
-            }
+      if (fastHover && !endFastHoverTimeout) {
+        endFastHoverTimeout = setTimeout(() => {
+          endFastHoverTimeout = null;
+          fastHover = false;
+        }, END_FAST_HOVER_DELAY);
+      }
 
-            infoCard.readyHide();
-        }
-    };
+      infoCard.readyHide();
+    },
+  };
 }
 
 const infoCardLinkHandlers = {
-    track: makeInfoCardLinkHandlers('track')
+  track: makeInfoCardLinkHandlers("track"),
 };
 
 function addInfoCardLinkHandlers(type) {
-    for (const a of document.querySelectorAll(`a[data-${type}]`)) {
-        for (const [ eventName, handler ] of Object.entries(infoCardLinkHandlers[type])) {
-            a.addEventListener(eventName, handler);
-        }
+  for (const a of document.querySelectorAll(`a[data-${type}]`)) {
+    for (const [eventName, handler] of Object.entries(
+      infoCardLinkHandlers[type]
+    )) {
+      a.addEventListener(eventName, handler);
     }
+  }
 }
 
 // Info cards are disa8led for now since they aren't quite ready for release,
@@ -415,5 +469,5 @@ function addInfoCardLinkHandlers(type) {
 //     localStorage.tryInfoCards = true;
 //
 if (localStorage.tryInfoCards) {
-    addInfoCardLinkHandlers('track');
+  addInfoCardLinkHandlers("track");
 }
diff --git a/src/static/lazy-loading.js b/src/static/lazy-loading.js
index a403d7ca..230dad21 100644
--- a/src/static/lazy-loading.js
+++ b/src/static/lazy-loading.js
@@ -7,45 +7,45 @@
 var observer;
 
 function loadImage(image) {
-    image.src = image.dataset.original;
+  image.src = image.dataset.original;
 }
 
 function lazyLoad(elements) {
-    for (var i = 0; i < elements.length; i++) {
-        var item = elements[i];
-        if (item.intersectionRatio > 0) {
-            observer.unobserve(item.target);
-            loadImage(item.target);
-        }
+  for (var i = 0; i < elements.length; i++) {
+    var item = elements[i];
+    if (item.intersectionRatio > 0) {
+      observer.unobserve(item.target);
+      loadImage(item.target);
     }
+  }
 }
 
 function lazyLoadMain() {
-    // This is a live HTMLCollection! We can't iter8te over it normally 'cuz
-    // we'd 8e mutating its value just 8y interacting with the DOM elements it
-    // contains. A while loop works just fine, even though you'd think reading
-    // over this code that this would 8e an infinitely hanging loop. It isn't!
-    var elements = document.getElementsByClassName('js-hide');
-    while (elements.length) {
-        elements[0].classList.remove('js-hide');
-    }
+  // This is a live HTMLCollection! We can't iter8te over it normally 'cuz
+  // we'd 8e mutating its value just 8y interacting with the DOM elements it
+  // contains. A while loop works just fine, even though you'd think reading
+  // over this code that this would 8e an infinitely hanging loop. It isn't!
+  var elements = document.getElementsByClassName("js-hide");
+  while (elements.length) {
+    elements[0].classList.remove("js-hide");
+  }
 
-    var lazyElements = document.getElementsByClassName('lazy');
-    if (window.IntersectionObserver) {
-        observer = new IntersectionObserver(lazyLoad, {
-            rootMargin: '200px',
-            threshold: 1.0
-        });
-        for (var i = 0; i < lazyElements.length; i++) {
-            observer.observe(lazyElements[i]);
-        }
-    } else {
-        for (var i = 0; i < lazyElements.length; i++) {
-            var element = lazyElements[i];
-            var original = element.getAttribute('data-original');
-            element.setAttribute('src', original);
-        }
+  var lazyElements = document.getElementsByClassName("lazy");
+  if (window.IntersectionObserver) {
+    observer = new IntersectionObserver(lazyLoad, {
+      rootMargin: "200px",
+      threshold: 1.0,
+    });
+    for (var i = 0; i < lazyElements.length; i++) {
+      observer.observe(lazyElements[i]);
+    }
+  } else {
+    for (var i = 0; i < lazyElements.length; i++) {
+      var element = lazyElements[i];
+      var original = element.getAttribute("data-original");
+      element.setAttribute("src", original);
     }
+  }
 }
 
-document.addEventListener('DOMContentLoaded', lazyLoadMain);
+document.addEventListener("DOMContentLoaded", lazyLoadMain);
diff --git a/src/static/site-basic.css b/src/static/site-basic.css
index d26584ae..586f37b5 100644
--- a/src/static/site-basic.css
+++ b/src/static/site-basic.css
@@ -4,16 +4,16 @@
  */
 
 html {
-    background-color: #222222;
-    color: white;
+  background-color: #222222;
+  color: white;
 }
 
 body {
-    padding: 15px;
+  padding: 15px;
 }
 
 main {
-    background-color: rgba(0, 0, 0, 0.6);
-    border: 1px dotted white;
-    padding: 20px;
+  background-color: rgba(0, 0, 0, 0.6);
+  border: 1px dotted white;
+  padding: 20px;
 }
diff --git a/src/static/site.css b/src/static/site.css
index e0031351..d80c57c5 100644
--- a/src/static/site.css
+++ b/src/static/site.css
@@ -4,492 +4,503 @@
  */
 
 :root {
-    --primary-color: #0088ff;
+  --primary-color: #0088ff;
 }
 
 body {
-    background: black;
-    margin: 10px;
-    overflow-y: scroll;
+  background: black;
+  margin: 10px;
+  overflow-y: scroll;
 }
 
 body::before {
-    content: "";
-    position: fixed;
-    top: 0;
-    left: 0;
-    width: 100%;
-    height: 100%;
-    z-index: -1;
+  content: "";
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: -1;
 
-    background-image: url("../media/bg.jpg");
-    background-position: center;
-    background-size: cover;
-    opacity: 0.5;
+  background-image: url("../media/bg.jpg");
+  background-position: center;
+  background-size: cover;
+  opacity: 0.5;
 }
 
 #page-container {
-    background-color: var(--bg-color, rgba(35, 35, 35, 0.80));
-    color: #ffffff;
+  background-color: var(--bg-color, rgba(35, 35, 35, 0.8));
+  color: #ffffff;
 
-    max-width: 1100px;
-    margin: 10px auto 50px;
-    padding: 15px 0;
+  max-width: 1100px;
+  margin: 10px auto 50px;
+  padding: 15px 0;
 
-    box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
+  box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
 }
 
 #page-container > * {
-    margin-left: 15px;
-    margin-right: 15px;
+  margin-left: 15px;
+  margin-right: 15px;
 }
 
 #banner {
-    margin: 10px 0;
-    width: 100%;
-    background: black;
-    background-color: var(--dim-color);
-    border-bottom: 1px solid var(--primary-color);
-    position: relative;
+  margin: 10px 0;
+  width: 100%;
+  background: black;
+  background-color: var(--dim-color);
+  border-bottom: 1px solid var(--primary-color);
+  position: relative;
 }
 
 #banner::after {
-    content: "";
-    box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35);
-    position: absolute;
-    top: 0;
-    left: 0;
-    right: 0;
-    bottom: 0;
-    pointer-events: none;
+  content: "";
+  box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35);
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  pointer-events: none;
 }
 
 #banner.dim img {
-    opacity: 0.8;
+  opacity: 0.8;
 }
 
 #banner img {
-    display: block;
-    width: 100%;
-    height: auto;
+  display: block;
+  width: 100%;
+  height: auto;
 }
 
 a {
-    color: var(--primary-color);
-    text-decoration: none;
+  color: var(--primary-color);
+  text-decoration: none;
 }
 
 a:hover {
-    text-decoration: underline;
+  text-decoration: underline;
 }
 
 #skippers {
-    position: absolute;
-    left: -10000px;
-    top: auto;
-    width: 1px;
-    height: 1px;
+  position: absolute;
+  left: -10000px;
+  top: auto;
+  width: 1px;
+  height: 1px;
 }
 
 #skippers:focus-within {
-    position: static;
-    width: unset;
-    height: unset;
+  position: static;
+  width: unset;
+  height: unset;
 }
 
 #skippers > .skipper:not(:last-child)::after {
-    content: " \00b7 ";
-    font-weight: 800;
+  content: " \00b7 ";
+  font-weight: 800;
 }
 
 .layout-columns {
-    display: flex;
+  display: flex;
 }
 
-#header, #secondary-nav, #skippers, #footer {
-    padding: 5px;
-    font-size: 0.85em;
+#header,
+#secondary-nav,
+#skippers,
+#footer {
+  padding: 5px;
+  font-size: 0.85em;
 }
 
-#header, #secondary-nav, #skippers {
-    margin-bottom: 10px;
+#header,
+#secondary-nav,
+#skippers {
+  margin-bottom: 10px;
 }
 
 #footer {
-    margin-top: 10px;
+  margin-top: 10px;
 }
 
 #header {
-    display: grid;
+  display: grid;
 }
 
 #header.nav-has-main-links.nav-has-content {
-    grid-template-columns: 2.5fr 3fr;
-    grid-template-rows: min-content 1fr;
-    grid-template-areas:
-        "main-links content"
-        "bottom-row content";
+  grid-template-columns: 2.5fr 3fr;
+  grid-template-rows: min-content 1fr;
+  grid-template-areas:
+    "main-links content"
+    "bottom-row content";
 }
 
 #header.nav-has-main-links:not(.nav-has-content) {
-    grid-template-columns: 1fr;
-    grid-template-areas:
-        "main-links"
-        "bottom-row";
+  grid-template-columns: 1fr;
+  grid-template-areas:
+    "main-links"
+    "bottom-row";
 }
 
 .nav-main-links {
-    grid-area: main-links;
-    margin-right: 20px;
+  grid-area: main-links;
+  margin-right: 20px;
 }
 
 .nav-content {
-    grid-area: content;
+  grid-area: content;
 }
 
 .nav-bottom-row {
-    grid-area: bottom-row;
-    align-self: start;
+  grid-area: bottom-row;
+  align-self: start;
 }
 
 .nav-main-links > span {
-    white-space: nowrap;
+  white-space: nowrap;
 }
 
 .nav-main-links > span > a.current {
-    font-weight: 800;
+  font-weight: 800;
 }
 
 .nav-links-index > span:not(:first-child):not(.no-divider)::before {
-    content: "\0020\00b7\0020";
-    font-weight: 800;
+  content: "\0020\00b7\0020";
+  font-weight: 800;
 }
 
 .nav-links-hierarchy > span:not(:first-child):not(.no-divider)::before {
-    content: "\0020/\0020";
+  content: "\0020/\0020";
 }
 
 #header .chronology {
-    display: block;
+  display: block;
 }
 
 #header .chronology .heading,
 #header .chronology .buttons {
-    display: inline-block;
+  display: inline-block;
 }
 
 #secondary-nav {
-    text-align: center;
+  text-align: center;
 }
 
 #secondary-nav:not(.no-hide) {
-    display: none;
+  display: none;
 }
 
 footer {
-    text-align: center;
-    font-style: oblique;
+  text-align: center;
+  font-style: oblique;
 }
 
 footer > :first-child {
-    margin-top: 0;
+  margin-top: 0;
 }
 
 footer > :last-child {
-    margin-bottom: 0;
+  margin-bottom: 0;
 }
 
 .footer-localization-links > span:not(:last-child)::after {
-    content: " \00b7 ";
-    font-weight: 800;
+  content: " \00b7 ";
+  font-weight: 800;
 }
 
 .nowrap {
-    white-space: nowrap;
+  white-space: nowrap;
 }
 
 .icons {
-    font-style: normal;
-    white-space: nowrap;
+  font-style: normal;
+  white-space: nowrap;
 }
 
 .icon {
-    display: inline-block;
-    width: 24px;
-    height: 1em;
-    position: relative;
+  display: inline-block;
+  width: 24px;
+  height: 1em;
+  position: relative;
 }
 
 .icon > svg {
-    width: 24px;
-    height: 24px;
-    top: -0.25em;
-    position: absolute;
-    fill: var(--primary-color);
+  width: 24px;
+  height: 24px;
+  top: -0.25em;
+  position: absolute;
+  fill: var(--primary-color);
 }
 
 .rerelease,
 .other-group-accent {
-    opacity: 0.7;
-    font-style: oblique;
+  opacity: 0.7;
+  font-style: oblique;
 }
 
 .other-group-accent {
-    white-space: nowrap;
+  white-space: nowrap;
 }
 
 .content-columns {
-    columns: 2;
+  columns: 2;
 }
 
 .content-columns .column {
-    break-inside: avoid;
+  break-inside: avoid;
 }
 
 .content-columns .column h2 {
-    margin-top: 0;
-    font-size: 1em;
+  margin-top: 0;
+  font-size: 1em;
 }
 
-.sidebar, #content, #header, #secondary-nav, #skippers, #footer {
-    background-color: rgba(0, 0, 0, 0.6);
-    border: 1px dotted var(--primary-color);
-    border-radius: 3px;
+.sidebar,
+#content,
+#header,
+#secondary-nav,
+#skippers,
+#footer {
+  background-color: rgba(0, 0, 0, 0.6);
+  border: 1px dotted var(--primary-color);
+  border-radius: 3px;
 }
 
 .sidebar-column {
-    flex: 1 1 20%;
-    min-width: 150px;
-    max-width: 250px;
-    flex-basis: 250px;
-    height: 100%;
+  flex: 1 1 20%;
+  min-width: 150px;
+  max-width: 250px;
+  flex-basis: 250px;
+  height: 100%;
 }
 
 .sidebar-multiple {
-    display: flex;
-    flex-direction: column;
+  display: flex;
+  flex-direction: column;
 }
 
 .sidebar-multiple .sidebar:not(:first-child) {
-    margin-top: 10px;
+  margin-top: 10px;
 }
 
 .sidebar {
-    padding: 5px;
-    font-size: 0.85em;
+  padding: 5px;
+  font-size: 0.85em;
 }
 
 #sidebar-left {
-    margin-right: 10px;
+  margin-right: 10px;
 }
 
 #sidebar-right {
-    margin-left: 10px;
+  margin-left: 10px;
 }
 
 .sidebar.wide {
-    max-width: 350px;
-    flex-basis: 300px;
-    flex-shrink: 0;
-    flex-grow: 1;
+  max-width: 350px;
+  flex-basis: 300px;
+  flex-shrink: 0;
+  flex-grow: 1;
 }
 
 #content {
-    box-sizing: border-box;
-    padding: 20px;
-    flex-grow: 1;
-    flex-shrink: 3;
-    overflow-wrap: anywhere;
+  box-sizing: border-box;
+  padding: 20px;
+  flex-grow: 1;
+  flex-shrink: 3;
+  overflow-wrap: anywhere;
 }
 
 .sidebar > h1,
 .sidebar > h2,
 .sidebar > h3,
 .sidebar > p {
-    text-align: center;
+  text-align: center;
 }
 
 .sidebar h1 {
-    font-size: 1.25em;
+  font-size: 1.25em;
 }
 
 .sidebar h2 {
-    font-size: 1.1em;
-    margin: 0;
+  font-size: 1.1em;
+  margin: 0;
 }
 
 .sidebar h3 {
-    font-size: 1.1em;
-    font-style: oblique;
-    font-variant: small-caps;
-    margin-top: 0.3em;
-    margin-bottom: 0em;
+  font-size: 1.1em;
+  font-style: oblique;
+  font-variant: small-caps;
+  margin-top: 0.3em;
+  margin-bottom: 0em;
 }
 
 .sidebar > p {
-    margin: 0.5em 0;
-    padding: 0 5px;
+  margin: 0.5em 0;
+  padding: 0 5px;
 }
 
 .sidebar hr {
-    color: #555;
-    margin: 10px 5px;
+  color: #555;
+  margin: 10px 5px;
 }
 
-.sidebar > ol, .sidebar > ul {
-    padding-left: 30px;
-    padding-right: 15px;
+.sidebar > ol,
+.sidebar > ul {
+  padding-left: 30px;
+  padding-right: 15px;
 }
 
 .sidebar > dl {
-    padding-right: 15px;
-    padding-left: 0;
+  padding-right: 15px;
+  padding-left: 0;
 }
 
 .sidebar > dl dt {
-    padding-left: 10px;
-    margin-top: 0.5em;
+  padding-left: 10px;
+  margin-top: 0.5em;
 }
 
 .sidebar > dl dt.current {
-    font-weight: 800;
+  font-weight: 800;
 }
 
 .sidebar > dl dd {
-    margin-left: 0;
+  margin-left: 0;
 }
 
 .sidebar > dl dd ul {
-    padding-left: 30px;
-    margin-left: 0;
+  padding-left: 30px;
+  margin-left: 0;
 }
 
 .sidebar > dl .side {
-    padding-left: 10px;
+  padding-left: 10px;
 }
 
 .sidebar li.current {
-    font-weight: 800;
+  font-weight: 800;
 }
 
 .sidebar li {
-    overflow-wrap: break-word;
+  overflow-wrap: break-word;
 }
 
 .sidebar > details.current summary {
-    font-weight: 800;
+  font-weight: 800;
 }
 
 .sidebar > details summary {
-    margin-top: 0.5em;
-    padding-left: 5px;
-    user-select: none;
+  margin-top: 0.5em;
+  padding-left: 5px;
+  user-select: none;
 }
 
 .sidebar > details summary .group-name {
-    color: var(--primary-color);
+  color: var(--primary-color);
 }
 
 .sidebar > details summary:hover {
-    cursor: pointer;
-    text-decoration: underline;
-    text-decoration-color: var(--primary-color);
+  cursor: pointer;
+  text-decoration: underline;
+  text-decoration-color: var(--primary-color);
 }
 
 .sidebar > details ul,
 .sidebar > details ol {
-    margin-top: 0;
-    margin-bottom: 0;
+  margin-top: 0;
+  margin-bottom: 0;
 }
 
 .sidebar > details:last-child {
-    margin-bottom: 10px;
+  margin-bottom: 10px;
 }
 
 .sidebar > details[open] {
-    margin-bottom: 1em;
+  margin-bottom: 1em;
 }
 
 .sidebar article {
-    text-align: left;
-    margin: 5px 5px 15px 5px;
+  text-align: left;
+  margin: 5px 5px 15px 5px;
 }
 
 .sidebar article:last-child {
-    margin-bottom: 5px;
+  margin-bottom: 5px;
 }
 
 .sidebar article h2,
 .news-index h2 {
-    border-bottom: 1px dotted;
+  border-bottom: 1px dotted;
 }
 
 .sidebar article h2 time,
 .news-index time {
-    float: right;
-    font-weight: normal;
+  float: right;
+  font-weight: normal;
 }
 
 #cover-art-container {
-    float: right;
-    width: 40%;
-    max-width: 400px;
-    margin: 0 0 10px 10px;
-    font-size: 0.8em;
+  float: right;
+  width: 40%;
+  max-width: 400px;
+  margin: 0 0 10px 10px;
+  font-size: 0.8em;
 }
 
 #cover-art img {
-    display: block;
-    width: 100%;
-    height: 100%;
+  display: block;
+  width: 100%;
+  height: 100%;
 }
 
 #cover-art-container p {
-    margin-top: 5px;
+  margin-top: 5px;
 }
 
 .image-container {
-    border: 2px solid var(--primary-color);
-    box-sizing: border-box;
-    position: relative;
-    padding: 5px;
-    text-align: left;
-    background-color: var(--dim-color);
-    color: white;
-    display: inline-block;
-    width: 100%;
-    height: 100%;
+  border: 2px solid var(--primary-color);
+  box-sizing: border-box;
+  position: relative;
+  padding: 5px;
+  text-align: left;
+  background-color: var(--dim-color);
+  color: white;
+  display: inline-block;
+  width: 100%;
+  height: 100%;
 }
 
 .image-inner-area {
-    overflow: hidden;
-    width: 100%;
-    height: 100%;
-    position: relative;
+  overflow: hidden;
+  width: 100%;
+  height: 100%;
+  position: relative;
 }
 
 .image-text-area {
-    position: absolute;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    right: 0;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    text-align: center;
-    padding: 5px 15px;
-    background: rgba(0, 0, 0, 0.65);
-    box-shadow: 0 0 5px rgba(0, 0, 0, 0.5) inset;
-    line-height: 1.35em;
-    color: var(--primary-color);
-    font-style: oblique;
-    text-shadow: 0 2px 5px rgba(0, 0, 0, 0.75);
+  position: absolute;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  right: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+  padding: 5px 15px;
+  background: rgba(0, 0, 0, 0.65);
+  box-shadow: 0 0 5px rgba(0, 0, 0, 0.5) inset;
+  line-height: 1.35em;
+  color: var(--primary-color);
+  font-style: oblique;
+  text-shadow: 0 2px 5px rgba(0, 0, 0, 0.75);
 }
 
 img {
-    object-fit: cover;
-    /* these unfortunately dont take effect while loading, so...
+  object-fit: cover;
+  /* these unfortunately dont take effect while loading, so...
     text-align: center;
     line-height: 2em;
     text-shadow: 0 0 5px black;
@@ -500,525 +511,528 @@ img {
 .js-hide,
 .js-show-once-data,
 .js-hide-once-data {
-    display: none;
+  display: none;
 }
 
 a.box:focus {
-    outline: 3px double var(--primary-color);
+  outline: 3px double var(--primary-color);
 }
 
 a.box:focus:not(:focus-visible) {
-    outline: none;
+  outline: none;
 }
 
 a.box img {
-    display: block;
-    width: 100%;
-    height: 100%;
+  display: block;
+  width: 100%;
+  height: 100%;
 }
 
 h1 {
-    font-size: 1.5em;
+  font-size: 1.5em;
 }
 
 #content li {
-    margin-bottom: 4px;
+  margin-bottom: 4px;
 }
 
 #content li i {
-    white-space: nowrap;
+  white-space: nowrap;
 }
 
 .grid-listing {
-    display: flex;
-    flex-wrap: wrap;
-    justify-content: center;
-    align-items: flex-start;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  align-items: flex-start;
 }
 
 .grid-item {
-    display: inline-block;
-    margin: 15px;
-    text-align: center;
-    background-color: #111111;
-    border: 1px dotted var(--primary-color);
-    border-radius: 2px;
-    padding: 5px;
+  display: inline-block;
+  margin: 15px;
+  text-align: center;
+  background-color: #111111;
+  border: 1px dotted var(--primary-color);
+  border-radius: 2px;
+  padding: 5px;
 }
 
 .grid-item img {
-    width: 100%;
-    height: 100%;
-    margin-top: auto;
-    margin-bottom: auto;
+  width: 100%;
+  height: 100%;
+  margin-top: auto;
+  margin-bottom: auto;
 }
 
 .grid-item span {
-    overflow-wrap: break-word;
-    hyphens: auto;
+  overflow-wrap: break-word;
+  hyphens: auto;
 }
 
 .grid-item:hover {
-    text-decoration: none;
+  text-decoration: none;
 }
 
 .grid-actions .grid-item:hover {
-    text-decoration: underline;
+  text-decoration: underline;
 }
 
 .grid-item > span {
-    display: block;
+  display: block;
 }
 
 .grid-item > span:not(:first-child) {
-    margin-top: 2px;
+  margin-top: 2px;
 }
 
 .grid-item > span:first-of-type {
-    margin-top: 6px;
+  margin-top: 6px;
 }
 
 .grid-item:hover > span:first-of-type {
-    text-decoration: underline;
+  text-decoration: underline;
 }
 
 .grid-listing > .grid-item {
-    flex: 1 1 26%;
+  flex: 1 1 26%;
 }
 
 .grid-actions {
-    display: flex;
-    flex-direction: column;
-    margin: 15px;
-    align-self: center;
+  display: flex;
+  flex-direction: column;
+  margin: 15px;
+  align-self: center;
 }
 
 .grid-actions > .grid-item {
-    flex-basis: unset !important;
-    margin: 5px;
-    --primary-color: inherit !important;
-    --dim-color: inherit !important;
+  flex-basis: unset !important;
+  margin: 5px;
+  --primary-color: inherit !important;
+  --dim-color: inherit !important;
 }
 
 .grid-item {
-    flex-basis: 240px;
-    min-width: 200px;
-    max-width: 240px;
+  flex-basis: 240px;
+  min-width: 200px;
+  max-width: 240px;
 }
 
 .grid-item:not(.large-grid-item) {
-    flex-basis: 180px;
-    min-width: 120px;
-    max-width: 180px;
-    font-size: 0.9em;
+  flex-basis: 180px;
+  min-width: 120px;
+  max-width: 180px;
+  font-size: 0.9em;
 }
 
 .square {
-    position: relative;
-    width: 100%;
+  position: relative;
+  width: 100%;
 }
 
 .square::after {
-    content: "";
-    display: block;
-    padding-bottom: 100%;
+  content: "";
+  display: block;
+  padding-bottom: 100%;
 }
 
 .square-content {
-    position: absolute;
-    width: 100%;
-    height: 100%;
+  position: absolute;
+  width: 100%;
+  height: 100%;
 }
 
 .vertical-square {
-    position: relative;
-    height: 100%;
+  position: relative;
+  height: 100%;
 }
 
 .vertical-square::after {
-    content: "";
-    display: block;
-    padding-right: 100%;
+  content: "";
+  display: block;
+  padding-right: 100%;
 }
 
 .reveal {
-    position: relative;
-    width: 100%;
-    height: 100%;
+  position: relative;
+  width: 100%;
+  height: 100%;
 }
 
 .reveal img {
-    filter: blur(20px);
-    opacity: 0.4;
+  filter: blur(20px);
+  opacity: 0.4;
 }
 
 .reveal-text {
-    color: white;
-    position: absolute;
-    top: 15px;
-    left: 10px;
-    right: 10px;
-    text-align: center;
-    font-weight: bold;
+  color: white;
+  position: absolute;
+  top: 15px;
+  left: 10px;
+  right: 10px;
+  text-align: center;
+  font-weight: bold;
 }
 
 .reveal-interaction {
-    opacity: 0.8;
+  opacity: 0.8;
 }
 
 .reveal.revealed img {
-    filter: none;
-    opacity: 1;
+  filter: none;
+  opacity: 1;
 }
 
 .reveal.revealed .reveal-text {
-    display: none;
+  display: none;
 }
 
 #content.top-index h1,
 #content.flash-index h1 {
-    text-align: center;
-    font-size: 2em;
+  text-align: center;
+  font-size: 2em;
 }
 
 #content.flash-index h2 {
-    text-align: center;
-    font-size: 2.5em;
-    font-variant: small-caps;
-    font-style: oblique;
-    margin-bottom: 0;
-    text-align: center;
-    width: 100%;
+  text-align: center;
+  font-size: 2.5em;
+  font-variant: small-caps;
+  font-style: oblique;
+  margin-bottom: 0;
+  text-align: center;
+  width: 100%;
 }
 
 #content.top-index h2 {
-    text-align: center;
-    font-size: 2em;
-    font-weight: normal;
-    margin-bottom: 0.25em;
+  text-align: center;
+  font-size: 2em;
+  font-weight: normal;
+  margin-bottom: 0.25em;
 }
 
 .quick-info {
-    text-align: center;
+  text-align: center;
 }
 
 ul.quick-info {
-    list-style: none;
-    padding-left: 0;
+  list-style: none;
+  padding-left: 0;
 }
 
 ul.quick-info li {
-    display: inline-block;
+  display: inline-block;
 }
 
 ul.quick-info li:not(:last-child)::after {
-    content: " \00b7 ";
-    font-weight: 800;
+  content: " \00b7 ";
+  font-weight: 800;
 }
 
 #intro-menu {
-    margin: 24px 0;
-    padding: 10px;
-    background-color: #222222;
-    text-align: center;
-    border: 1px dotted var(--primary-color);
-    border-radius: 2px;
+  margin: 24px 0;
+  padding: 10px;
+  background-color: #222222;
+  text-align: center;
+  border: 1px dotted var(--primary-color);
+  border-radius: 2px;
 }
 
 #intro-menu p {
-    margin: 12px 0;
+  margin: 12px 0;
 }
 
 #intro-menu a {
-    margin: 0 6px;
+  margin: 0 6px;
 }
 
 li .by {
-    display: inline-block;
-    font-style: oblique;
+  display: inline-block;
+  font-style: oblique;
 }
 
 li .by a {
-    display: inline-block;
+  display: inline-block;
 }
 
 p code {
-    font-size: 1em;
-    font-family: 'courier new';
-    font-weight: 800;
+  font-size: 1em;
+  font-family: "courier new";
+  font-weight: 800;
 }
 
 blockquote {
-    margin-left: 40px;
-    max-width: 600px;
-    margin-right: 0;
+  margin-left: 40px;
+  max-width: 600px;
+  margin-right: 0;
 }
 
 .long-content {
-    margin-left: 12%;
-    margin-right: 12%;
+  margin-left: 12%;
+  margin-right: 12%;
 }
 
 p img {
-    max-width: 100%;
-    height: auto;
+  max-width: 100%;
+  height: auto;
 }
 
 dl dt {
-    padding-left: 40px;
-    max-width: 600px;
+  padding-left: 40px;
+  max-width: 600px;
 }
 
 dl dt {
-    margin-bottom: 2px;
+  margin-bottom: 2px;
 }
 
 dl dd {
-    margin-bottom: 1em;
+  margin-bottom: 1em;
 }
 
-dl ul, dl ol {
-    margin-top: 0;
-    margin-bottom: 0;
+dl ul,
+dl ol {
+  margin-top: 0;
+  margin-bottom: 0;
 }
 
 .album-group-list dt {
-    font-style: oblique;
-    padding-left: 0;
+  font-style: oblique;
+  padding-left: 0;
 }
 
 .album-group-list dd {
-    margin-left: 0;
+  margin-left: 0;
 }
 
 .group-chronology-link {
-    font-style: oblique;
+  font-style: oblique;
 }
 
 hr.split::before {
-    content: "(split)";
-    color: #808080;
+  content: "(split)";
+  color: #808080;
 }
 
 hr.split {
-    position: relative;
-    overflow: hidden;
-    border: none;
+  position: relative;
+  overflow: hidden;
+  border: none;
 }
 
 hr.split::after {
-    display: inline-block;
-    content: "";
-    border: 1px inset #808080;
-    width: 100%;
-    position: absolute;
-    top: 50%;
-    margin-top: -2px;
-    margin-left: 10px;
+  display: inline-block;
+  content: "";
+  border: 1px inset #808080;
+  width: 100%;
+  position: absolute;
+  top: 50%;
+  margin-top: -2px;
+  margin-left: 10px;
 }
 
 li > ul {
-    margin-top: 5px;
+  margin-top: 5px;
 }
 
 #info-card-container {
-    position: absolute;
+  position: absolute;
 
-    left: 0;
-    right: 10px;
+  left: 0;
+  right: 10px;
 
-    pointer-events: none; /* Padding area shouldn't 8e interactive. */
-    display: none;
+  pointer-events: none; /* Padding area shouldn't 8e interactive. */
+  display: none;
 }
 
 #info-card-container.show,
 #info-card-container.hide {
-    display: flex;
+  display: flex;
 }
 
 #info-card-container > * {
-    flex-basis: 400px;
+  flex-basis: 400px;
 }
 
 #info-card-container.show {
-    animation: 0.2s linear forwards info-card-show;
-    transition: top 0.1s, left 0.1s;
+  animation: 0.2s linear forwards info-card-show;
+  transition: top 0.1s, left 0.1s;
 }
 
 #info-card-container.hide {
-    animation: 0.2s linear forwards info-card-hide;
+  animation: 0.2s linear forwards info-card-hide;
 }
 
 @keyframes info-card-show {
-    0% {
-        opacity: 0;
-        margin-top: -5px;
-    }
+  0% {
+    opacity: 0;
+    margin-top: -5px;
+  }
 
-    100% {
-        opacity: 1;
-        margin-top: 0;
-    }
+  100% {
+    opacity: 1;
+    margin-top: 0;
+  }
 }
 
 @keyframes info-card-hide {
-    0% {
-        opacity: 1;
-        margin-top: 0;
-    }
+  0% {
+    opacity: 1;
+    margin-top: 0;
+  }
 
-    100% {
-        opacity: 0;
-        margin-top: 5px;
-        display: none !important;
-    }
+  100% {
+    opacity: 0;
+    margin-top: 5px;
+    display: none !important;
+  }
 }
 
 .info-card-decor {
-    padding-left: 3ch;
-    border-top: 1px solid white;
+  padding-left: 3ch;
+  border-top: 1px solid white;
 }
 
 .info-card {
-    background-color: black;
-    color: white;
+  background-color: black;
+  color: white;
 
-    border: 1px dotted var(--primary-color);
-    border-radius: 3px;
-    box-shadow: 0 5px 5px black;
+  border: 1px dotted var(--primary-color);
+  border-radius: 3px;
+  box-shadow: 0 5px 5px black;
 
-    padding: 5px;
-    font-size: 0.9em;
+  padding: 5px;
+  font-size: 0.9em;
 
-    pointer-events: none;
+  pointer-events: none;
 }
 
 .info-card::after {
-    content: "";
-    display: block;
-    clear: both;
+  content: "";
+  display: block;
+  clear: both;
 }
 
 #info-card-container.show .info-card {
-    animation: 0.01s linear 0.2s forwards info-card-become-interactive;
+  animation: 0.01s linear 0.2s forwards info-card-become-interactive;
 }
 
 @keyframes info-card-become-interactive {
-    to {
-        pointer-events: auto;
-    }
+  to {
+    pointer-events: auto;
+  }
 }
 
 .info-card-art-container {
-    float: right;
+  float: right;
 
-    width: 40%;
-    margin: 5px;
-    font-size: 0.8em;
+  width: 40%;
+  margin: 5px;
+  font-size: 0.8em;
 
-    /* Dynamically shown. */
-    display: none;
+  /* Dynamically shown. */
+  display: none;
 }
 
 .info-card-art-container .image-container {
-    padding: 2px;
+  padding: 2px;
 }
 
 .info-card-art {
-    display: block;
-    width: 100%;
-    height: 100%;
+  display: block;
+  width: 100%;
+  height: 100%;
 }
 
 .info-card-name {
-    font-size: 1em;
-    border-bottom: 1px dotted;
-    margin: 0;
+  font-size: 1em;
+  border-bottom: 1px dotted;
+  margin: 0;
 }
 
 .info-card p {
-    margin-top: 0.25em;
-    margin-bottom: 0.25em;
+  margin-top: 0.25em;
+  margin-bottom: 0.25em;
 }
 
 .info-card p:last-child {
-    margin-bottom: 0;
+  margin-bottom: 0;
 }
 
 @media (max-width: 900px) {
-    .sidebar-column:not(.no-hide) {
-        display: none;
-    }
+  .sidebar-column:not(.no-hide) {
+    display: none;
+  }
 
-    #secondary-nav:not(.no-hide) {
-        display: block;
-    }
+  #secondary-nav:not(.no-hide) {
+    display: block;
+  }
 
-    .layout-columns.vertical-when-thin {
-        flex-direction: column;
-    }
+  .layout-columns.vertical-when-thin {
+    flex-direction: column;
+  }
 
-    .layout-columns.vertical-when-thin > *:not(:last-child) {
-        margin-bottom: 10px;
-    }
+  .layout-columns.vertical-when-thin > *:not(:last-child) {
+    margin-bottom: 10px;
+  }
 
-    .sidebar-column.no-hide {
-        max-width: unset !important;
-        flex-basis: unset !important;
-        margin-right: 0 !important;
-        margin-left: 0 !important;
-    }
+  .sidebar-column.no-hide {
+    max-width: unset !important;
+    flex-basis: unset !important;
+    margin-right: 0 !important;
+    margin-left: 0 !important;
+  }
 
-    .sidebar .news-entry:not(.first-news-entry) {
-        display: none;
-    }
+  .sidebar .news-entry:not(.first-news-entry) {
+    display: none;
+  }
 }
 
 @media (max-width: 600px) {
-    .content-columns {
-        columns: 1;
-    }
+  .content-columns {
+    columns: 1;
+  }
 
-    #cover-art-container {
-        float: none;
-        margin: 0 10px 10px 10px;
-        margin: 0;
-        width: 100%;
-        max-width: unset;
-    }
+  #cover-art-container {
+    float: none;
+    margin: 0 10px 10px 10px;
+    margin: 0;
+    width: 100%;
+    max-width: unset;
+  }
 
-    #header {
-        display: block;
-    }
+  #header {
+    display: block;
+  }
 
-    #header > div:not(:first-child) {
-        margin-top: 0.5em;
-    }
+  #header > div:not(:first-child) {
+    margin-top: 0.5em;
+  }
 }
 
 /* important easter egg mode */
 
-html[data-language-code=preview-en][data-url-key="localized.home"] #content h1::after {
-    font-family: cursive;
-    display: block;
-    content: "(Preview Build)";
+html[data-language-code="preview-en"][data-url-key="localized.home"]
+  #content
+  h1::after {
+  font-family: cursive;
+  display: block;
+  content: "(Preview Build)";
 }
 
-html[data-language-code=preview-en] #header h2 > :first-child::before {
-    content: "(Preview Build! ✨) ";
-    animation: preview-notice 4s infinite;
+html[data-language-code="preview-en"] #header h2 > :first-child::before {
+  content: "(Preview Build! ✨) ";
+  animation: preview-notice 4s infinite;
 }
 
 @keyframes preview-notice {
-    0% {
-        color: #cc5500;
-    }
+  0% {
+    color: #cc5500;
+  }
 
-    50% {
-        color: #ffaa00;
-    }
+  50% {
+    color: #ffaa00;
+  }
 
-    100% {
-        color: #cc5500;
-    }
+  100% {
+    color: #cc5500;
+  }
 }
diff --git a/src/strings-default.json b/src/strings-default.json
index fb2e333c..d94f6deb 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -1,379 +1,379 @@
 {
-    "meta.languageCode": "en",
-    "meta.languageName": "English",
-    "count.tracks": "{TRACKS}",
-    "count.tracks.withUnit.zero": "",
-    "count.tracks.withUnit.one": "{TRACKS} track",
-    "count.tracks.withUnit.two": "",
-    "count.tracks.withUnit.few": "",
-    "count.tracks.withUnit.many": "",
-    "count.tracks.withUnit.other": "{TRACKS} tracks",
-    "count.additionalFiles": "{FILES}",
-    "count.additionalFiles.withUnit.zero": "",
-    "count.additionalFiles.withUnit.one": "{FILES} additional file",
-    "count.additionalFiles.withUnit.two": "",
-    "count.additionalFiles.withUnit.few": "",
-    "count.additionalFiles.withUnit.many": "",
-    "count.additionalFiles.withUnit.other": "{FILES} additional files",
-    "count.albums": "{ALBUMS}",
-    "count.albums.withUnit.zero": "",
-    "count.albums.withUnit.one": "{ALBUMS} album",
-    "count.albums.withUnit.two": "",
-    "count.albums.withUnit.two": "",
-    "count.albums.withUnit.few": "",
-    "count.albums.withUnit.many": "",
-    "count.albums.withUnit.other": "{ALBUMS} albums",
-    "count.commentaryEntries": "{ENTRIES}",
-    "count.commentaryEntries.withUnit.zero": "",
-    "count.commentaryEntries.withUnit.one": "{ENTRIES} entry",
-    "count.commentaryEntries.withUnit.two": "",
-    "count.commentaryEntries.withUnit.few": "",
-    "count.commentaryEntries.withUnit.many": "",
-    "count.commentaryEntries.withUnit.other": "{ENTRIES} entries",
-    "count.contributions": "{CONTRIBUTIONS}",
-    "count.contributions.withUnit.zero": "",
-    "count.contributions.withUnit.one": "{CONTRIBUTIONS} contribution",
-    "count.contributions.withUnit.two": "",
-    "count.contributions.withUnit.few": "",
-    "count.contributions.withUnit.many": "",
-    "count.contributions.withUnit.other": "{CONTRIBUTIONS} contributions",
-    "count.coverArts": "{COVER_ARTS}",
-    "count.coverArts.withUnit.zero": "",
-    "count.coverArts.withUnit.one": "{COVER_ARTS} cover art",
-    "count.coverArts.withUnit.two": "",
-    "count.coverArts.withUnit.few": "",
-    "count.coverArts.withUnit.many": "",
-    "count.coverArts.withUnit.other": "{COVER_ARTS} cover arts",
-    "count.timesReferenced": "{TIMES_REFERENCED}",
-    "count.timesReferenced.withUnit.zero": "",
-    "count.timesReferenced.withUnit.one": "{TIMES_REFERENCED} time referenced",
-    "count.timesReferenced.withUnit.two": "",
-    "count.timesReferenced.withUnit.few": "",
-    "count.timesReferenced.withUnit.many": "",
-    "count.timesReferenced.withUnit.other": "{TIMES_REFERENCED} times referenced",
-    "count.words": "{WORDS}",
-    "count.words.thousand": "{WORDS}k",
-    "count.words.withUnit.zero": "",
-    "count.words.withUnit.one": "{WORDS} word",
-    "count.words.withUnit.two": "",
-    "count.words.withUnit.few": "",
-    "count.words.withUnit.many": "",
-    "count.words.withUnit.other": "{WORDS} words",
-    "count.timesUsed": "{TIMES_USED}",
-    "count.timesUsed.withUnit.zero": "",
-    "count.timesUsed.withUnit.one": "used {TIMES_USED} time",
-    "count.timesUsed.withUnit.two": "",
-    "count.timesUsed.withUnit.few": "",
-    "count.timesUsed.withUnit.many": "",
-    "count.timesUsed.withUnit.other": "used {TIMES_USED} times",
-    "count.index.zero": "",
-    "count.index.one": "{INDEX}st",
-    "count.index.two": "{INDEX}nd",
-    "count.index.few": "{INDEX}rd",
-    "count.index.many": "",
-    "count.index.other": "{INDEX}th",
-    "count.duration.hours": "{HOURS}:{MINUTES}:{SECONDS}",
-    "count.duration.hours.withUnit": "{HOURS}:{MINUTES}:{SECONDS} hours",
-    "count.duration.minutes": "{MINUTES}:{SECONDS}",
-    "count.duration.minutes.withUnit": "{MINUTES}:{SECONDS} minutes",
-    "count.duration.approximate": "~{DURATION}",
-    "count.duration.missing": "_:__",
-    "count.fileSize.terabytes": "{TERABYTES} TB",
-    "count.fileSize.gigabytes": "{GIGABYTES} GB",
-    "count.fileSize.megabytes": "{MEGABYTES} MB",
-    "count.fileSize.kilobytes": "{KILOBYTES} kB",
-    "count.fileSize.bytes": "{BYTES} bytes",
-    "releaseInfo.by": "By {ARTISTS}.",
-    "releaseInfo.from": "From {ALBUM}.",
-    "releaseInfo.coverArtBy": "Cover art by {ARTISTS}.",
-    "releaseInfo.wallpaperArtBy": "Wallpaper art by {ARTISTS}.",
-    "releaseInfo.bannerArtBy": "Banner art by {ARTISTS}.",
-    "releaseInfo.released": "Released {DATE}.",
-    "releaseInfo.artReleased": "Art released {DATE}.",
-    "releaseInfo.addedToWiki": "Added to wiki {DATE}.",
-    "releaseInfo.duration": "Duration: {DURATION}.",
-    "releaseInfo.viewCommentary": "View {LINK}!",
-    "releaseInfo.viewCommentary.link": "commentary page",
-    "releaseInfo.listenOn": "Listen on {LINKS}.",
-    "releaseInfo.listenOn.noLinks": "This track has no URLs at which it can be listened.",
-    "releaseInfo.visitOn": "Visit on {LINKS}.",
-    "releaseInfo.playOn": "Play on {LINKS}.",
-    "releaseInfo.alsoReleasedAs": "Also released as:",
-    "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})",
-    "releaseInfo.contributors": "Contributors:",
-    "releaseInfo.tracksReferenced": "Tracks that {TRACK} references:",
-    "releaseInfo.tracksThatReference": "Tracks that reference {TRACK}:",
-    "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:",
-    "releaseInfo.flashesThatFeature.item": "{FLASH}",
-    "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})",
-    "releaseInfo.lyrics": "Lyrics:",
-    "releaseInfo.artistCommentary": "Artist commentary:",
-    "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!",
-    "releaseInfo.artTags": "Tags:",
-    "releaseInfo.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}",
-    "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:",
-    "releaseInfo.additionalFiles.heading": "Has {ADDITIONAL_FILES}:",
-    "releaseInfo.additionalFiles.entry": "{TITLE}",
-    "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}",
-    "releaseInfo.additionalFiles.file": "{FILE}",
-    "releaseInfo.additionalFiles.file.withSize": "{FILE} ({SIZE})",
-    "releaseInfo.note": "Note:",
-    "trackList.section.withDuration": "{SECTION} ({DURATION}):",
-    "trackList.group": "{GROUP}:",
-    "trackList.group.other": "Other",
-    "trackList.item.withDuration": "({DURATION}) {TRACK}",
-    "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}",
-    "trackList.item.withArtists": "{TRACK} {BY}",
-    "trackList.item.withArtists.by": "by {ARTISTS}",
-    "trackList.item.rerelease": "{TRACK} (re-release)",
-    "misc.alt.albumCover": "album cover",
-    "misc.alt.albumBanner": "album banner",
-    "misc.alt.trackCover": "track cover",
-    "misc.alt.artistAvatar": "artist avatar",
-    "misc.alt.flashArt": "flash art",
-    "misc.chronology.seeArtistPages": "(See artist pages for chronology info!)",
-    "misc.chronology.heading.coverArt": "{INDEX} cover art by {ARTIST}",
-    "misc.chronology.heading.flash": "{INDEX} flash/game by {ARTIST}",
-    "misc.chronology.heading.track": "{INDEX} track by {ARTIST}",
-    "misc.external.domain": "External ({DOMAIN})",
-    "misc.external.local": "Wiki Archive (local upload)",
-    "misc.external.bandcamp": "Bandcamp",
-    "misc.external.bandcamp.domain": "Bandcamp ({DOMAIN})",
-    "misc.external.deviantart": "DeviantArt",
-    "misc.external.instagram": "Instagram",
-    "misc.external.mastodon": "Mastodon",
-    "misc.external.mastodon.domain": "Mastodon ({DOMAIN})",
-    "misc.external.patreon": "Patreon",
-    "misc.external.poetryFoundation": "Poetry Foundation",
-    "misc.external.soundcloud": "SoundCloud",
-    "misc.external.tumblr": "Tumblr",
-    "misc.external.twitter": "Twitter",
-    "misc.external.wikipedia": "Wikipedia",
-    "misc.external.youtube": "YouTube",
-    "misc.external.youtube.playlist": "YouTube (playlist)",
-    "misc.external.youtube.fullAlbum": "YouTube (full album)",
-    "misc.external.flash.bgreco": "{LINK} (HQ Audio)",
-    "misc.external.flash.homestuck.page": "{LINK} (page {PAGE})",
-    "misc.external.flash.homestuck.secret": "{LINK} (secret page)",
-    "misc.external.flash.youtube": "{LINK} (on any device)",
-    "misc.nav.previous": "Previous",
-    "misc.nav.next": "Next",
-    "misc.nav.info": "Info",
-    "misc.nav.gallery": "Gallery",
-    "misc.pageTitle": "{TITLE}",
-    "misc.pageTitle.withWikiName": "{TITLE} | {WIKI_NAME}",
-    "misc.skippers.skipToContent": "Skip to content",
-    "misc.skippers.skipToSidebar": "Skip to sidebar",
-    "misc.skippers.skipToSidebar.left": "Skip to sidebar (left)",
-    "misc.skippers.skipToSidebar.right": "Skip to sidebar (right)",
-    "misc.skippers.skipToFooter": "Skip to footer",
-    "misc.socialEmbed.heading": "{WIKI_NAME} | {HEADING}",
-    "misc.jumpTo": "Jump to:",
-    "misc.jumpTo.withLinks": "Jump to: {LINKS}.",
-    "misc.contentWarnings": "cw: {WARNINGS}",
-    "misc.contentWarnings.reveal": "click to show",
-    "misc.albumGrid.details": "({TRACKS}, {TIME})",
-    "misc.albumGrid.noCoverArt": "{ALBUM}",
-    "misc.uiLanguage": "UI Language: {LANGUAGES}",
-    "homepage.title": "{TITLE}",
-    "homepage.news.title": "News",
-    "homepage.news.entry.viewRest": "(View rest of entry!)",
-    "albumSidebar.trackList.fallbackGroupName": "Track list",
-    "albumSidebar.trackList.group": "{GROUP}",
-    "albumSidebar.trackList.group.withRange": "{GROUP} ({RANGE})",
-    "albumSidebar.trackList.item": "{TRACK}",
-    "albumSidebar.groupBox.title": "{GROUP}",
-    "albumSidebar.groupBox.next": "Next: {ALBUM}",
-    "albumSidebar.groupBox.previous": "Previous: {ALBUM}",
-    "albumPage.title": "{ALBUM}",
-    "albumPage.nav.album": "{ALBUM}",
-    "albumPage.nav.randomTrack": "Random Track",
-    "albumCommentaryPage.title": "{ALBUM} - Commentary",
-    "albumCommentaryPage.infoLine": "{WORDS} across {ENTRIES}.",
-    "albumCommentaryPage.nav.album": "Album: {ALBUM}",
-    "albumCommentaryPage.entry.title.albumCommentary": "Album commentary",
-    "albumCommentaryPage.entry.title.trackCommentary": "{TRACK}",
-    "artistPage.title": "{ARTIST}",
-    "artistPage.creditList.album": "{ALBUM}",
-    "artistPage.creditList.album.withDate": "{ALBUM} ({DATE})",
-    "artistPage.creditList.album.withDuration": "{ALBUM} ({DURATION})",
-    "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})",
-    "artistPage.creditList.flashAct": "{ACT}",
-    "artistPage.creditList.flashAct.withDateRange": "{ACT} ({DATE_RANGE})",
-    "artistPage.creditList.entry.track": "{TRACK}",
-    "artistPage.creditList.entry.track.withDuration": "({DURATION}) {TRACK}",
-    "artistPage.creditList.entry.album.coverArt": "(cover art)",
-    "artistPage.creditList.entry.album.wallpaperArt": "(wallpaper art)",
-    "artistPage.creditList.entry.album.bannerArt": "(banner art)",
-    "artistPage.creditList.entry.album.commentary": "(album commentary)",
-    "artistPage.creditList.entry.flash": "{FLASH}",
-    "artistPage.creditList.entry.rerelease": "{ENTRY} (re-release)",
-    "artistPage.creditList.entry.withContribution": "{ENTRY} ({CONTRIBUTION})",
-    "artistPage.creditList.entry.withArtists": "{ENTRY} (with {ARTISTS})",
-    "artistPage.creditList.entry.withArtists.withContribution": "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})",
-    "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.",
-    "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}",
-    "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}",
-    "artistPage.groupsLine.item": "{GROUP} ({CONTRIBUTIONS})",
-    "artistPage.trackList.title": "Tracks",
-    "artistPage.artList.title": "Art",
-    "artistPage.flashList.title": "Flashes & Games",
-    "artistPage.commentaryList.title": "Commentary",
-    "artistPage.viewArtGallery": "View {LINK}!",
-    "artistPage.viewArtGallery.orBrowseList": "View {LINK}! Or browse the list:",
-    "artistPage.viewArtGallery.link": "art gallery",
-    "artistPage.nav.artist": "Artist: {ARTIST}",
-    "artistGalleryPage.title": "{ARTIST} - Gallery",
-    "artistGalleryPage.infoLine": "Contributed to {COVER_ARTS}.",
-    "commentaryIndex.title": "Commentary",
-    "commentaryIndex.infoLine": "{WORDS} across {ENTRIES}, in all.",
-    "commentaryIndex.albumList.title": "Choose an album:",
-    "commentaryIndex.albumList.item": "{ALBUM} ({WORDS} across {ENTRIES})",
-    "flashIndex.title": "Flashes & Games",
-    "flashPage.title": "{FLASH}",
-    "flashPage.nav.flash": "{FLASH}",
-    "groupSidebar.title": "Groups",
-    "groupSidebar.groupList.category": "{CATEGORY}",
-    "groupSidebar.groupList.item": "{GROUP}",
-    "groupPage.nav.group": "Group: {GROUP}",
-    "groupInfoPage.title": "{GROUP}",
-    "groupInfoPage.viewAlbumGallery": "View {LINK}! Or browse the list:",
-    "groupInfoPage.viewAlbumGallery.link": "album gallery",
-    "groupInfoPage.albumList.title": "Albums",
-    "groupInfoPage.albumList.item": "({YEAR}) {ALBUM}",
-    "groupInfoPage.albumList.item.withoutYear": "{ALBUM}",
-    "groupInfoPage.albumList.item.withAccent": "{ITEM} {ACCENT}",
-    "groupInfoPage.albumList.item.otherGroupAccent": "(from {GROUP})",
-    "groupGalleryPage.title": "{GROUP} - Gallery",
-    "groupGalleryPage.infoLine": "{TRACKS} across {ALBUMS}, totaling {TIME}.",
-    "groupGalleryPage.anotherGroupLine": "({LINK})",
-    "groupGalleryPage.anotherGroupLine.link": "Choose another group to filter by!",
-    "listingIndex.title": "Listings",
-    "listingIndex.infoLine": "{WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.",
-    "listingIndex.exploreList": "Feel free to explore any of the listings linked below and in the sidebar!",
-    "listingPage.target.album": "Albums",
-    "listingPage.target.artist": "Artists",
-    "listingPage.target.group": "Groups",
-    "listingPage.target.track": "Tracks",
-    "listingPage.target.tag": "Tags",
-    "listingPage.target.other": "Other",
-    "listingPage.listAlbums.byName.title": "Albums - by Name",
-    "listingPage.listAlbums.byName.title.short": "...by Name",
-    "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})",
-    "listingPage.listAlbums.byTracks.title": "Albums - by Tracks",
-    "listingPage.listAlbums.byTracks.title.short": "...by Tracks",
-    "listingPage.listAlbums.byTracks.item": "{ALBUM} ({TRACKS})",
-    "listingPage.listAlbums.byDuration.title": "Albums - by Duration",
-    "listingPage.listAlbums.byDuration.title.short": "...by Duration",
-    "listingPage.listAlbums.byDuration.item": "{ALBUM} ({DURATION})",
-    "listingPage.listAlbums.byDate.title": "Albums - by Date",
-    "listingPage.listAlbums.byDate.title.short": "...by Date",
-    "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})",
-    "listingPage.listAlbums.byDateAdded.title.short": "...by Date Added to Wiki",
-    "listingPage.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki",
-    "listingPage.listAlbums.byDateAdded.date": "{DATE}",
-    "listingPage.listAlbums.byDateAdded.album": "{ALBUM}",
-    "listingPage.listArtists.byName.title": "Artists - by Name",
-    "listingPage.listArtists.byName.title.short": "...by Name",
-    "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})",
-    "listingPage.listArtists.byContribs.title": "Artists - by Contributions",
-    "listingPage.listArtists.byContribs.title.short": "...by Contributions",
-    "listingPage.listArtists.byContribs.item": "{ARTIST} ({CONTRIBUTIONS})",
-    "listingPage.listArtists.byCommentary.title": "Artists - by Commentary Entries",
-    "listingPage.listArtists.byCommentary.title.short": "...by Commentary Entries",
-    "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})",
-    "listingPage.listArtists.byDuration.title": "Artists - by Duration",
-    "listingPage.listArtists.byDuration.title.short": "...by Duration",
-    "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})",
-    "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution",
-    "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution",
-    "listingPage.listArtists.byLatest.item": "{ARTIST} ({DATE})",
-    "listingPage.listGroups.byName.title": "Groups - by Name",
-    "listingPage.listGroups.byName.title.short": "...by Name",
-    "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})",
-    "listingPage.listGroups.byName.item.gallery": "Gallery",
-    "listingPage.listGroups.byCategory.title": "Groups - by Category",
-    "listingPage.listGroups.byCategory.title.short": "...by Category",
-    "listingPage.listGroups.byCategory.category": "{CATEGORY}",
-    "listingPage.listGroups.byCategory.group": "{GROUP} ({GALLERY})",
-    "listingPage.listGroups.byCategory.group.gallery": "Gallery",
-    "listingPage.listGroups.byAlbums.title": "Groups - by Albums",
-    "listingPage.listGroups.byAlbums.title.short": "...by Albums",
-    "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})",
-    "listingPage.listGroups.byTracks.title": "Groups - by Tracks",
-    "listingPage.listGroups.byTracks.title.short": "...by Tracks",
-    "listingPage.listGroups.byTracks.item": "{GROUP} ({TRACKS})",
-    "listingPage.listGroups.byDuration.title": "Groups - by Duration",
-    "listingPage.listGroups.byDuration.title.short": "...by Duration",
-    "listingPage.listGroups.byDuration.item": "{GROUP} ({DURATION})",
-    "listingPage.listGroups.byLatest.title": "Groups - by Latest Album",
-    "listingPage.listGroups.byLatest.title.short": "...by Latest Album",
-    "listingPage.listGroups.byLatest.item": "{GROUP} ({DATE})",
-    "listingPage.listTracks.byName.title": "Tracks - by Name",
-    "listingPage.listTracks.byName.title.short": "...by Name",
-    "listingPage.listTracks.byName.item": "{TRACK}",
-    "listingPage.listTracks.byAlbum.title": "Tracks - by Album",
-    "listingPage.listTracks.byAlbum.title.short": "...by Album",
-    "listingPage.listTracks.byAlbum.album": "{ALBUM}",
-    "listingPage.listTracks.byAlbum.track": "{TRACK}",
-    "listingPage.listTracks.byDate.title": "Tracks - by Date",
-    "listingPage.listTracks.byDate.title.short": "...by Date",
-    "listingPage.listTracks.byDate.album": "{ALBUM} ({DATE})",
-    "listingPage.listTracks.byDate.track": "{TRACK}",
-    "listingPage.listTracks.byDate.track.rerelease": "{TRACK} (re-release)",
-    "listingPage.listTracks.byDuration.title": "Tracks - by Duration",
-    "listingPage.listTracks.byDuration.title.short": "...by Duration",
-    "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})",
-    "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)",
-    "listingPage.listTracks.byDurationInAlbum.title.short": "...by Duration (in Album)",
-    "listingPage.listTracks.byDurationInAlbum.album": "{ALBUM}",
-    "listingPage.listTracks.byDurationInAlbum.track": "{TRACK} ({DURATION})",
-    "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced",
-    "listingPage.listTracks.byTimesReferenced.title.short": "...by Times Referenced",
-    "listingPage.listTracks.byTimesReferenced.item": "{TRACK} ({TIMES_REFERENCED})",
-    "listingPage.listTracks.inFlashes.byAlbum.title": "Tracks - in Flashes & Games (by Album)",
-    "listingPage.listTracks.inFlashes.byAlbum.title.short": "...in Flashes & Games (by Album)",
-    "listingPage.listTracks.inFlashes.byAlbum.album": "{ALBUM} ({DATE})",
-    "listingPage.listTracks.inFlashes.byAlbum.track": "{TRACK} (in {FLASHES})",
-    "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)",
-    "listingPage.listTracks.inFlashes.byFlash.title.short": "...in Flashes & Games (by Flash)",
-    "listingPage.listTracks.inFlashes.byFlash.flash": "{FLASH} ({DATE})",
-    "listingPage.listTracks.inFlashes.byFlash.track": "{TRACK} (from {ALBUM})",
-    "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics",
-    "listingPage.listTracks.withLyrics.title.short": "...with Lyrics",
-    "listingPage.listTracks.withLyrics.album": "{ALBUM} ({DATE})",
-    "listingPage.listTracks.withLyrics.track": "{TRACK}",
-    "listingPage.listTags.byName.title": "Tags - by Name",
-    "listingPage.listTags.byName.title.short": "...by Name",
-    "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})",
-    "listingPage.listTags.byUses.title": "Tags - by Uses",
-    "listingPage.listTags.byUses.title.short": "...by Uses",
-    "listingPage.listTags.byUses.item": "{TAG} ({TIMES_USED})",
-    "listingPage.other.randomPages.title": "Random Pages",
-    "listingPage.other.randomPages.title.short": "Random Pages",
-    "listingPage.misc.trackContributors": "Track Contributors",
-    "listingPage.misc.artContributors": "Art Contributors",
-    "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors",
-    "newsIndex.title": "News",
-    "newsIndex.entry.viewRest": "(View rest of entry!)",
-    "newsEntryPage.title": "{ENTRY}",
-    "newsEntryPage.published": "(Published {DATE}.)",
-    "newsEntryPage.nav.news": "News",
-    "newsEntryPage.nav.entry": "{DATE}: {ENTRY}",
-    "redirectPage.title": "Moved to {TITLE}",
-    "redirectPage.infoLine": "This page has been moved to {TARGET}.",
-    "tagPage.title": "{TAG}",
-    "tagPage.infoLine": "Appears in {COVER_ARTS}.",
-    "tagPage.nav.tag": "Tag: {TAG}",
-    "trackPage.title": "{TRACK}",
-    "trackPage.referenceList.fandom": "Fandom:",
-    "trackPage.referenceList.official": "Official:",
-    "trackPage.nav.track": "{TRACK}",
-    "trackPage.nav.track.withNumber": "{NUMBER}. {TRACK}",
-    "trackPage.nav.random": "Random",
-    "trackPage.socialEmbed.heading": "{ALBUM}",
-    "trackPage.socialEmbed.title": "{TRACK}",
-    "trackPage.socialEmbed.body.withArtists.withCoverArtists": "By {ARTISTS}; art by {COVER_ARTISTS}.",
-    "trackPage.socialEmbed.body.withArtists": "By {ARTISTS}.",
-    "trackPage.socialEmbed.body.withCoverArtists": "Art by {COVER_ARTISTS}."
+  "meta.languageCode": "en",
+  "meta.languageName": "English",
+  "count.tracks": "{TRACKS}",
+  "count.tracks.withUnit.zero": "",
+  "count.tracks.withUnit.one": "{TRACKS} track",
+  "count.tracks.withUnit.two": "",
+  "count.tracks.withUnit.few": "",
+  "count.tracks.withUnit.many": "",
+  "count.tracks.withUnit.other": "{TRACKS} tracks",
+  "count.additionalFiles": "{FILES}",
+  "count.additionalFiles.withUnit.zero": "",
+  "count.additionalFiles.withUnit.one": "{FILES} additional file",
+  "count.additionalFiles.withUnit.two": "",
+  "count.additionalFiles.withUnit.few": "",
+  "count.additionalFiles.withUnit.many": "",
+  "count.additionalFiles.withUnit.other": "{FILES} additional files",
+  "count.albums": "{ALBUMS}",
+  "count.albums.withUnit.zero": "",
+  "count.albums.withUnit.one": "{ALBUMS} album",
+  "count.albums.withUnit.two": "",
+  "count.albums.withUnit.two": "",
+  "count.albums.withUnit.few": "",
+  "count.albums.withUnit.many": "",
+  "count.albums.withUnit.other": "{ALBUMS} albums",
+  "count.commentaryEntries": "{ENTRIES}",
+  "count.commentaryEntries.withUnit.zero": "",
+  "count.commentaryEntries.withUnit.one": "{ENTRIES} entry",
+  "count.commentaryEntries.withUnit.two": "",
+  "count.commentaryEntries.withUnit.few": "",
+  "count.commentaryEntries.withUnit.many": "",
+  "count.commentaryEntries.withUnit.other": "{ENTRIES} entries",
+  "count.contributions": "{CONTRIBUTIONS}",
+  "count.contributions.withUnit.zero": "",
+  "count.contributions.withUnit.one": "{CONTRIBUTIONS} contribution",
+  "count.contributions.withUnit.two": "",
+  "count.contributions.withUnit.few": "",
+  "count.contributions.withUnit.many": "",
+  "count.contributions.withUnit.other": "{CONTRIBUTIONS} contributions",
+  "count.coverArts": "{COVER_ARTS}",
+  "count.coverArts.withUnit.zero": "",
+  "count.coverArts.withUnit.one": "{COVER_ARTS} cover art",
+  "count.coverArts.withUnit.two": "",
+  "count.coverArts.withUnit.few": "",
+  "count.coverArts.withUnit.many": "",
+  "count.coverArts.withUnit.other": "{COVER_ARTS} cover arts",
+  "count.timesReferenced": "{TIMES_REFERENCED}",
+  "count.timesReferenced.withUnit.zero": "",
+  "count.timesReferenced.withUnit.one": "{TIMES_REFERENCED} time referenced",
+  "count.timesReferenced.withUnit.two": "",
+  "count.timesReferenced.withUnit.few": "",
+  "count.timesReferenced.withUnit.many": "",
+  "count.timesReferenced.withUnit.other": "{TIMES_REFERENCED} times referenced",
+  "count.words": "{WORDS}",
+  "count.words.thousand": "{WORDS}k",
+  "count.words.withUnit.zero": "",
+  "count.words.withUnit.one": "{WORDS} word",
+  "count.words.withUnit.two": "",
+  "count.words.withUnit.few": "",
+  "count.words.withUnit.many": "",
+  "count.words.withUnit.other": "{WORDS} words",
+  "count.timesUsed": "{TIMES_USED}",
+  "count.timesUsed.withUnit.zero": "",
+  "count.timesUsed.withUnit.one": "used {TIMES_USED} time",
+  "count.timesUsed.withUnit.two": "",
+  "count.timesUsed.withUnit.few": "",
+  "count.timesUsed.withUnit.many": "",
+  "count.timesUsed.withUnit.other": "used {TIMES_USED} times",
+  "count.index.zero": "",
+  "count.index.one": "{INDEX}st",
+  "count.index.two": "{INDEX}nd",
+  "count.index.few": "{INDEX}rd",
+  "count.index.many": "",
+  "count.index.other": "{INDEX}th",
+  "count.duration.hours": "{HOURS}:{MINUTES}:{SECONDS}",
+  "count.duration.hours.withUnit": "{HOURS}:{MINUTES}:{SECONDS} hours",
+  "count.duration.minutes": "{MINUTES}:{SECONDS}",
+  "count.duration.minutes.withUnit": "{MINUTES}:{SECONDS} minutes",
+  "count.duration.approximate": "~{DURATION}",
+  "count.duration.missing": "_:__",
+  "count.fileSize.terabytes": "{TERABYTES} TB",
+  "count.fileSize.gigabytes": "{GIGABYTES} GB",
+  "count.fileSize.megabytes": "{MEGABYTES} MB",
+  "count.fileSize.kilobytes": "{KILOBYTES} kB",
+  "count.fileSize.bytes": "{BYTES} bytes",
+  "releaseInfo.by": "By {ARTISTS}.",
+  "releaseInfo.from": "From {ALBUM}.",
+  "releaseInfo.coverArtBy": "Cover art by {ARTISTS}.",
+  "releaseInfo.wallpaperArtBy": "Wallpaper art by {ARTISTS}.",
+  "releaseInfo.bannerArtBy": "Banner art by {ARTISTS}.",
+  "releaseInfo.released": "Released {DATE}.",
+  "releaseInfo.artReleased": "Art released {DATE}.",
+  "releaseInfo.addedToWiki": "Added to wiki {DATE}.",
+  "releaseInfo.duration": "Duration: {DURATION}.",
+  "releaseInfo.viewCommentary": "View {LINK}!",
+  "releaseInfo.viewCommentary.link": "commentary page",
+  "releaseInfo.listenOn": "Listen on {LINKS}.",
+  "releaseInfo.listenOn.noLinks": "This track has no URLs at which it can be listened.",
+  "releaseInfo.visitOn": "Visit on {LINKS}.",
+  "releaseInfo.playOn": "Play on {LINKS}.",
+  "releaseInfo.alsoReleasedAs": "Also released as:",
+  "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})",
+  "releaseInfo.contributors": "Contributors:",
+  "releaseInfo.tracksReferenced": "Tracks that {TRACK} references:",
+  "releaseInfo.tracksThatReference": "Tracks that reference {TRACK}:",
+  "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:",
+  "releaseInfo.flashesThatFeature.item": "{FLASH}",
+  "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})",
+  "releaseInfo.lyrics": "Lyrics:",
+  "releaseInfo.artistCommentary": "Artist commentary:",
+  "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!",
+  "releaseInfo.artTags": "Tags:",
+  "releaseInfo.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}",
+  "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:",
+  "releaseInfo.additionalFiles.heading": "Has {ADDITIONAL_FILES}:",
+  "releaseInfo.additionalFiles.entry": "{TITLE}",
+  "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}",
+  "releaseInfo.additionalFiles.file": "{FILE}",
+  "releaseInfo.additionalFiles.file.withSize": "{FILE} ({SIZE})",
+  "releaseInfo.note": "Note:",
+  "trackList.section.withDuration": "{SECTION} ({DURATION}):",
+  "trackList.group": "{GROUP}:",
+  "trackList.group.other": "Other",
+  "trackList.item.withDuration": "({DURATION}) {TRACK}",
+  "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}",
+  "trackList.item.withArtists": "{TRACK} {BY}",
+  "trackList.item.withArtists.by": "by {ARTISTS}",
+  "trackList.item.rerelease": "{TRACK} (re-release)",
+  "misc.alt.albumCover": "album cover",
+  "misc.alt.albumBanner": "album banner",
+  "misc.alt.trackCover": "track cover",
+  "misc.alt.artistAvatar": "artist avatar",
+  "misc.alt.flashArt": "flash art",
+  "misc.chronology.seeArtistPages": "(See artist pages for chronology info!)",
+  "misc.chronology.heading.coverArt": "{INDEX} cover art by {ARTIST}",
+  "misc.chronology.heading.flash": "{INDEX} flash/game by {ARTIST}",
+  "misc.chronology.heading.track": "{INDEX} track by {ARTIST}",
+  "misc.external.domain": "External ({DOMAIN})",
+  "misc.external.local": "Wiki Archive (local upload)",
+  "misc.external.bandcamp": "Bandcamp",
+  "misc.external.bandcamp.domain": "Bandcamp ({DOMAIN})",
+  "misc.external.deviantart": "DeviantArt",
+  "misc.external.instagram": "Instagram",
+  "misc.external.mastodon": "Mastodon",
+  "misc.external.mastodon.domain": "Mastodon ({DOMAIN})",
+  "misc.external.patreon": "Patreon",
+  "misc.external.poetryFoundation": "Poetry Foundation",
+  "misc.external.soundcloud": "SoundCloud",
+  "misc.external.tumblr": "Tumblr",
+  "misc.external.twitter": "Twitter",
+  "misc.external.wikipedia": "Wikipedia",
+  "misc.external.youtube": "YouTube",
+  "misc.external.youtube.playlist": "YouTube (playlist)",
+  "misc.external.youtube.fullAlbum": "YouTube (full album)",
+  "misc.external.flash.bgreco": "{LINK} (HQ Audio)",
+  "misc.external.flash.homestuck.page": "{LINK} (page {PAGE})",
+  "misc.external.flash.homestuck.secret": "{LINK} (secret page)",
+  "misc.external.flash.youtube": "{LINK} (on any device)",
+  "misc.nav.previous": "Previous",
+  "misc.nav.next": "Next",
+  "misc.nav.info": "Info",
+  "misc.nav.gallery": "Gallery",
+  "misc.pageTitle": "{TITLE}",
+  "misc.pageTitle.withWikiName": "{TITLE} | {WIKI_NAME}",
+  "misc.skippers.skipToContent": "Skip to content",
+  "misc.skippers.skipToSidebar": "Skip to sidebar",
+  "misc.skippers.skipToSidebar.left": "Skip to sidebar (left)",
+  "misc.skippers.skipToSidebar.right": "Skip to sidebar (right)",
+  "misc.skippers.skipToFooter": "Skip to footer",
+  "misc.socialEmbed.heading": "{WIKI_NAME} | {HEADING}",
+  "misc.jumpTo": "Jump to:",
+  "misc.jumpTo.withLinks": "Jump to: {LINKS}.",
+  "misc.contentWarnings": "cw: {WARNINGS}",
+  "misc.contentWarnings.reveal": "click to show",
+  "misc.albumGrid.details": "({TRACKS}, {TIME})",
+  "misc.albumGrid.noCoverArt": "{ALBUM}",
+  "misc.uiLanguage": "UI Language: {LANGUAGES}",
+  "homepage.title": "{TITLE}",
+  "homepage.news.title": "News",
+  "homepage.news.entry.viewRest": "(View rest of entry!)",
+  "albumSidebar.trackList.fallbackGroupName": "Track list",
+  "albumSidebar.trackList.group": "{GROUP}",
+  "albumSidebar.trackList.group.withRange": "{GROUP} ({RANGE})",
+  "albumSidebar.trackList.item": "{TRACK}",
+  "albumSidebar.groupBox.title": "{GROUP}",
+  "albumSidebar.groupBox.next": "Next: {ALBUM}",
+  "albumSidebar.groupBox.previous": "Previous: {ALBUM}",
+  "albumPage.title": "{ALBUM}",
+  "albumPage.nav.album": "{ALBUM}",
+  "albumPage.nav.randomTrack": "Random Track",
+  "albumCommentaryPage.title": "{ALBUM} - Commentary",
+  "albumCommentaryPage.infoLine": "{WORDS} across {ENTRIES}.",
+  "albumCommentaryPage.nav.album": "Album: {ALBUM}",
+  "albumCommentaryPage.entry.title.albumCommentary": "Album commentary",
+  "albumCommentaryPage.entry.title.trackCommentary": "{TRACK}",
+  "artistPage.title": "{ARTIST}",
+  "artistPage.creditList.album": "{ALBUM}",
+  "artistPage.creditList.album.withDate": "{ALBUM} ({DATE})",
+  "artistPage.creditList.album.withDuration": "{ALBUM} ({DURATION})",
+  "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})",
+  "artistPage.creditList.flashAct": "{ACT}",
+  "artistPage.creditList.flashAct.withDateRange": "{ACT} ({DATE_RANGE})",
+  "artistPage.creditList.entry.track": "{TRACK}",
+  "artistPage.creditList.entry.track.withDuration": "({DURATION}) {TRACK}",
+  "artistPage.creditList.entry.album.coverArt": "(cover art)",
+  "artistPage.creditList.entry.album.wallpaperArt": "(wallpaper art)",
+  "artistPage.creditList.entry.album.bannerArt": "(banner art)",
+  "artistPage.creditList.entry.album.commentary": "(album commentary)",
+  "artistPage.creditList.entry.flash": "{FLASH}",
+  "artistPage.creditList.entry.rerelease": "{ENTRY} (re-release)",
+  "artistPage.creditList.entry.withContribution": "{ENTRY} ({CONTRIBUTION})",
+  "artistPage.creditList.entry.withArtists": "{ENTRY} (with {ARTISTS})",
+  "artistPage.creditList.entry.withArtists.withContribution": "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})",
+  "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.",
+  "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}",
+  "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}",
+  "artistPage.groupsLine.item": "{GROUP} ({CONTRIBUTIONS})",
+  "artistPage.trackList.title": "Tracks",
+  "artistPage.artList.title": "Art",
+  "artistPage.flashList.title": "Flashes & Games",
+  "artistPage.commentaryList.title": "Commentary",
+  "artistPage.viewArtGallery": "View {LINK}!",
+  "artistPage.viewArtGallery.orBrowseList": "View {LINK}! Or browse the list:",
+  "artistPage.viewArtGallery.link": "art gallery",
+  "artistPage.nav.artist": "Artist: {ARTIST}",
+  "artistGalleryPage.title": "{ARTIST} - Gallery",
+  "artistGalleryPage.infoLine": "Contributed to {COVER_ARTS}.",
+  "commentaryIndex.title": "Commentary",
+  "commentaryIndex.infoLine": "{WORDS} across {ENTRIES}, in all.",
+  "commentaryIndex.albumList.title": "Choose an album:",
+  "commentaryIndex.albumList.item": "{ALBUM} ({WORDS} across {ENTRIES})",
+  "flashIndex.title": "Flashes & Games",
+  "flashPage.title": "{FLASH}",
+  "flashPage.nav.flash": "{FLASH}",
+  "groupSidebar.title": "Groups",
+  "groupSidebar.groupList.category": "{CATEGORY}",
+  "groupSidebar.groupList.item": "{GROUP}",
+  "groupPage.nav.group": "Group: {GROUP}",
+  "groupInfoPage.title": "{GROUP}",
+  "groupInfoPage.viewAlbumGallery": "View {LINK}! Or browse the list:",
+  "groupInfoPage.viewAlbumGallery.link": "album gallery",
+  "groupInfoPage.albumList.title": "Albums",
+  "groupInfoPage.albumList.item": "({YEAR}) {ALBUM}",
+  "groupInfoPage.albumList.item.withoutYear": "{ALBUM}",
+  "groupInfoPage.albumList.item.withAccent": "{ITEM} {ACCENT}",
+  "groupInfoPage.albumList.item.otherGroupAccent": "(from {GROUP})",
+  "groupGalleryPage.title": "{GROUP} - Gallery",
+  "groupGalleryPage.infoLine": "{TRACKS} across {ALBUMS}, totaling {TIME}.",
+  "groupGalleryPage.anotherGroupLine": "({LINK})",
+  "groupGalleryPage.anotherGroupLine.link": "Choose another group to filter by!",
+  "listingIndex.title": "Listings",
+  "listingIndex.infoLine": "{WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.",
+  "listingIndex.exploreList": "Feel free to explore any of the listings linked below and in the sidebar!",
+  "listingPage.target.album": "Albums",
+  "listingPage.target.artist": "Artists",
+  "listingPage.target.group": "Groups",
+  "listingPage.target.track": "Tracks",
+  "listingPage.target.tag": "Tags",
+  "listingPage.target.other": "Other",
+  "listingPage.listAlbums.byName.title": "Albums - by Name",
+  "listingPage.listAlbums.byName.title.short": "...by Name",
+  "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})",
+  "listingPage.listAlbums.byTracks.title": "Albums - by Tracks",
+  "listingPage.listAlbums.byTracks.title.short": "...by Tracks",
+  "listingPage.listAlbums.byTracks.item": "{ALBUM} ({TRACKS})",
+  "listingPage.listAlbums.byDuration.title": "Albums - by Duration",
+  "listingPage.listAlbums.byDuration.title.short": "...by Duration",
+  "listingPage.listAlbums.byDuration.item": "{ALBUM} ({DURATION})",
+  "listingPage.listAlbums.byDate.title": "Albums - by Date",
+  "listingPage.listAlbums.byDate.title.short": "...by Date",
+  "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})",
+  "listingPage.listAlbums.byDateAdded.title.short": "...by Date Added to Wiki",
+  "listingPage.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki",
+  "listingPage.listAlbums.byDateAdded.date": "{DATE}",
+  "listingPage.listAlbums.byDateAdded.album": "{ALBUM}",
+  "listingPage.listArtists.byName.title": "Artists - by Name",
+  "listingPage.listArtists.byName.title.short": "...by Name",
+  "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})",
+  "listingPage.listArtists.byContribs.title": "Artists - by Contributions",
+  "listingPage.listArtists.byContribs.title.short": "...by Contributions",
+  "listingPage.listArtists.byContribs.item": "{ARTIST} ({CONTRIBUTIONS})",
+  "listingPage.listArtists.byCommentary.title": "Artists - by Commentary Entries",
+  "listingPage.listArtists.byCommentary.title.short": "...by Commentary Entries",
+  "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})",
+  "listingPage.listArtists.byDuration.title": "Artists - by Duration",
+  "listingPage.listArtists.byDuration.title.short": "...by Duration",
+  "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})",
+  "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution",
+  "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution",
+  "listingPage.listArtists.byLatest.item": "{ARTIST} ({DATE})",
+  "listingPage.listGroups.byName.title": "Groups - by Name",
+  "listingPage.listGroups.byName.title.short": "...by Name",
+  "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})",
+  "listingPage.listGroups.byName.item.gallery": "Gallery",
+  "listingPage.listGroups.byCategory.title": "Groups - by Category",
+  "listingPage.listGroups.byCategory.title.short": "...by Category",
+  "listingPage.listGroups.byCategory.category": "{CATEGORY}",
+  "listingPage.listGroups.byCategory.group": "{GROUP} ({GALLERY})",
+  "listingPage.listGroups.byCategory.group.gallery": "Gallery",
+  "listingPage.listGroups.byAlbums.title": "Groups - by Albums",
+  "listingPage.listGroups.byAlbums.title.short": "...by Albums",
+  "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})",
+  "listingPage.listGroups.byTracks.title": "Groups - by Tracks",
+  "listingPage.listGroups.byTracks.title.short": "...by Tracks",
+  "listingPage.listGroups.byTracks.item": "{GROUP} ({TRACKS})",
+  "listingPage.listGroups.byDuration.title": "Groups - by Duration",
+  "listingPage.listGroups.byDuration.title.short": "...by Duration",
+  "listingPage.listGroups.byDuration.item": "{GROUP} ({DURATION})",
+  "listingPage.listGroups.byLatest.title": "Groups - by Latest Album",
+  "listingPage.listGroups.byLatest.title.short": "...by Latest Album",
+  "listingPage.listGroups.byLatest.item": "{GROUP} ({DATE})",
+  "listingPage.listTracks.byName.title": "Tracks - by Name",
+  "listingPage.listTracks.byName.title.short": "...by Name",
+  "listingPage.listTracks.byName.item": "{TRACK}",
+  "listingPage.listTracks.byAlbum.title": "Tracks - by Album",
+  "listingPage.listTracks.byAlbum.title.short": "...by Album",
+  "listingPage.listTracks.byAlbum.album": "{ALBUM}",
+  "listingPage.listTracks.byAlbum.track": "{TRACK}",
+  "listingPage.listTracks.byDate.title": "Tracks - by Date",
+  "listingPage.listTracks.byDate.title.short": "...by Date",
+  "listingPage.listTracks.byDate.album": "{ALBUM} ({DATE})",
+  "listingPage.listTracks.byDate.track": "{TRACK}",
+  "listingPage.listTracks.byDate.track.rerelease": "{TRACK} (re-release)",
+  "listingPage.listTracks.byDuration.title": "Tracks - by Duration",
+  "listingPage.listTracks.byDuration.title.short": "...by Duration",
+  "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})",
+  "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)",
+  "listingPage.listTracks.byDurationInAlbum.title.short": "...by Duration (in Album)",
+  "listingPage.listTracks.byDurationInAlbum.album": "{ALBUM}",
+  "listingPage.listTracks.byDurationInAlbum.track": "{TRACK} ({DURATION})",
+  "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced",
+  "listingPage.listTracks.byTimesReferenced.title.short": "...by Times Referenced",
+  "listingPage.listTracks.byTimesReferenced.item": "{TRACK} ({TIMES_REFERENCED})",
+  "listingPage.listTracks.inFlashes.byAlbum.title": "Tracks - in Flashes & Games (by Album)",
+  "listingPage.listTracks.inFlashes.byAlbum.title.short": "...in Flashes & Games (by Album)",
+  "listingPage.listTracks.inFlashes.byAlbum.album": "{ALBUM} ({DATE})",
+  "listingPage.listTracks.inFlashes.byAlbum.track": "{TRACK} (in {FLASHES})",
+  "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)",
+  "listingPage.listTracks.inFlashes.byFlash.title.short": "...in Flashes & Games (by Flash)",
+  "listingPage.listTracks.inFlashes.byFlash.flash": "{FLASH} ({DATE})",
+  "listingPage.listTracks.inFlashes.byFlash.track": "{TRACK} (from {ALBUM})",
+  "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics",
+  "listingPage.listTracks.withLyrics.title.short": "...with Lyrics",
+  "listingPage.listTracks.withLyrics.album": "{ALBUM} ({DATE})",
+  "listingPage.listTracks.withLyrics.track": "{TRACK}",
+  "listingPage.listTags.byName.title": "Tags - by Name",
+  "listingPage.listTags.byName.title.short": "...by Name",
+  "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})",
+  "listingPage.listTags.byUses.title": "Tags - by Uses",
+  "listingPage.listTags.byUses.title.short": "...by Uses",
+  "listingPage.listTags.byUses.item": "{TAG} ({TIMES_USED})",
+  "listingPage.other.randomPages.title": "Random Pages",
+  "listingPage.other.randomPages.title.short": "Random Pages",
+  "listingPage.misc.trackContributors": "Track Contributors",
+  "listingPage.misc.artContributors": "Art Contributors",
+  "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors",
+  "newsIndex.title": "News",
+  "newsIndex.entry.viewRest": "(View rest of entry!)",
+  "newsEntryPage.title": "{ENTRY}",
+  "newsEntryPage.published": "(Published {DATE}.)",
+  "newsEntryPage.nav.news": "News",
+  "newsEntryPage.nav.entry": "{DATE}: {ENTRY}",
+  "redirectPage.title": "Moved to {TITLE}",
+  "redirectPage.infoLine": "This page has been moved to {TARGET}.",
+  "tagPage.title": "{TAG}",
+  "tagPage.infoLine": "Appears in {COVER_ARTS}.",
+  "tagPage.nav.tag": "Tag: {TAG}",
+  "trackPage.title": "{TRACK}",
+  "trackPage.referenceList.fandom": "Fandom:",
+  "trackPage.referenceList.official": "Official:",
+  "trackPage.nav.track": "{TRACK}",
+  "trackPage.nav.track.withNumber": "{NUMBER}. {TRACK}",
+  "trackPage.nav.random": "Random",
+  "trackPage.socialEmbed.heading": "{ALBUM}",
+  "trackPage.socialEmbed.title": "{TRACK}",
+  "trackPage.socialEmbed.body.withArtists.withCoverArtists": "By {ARTISTS}; art by {COVER_ARTISTS}.",
+  "trackPage.socialEmbed.body.withArtists": "By {ARTISTS}.",
+  "trackPage.socialEmbed.body.withCoverArtists": "Art by {COVER_ARTISTS}."
 }
diff --git a/src/upd8.js b/src/upd8.js
index d9bca28f..576166aa 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -31,153 +31,145 @@
 // Oh yeah, like. Just run this through some relatively recent version of
 // node.js and you'll 8e fine. ...Within the project root. O8viously.
 
-import * as path from 'path';
-import { promisify } from 'util';
-import { fileURLToPath } from 'url';
+import * as path from "path";
+import { promisify } from "util";
+import { fileURLToPath } from "url";
 
 // I made this dependency myself! A long, long time ago. It is pro8a8ly my
 // most useful li8rary ever. I'm not sure 8esides me actually uses it, though.
-import fixWS from 'fix-whitespace';
+import fixWS from "fix-whitespace";
 // Wait nevermind, I forgot a8out why-do-kids-love-the-taste-of-cinnamon-toast-
 // crunch. THAT is my 8est li8rary.
 
 // It stands for "HTML Entities", apparently. Cursed.
-import he from 'he';
+import he from "he";
 
 import {
-    copyFile,
-    mkdir,
-    readFile,
-    stat,
-    symlink,
-    writeFile,
-    unlink,
-} from 'fs/promises';
+  copyFile,
+  mkdir,
+  readFile,
+  stat,
+  symlink,
+  writeFile,
+  unlink,
+} from "fs/promises";
 
-import { inspect as nodeInspect } from 'util';
+import { inspect as nodeInspect } from "util";
 
-import genThumbs from './gen-thumbs.js';
-import { listingSpec, listingTargetSpec } from './listing-spec.js';
-import urlSpec from './url-spec.js';
-import * as pageSpecs from './page/index.js';
+import genThumbs from "./gen-thumbs.js";
+import { listingSpec, listingTargetSpec } from "./listing-spec.js";
+import urlSpec from "./url-spec.js";
+import * as pageSpecs from "./page/index.js";
 
-import find, { bindFind } from './util/find.js';
-import * as html from './util/html.js';
-import unbound_link, {getLinkThemeString} from './util/link.js';
-import { findFiles } from './util/io.js';
+import find, { bindFind } from "./util/find.js";
+import * as html from "./util/html.js";
+import unbound_link, { getLinkThemeString } from "./util/link.js";
+import { findFiles } from "./util/io.js";
 
-import CacheableObject from './data/cacheable-object.js';
+import CacheableObject from "./data/cacheable-object.js";
 
-import { serializeThings } from './data/serialize.js';
+import { serializeThings } from "./data/serialize.js";
 
-import {
-    Language,
-} from './data/things.js';
-
-import {
-    filterDuplicateDirectories,
-    filterReferenceErrors,
-    linkWikiDataArrays,
-    loadAndProcessDataDocuments,
-    sortWikiDataArrays,
-    WIKI_INFO_FILE,
-} from './data/yaml.js';
+import { Language } from "./data/things.js";
 
 import {
-    fancifyFlashURL,
-    fancifyURL,
-    generateAdditionalFilesShortcut,
-    generateAdditionalFilesList,
-    generateChronologyLinks,
-    generateCoverLink,
-    generateInfoGalleryLinks,
-    generatePreviousNextLinks,
-    generateTrackListDividedByGroups,
-    getAlbumGridHTML,
-    getAlbumStylesheet,
-    getArtistString,
-    getFlashGridHTML,
-    getFooterLocalizationLinks,
-    getGridHTML,
-    getRevealStringFromTags,
-    getRevealStringFromWarnings,
-    getThemeString,
-    iconifyURL
-} from './misc-templates.js';
+  filterDuplicateDirectories,
+  filterReferenceErrors,
+  linkWikiDataArrays,
+  loadAndProcessDataDocuments,
+  sortWikiDataArrays,
+  WIKI_INFO_FILE,
+} from "./data/yaml.js";
 
 import {
-    color,
-    decorateTime,
-    logWarn,
-    logInfo,
-    logError,
-    parseOptions,
-    progressPromiseAll,
-    ENABLE_COLOR
-} from './util/cli.js';
+  fancifyFlashURL,
+  fancifyURL,
+  generateAdditionalFilesShortcut,
+  generateAdditionalFilesList,
+  generateChronologyLinks,
+  generateCoverLink,
+  generateInfoGalleryLinks,
+  generatePreviousNextLinks,
+  generateTrackListDividedByGroups,
+  getAlbumGridHTML,
+  getAlbumStylesheet,
+  getArtistString,
+  getFlashGridHTML,
+  getFooterLocalizationLinks,
+  getGridHTML,
+  getRevealStringFromTags,
+  getRevealStringFromWarnings,
+  getThemeString,
+  iconifyURL,
+} from "./misc-templates.js";
 
 import {
-    validateReplacerSpec,
-    transformInline
-} from './util/replacer.js';
+  color,
+  decorateTime,
+  logWarn,
+  logInfo,
+  logError,
+  parseOptions,
+  progressPromiseAll,
+  ENABLE_COLOR,
+} from "./util/cli.js";
+
+import { validateReplacerSpec, transformInline } from "./util/replacer.js";
 
 import {
-    chunkByConditions,
-    chunkByProperties,
-    getAlbumCover,
-    getAlbumListTag,
-    getAllTracks,
-    getArtistAvatar,
-    getArtistNumContributions,
-    getFlashCover,
-    getKebabCase,
-    getTotalDuration,
-    getTrackCover,
-} from './util/wiki-data.js';
+  chunkByConditions,
+  chunkByProperties,
+  getAlbumCover,
+  getAlbumListTag,
+  getAllTracks,
+  getArtistAvatar,
+  getArtistNumContributions,
+  getFlashCover,
+  getKebabCase,
+  getTotalDuration,
+  getTrackCover,
+} from "./util/wiki-data.js";
 
 import {
-    serializeContribs,
-    serializeCover,
-    serializeGroupsForAlbum,
-    serializeGroupsForTrack,
-    serializeImagePaths,
-    serializeLink
-} from './util/serialize.js';
+  serializeContribs,
+  serializeCover,
+  serializeGroupsForAlbum,
+  serializeGroupsForTrack,
+  serializeImagePaths,
+  serializeLink,
+} from "./util/serialize.js";
 
 import {
-    bindOpts,
-    decorateErrorWithIndex,
-    filterAggregateAsync,
-    filterEmptyLines,
-    mapAggregate,
-    mapAggregateAsync,
-    openAggregate,
-    queue,
-    showAggregate,
-    splitArray,
-    unique,
-    withAggregate,
-    withEntries
-} from './util/sugar.js';
-
-import {
-    generateURLs,
-    thumb
-} from './util/urls.js';
+  bindOpts,
+  decorateErrorWithIndex,
+  filterAggregateAsync,
+  filterEmptyLines,
+  mapAggregate,
+  mapAggregateAsync,
+  openAggregate,
+  queue,
+  showAggregate,
+  splitArray,
+  unique,
+  withAggregate,
+  withEntries,
+} from "./util/sugar.js";
+
+import { generateURLs, thumb } from "./util/urls.js";
 
 // Pensive emoji!
 import {
-    FANDOM_GROUP_DIRECTORY,
-    OFFICIAL_GROUP_DIRECTORY
-} from './util/magic-constants.js';
+  FANDOM_GROUP_DIRECTORY,
+  OFFICIAL_GROUP_DIRECTORY,
+} from "./util/magic-constants.js";
 
-import FileSizePreloader from './file-size-preloader.js';
+import FileSizePreloader from "./file-size-preloader.js";
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 const CACHEBUST = 10;
 
-const DEFAULT_STRINGS_FILE = 'strings-default.json';
+const DEFAULT_STRINGS_FILE = "strings-default.json";
 
 // Code that's common 8etween the 8uild code (i.e. upd8.js) and gener8ted
 // site code should 8e put here. Which, uh, ~~only really means this one
@@ -186,20 +178,20 @@ const DEFAULT_STRINGS_FILE = 'strings-default.json';
 // Rather than hard code it, anything in this directory can 8e shared across
 // 8oth ends of the code8ase.
 // (This gets symlinked into the --data-path directory.)
-const UTILITY_DIRECTORY = 'util';
+const UTILITY_DIRECTORY = "util";
 
 // Code that's used only in the static site! CSS, cilent JS, etc.
 // (This gets symlinked into the --data-path directory.)
-const STATIC_DIRECTORY = 'static';
+const STATIC_DIRECTORY = "static";
 
 // This exists adjacent to index.html for any page with oEmbed metadata.
-const OEMBED_JSON_FILE = 'oembed.json';
+const OEMBED_JSON_FILE = "oembed.json";
 
 // Automatically copied (if present) from media directory to site root.
-const FAVICON_FILE = 'favicon.ico';
+const FAVICON_FILE = "favicon.ico";
 
 function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
+  return nodeInspect(value, { colors: ENABLE_COLOR });
 }
 
 // Shared varia8les! These are more efficient to access than a shared varia8le
@@ -223,556 +215,610 @@ let queueSize;
 const urls = generateURLs(urlSpec);
 
 function splitLines(text) {
-    return text.split(/\r\n|\r|\n/);
+  return text.split(/\r\n|\r|\n/);
 }
 
 const replacerSpec = {
-    'album': {
-        find: 'album',
-        link: 'album'
-    },
-    'album-commentary': {
-        find: 'album',
-        link: 'albumCommentary'
-    },
-    'artist': {
-        find: 'artist',
-        link: 'artist'
-    },
-    'artist-gallery': {
-        find: 'artist',
-        link: 'artistGallery'
-    },
-    'commentary-index': {
-        find: null,
-        link: 'commentaryIndex'
-    },
-    'date': {
-        find: null,
-        value: ref => new Date(ref),
-        html: (date, {language}) => `<time datetime="${date.toString()}">${language.formatDate(date)}</time>`
-    },
-    'flash': {
-        find: 'flash',
-        link: 'flash',
-        transformName(name, node, input) {
-            const nextCharacter = input[node.iEnd];
-            const lastCharacter = name[name.length - 1];
-            if (
-                ![' ', '\n', '<'].includes(nextCharacter) &&
-                lastCharacter === '.'
-            ) {
-                return name.slice(0, -1);
-            } else {
-                return name;
-            }
-        }
-    },
-    'group': {
-        find: 'group',
-        link: 'groupInfo'
-    },
-    'group-gallery': {
-        find: 'group',
-        link: 'groupGallery'
-    },
-    'home': {
-        find: null,
-        link: 'home'
-    },
-    'listing-index': {
-        find: null,
-        link: 'listingIndex'
-    },
-    'listing': {
-        find: 'listing',
-        link: 'listing'
-    },
-    'media': {
-        find: null,
-        link: 'media'
-    },
-    'news-index': {
-        find: null,
-        link: 'newsIndex'
-    },
-    'news-entry': {
-        find: 'newsEntry',
-        link: 'newsEntry'
-    },
-    'root': {
-        find: null,
-        link: 'root'
-    },
-    'site': {
-        find: null,
-        link: 'site'
-    },
-    'static': {
-        find: 'staticPage',
-        link: 'staticPage'
-    },
-    'string': {
-        find: null,
-        value: ref => ref,
-        html: (ref, {language, args}) => language.$(ref, args)
+  album: {
+    find: "album",
+    link: "album",
+  },
+  "album-commentary": {
+    find: "album",
+    link: "albumCommentary",
+  },
+  artist: {
+    find: "artist",
+    link: "artist",
+  },
+  "artist-gallery": {
+    find: "artist",
+    link: "artistGallery",
+  },
+  "commentary-index": {
+    find: null,
+    link: "commentaryIndex",
+  },
+  date: {
+    find: null,
+    value: (ref) => new Date(ref),
+    html: (date, { language }) =>
+      `<time datetime="${date.toString()}">${language.formatDate(date)}</time>`,
+  },
+  flash: {
+    find: "flash",
+    link: "flash",
+    transformName(name, node, input) {
+      const nextCharacter = input[node.iEnd];
+      const lastCharacter = name[name.length - 1];
+      if (![" ", "\n", "<"].includes(nextCharacter) && lastCharacter === ".") {
+        return name.slice(0, -1);
+      } else {
+        return name;
+      }
     },
-    'tag': {
-        find: 'artTag',
-        link: 'tag'
-    },
-    'track': {
-        find: 'track',
-        link: 'track'
-    }
+  },
+  group: {
+    find: "group",
+    link: "groupInfo",
+  },
+  "group-gallery": {
+    find: "group",
+    link: "groupGallery",
+  },
+  home: {
+    find: null,
+    link: "home",
+  },
+  "listing-index": {
+    find: null,
+    link: "listingIndex",
+  },
+  listing: {
+    find: "listing",
+    link: "listing",
+  },
+  media: {
+    find: null,
+    link: "media",
+  },
+  "news-index": {
+    find: null,
+    link: "newsIndex",
+  },
+  "news-entry": {
+    find: "newsEntry",
+    link: "newsEntry",
+  },
+  root: {
+    find: null,
+    link: "root",
+  },
+  site: {
+    find: null,
+    link: "site",
+  },
+  static: {
+    find: "staticPage",
+    link: "staticPage",
+  },
+  string: {
+    find: null,
+    value: (ref) => ref,
+    html: (ref, { language, args }) => language.$(ref, args),
+  },
+  tag: {
+    find: "artTag",
+    link: "tag",
+  },
+  track: {
+    find: "track",
+    link: "track",
+  },
 };
 
-if (!validateReplacerSpec(replacerSpec, {find, link: unbound_link})) {
-    process.exit();
+if (!validateReplacerSpec(replacerSpec, { find, link: unbound_link })) {
+  process.exit();
 }
 
-function parseAttributes(string, {to}) {
-    const attributes = Object.create(null);
-    const skipWhitespace = i => {
-        const ws = /\s/;
-        if (ws.test(string[i])) {
-            const match = string.slice(i).match(/[^\s]/);
-            if (match) {
-                return i + match.index;
-            } else {
-                return string.length;
-            }
-        } else {
-            return i;
-        }
-    };
-
-    for (let i = 0; i < string.length;) {
-        i = skipWhitespace(i);
-        const aStart = i;
-        const aEnd = i + string.slice(i).match(/[\s=]|$/).index;
-        const attribute = string.slice(aStart, aEnd);
-        i = skipWhitespace(aEnd);
-        if (string[i] === '=') {
-            i = skipWhitespace(i + 1);
-            let end, endOffset;
-            if (string[i] === '"' || string[i] === "'") {
-                end = string[i];
-                endOffset = 1;
-                i++;
-            } else {
-                end = '\\s';
-                endOffset = 0;
-            }
-            const vStart = i;
-            const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index;
-            const value = string.slice(vStart, vEnd);
-            i = vEnd + endOffset;
-            if (attribute === 'src' && value.startsWith('media/')) {
-                attributes[attribute] = to('media.path', value.slice('media/'.length));
-            } else {
-                attributes[attribute] = value;
-            }
-        } else {
-            attributes[attribute] = attribute;
-        }
+function parseAttributes(string, { to }) {
+  const attributes = Object.create(null);
+  const skipWhitespace = (i) => {
+    const ws = /\s/;
+    if (ws.test(string[i])) {
+      const match = string.slice(i).match(/[^\s]/);
+      if (match) {
+        return i + match.index;
+      } else {
+        return string.length;
+      }
+    } else {
+      return i;
     }
-    return Object.fromEntries(Object.entries(attributes).map(([ key, val ]) => [
-        key,
-        val === 'true' ? true :
-        val === 'false' ? false :
-        val === key ? true :
-        val
-    ]));
+  };
+
+  for (let i = 0; i < string.length; ) {
+    i = skipWhitespace(i);
+    const aStart = i;
+    const aEnd = i + string.slice(i).match(/[\s=]|$/).index;
+    const attribute = string.slice(aStart, aEnd);
+    i = skipWhitespace(aEnd);
+    if (string[i] === "=") {
+      i = skipWhitespace(i + 1);
+      let end, endOffset;
+      if (string[i] === '"' || string[i] === "'") {
+        end = string[i];
+        endOffset = 1;
+        i++;
+      } else {
+        end = "\\s";
+        endOffset = 0;
+      }
+      const vStart = i;
+      const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index;
+      const value = string.slice(vStart, vEnd);
+      i = vEnd + endOffset;
+      if (attribute === "src" && value.startsWith("media/")) {
+        attributes[attribute] = to("media.path", value.slice("media/".length));
+      } else {
+        attributes[attribute] = value;
+      }
+    } else {
+      attributes[attribute] = attribute;
+    }
+  }
+  return Object.fromEntries(
+    Object.entries(attributes).map(([key, val]) => [
+      key,
+      val === "true"
+        ? true
+        : val === "false"
+        ? false
+        : val === key
+        ? true
+        : val,
+    ])
+  );
 }
 
 function joinLineBreaks(sourceLines) {
-    const outLines = [];
-
-    let lineSoFar = '';
-    for (let i = 0; i < sourceLines.length; i++) {
-        const line = sourceLines[i];
-        lineSoFar += line;
-        if (!line.endsWith('<br>')) {
-            outLines.push(lineSoFar);
-            lineSoFar = '';
-        }
+  const outLines = [];
+
+  let lineSoFar = "";
+  for (let i = 0; i < sourceLines.length; i++) {
+    const line = sourceLines[i];
+    lineSoFar += line;
+    if (!line.endsWith("<br>")) {
+      outLines.push(lineSoFar);
+      lineSoFar = "";
     }
+  }
 
-    if (lineSoFar) {
-        outLines.push(lineSoFar);
-    }
+  if (lineSoFar) {
+    outLines.push(lineSoFar);
+  }
 
-    return outLines;
+  return outLines;
 }
 
-function transformMultiline(text, {
-    parseAttributes,
-    transformInline
-}) {
-    // Heck yes, HTML magics.
-
-    text = transformInline(text.trim());
-
-    const outLines = [];
-
-    const indentString = ' '.repeat(4);
-
-    let levelIndents = [];
-    const openLevel = indent => {
-        // opening a sublist is a pain: to be semantically *and* visually
-        // correct, we have to append the <ul> at the end of the existing
-        // previous <li>
-        const previousLine = outLines[outLines.length - 1];
-        if (previousLine?.endsWith('</li>')) {
-            // we will re-close the <li> later
-            outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>';
-        } else {
-            // if the previous line isn't a list item, this is the opening of
-            // the first list level, so no need for indent
-            outLines.push('<ul>');
-        }
-        levelIndents.push(indent);
-    };
-    const closeLevel = () => {
-        levelIndents.pop();
-        if (levelIndents.length) {
-            // closing a sublist, so close the list item containing it too
-            outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>');
-        } else {
-            // closing the final list level! no need for indent here
-            outLines.push('</ul>');
-        }
-    };
+function transformMultiline(text, { parseAttributes, transformInline }) {
+  // Heck yes, HTML magics.
 
-    // okay yes we should support nested formatting, more than one blockquote
-    // layer, etc, but hear me out here: making all that work would basically
-    // be the same as implementing an entire markdown converter, which im not
-    // interested in doing lol. sorry!!!
-    let inBlockquote = false;
-
-    let lines = splitLines(text);
-    lines = joinLineBreaks(lines);
-    for (let line of lines) {
-        const imageLine = line.startsWith('<img');
-        line = line.replace(/<img (.*?)>/g, (match, attributes) => img({
-            lazy: true,
-            link: true,
-            thumb: 'medium',
-            ...parseAttributes(attributes)
-        }));
-
-        let indentThisLine = 0;
-        let lineContent = line;
-        let lineTag = 'p';
-
-        const listMatch = line.match(/^( *)- *(.*)$/);
-        if (listMatch) {
-            // is a list item!
-            if (!levelIndents.length) {
-                // first level is always indent = 0, regardless of actual line
-                // content (this is to avoid going to a lesser indent than the
-                // initial level)
-                openLevel(0);
-            } else {
-                // find level corresponding to indent
-                const indent = listMatch[1].length;
-                let i;
-                for (i = levelIndents.length - 1; i >= 0; i--) {
-                    if (levelIndents[i] <= indent) break;
-                }
-                // note: i cannot equal -1 because the first indentation level
-                // is always 0, and the minimum indentation is also 0
-                if (levelIndents[i] === indent) {
-                    // same indent! return to that level
-                    while (levelIndents.length - 1 > i) closeLevel();
-                    // (if this is already the current level, the above loop
-                    // will do nothing)
-                } else if (levelIndents[i] < indent) {
-                    // lesser indent! branch based on index
-                    if (i === levelIndents.length - 1) {
-                        // top level is lesser: add a new level
-                        openLevel(indent);
-                    } else {
-                        // lower level is lesser: return to that level
-                        while (levelIndents.length - 1 > i) closeLevel();
-                    }
-                }
-            }
-            // finally, set variables for appending content line
-            indentThisLine = levelIndents.length;
-            lineContent = listMatch[2];
-            lineTag = 'li';
-        } else {
-            // not a list item! close any existing list levels
-            while (levelIndents.length) closeLevel();
-
-            // like i said, no nested shenanigans - quotes only appear outside
-            // of lists. sorry!
-            const quoteMatch = line.match(/^> *(.*)$/);
-            if (quoteMatch) {
-                // is a quote! open a blockquote tag if it doesnt already exist
-                if (!inBlockquote) {
-                    inBlockquote = true;
-                    outLines.push('<blockquote>');
-                }
-                indentThisLine = 1;
-                lineContent = quoteMatch[1];
-            } else if (inBlockquote) {
-                // not a quote! close a blockquote tag if it exists
-                inBlockquote = false;
-                outLines.push('</blockquote>');
-            }
+  text = transformInline(text.trim());
 
-            // let some escaped symbols display as the normal symbol, since the
-            // point of escaping them is just to avoid having them be treated as
-            // syntax markers!
-            if (lineContent.match(/( *)\\-/)) {
-                lineContent = lineContent.replace('\\-', '-');
-            } else if (lineContent.match(/( *)\\>/)) {
-                lineContent = lineContent.replace('\\>', '>');
-            }
-        }
+  const outLines = [];
 
-        if (lineTag === 'p') {
-            // certain inline element tags should still be postioned within a
-            // paragraph; other elements (e.g. headings) should be added as-is
-            const elementMatch = line.match(/^<(.*?)[ >]/);
-            if (elementMatch && !imageLine && !['a', 'abbr', 'b', 'bdo', 'br', 'cite', 'code', 'data', 'datalist', 'del', 'dfn', 'em', 'i', 'img', 'ins', 'kbd', 'mark', 'output', 'picture', 'q', 'ruby', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'svg', 'time', 'var', 'wbr'].includes(elementMatch[1])) {
-                lineTag = '';
-            }
-        }
+  const indentString = " ".repeat(4);
 
-        let pushString = indentString.repeat(indentThisLine);
-        if (lineTag) {
-            pushString += `<${lineTag}>${lineContent}</${lineTag}>`;
-        } else {
-            pushString += lineContent;
-        }
-        outLines.push(pushString);
+  let levelIndents = [];
+  const openLevel = (indent) => {
+    // opening a sublist is a pain: to be semantically *and* visually
+    // correct, we have to append the <ul> at the end of the existing
+    // previous <li>
+    const previousLine = outLines[outLines.length - 1];
+    if (previousLine?.endsWith("</li>")) {
+      // we will re-close the <li> later
+      outLines[outLines.length - 1] = previousLine.slice(0, -5) + " <ul>";
+    } else {
+      // if the previous line isn't a list item, this is the opening of
+      // the first list level, so no need for indent
+      outLines.push("<ul>");
     }
+    levelIndents.push(indent);
+  };
+  const closeLevel = () => {
+    levelIndents.pop();
+    if (levelIndents.length) {
+      // closing a sublist, so close the list item containing it too
+      outLines.push(indentString.repeat(levelIndents.length) + "</ul></li>");
+    } else {
+      // closing the final list level! no need for indent here
+      outLines.push("</ul>");
+    }
+  };
+
+  // okay yes we should support nested formatting, more than one blockquote
+  // layer, etc, but hear me out here: making all that work would basically
+  // be the same as implementing an entire markdown converter, which im not
+  // interested in doing lol. sorry!!!
+  let inBlockquote = false;
+
+  let lines = splitLines(text);
+  lines = joinLineBreaks(lines);
+  for (let line of lines) {
+    const imageLine = line.startsWith("<img");
+    line = line.replace(/<img (.*?)>/g, (match, attributes) =>
+      img({
+        lazy: true,
+        link: true,
+        thumb: "medium",
+        ...parseAttributes(attributes),
+      })
+    );
 
-    // after processing all lines...
-
-    // if still in a list, close all levels
-    while (levelIndents.length) closeLevel();
-
-    // if still in a blockquote, close its tag
-    if (inBlockquote) {
+    let indentThisLine = 0;
+    let lineContent = line;
+    let lineTag = "p";
+
+    const listMatch = line.match(/^( *)- *(.*)$/);
+    if (listMatch) {
+      // is a list item!
+      if (!levelIndents.length) {
+        // first level is always indent = 0, regardless of actual line
+        // content (this is to avoid going to a lesser indent than the
+        // initial level)
+        openLevel(0);
+      } else {
+        // find level corresponding to indent
+        const indent = listMatch[1].length;
+        let i;
+        for (i = levelIndents.length - 1; i >= 0; i--) {
+          if (levelIndents[i] <= indent) break;
+        }
+        // note: i cannot equal -1 because the first indentation level
+        // is always 0, and the minimum indentation is also 0
+        if (levelIndents[i] === indent) {
+          // same indent! return to that level
+          while (levelIndents.length - 1 > i) closeLevel();
+          // (if this is already the current level, the above loop
+          // will do nothing)
+        } else if (levelIndents[i] < indent) {
+          // lesser indent! branch based on index
+          if (i === levelIndents.length - 1) {
+            // top level is lesser: add a new level
+            openLevel(indent);
+          } else {
+            // lower level is lesser: return to that level
+            while (levelIndents.length - 1 > i) closeLevel();
+          }
+        }
+      }
+      // finally, set variables for appending content line
+      indentThisLine = levelIndents.length;
+      lineContent = listMatch[2];
+      lineTag = "li";
+    } else {
+      // not a list item! close any existing list levels
+      while (levelIndents.length) closeLevel();
+
+      // like i said, no nested shenanigans - quotes only appear outside
+      // of lists. sorry!
+      const quoteMatch = line.match(/^> *(.*)$/);
+      if (quoteMatch) {
+        // is a quote! open a blockquote tag if it doesnt already exist
+        if (!inBlockquote) {
+          inBlockquote = true;
+          outLines.push("<blockquote>");
+        }
+        indentThisLine = 1;
+        lineContent = quoteMatch[1];
+      } else if (inBlockquote) {
+        // not a quote! close a blockquote tag if it exists
         inBlockquote = false;
-        outLines.push('</blockquote>');
+        outLines.push("</blockquote>");
+      }
+
+      // let some escaped symbols display as the normal symbol, since the
+      // point of escaping them is just to avoid having them be treated as
+      // syntax markers!
+      if (lineContent.match(/( *)\\-/)) {
+        lineContent = lineContent.replace("\\-", "-");
+      } else if (lineContent.match(/( *)\\>/)) {
+        lineContent = lineContent.replace("\\>", ">");
+      }
     }
 
-    return outLines.join('\n');
-}
+    if (lineTag === "p") {
+      // certain inline element tags should still be postioned within a
+      // paragraph; other elements (e.g. headings) should be added as-is
+      const elementMatch = line.match(/^<(.*?)[ >]/);
+      if (
+        elementMatch &&
+        !imageLine &&
+        ![
+          "a",
+          "abbr",
+          "b",
+          "bdo",
+          "br",
+          "cite",
+          "code",
+          "data",
+          "datalist",
+          "del",
+          "dfn",
+          "em",
+          "i",
+          "img",
+          "ins",
+          "kbd",
+          "mark",
+          "output",
+          "picture",
+          "q",
+          "ruby",
+          "samp",
+          "small",
+          "span",
+          "strong",
+          "sub",
+          "sup",
+          "svg",
+          "time",
+          "var",
+          "wbr",
+        ].includes(elementMatch[1])
+      ) {
+        lineTag = "";
+      }
+    }
 
-function transformLyrics(text, {
-    transformInline,
-    transformMultiline
-}) {
-    // Different from transformMultiline 'cuz it joins multiple lines together
-    // with line 8reaks (<br>); transformMultiline treats each line as its own
-    // complete paragraph (or list, etc).
-
-    // If it looks like old data, then like, oh god.
-    // Use the normal transformMultiline tool.
-    if (text.includes('<br')) {
-        return transformMultiline(text);
+    let pushString = indentString.repeat(indentThisLine);
+    if (lineTag) {
+      pushString += `<${lineTag}>${lineContent}</${lineTag}>`;
+    } else {
+      pushString += lineContent;
     }
+    outLines.push(pushString);
+  }
 
-    text = transformInline(text.trim());
+  // after processing all lines...
 
-    let buildLine = '';
-    const addLine = () => outLines.push(`<p>${buildLine}</p>`);
-    const outLines = [];
-    for (const line of text.split('\n')) {
-        if (line.length) {
-            if (buildLine.length) {
-                buildLine += '<br>';
-            }
-            buildLine += line;
-        } else if (buildLine.length) {
-            addLine();
-            buildLine = '';
-        }
-    }
-    if (buildLine.length) {
-        addLine();
+  // if still in a list, close all levels
+  while (levelIndents.length) closeLevel();
+
+  // if still in a blockquote, close its tag
+  if (inBlockquote) {
+    inBlockquote = false;
+    outLines.push("</blockquote>");
+  }
+
+  return outLines.join("\n");
+}
+
+function transformLyrics(text, { transformInline, transformMultiline }) {
+  // Different from transformMultiline 'cuz it joins multiple lines together
+  // with line 8reaks (<br>); transformMultiline treats each line as its own
+  // complete paragraph (or list, etc).
+
+  // If it looks like old data, then like, oh god.
+  // Use the normal transformMultiline tool.
+  if (text.includes("<br")) {
+    return transformMultiline(text);
+  }
+
+  text = transformInline(text.trim());
+
+  let buildLine = "";
+  const addLine = () => outLines.push(`<p>${buildLine}</p>`);
+  const outLines = [];
+  for (const line of text.split("\n")) {
+    if (line.length) {
+      if (buildLine.length) {
+        buildLine += "<br>";
+      }
+      buildLine += line;
+    } else if (buildLine.length) {
+      addLine();
+      buildLine = "";
     }
-    return outLines.join('\n');
+  }
+  if (buildLine.length) {
+    addLine();
+  }
+  return outLines.join("\n");
 }
 
 function stringifyThings(thingData) {
-    return JSON.stringify(serializeThings(thingData));
+  return JSON.stringify(serializeThings(thingData));
 }
 
 function img({
-    src,
-    alt,
-    noSrcText = '',
-    thumb: thumbKey,
-    reveal,
-    id,
+  src,
+  alt,
+  noSrcText = "",
+  thumb: thumbKey,
+  reveal,
+  id,
+  class: className,
+  width,
+  height,
+  link = false,
+  lazy = false,
+  square = false,
+}) {
+  const willSquare = square;
+  const willLink = typeof link === "string" || link;
+
+  const originalSrc = src;
+  const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src);
+
+  const imgAttributes = html.attributes({
+    id: link ? "" : id,
     class: className,
+    alt,
     width,
     height,
-    link = false,
-    lazy = false,
-    square = false
-}) {
-    const willSquare = square;
-    const willLink = typeof link === 'string' || link;
-
-    const originalSrc = src;
-    const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src);
-
-    const imgAttributes = html.attributes({
-        id: link ? '' : id,
-        class: className,
-        alt,
-        width,
-        height
-    });
-
-    const noSrcHTML = !src && wrap(`<div class="image-text-area">${noSrcText}</div>`);
-    const nonlazyHTML = src && wrap(`<img src="${thumbSrc}" ${imgAttributes}>`);
-    const lazyHTML = src && lazy && wrap(`<img class="lazy" data-original="${thumbSrc}" ${imgAttributes}>`, true);
+  });
+
+  const noSrcHTML =
+    !src && wrap(`<div class="image-text-area">${noSrcText}</div>`);
+  const nonlazyHTML = src && wrap(`<img src="${thumbSrc}" ${imgAttributes}>`);
+  const lazyHTML =
+    src &&
+    lazy &&
+    wrap(
+      `<img class="lazy" data-original="${thumbSrc}" ${imgAttributes}>`,
+      true
+    );
 
-    if (!src) {
-        return noSrcHTML;
-    } else if (lazy) {
-        return fixWS`
+  if (!src) {
+    return noSrcHTML;
+  } else if (lazy) {
+    return fixWS`
             <noscript>${nonlazyHTML}</noscript>
             ${lazyHTML}
         `;
-    } else {
-        return nonlazyHTML;
-    }
+  } else {
+    return nonlazyHTML;
+  }
 
-    function wrap(input, hide = false) {
-        let wrapped = input;
+  function wrap(input, hide = false) {
+    let wrapped = input;
 
-        wrapped = `<div class="image-inner-area">${wrapped}</div>`;
-        wrapped = `<div class="image-container">${wrapped}</div>`;
+    wrapped = `<div class="image-inner-area">${wrapped}</div>`;
+    wrapped = `<div class="image-container">${wrapped}</div>`;
 
-        if (reveal) {
-            wrapped = fixWS`
+    if (reveal) {
+      wrapped = fixWS`
                 <div class="reveal">
                     ${wrapped}
                     <span class="reveal-text">${reveal}</span>
                 </div>
             `;
-        }
-
-        if (willSquare) {
-            wrapped = html.tag('div', {class: 'square-content'}, wrapped);
-            wrapped = html.tag('div', {class: ['square', hide && !willLink && 'js-hide']}, wrapped);
-        }
+    }
 
-        if (willLink) {
-            wrapped = html.tag('a', {
-                id,
-                class: ['box', hide && 'js-hide'],
-                href: typeof link === 'string' ? link : originalSrc
-            }, wrapped);
-        }
+    if (willSquare) {
+      wrapped = html.tag("div", { class: "square-content" }, wrapped);
+      wrapped = html.tag(
+        "div",
+        { class: ["square", hide && !willLink && "js-hide"] },
+        wrapped
+      );
+    }
 
-        return wrapped;
+    if (willLink) {
+      wrapped = html.tag(
+        "a",
+        {
+          id,
+          class: ["box", hide && "js-hide"],
+          href: typeof link === "string" ? link : originalSrc,
+        },
+        wrapped
+      );
     }
+
+    return wrapped;
+  }
 }
 
 function validateWritePath(path, urlGroup) {
-    if (!Array.isArray(path)) {
-        return {error: `Expected array, got ${path}`};
-    }
+  if (!Array.isArray(path)) {
+    return { error: `Expected array, got ${path}` };
+  }
 
-    const { paths } = urlGroup;
+  const { paths } = urlGroup;
 
-    const definedKeys = Object.keys(paths);
-    const specifiedKey = path[0];
+  const definedKeys = Object.keys(paths);
+  const specifiedKey = path[0];
 
-    if (!definedKeys.includes(specifiedKey)) {
-        return {error: `Specified key ${specifiedKey} isn't defined`};
-    }
+  if (!definedKeys.includes(specifiedKey)) {
+    return { error: `Specified key ${specifiedKey} isn't defined` };
+  }
 
-    const expectedArgs = paths[specifiedKey].match(/<>/g)?.length ?? 0;
-    const specifiedArgs = path.length - 1;
+  const expectedArgs = paths[specifiedKey].match(/<>/g)?.length ?? 0;
+  const specifiedArgs = path.length - 1;
 
-    if (specifiedArgs !== expectedArgs) {
-        return {error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`};
-    }
+  if (specifiedArgs !== expectedArgs) {
+    return {
+      error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`,
+    };
+  }
 
-    return {success: true};
+  return { success: true };
 }
 
 function validateWriteObject(obj) {
-    if (typeof obj !== 'object') {
-        return {error: `Expected object, got ${typeof obj}`};
-    }
+  if (typeof obj !== "object") {
+    return { error: `Expected object, got ${typeof obj}` };
+  }
 
-    if (typeof obj.type !== 'string') {
-        return {error: `Expected type to be string, got ${obj.type}`};
-    }
+  if (typeof obj.type !== "string") {
+    return { error: `Expected type to be string, got ${obj.type}` };
+  }
 
-    switch (obj.type) {
-        case 'legacy': {
-            if (typeof obj.write !== 'function') {
-                return {error: `Expected write to be string, got ${obj.write}`};
-            }
+  switch (obj.type) {
+    case "legacy": {
+      if (typeof obj.write !== "function") {
+        return { error: `Expected write to be string, got ${obj.write}` };
+      }
 
-            break;
-        }
+      break;
+    }
 
-        case 'page': {
-            const path = validateWritePath(obj.path, urlSpec.localized);
-            if (path.error) {
-                return {error: `Path validation failed: ${path.error}`};
-            }
+    case "page": {
+      const path = validateWritePath(obj.path, urlSpec.localized);
+      if (path.error) {
+        return { error: `Path validation failed: ${path.error}` };
+      }
 
-            if (typeof obj.page !== 'function') {
-                return {error: `Expected page to be function, got ${obj.content}`};
-            }
+      if (typeof obj.page !== "function") {
+        return { error: `Expected page to be function, got ${obj.content}` };
+      }
 
-            break;
-        }
+      break;
+    }
 
-        case 'data': {
-            const path = validateWritePath(obj.path, urlSpec.data);
-            if (path.error) {
-                return {error: `Path validation failed: ${path.error}`};
-            }
+    case "data": {
+      const path = validateWritePath(obj.path, urlSpec.data);
+      if (path.error) {
+        return { error: `Path validation failed: ${path.error}` };
+      }
 
-            if (typeof obj.data !== 'function') {
-                return {error: `Expected data to be function, got ${obj.data}`};
-            }
+      if (typeof obj.data !== "function") {
+        return { error: `Expected data to be function, got ${obj.data}` };
+      }
 
-            break;
-        }
+      break;
+    }
 
-        case 'redirect': {
-            const fromPath = validateWritePath(obj.fromPath, urlSpec.localized);
-            if (fromPath.error) {
-                return {error: `Path (fromPath) validation failed: ${fromPath.error}`};
-            }
+    case "redirect": {
+      const fromPath = validateWritePath(obj.fromPath, urlSpec.localized);
+      if (fromPath.error) {
+        return {
+          error: `Path (fromPath) validation failed: ${fromPath.error}`,
+        };
+      }
 
-            const toPath = validateWritePath(obj.toPath, urlSpec.localized);
-            if (toPath.error) {
-                return {error: `Path (toPath) validation failed: ${toPath.error}`};
-            }
+      const toPath = validateWritePath(obj.toPath, urlSpec.localized);
+      if (toPath.error) {
+        return { error: `Path (toPath) validation failed: ${toPath.error}` };
+      }
 
-            if (typeof obj.title !== 'function') {
-                return {error: `Expected title to be function, got ${obj.title}`};
-            }
+      if (typeof obj.title !== "function") {
+        return { error: `Expected title to be function, got ${obj.title}` };
+      }
 
-            break;
-        }
+      break;
+    }
 
-        default: {
-            return {error: `Unknown type: ${obj.type}`};
-        }
+    default: {
+      return { error: `Unknown type: ${obj.type}` };
     }
+  }
 
-    return {success: true};
+  return { success: true };
 }
 
 /*
@@ -787,12 +833,10 @@ async function writeData(subKey, directory, data) {
 // touching the original one (which had contained everything).
 const writePage = {};
 
-writePage.to = ({
-    baseDirectory,
-    pageSubKey,
-    paths
-}) => (targetFullKey, ...args) => {
-    const [ groupKey, subKey ] = targetFullKey.split('.');
+writePage.to =
+  ({ baseDirectory, pageSubKey, paths }) =>
+  (targetFullKey, ...args) => {
+    const [groupKey, subKey] = targetFullKey.split(".");
     let path = paths.subdirectoryPrefix;
 
     let from;
@@ -800,33 +844,39 @@ writePage.to = ({
 
     // When linking to *outside* the localized area of the site, we need to
     // make sure the result is correctly relative to the 8ase directory.
-    if (groupKey !== 'localized' && groupKey !== 'localizedDefaultLanguage' && baseDirectory) {
-        from = 'localizedWithBaseDirectory.' + pageSubKey;
-        to = targetFullKey;
-    } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) {
-        // Special case for specifically linking *from* a page with base
-        // directory *to* a page without! Used for the language switcher and
-        // hopefully nothing else oh god.
-        from = 'localizedWithBaseDirectory.' + pageSubKey;
-        to = 'localized.' + subKey;
-    } else if (groupKey === 'localizedDefaultLanguage') {
-        // Linking to the default, except surprise, we're already IN the default
-        // (no baseDirectory set).
-        from = 'localized.' + pageSubKey;
-        to = 'localized.' + subKey;
+    if (
+      groupKey !== "localized" &&
+      groupKey !== "localizedDefaultLanguage" &&
+      baseDirectory
+    ) {
+      from = "localizedWithBaseDirectory." + pageSubKey;
+      to = targetFullKey;
+    } else if (groupKey === "localizedDefaultLanguage" && baseDirectory) {
+      // Special case for specifically linking *from* a page with base
+      // directory *to* a page without! Used for the language switcher and
+      // hopefully nothing else oh god.
+      from = "localizedWithBaseDirectory." + pageSubKey;
+      to = "localized." + subKey;
+    } else if (groupKey === "localizedDefaultLanguage") {
+      // Linking to the default, except surprise, we're already IN the default
+      // (no baseDirectory set).
+      from = "localized." + pageSubKey;
+      to = "localized." + subKey;
     } else {
-        // If we're linking inside the localized area (or there just is no
-        // 8ase directory), the 8ase directory doesn't matter.
-        from = 'localized.' + pageSubKey;
-        to = targetFullKey;
+      // If we're linking inside the localized area (or there just is no
+      // 8ase directory), the 8ase directory doesn't matter.
+      from = "localized." + pageSubKey;
+      to = targetFullKey;
     }
 
     path += urls.from(from).to(to, ...args);
 
     return path;
-};
+  };
 
-writePage.html = (pageInfo, {
+writePage.html = (
+  pageInfo,
+  {
     defaultLanguage,
     language,
     languages,
@@ -835,486 +885,653 @@ writePage.html = (pageInfo, {
     oEmbedJSONHref,
     to,
     transformMultiline,
-    wikiData
-}) => {
-    const { wikiInfo } = wikiData;
-
-    let {
-        title = '',
-        meta = {},
-        theme = '',
-        stylesheet = '',
-
-        showWikiNameInTitle = true,
-
-        // missing properties are auto-filled, see below!
-        body = {},
-        banner = {},
-        main = {},
-        sidebarLeft = {},
-        sidebarRight = {},
-        nav = {},
-        secondaryNav = {},
-        footer = {},
-        socialEmbed = {},
-    } = pageInfo;
-
-    body.style ??= '';
-
-    theme = theme || getThemeString(wikiInfo.color);
-
-    banner ||= {};
-    banner.classes ??= [];
-    banner.src ??= '';
-    banner.position ??= '';
-    banner.dimensions ??= [0, 0];
-
-    main.classes ??= [];
-    main.content ??= '';
-
-    sidebarLeft ??= {};
-    sidebarRight ??= {};
-
-    for (const sidebar of [sidebarLeft, sidebarRight]) {
-        sidebar.classes ??= [];
-        sidebar.content ??= '';
-        sidebar.collapse ??= true;
-    }
-
-    nav.classes ??= [];
-    nav.content ??= '';
-    nav.bottomRowContent ??= '';
-    nav.links ??= [];
-    nav.linkContainerClasses ??= [];
-
-    secondaryNav ??= {};
-    secondaryNav.content ??= '';
-    secondaryNav.content ??= '';
-
-    footer.classes ??= [];
-    footer.content ??= (wikiInfo.footerContent ? transformMultiline(wikiInfo.footerContent) : '');
-
-    footer.content += '\n' + getFooterLocalizationLinks(paths.pathname, {
-        defaultLanguage, languages, paths, language, to
+    wikiData,
+  }
+) => {
+  const { wikiInfo } = wikiData;
+
+  let {
+    title = "",
+    meta = {},
+    theme = "",
+    stylesheet = "",
+
+    showWikiNameInTitle = true,
+
+    // missing properties are auto-filled, see below!
+    body = {},
+    banner = {},
+    main = {},
+    sidebarLeft = {},
+    sidebarRight = {},
+    nav = {},
+    secondaryNav = {},
+    footer = {},
+    socialEmbed = {},
+  } = pageInfo;
+
+  body.style ??= "";
+
+  theme = theme || getThemeString(wikiInfo.color);
+
+  banner ||= {};
+  banner.classes ??= [];
+  banner.src ??= "";
+  banner.position ??= "";
+  banner.dimensions ??= [0, 0];
+
+  main.classes ??= [];
+  main.content ??= "";
+
+  sidebarLeft ??= {};
+  sidebarRight ??= {};
+
+  for (const sidebar of [sidebarLeft, sidebarRight]) {
+    sidebar.classes ??= [];
+    sidebar.content ??= "";
+    sidebar.collapse ??= true;
+  }
+
+  nav.classes ??= [];
+  nav.content ??= "";
+  nav.bottomRowContent ??= "";
+  nav.links ??= [];
+  nav.linkContainerClasses ??= [];
+
+  secondaryNav ??= {};
+  secondaryNav.content ??= "";
+  secondaryNav.content ??= "";
+
+  footer.classes ??= [];
+  footer.content ??= wikiInfo.footerContent
+    ? transformMultiline(wikiInfo.footerContent)
+    : "";
+
+  footer.content +=
+    "\n" +
+    getFooterLocalizationLinks(paths.pathname, {
+      defaultLanguage,
+      languages,
+      paths,
+      language,
+      to,
     });
 
-    const canonical = (wikiInfo.canonicalBase
-        ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathname)
-        : '');
-
-    const localizedCanonical = (wikiInfo.canonicalBase
-        ? Object.entries(localizedPaths).map(([ code, { pathname } ]) => ({
-            lang: code,
-            href: wikiInfo.canonicalBase + (pathname === '/' ? '' : pathname)
-        }))
-        : []);
-
-    const collapseSidebars = (sidebarLeft.collapse !== false) && (sidebarRight.collapse !== false);
-
-    const mainHTML = main.content && html.tag('main', {
-        id: 'content',
-        class: main.classes
-    }, main.content);
-
-    const footerHTML = footer.content && html.tag('footer', {
-        id: 'footer',
-        class: footer.classes
-    }, footer.content);
-
-    const generateSidebarHTML = (id, {
-        content,
-        multiple,
-        classes,
-        collapse = true,
-        wide = false
-    }) => (content
-        ? html.tag('div',
-            {id, class: [
-                'sidebar-column',
-                'sidebar',
-                wide && 'wide',
-                !collapse && 'no-hide',
-                ...classes
-            ]},
-            content)
-        : multiple ? html.tag('div',
-            {id, class: [
-                'sidebar-column',
-                'sidebar-multiple',
-                wide && 'wide',
-                !collapse && 'no-hide'
-            ]},
-            multiple.map(content => html.tag('div',
-                {class: ['sidebar', ...classes]},
-                content)))
-        : '');
-
-    const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft);
-    const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight);
-
-    if (nav.simple) {
-        nav.linkContainerClasses = ['nav-links-hierarchy'];
-        nav.links = [
-            {toHome: true},
-            {toCurrentPage: true}
-        ];
-    }
-
-    const links = (nav.links || []).filter(Boolean);
-
-    const navLinkParts = [];
-    for (let i = 0; i < links.length; i++) {
-        let cur = links[i];
-        const prev = links[i - 1];
-        const next = links[i + 1];
+  const canonical = wikiInfo.canonicalBase
+    ? wikiInfo.canonicalBase + (paths.pathname === "/" ? "" : paths.pathname)
+    : "";
+
+  const localizedCanonical = wikiInfo.canonicalBase
+    ? Object.entries(localizedPaths).map(([code, { pathname }]) => ({
+        lang: code,
+        href: wikiInfo.canonicalBase + (pathname === "/" ? "" : pathname),
+      }))
+    : [];
+
+  const collapseSidebars =
+    sidebarLeft.collapse !== false && sidebarRight.collapse !== false;
+
+  const mainHTML =
+    main.content &&
+    html.tag(
+      "main",
+      {
+        id: "content",
+        class: main.classes,
+      },
+      main.content
+    );
 
-        let { title: linkTitle } = cur;
+  const footerHTML =
+    footer.content &&
+    html.tag(
+      "footer",
+      {
+        id: "footer",
+        class: footer.classes,
+      },
+      footer.content
+    );
 
-        if (cur.toHome) {
-            linkTitle ??= wikiInfo.nameShort;
-        } else if (cur.toCurrentPage) {
-            linkTitle ??= title;
-        }
+  const generateSidebarHTML = (
+    id,
+    { content, multiple, classes, collapse = true, wide = false }
+  ) =>
+    content
+      ? html.tag(
+          "div",
+          {
+            id,
+            class: [
+              "sidebar-column",
+              "sidebar",
+              wide && "wide",
+              !collapse && "no-hide",
+              ...classes,
+            ],
+          },
+          content
+        )
+      : multiple
+      ? html.tag(
+          "div",
+          {
+            id,
+            class: [
+              "sidebar-column",
+              "sidebar-multiple",
+              wide && "wide",
+              !collapse && "no-hide",
+            ],
+          },
+          multiple.map((content) =>
+            html.tag("div", { class: ["sidebar", ...classes] }, content)
+          )
+        )
+      : "";
+
+  const sidebarLeftHTML = generateSidebarHTML("sidebar-left", sidebarLeft);
+  const sidebarRightHTML = generateSidebarHTML("sidebar-right", sidebarRight);
+
+  if (nav.simple) {
+    nav.linkContainerClasses = ["nav-links-hierarchy"];
+    nav.links = [{ toHome: true }, { toCurrentPage: true }];
+  }
+
+  const links = (nav.links || []).filter(Boolean);
+
+  const navLinkParts = [];
+  for (let i = 0; i < links.length; i++) {
+    let cur = links[i];
+    const prev = links[i - 1];
+    const next = links[i + 1];
+
+    let { title: linkTitle } = cur;
+
+    if (cur.toHome) {
+      linkTitle ??= wikiInfo.nameShort;
+    } else if (cur.toCurrentPage) {
+      linkTitle ??= title;
+    }
 
-        let partContent;
+    let partContent;
 
-        if (typeof cur.html === 'string') {
-            if (!cur.html) {
-                logWarn`Empty HTML in nav link ${JSON.stringify(cur)}`;
-                console.trace();
-            }
-            partContent = cur.html;
-        } else {
-            const attributes = {
-                class: (cur.toCurrentPage || i === links.length - 1) && 'current',
-                href: (
-                    cur.toCurrentPage ? '' :
-                    cur.toHome ? to('localized.home') :
-                    cur.path ? to(...cur.path) :
-                    cur.href ? (() => {
-                        logWarn`Using legacy href format nav link in ${paths.pathname}`;
-                        return cur.href;
-                    })() :
-                    null)
-            };
-            if (attributes.href === null) {
-                throw new Error(`Expected some href specifier for link to ${linkTitle} (${JSON.stringify(cur)})`);
-            }
-            partContent = html.tag('a', attributes, linkTitle);
-        }
+    if (typeof cur.html === "string") {
+      if (!cur.html) {
+        logWarn`Empty HTML in nav link ${JSON.stringify(cur)}`;
+        console.trace();
+      }
+      partContent = cur.html;
+    } else {
+      const attributes = {
+        class: (cur.toCurrentPage || i === links.length - 1) && "current",
+        href: cur.toCurrentPage
+          ? ""
+          : cur.toHome
+          ? to("localized.home")
+          : cur.path
+          ? to(...cur.path)
+          : cur.href
+          ? (() => {
+              logWarn`Using legacy href format nav link in ${paths.pathname}`;
+              return cur.href;
+            })()
+          : null,
+      };
+      if (attributes.href === null) {
+        throw new Error(
+          `Expected some href specifier for link to ${linkTitle} (${JSON.stringify(
+            cur
+          )})`
+        );
+      }
+      partContent = html.tag("a", attributes, linkTitle);
+    }
 
-        const part = html.tag('span',
-            {class: cur.divider === false && 'no-divider'},
-            partContent);
+    const part = html.tag(
+      "span",
+      { class: cur.divider === false && "no-divider" },
+      partContent
+    );
 
-        navLinkParts.push(part);
-    }
+    navLinkParts.push(part);
+  }
 
-    const navHTML = html.tag('nav', {
-        [html.onlyIfContent]: true,
-        id: 'header',
-        class: [
-            ...nav.classes,
-            links.length && 'nav-has-main-links',
-            nav.content && 'nav-has-content',
-            nav.bottomRowContent && 'nav-has-bottom-row',
-        ],
-    }, [
-        links.length && html.tag('div',
-            {class: ['nav-main-links', ...nav.linkContainerClasses]},
-            navLinkParts),
-        nav.content && html.tag('div', {class: 'nav-content'}, nav.content),
-        nav.bottomRowContent && html.tag('div', {class: 'nav-bottom-row'}, nav.bottomRowContent),
-    ]);
-
-    const secondaryNavHTML = html.tag('nav', {
-        [html.onlyIfContent]: true,
-        id: 'secondary-nav',
-        class: secondaryNav.classes
-    }, [
-        secondaryNav.content
-    ]);
-
-    const bannerSrc = (
-        banner.src ? banner.src :
-        banner.path ? to(...banner.path) :
-        null);
-
-    const bannerHTML = banner.position && bannerSrc && html.tag('div',
-        {
-            id: 'banner',
-            class: banner.classes
-        },
-        html.tag('img', {
-            src: bannerSrc,
-            alt: banner.alt,
-            width: banner.dimensions[0] || 1100,
-            height: banner.dimensions[1] || 200
-        })
+  const navHTML = html.tag(
+    "nav",
+    {
+      [html.onlyIfContent]: true,
+      id: "header",
+      class: [
+        ...nav.classes,
+        links.length && "nav-has-main-links",
+        nav.content && "nav-has-content",
+        nav.bottomRowContent && "nav-has-bottom-row",
+      ],
+    },
+    [
+      links.length &&
+        html.tag(
+          "div",
+          { class: ["nav-main-links", ...nav.linkContainerClasses] },
+          navLinkParts
+        ),
+      nav.content && html.tag("div", { class: "nav-content" }, nav.content),
+      nav.bottomRowContent &&
+        html.tag("div", { class: "nav-bottom-row" }, nav.bottomRowContent),
+    ]
+  );
+
+  const secondaryNavHTML = html.tag(
+    "nav",
+    {
+      [html.onlyIfContent]: true,
+      id: "secondary-nav",
+      class: secondaryNav.classes,
+    },
+    [secondaryNav.content]
+  );
+
+  const bannerSrc = banner.src
+    ? banner.src
+    : banner.path
+    ? to(...banner.path)
+    : null;
+
+  const bannerHTML =
+    banner.position &&
+    bannerSrc &&
+    html.tag(
+      "div",
+      {
+        id: "banner",
+        class: banner.classes,
+      },
+      html.tag("img", {
+        src: bannerSrc,
+        alt: banner.alt,
+        width: banner.dimensions[0] || 1100,
+        height: banner.dimensions[1] || 200,
+      })
     );
 
-    const layoutHTML = [
-        navHTML,
-        banner.position === 'top' && bannerHTML,
-        secondaryNavHTML,
-        html.tag('div',
-            {class: ['layout-columns', !collapseSidebars && 'vertical-when-thin']},
-            [
-                sidebarLeftHTML,
-                mainHTML,
-                sidebarRightHTML
-            ]),
-        banner.position === 'bottom' && bannerHTML,
-        footerHTML
-    ].filter(Boolean).join('\n');
-
-    const infoCardHTML = fixWS`
+  const layoutHTML = [
+    navHTML,
+    banner.position === "top" && bannerHTML,
+    secondaryNavHTML,
+    html.tag(
+      "div",
+      { class: ["layout-columns", !collapseSidebars && "vertical-when-thin"] },
+      [sidebarLeftHTML, mainHTML, sidebarRightHTML]
+    ),
+    banner.position === "bottom" && bannerHTML,
+    footerHTML,
+  ]
+    .filter(Boolean)
+    .join("\n");
+
+  const infoCardHTML = fixWS`
         <div id="info-card-container">
             <div class="info-card-decor">
                 <div class="info-card">
                     <div class="info-card-art-container no-reveal">
                         ${img({
-                            class: 'info-card-art',
-                            src: '',
-                            link: true,
-                            square: true
+                          class: "info-card-art",
+                          src: "",
+                          link: true,
+                          square: true,
                         })}
                     </div>
                     <div class="info-card-art-container reveal">
                         ${img({
-                            class: 'info-card-art',
-                            src: '',
-                            link: true,
-                            square: true,
-                            reveal: getRevealStringFromWarnings('<span class="info-card-art-warnings"></span>', {language})
+                          class: "info-card-art",
+                          src: "",
+                          link: true,
+                          square: true,
+                          reveal: getRevealStringFromWarnings(
+                            '<span class="info-card-art-warnings"></span>',
+                            { language }
+                          ),
                         })}
                     </div>
                     <h1 class="info-card-name"><a></a></h1>
-                    <p class="info-card-album">${language.$('releaseInfo.from', {album: '<a></a>'})}</p>
-                    <p class="info-card-artists">${language.$('releaseInfo.by', {artists: '<span></span>'})}</p>
-                    <p class="info-card-cover-artists">${language.$('releaseInfo.coverArtBy', {artists: '<span></span>'})}</p>
+                    <p class="info-card-album">${language.$(
+                      "releaseInfo.from",
+                      { album: "<a></a>" }
+                    )}</p>
+                    <p class="info-card-artists">${language.$(
+                      "releaseInfo.by",
+                      { artists: "<span></span>" }
+                    )}</p>
+                    <p class="info-card-cover-artists">${language.$(
+                      "releaseInfo.coverArtBy",
+                      { artists: "<span></span>" }
+                    )}</p>
                 </div>
             </div>
         </div>
     `;
 
-    const socialEmbedHTML = [
-        socialEmbed.title && html.tag('meta', {property: 'og:title', content: socialEmbed.title}),
-        socialEmbed.description && html.tag('meta', {property: 'og:description', content: socialEmbed.description}),
-        socialEmbed.image && html.tag('meta', {property: 'og:image', content: socialEmbed.image}),
-        socialEmbed.color && html.tag('meta', {name: 'theme-color', content: socialEmbed.color}),
-        oEmbedJSONHref && html.tag('link', {type: 'application/json+oembed', href: oEmbedJSONHref}),
-    ].filter(Boolean).join('\n');
-
-    return filterEmptyLines(fixWS`
+  const socialEmbedHTML = [
+    socialEmbed.title &&
+      html.tag("meta", { property: "og:title", content: socialEmbed.title }),
+    socialEmbed.description &&
+      html.tag("meta", {
+        property: "og:description",
+        content: socialEmbed.description,
+      }),
+    socialEmbed.image &&
+      html.tag("meta", { property: "og:image", content: socialEmbed.image }),
+    socialEmbed.color &&
+      html.tag("meta", { name: "theme-color", content: socialEmbed.color }),
+    oEmbedJSONHref &&
+      html.tag("link", {
+        type: "application/json+oembed",
+        href: oEmbedJSONHref,
+      }),
+  ]
+    .filter(Boolean)
+    .join("\n");
+
+  return filterEmptyLines(fixWS`
         <!DOCTYPE html>
         <html ${html.attributes({
-            lang: language.intlCode,
-            'data-language-code': language.code,
-            'data-url-key': paths.toPath[0],
-            ...Object.fromEntries(paths.toPath.slice(1).map((v, i) => [['data-url-value' + i], v])),
-            'data-rebase-localized': to('localized.root'),
-            'data-rebase-shared': to('shared.root'),
-            'data-rebase-media': to('media.root'),
-            'data-rebase-data': to('data.root')
+          lang: language.intlCode,
+          "data-language-code": language.code,
+          "data-url-key": paths.toPath[0],
+          ...Object.fromEntries(
+            paths.toPath.slice(1).map((v, i) => [["data-url-value" + i], v])
+          ),
+          "data-rebase-localized": to("localized.root"),
+          "data-rebase-shared": to("shared.root"),
+          "data-rebase-media": to("media.root"),
+          "data-rebase-data": to("data.root"),
         })}>
             <head>
-                <title>${(showWikiNameInTitle
-                    ? language.formatString('misc.pageTitle.withWikiName', {
+                <title>${
+                  showWikiNameInTitle
+                    ? language.formatString("misc.pageTitle.withWikiName", {
                         title,
-                        wikiName: wikiInfo.nameShort
-                    })
-                    : language.formatString('misc.pageTitle', {title}))}</title>
+                        wikiName: wikiInfo.nameShort,
+                      })
+                    : language.formatString("misc.pageTitle", { title })
+                }</title>
                 <meta charset="utf-8">
                 <meta name="viewport" content="width=device-width, initial-scale=1">
-                ${Object.entries(meta).filter(([ key, value ]) => value).map(([ key, value ]) => `<meta ${key}="${html.escapeAttributeValue(value)}">`).join('\n')}
+                ${Object.entries(meta)
+                  .filter(([key, value]) => value)
+                  .map(
+                    ([key, value]) =>
+                      `<meta ${key}="${html.escapeAttributeValue(value)}">`
+                  )
+                  .join("\n")}
                 ${canonical && `<link rel="canonical" href="${canonical}">`}
-                ${localizedCanonical.map(({ lang, href }) => `<link rel="alternate" hreflang="${lang}" href="${href}">`).join('\n')}
+                ${localizedCanonical
+                  .map(
+                    ({ lang, href }) =>
+                      `<link rel="alternate" hreflang="${lang}" href="${href}">`
+                  )
+                  .join("\n")}
                 ${socialEmbedHTML}
-                <link rel="stylesheet" href="${to('shared.staticFile', `site.css?${CACHEBUST}`)}">
-                ${(theme || stylesheet) && fixWS`
+                <link rel="stylesheet" href="${to(
+                  "shared.staticFile",
+                  `site.css?${CACHEBUST}`
+                )}">
+                ${
+                  (theme || stylesheet) &&
+                  fixWS`
                     <style>
                         ${theme}
                         ${stylesheet}
                     </style>
-                `}
-                <script src="${to('shared.staticFile', `lazy-loading.js?${CACHEBUST}`)}"></script>
+                `
+                }
+                <script src="${to(
+                  "shared.staticFile",
+                  `lazy-loading.js?${CACHEBUST}`
+                )}"></script>
             </head>
-            <body ${html.attributes({style: body.style || ''})}>
+            <body ${html.attributes({ style: body.style || "" })}>
                 <div id="page-container">
-                    ${mainHTML && fixWS`
+                    ${
+                      mainHTML &&
+                      fixWS`
                         <div id="skippers">
                             ${[
-                                ['#content', language.$('misc.skippers.skipToContent')],
-                                sidebarLeftHTML && ['#sidebar-left', (sidebarRightHTML
-                                    ? language.$('misc.skippers.skipToSidebar.left')
-                                    : language.$('misc.skippers.skipToSidebar'))],
-                                sidebarRightHTML && ['#sidebar-right', (sidebarLeftHTML
-                                    ? language.$('misc.skippers.skipToSidebar.right')
-                                    : language.$('misc.skippers.skipToSidebar'))],
-                                footerHTML && ['#footer', language.$('misc.skippers.skipToFooter')]
-                            ].filter(Boolean).map(([ href, title ]) => fixWS`
+                              [
+                                "#content",
+                                language.$("misc.skippers.skipToContent"),
+                              ],
+                              sidebarLeftHTML && [
+                                "#sidebar-left",
+                                sidebarRightHTML
+                                  ? language.$(
+                                      "misc.skippers.skipToSidebar.left"
+                                    )
+                                  : language.$("misc.skippers.skipToSidebar"),
+                              ],
+                              sidebarRightHTML && [
+                                "#sidebar-right",
+                                sidebarLeftHTML
+                                  ? language.$(
+                                      "misc.skippers.skipToSidebar.right"
+                                    )
+                                  : language.$("misc.skippers.skipToSidebar"),
+                              ],
+                              footerHTML && [
+                                "#footer",
+                                language.$("misc.skippers.skipToFooter"),
+                              ],
+                            ]
+                              .filter(Boolean)
+                              .map(
+                                ([href, title]) => fixWS`
                                 <span class="skipper"><a href="${href}">${title}</a></span>
-                            `).join('\n')}
+                            `
+                              )
+                              .join("\n")}
                         </div>
-                    `}
+                    `
+                    }
                     ${layoutHTML}
                 </div>
                 ${infoCardHTML}
-                <script type="module" src="${to('shared.staticFile', `client.js?${CACHEBUST}`)}"></script>
+                <script type="module" src="${to(
+                  "shared.staticFile",
+                  `client.js?${CACHEBUST}`
+                )}"></script>
             </body>
         </html>
     `);
 };
 
-writePage.oEmbedJSON = (pageInfo, {
-    language,
-    wikiData,
-}) => {
-    const { socialEmbed } = pageInfo;
-    const { wikiInfo } = wikiData;
-    const { canonicalBase, nameShort } = wikiInfo;
-
-    if (!socialEmbed) return '';
-
-    const entries = [
-        socialEmbed.heading && ['author_name',
-            language.$('misc.socialEmbed.heading', {
-                wikiName: nameShort,
-                heading: socialEmbed.heading
-            })],
-        socialEmbed.headingLink && canonicalBase && ['author_url',
-            canonicalBase.replace(/\/$/, '') + '/' +
-            socialEmbed.headingLink.replace(/^\//, '')],
-    ].filter(Boolean);
-
-    if (!entries.length) return '';
-
-    return JSON.stringify(Object.fromEntries(entries));
+writePage.oEmbedJSON = (pageInfo, { language, wikiData }) => {
+  const { socialEmbed } = pageInfo;
+  const { wikiInfo } = wikiData;
+  const { canonicalBase, nameShort } = wikiInfo;
+
+  if (!socialEmbed) return "";
+
+  const entries = [
+    socialEmbed.heading && [
+      "author_name",
+      language.$("misc.socialEmbed.heading", {
+        wikiName: nameShort,
+        heading: socialEmbed.heading,
+      }),
+    ],
+    socialEmbed.headingLink &&
+      canonicalBase && [
+        "author_url",
+        canonicalBase.replace(/\/$/, "") +
+          "/" +
+          socialEmbed.headingLink.replace(/^\//, ""),
+      ],
+  ].filter(Boolean);
+
+  if (!entries.length) return "";
+
+  return JSON.stringify(Object.fromEntries(entries));
 };
 
-writePage.write = async ({
-    html,
-    oEmbedJSON = '',
-    paths,
-}) => {
-    await mkdir(paths.outputDirectory, {recursive: true});
-    await Promise.all([
-        writeFile(paths.outputFile, html),
-        oEmbedJSON && writeFile(paths.oEmbedJSONFile, oEmbedJSON)
-    ].filter(Boolean));
+writePage.write = async ({ html, oEmbedJSON = "", paths }) => {
+  await mkdir(paths.outputDirectory, { recursive: true });
+  await Promise.all(
+    [
+      writeFile(paths.outputFile, html),
+      oEmbedJSON && writeFile(paths.oEmbedJSONFile, oEmbedJSON),
+    ].filter(Boolean)
+  );
 };
 
 // TODO: This only supports one <>-style argument.
-writePage.paths = (baseDirectory, fullKey, directory = '', {
-    file = 'index.html'
-} = {}) => {
-    const [ groupKey, subKey ] = fullKey.split('.');
-
-    const pathname = (groupKey === 'localized' && baseDirectory
-        ? urls.from('shared.root').toDevice('localizedWithBaseDirectory.' + subKey, baseDirectory, directory)
-        : urls.from('shared.root').toDevice(fullKey, directory));
-
-    // Needed for the rare directory which itself contains a slash, e.g. for
-    // listings, with directories like 'albums/by-name'.
-    const subdirectoryPrefix = '../'.repeat(directory.split('/').length - 1);
-
-    const outputDirectory = path.join(outputPath, pathname);
-    const outputFile = path.join(outputDirectory, file);
-    const oEmbedJSONFile = path.join(outputDirectory, OEMBED_JSON_FILE);
-
-    return {
-        toPath: [fullKey, directory],
-        pathname,
-        subdirectoryPrefix,
-        outputDirectory, outputFile,
-        oEmbedJSONFile,
-    };
+writePage.paths = (
+  baseDirectory,
+  fullKey,
+  directory = "",
+  { file = "index.html" } = {}
+) => {
+  const [groupKey, subKey] = fullKey.split(".");
+
+  const pathname =
+    groupKey === "localized" && baseDirectory
+      ? urls
+          .from("shared.root")
+          .toDevice(
+            "localizedWithBaseDirectory." + subKey,
+            baseDirectory,
+            directory
+          )
+      : urls.from("shared.root").toDevice(fullKey, directory);
+
+  // Needed for the rare directory which itself contains a slash, e.g. for
+  // listings, with directories like 'albums/by-name'.
+  const subdirectoryPrefix = "../".repeat(directory.split("/").length - 1);
+
+  const outputDirectory = path.join(outputPath, pathname);
+  const outputFile = path.join(outputDirectory, file);
+  const oEmbedJSONFile = path.join(outputDirectory, OEMBED_JSON_FILE);
+
+  return {
+    toPath: [fullKey, directory],
+    pathname,
+    subdirectoryPrefix,
+    outputDirectory,
+    outputFile,
+    oEmbedJSONFile,
+  };
 };
 
 async function writeFavicon() {
+  try {
+    await stat(path.join(mediaPath, FAVICON_FILE));
+  } catch (error) {
+    return;
+  }
+
+  try {
+    await copyFile(
+      path.join(mediaPath, FAVICON_FILE),
+      path.join(outputPath, FAVICON_FILE)
+    );
+  } catch (error) {
+    logWarn`Failed to copy favicon! ${error.message}`;
+    return;
+  }
+
+  logInfo`Copied favicon to site root.`;
+}
+
+function writeSymlinks() {
+  return progressPromiseAll("Writing site symlinks.", [
+    link(path.join(__dirname, UTILITY_DIRECTORY), "shared.utilityRoot"),
+    link(path.join(__dirname, STATIC_DIRECTORY), "shared.staticRoot"),
+    link(mediaPath, "media.root"),
+  ]);
+
+  async function link(directory, urlKey) {
+    const pathname = urls.from("shared.root").toDevice(urlKey);
+    const file = path.join(outputPath, pathname);
     try {
-        await stat(path.join(mediaPath, FAVICON_FILE));
+      await unlink(file);
     } catch (error) {
-        return;
+      if (error.code !== "ENOENT") {
+        throw error;
+      }
     }
-
     try {
-        await copyFile(
-            path.join(mediaPath, FAVICON_FILE),
-            path.join(outputPath, FAVICON_FILE)
-        );
+      await symlink(path.resolve(directory), file);
     } catch (error) {
-        logWarn`Failed to copy favicon! ${error.message}`;
-        return;
+      if (error.code === "EPERM") {
+        await symlink(path.resolve(directory), file, "junction");
+      }
     }
-
-    logInfo`Copied favicon to site root.`;
+  }
 }
 
-function writeSymlinks() {
-    return progressPromiseAll('Writing site symlinks.', [
-        link(path.join(__dirname, UTILITY_DIRECTORY), 'shared.utilityRoot'),
-        link(path.join(__dirname, STATIC_DIRECTORY), 'shared.staticRoot'),
-        link(mediaPath, 'media.root')
-    ]);
-
-    async function link(directory, urlKey) {
-        const pathname = urls.from('shared.root').toDevice(urlKey);
-        const file = path.join(outputPath, pathname);
-        try {
-            await unlink(file);
-        } catch (error) {
-            if (error.code !== 'ENOENT') {
-                throw error;
-            }
-        }
-        try {
-            await symlink(path.resolve(directory), file);
-        } catch (error) {
-            if (error.code === 'EPERM') {
-                await symlink(path.resolve(directory), file, 'junction');
-            }
-        }
-    }
-}
-
-function writeSharedFilesAndPages({language, wikiData}) {
-    const { groupData, wikiInfo } = wikiData;
-
-    const redirect = async (title, from, urlKey, directory) => {
-        const target = path.relative(from, urls.from('shared.root').to(urlKey, directory));
-        const content = generateRedirectPage(title, target, {language});
-        await mkdir(path.join(outputPath, from), {recursive: true});
-        await writeFile(path.join(outputPath, from, 'index.html'), content);
-    };
-
-    return progressPromiseAll(`Writing files & pages shared across languages.`, [
-        groupData?.some(group => group.directory === 'fandom') &&
-        redirect('Fandom - Gallery', 'albums/fandom', 'localized.groupGallery', 'fandom'),
-
-        groupData?.some(group => group.directory === 'official') &&
-        redirect('Official - Gallery', 'albums/official', 'localized.groupGallery', 'official'),
+function writeSharedFilesAndPages({ language, wikiData }) {
+  const { groupData, wikiInfo } = wikiData;
 
-        wikiInfo.enableListings &&
-        redirect('Album Commentary', 'list/all-commentary', 'localized.commentaryIndex', ''),
-
-        writeFile(path.join(outputPath, 'data.json'), fixWS`
+  const redirect = async (title, from, urlKey, directory) => {
+    const target = path.relative(
+      from,
+      urls.from("shared.root").to(urlKey, directory)
+    );
+    const content = generateRedirectPage(title, target, { language });
+    await mkdir(path.join(outputPath, from), { recursive: true });
+    await writeFile(path.join(outputPath, from, "index.html"), content);
+  };
+
+  return progressPromiseAll(
+    `Writing files & pages shared across languages.`,
+    [
+      groupData?.some((group) => group.directory === "fandom") &&
+        redirect(
+          "Fandom - Gallery",
+          "albums/fandom",
+          "localized.groupGallery",
+          "fandom"
+        ),
+
+      groupData?.some((group) => group.directory === "official") &&
+        redirect(
+          "Official - Gallery",
+          "albums/official",
+          "localized.groupGallery",
+          "official"
+        ),
+
+      wikiInfo.enableListings &&
+        redirect(
+          "Album Commentary",
+          "list/all-commentary",
+          "localized.commentaryIndex",
+          ""
+        ),
+
+      writeFile(
+        path.join(outputPath, "data.json"),
+        fixWS`
             {
                 "albumData": ${stringifyThings(wikiData.albumData)},
-                ${wikiInfo.enableFlashesAndGames && `"flashData": ${stringifyThings(wikiData.flashData)},`}
+                ${
+                  wikiInfo.enableFlashesAndGames &&
+                  `"flashData": ${stringifyThings(wikiData.flashData)},`
+                }
                 "artistData": ${stringifyThings(wikiData.artistData)}
             }
-        `)
-    ].filter(Boolean));
+        `
+      ),
+    ].filter(Boolean)
+  );
 }
 
-function generateRedirectPage(title, target, {language}) {
-    return fixWS`
+function generateRedirectPage(title, target, { language }) {
+  return fixWS`
         <!DOCTYPE html>
         <html>
             <head>
-                <title>${language.$('redirectPage.title', {title})}</title>
+                <title>${language.$("redirectPage.title", { title })}</title>
                 <meta charset="utf-8">
                 <meta http-equiv="refresh" content="0;url=${target}">
                 <link rel="canonical" href="${target}">
@@ -1322,9 +1539,9 @@ function generateRedirectPage(title, target, {language}) {
             </head>
             <body>
                 <main>
-                    <h1>${language.$('redirectPage.title', {title})}</h1>
-                    <p>${language.$('redirectPage.infoLine', {
-                        target: `<a href="${target}">${target}</a>`
+                    <h1>${language.$("redirectPage.title", { title })}</h1>
+                    <p>${language.$("redirectPage.infoLine", {
+                      target: `<a href="${target}">${target}</a>`,
                     })}</p>
                 </main>
             </body>
@@ -1334,622 +1551,663 @@ function generateRedirectPage(title, target, {language}) {
 
 // RIP toAnythingMan (previously getHrefOfAnythingMan), 2020-05-25<>2021-05-14.
 // ........Yet the function 8reathes life anew as linkAnythingMan! ::::)
-function linkAnythingMan(anythingMan, {link, wikiData, ...opts}) {
-    return (
-        wikiData.albumData.includes(anythingMan) ? link.album(anythingMan, opts) :
-        wikiData.trackData.includes(anythingMan) ? link.track(anythingMan, opts) :
-        wikiData.flashData?.includes(anythingMan) ? link.flash(anythingMan, opts) :
-        'idk bud'
-    )
+function linkAnythingMan(anythingMan, { link, wikiData, ...opts }) {
+  return wikiData.albumData.includes(anythingMan)
+    ? link.album(anythingMan, opts)
+    : wikiData.trackData.includes(anythingMan)
+    ? link.track(anythingMan, opts)
+    : wikiData.flashData?.includes(anythingMan)
+    ? link.flash(anythingMan, opts)
+    : "idk bud";
 }
 
 async function processLanguageFile(file) {
-    const contents = await readFile(file, 'utf-8');
-    const json = JSON.parse(contents);
-
-    const code = json['meta.languageCode'];
-    if (!code) {
-        throw new Error(`Missing language code (file: ${file})`);
-    }
-    delete json['meta.languageCode'];
-
-    const intlCode = json['meta.languageIntlCode'] ?? null;
-    delete json['meta.languageIntlCode'];
-
-    const name = json['meta.languageName'];
-    if (!name) {
-        throw new Error(`Missing language name (${code})`);
-    }
-    delete json['meta.languageName'];
-
-    const hidden = json['meta.hidden'] ?? false;
-    delete json['meta.hidden'];
-
-    if (json['meta.baseDirectory']) {
-        logWarn`(${code}) Language JSON still has unused meta.baseDirectory`;
-        delete json['meta.baseDirectory'];
-    }
-
-    const language = new Language();
-    language.code = code;
-    language.intlCode = intlCode;
-    language.name = name;
-    language.hidden = hidden;
-    language.escapeHTML = string => he.encode(string, {useNamedReferences: true});
-    language.strings = json;
-    return language;
+  const contents = await readFile(file, "utf-8");
+  const json = JSON.parse(contents);
+
+  const code = json["meta.languageCode"];
+  if (!code) {
+    throw new Error(`Missing language code (file: ${file})`);
+  }
+  delete json["meta.languageCode"];
+
+  const intlCode = json["meta.languageIntlCode"] ?? null;
+  delete json["meta.languageIntlCode"];
+
+  const name = json["meta.languageName"];
+  if (!name) {
+    throw new Error(`Missing language name (${code})`);
+  }
+  delete json["meta.languageName"];
+
+  const hidden = json["meta.hidden"] ?? false;
+  delete json["meta.hidden"];
+
+  if (json["meta.baseDirectory"]) {
+    logWarn`(${code}) Language JSON still has unused meta.baseDirectory`;
+    delete json["meta.baseDirectory"];
+  }
+
+  const language = new Language();
+  language.code = code;
+  language.intlCode = intlCode;
+  language.name = name;
+  language.hidden = hidden;
+  language.escapeHTML = (string) =>
+    he.encode(string, { useNamedReferences: true });
+  language.strings = json;
+  return language;
 }
 
 // Wrapper function for running a function once for all languages.
-async function wrapLanguages(fn, {languages, writeOneLanguage = null}) {
-    const k = writeOneLanguage;
-    const languagesToRun = (k
-        ? {[k]: languages[k]}
-        : languages);
+async function wrapLanguages(fn, { languages, writeOneLanguage = null }) {
+  const k = writeOneLanguage;
+  const languagesToRun = k ? { [k]: languages[k] } : languages;
 
-    const entries = Object.entries(languagesToRun)
-        .filter(([ key ]) => key !== 'default');
+  const entries = Object.entries(languagesToRun).filter(
+    ([key]) => key !== "default"
+  );
 
-    for (let i = 0; i < entries.length; i++) {
-        const [ key, language ] = entries[i];
+  for (let i = 0; i < entries.length; i++) {
+    const [key, language] = entries[i];
 
-        await fn(language, i, entries);
-    }
+    await fn(language, i, entries);
+  }
 }
 
 async function main() {
-    Error.stackTraceLimit = Infinity;
+  Error.stackTraceLimit = Infinity;
 
-    const WD = wikiData;
+  const WD = wikiData;
 
-    WD.listingSpec = listingSpec;
-    WD.listingTargetSpec = listingTargetSpec;
-
-    const miscOptions = await parseOptions(process.argv.slice(2), {
-        // Data files for the site, including flash, artist, and al8um data,
-        // and like a jillion other things too. Pretty much everything which
-        // makes an individual wiki what it is goes here!
-        'data-path': {
-            type: 'value'
-        },
+  WD.listingSpec = listingSpec;
+  WD.listingTargetSpec = listingTargetSpec;
 
-        // Static media will 8e referenced in the site here! The contents are
-        // categorized; check out MEDIA_ALBUM_ART_DIRECTORY and other constants
-        // near the top of this file (upd8.js).
-        'media-path': {
-            type: 'value'
-        },
-
-        // String files! For the most part, this is used for translating the
-        // site to different languages, though you can also customize strings
-        // for your own 8uild of the site if you'd like. Files here should all
-        // match the format in strings-default.json in this repository. (If a
-        // language file is missing any strings, the site code will fall 8ack
-        // to what's specified in strings-default.json.)
-        //
-        // Unlike the other options here, this one's optional - the site will
-        // 8uild with the default (English) strings if this path is left
-        // unspecified.
-        'lang-path': {
-            type: 'value'
-        },
+  const miscOptions = await parseOptions(process.argv.slice(2), {
+    // Data files for the site, including flash, artist, and al8um data,
+    // and like a jillion other things too. Pretty much everything which
+    // makes an individual wiki what it is goes here!
+    "data-path": {
+      type: "value",
+    },
 
-        // This is the output directory. It's the one you'll upload online with
-        // rsync or whatever when you're pushing an upd8, and also the one
-        // you'd archive if you wanted to make a 8ackup of the whole dang
-        // site. Just keep in mind that the gener8ted result will contain a
-        // couple symlinked directories, so if you're uploading, you're pro8a8ly
-        // gonna want to resolve those yourself.
-        'out-path': {
-            type: 'value'
-        },
+    // Static media will 8e referenced in the site here! The contents are
+    // categorized; check out MEDIA_ALBUM_ART_DIRECTORY and other constants
+    // near the top of this file (upd8.js).
+    "media-path": {
+      type: "value",
+    },
 
-        // Thum8nail gener8tion is *usually* something you want, 8ut it can 8e
-        // kinda a pain to run every time, since it does necessit8te reading
-        // every media file at run time. Pass this to skip it.
-        'skip-thumbs': {
-            type: 'flag'
-        },
+    // String files! For the most part, this is used for translating the
+    // site to different languages, though you can also customize strings
+    // for your own 8uild of the site if you'd like. Files here should all
+    // match the format in strings-default.json in this repository. (If a
+    // language file is missing any strings, the site code will fall 8ack
+    // to what's specified in strings-default.json.)
+    //
+    // Unlike the other options here, this one's optional - the site will
+    // 8uild with the default (English) strings if this path is left
+    // unspecified.
+    "lang-path": {
+      type: "value",
+    },
 
-        // Or, if you *only* want to gener8te newly upd8ted thum8nails, you can
-        // pass this flag! It exits 8efore 8uilding the rest of the site.
-        'thumbs-only': {
-            type: 'flag'
-        },
+    // This is the output directory. It's the one you'll upload online with
+    // rsync or whatever when you're pushing an upd8, and also the one
+    // you'd archive if you wanted to make a 8ackup of the whole dang
+    // site. Just keep in mind that the gener8ted result will contain a
+    // couple symlinked directories, so if you're uploading, you're pro8a8ly
+    // gonna want to resolve those yourself.
+    "out-path": {
+      type: "value",
+    },
 
-        // Just working on data entries and not interested in actually
-        // generating site HTML yet? This flag will cut execution off right
-        // 8efore any site 8uilding actually happens.
-        'no-build': {
-            type: 'flag'
-        },
+    // Thum8nail gener8tion is *usually* something you want, 8ut it can 8e
+    // kinda a pain to run every time, since it does necessit8te reading
+    // every media file at run time. Pass this to skip it.
+    "skip-thumbs": {
+      type: "flag",
+    },
 
-        // Only want to 8uild one language during testing? This can chop down
-        // 8uild times a pretty 8ig chunk! Just pass a single language code.
-        'lang': {
-            type: 'value'
-        },
+    // Or, if you *only* want to gener8te newly upd8ted thum8nails, you can
+    // pass this flag! It exits 8efore 8uilding the rest of the site.
+    "thumbs-only": {
+      type: "flag",
+    },
 
-        // Working without a dev server and just using file:// URLs in your we8
-        // 8rowser? This will automatically append index.html to links across
-        // the site. Not recommended for production, since it isn't guaranteed
-        // 100% error-free (and index.html-style links are less pretty anyway).
-        'append-index-html': {
-            type: 'flag'
-        },
+    // Just working on data entries and not interested in actually
+    // generating site HTML yet? This flag will cut execution off right
+    // 8efore any site 8uilding actually happens.
+    "no-build": {
+      type: "flag",
+    },
 
-        // Want sweet, sweet trace8ack info in aggreg8te error messages? This
-        // will print all the juicy details (or at least the first relevant
-        // line) right to your output, 8ut also pro8a8ly give you a headache
-        // 8ecause wow that is a lot of visual noise.
-        'show-traces': {
-            type: 'flag'
-        },
+    // Only want to 8uild one language during testing? This can chop down
+    // 8uild times a pretty 8ig chunk! Just pass a single language code.
+    lang: {
+      type: "value",
+    },
 
-        'queue-size': {
-            type: 'value',
-            validate(size) {
-                if (parseInt(size) !== parseFloat(size)) return 'an integer';
-                if (parseInt(size) < 0) return 'a counting number or zero';
-                return true;
-            }
-        },
-        queue: {alias: 'queue-size'},
+    // Working without a dev server and just using file:// URLs in your we8
+    // 8rowser? This will automatically append index.html to links across
+    // the site. Not recommended for production, since it isn't guaranteed
+    // 100% error-free (and index.html-style links are less pretty anyway).
+    "append-index-html": {
+      type: "flag",
+    },
 
-        // This option is super slow and has the potential for bugs! It puts
-        // CacheableObject in a mode where every instance is a Proxy which will
-        // keep track of invalid property accesses.
-        'show-invalid-property-accesses': {
-            type: 'flag'
-        },
+    // Want sweet, sweet trace8ack info in aggreg8te error messages? This
+    // will print all the juicy details (or at least the first relevant
+    // line) right to your output, 8ut also pro8a8ly give you a headache
+    // 8ecause wow that is a lot of visual noise.
+    "show-traces": {
+      type: "flag",
+    },
 
-        [parseOptions.handleUnknown]: () => {}
-    });
+    "queue-size": {
+      type: "value",
+      validate(size) {
+        if (parseInt(size) !== parseFloat(size)) return "an integer";
+        if (parseInt(size) < 0) return "a counting number or zero";
+        return true;
+      },
+    },
+    queue: { alias: "queue-size" },
 
-    dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA;
-    mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA;
-    langPath = miscOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
-    outputPath = miscOptions['out-path'] || process.env.HSMUSIC_OUT;
+    // This option is super slow and has the potential for bugs! It puts
+    // CacheableObject in a mode where every instance is a Proxy which will
+    // keep track of invalid property accesses.
+    "show-invalid-property-accesses": {
+      type: "flag",
+    },
 
-    const writeOneLanguage = miscOptions['lang'];
+    [parseOptions.handleUnknown]: () => {},
+  });
 
-    {
-        let errored = false;
-        const error = (cond, msg) => {
-            if (cond) {
-                console.error(`\x1b[31;1m${msg}\x1b[0m`);
-                errored = true;
-            }
-        };
-        error(!dataPath,   `Expected --data-path option or HSMUSIC_DATA to be set`);
-        error(!mediaPath,  `Expected --media-path option or HSMUSIC_MEDIA to be set`);
-        error(!outputPath, `Expected --out-path option or HSMUSIC_OUT to be set`);
-        if (errored) {
-            return;
-        }
-    }
+  dataPath = miscOptions["data-path"] || process.env.HSMUSIC_DATA;
+  mediaPath = miscOptions["media-path"] || process.env.HSMUSIC_MEDIA;
+  langPath = miscOptions["lang-path"] || process.env.HSMUSIC_LANG; // Can 8e left unset!
+  outputPath = miscOptions["out-path"] || process.env.HSMUSIC_OUT;
 
-    const appendIndexHTML = miscOptions['append-index-html'] ?? false;
-    if (appendIndexHTML) {
-        logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`;
-        unbound_link.globalOptions.appendIndexHTML = true;
-    }
+  const writeOneLanguage = miscOptions["lang"];
 
-    const skipThumbs = miscOptions['skip-thumbs'] ?? false;
-    const thumbsOnly = miscOptions['thumbs-only'] ?? false;
-    const noBuild = miscOptions['no-build'] ?? false;
-    const showAggregateTraces = miscOptions['show-traces'] ?? false;
-
-    const niceShowAggregate = (error, ...opts) => {
-        showAggregate(error, {
-            showTraces: showAggregateTraces,
-            pathToFile: f => path.relative(__dirname, f),
-            ...opts
-        });
+  {
+    let errored = false;
+    const error = (cond, msg) => {
+      if (cond) {
+        console.error(`\x1b[31;1m${msg}\x1b[0m`);
+        errored = true;
+      }
     };
-
-    if (skipThumbs && thumbsOnly) {
-        logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`;
-        return;
+    error(!dataPath, `Expected --data-path option or HSMUSIC_DATA to be set`);
+    error(
+      !mediaPath,
+      `Expected --media-path option or HSMUSIC_MEDIA to be set`
+    );
+    error(!outputPath, `Expected --out-path option or HSMUSIC_OUT to be set`);
+    if (errored) {
+      return;
     }
-
-    if (skipThumbs) {
-        logInfo`Skipping thumbnail generation.`;
-    } else {
-        logInfo`Begin thumbnail generation... -----+`;
-        const result = await genThumbs(mediaPath, {queueSize, quiet: true});
-        logInfo`Done thumbnail generation! --------+`;
-        if (!result) return;
-        if (thumbsOnly) return;
+  }
+
+  const appendIndexHTML = miscOptions["append-index-html"] ?? false;
+  if (appendIndexHTML) {
+    logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`;
+    unbound_link.globalOptions.appendIndexHTML = true;
+  }
+
+  const skipThumbs = miscOptions["skip-thumbs"] ?? false;
+  const thumbsOnly = miscOptions["thumbs-only"] ?? false;
+  const noBuild = miscOptions["no-build"] ?? false;
+  const showAggregateTraces = miscOptions["show-traces"] ?? false;
+
+  const niceShowAggregate = (error, ...opts) => {
+    showAggregate(error, {
+      showTraces: showAggregateTraces,
+      pathToFile: (f) => path.relative(__dirname, f),
+      ...opts,
+    });
+  };
+
+  if (skipThumbs && thumbsOnly) {
+    logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`;
+    return;
+  }
+
+  if (skipThumbs) {
+    logInfo`Skipping thumbnail generation.`;
+  } else {
+    logInfo`Begin thumbnail generation... -----+`;
+    const result = await genThumbs(mediaPath, { queueSize, quiet: true });
+    logInfo`Done thumbnail generation! --------+`;
+    if (!result) return;
+    if (thumbsOnly) return;
+  }
+
+  const showInvalidPropertyAccesses =
+    miscOptions["show-invalid-property-accesses"] ?? false;
+
+  if (showInvalidPropertyAccesses) {
+    CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
+  }
+
+  const { aggregate: processDataAggregate, result: wikiDataResult } =
+    await loadAndProcessDataDocuments({ dataPath });
+
+  Object.assign(wikiData, wikiDataResult);
+
+  {
+    const logThings = (thingDataProp, label) =>
+      logInfo` - ${
+        wikiData[thingDataProp]?.length ?? color.red("(Missing!)")
+      } ${color.normal(color.dim(label))}`;
+    try {
+      logInfo`Loaded data and processed objects:`;
+      logThings("albumData", "albums");
+      logThings("trackData", "tracks");
+      logThings("artistData", "artists");
+      if (wikiData.flashData) {
+        logThings("flashData", "flashes");
+        logThings("flashActData", "flash acts");
+      }
+      logThings("groupData", "groups");
+      logThings("groupCategoryData", "group categories");
+      logThings("artTagData", "art tags");
+      if (wikiData.newsData) {
+        logThings("newsData", "news entries");
+      }
+      logThings("staticPageData", "static pages");
+      if (wikiData.homepageLayout) {
+        logInfo` - ${1} homepage layout (${
+          wikiData.homepageLayout.rows.length
+        } rows)`;
+      }
+      if (wikiData.wikiInfo) {
+        logInfo` - ${1} wiki config file`;
+      }
+    } catch (error) {
+      console.error(`Error showing data summary:`, error);
     }
 
-    const showInvalidPropertyAccesses = miscOptions['show-invalid-property-accesses'] ?? false;
+    let errorless = true;
+    try {
+      processDataAggregate.close();
+    } catch (error) {
+      niceShowAggregate(error);
+      logWarn`The above errors were detected while processing data files.`;
+      logWarn`If the remaining valid data is complete enough, the wiki will`;
+      logWarn`still build - but all errored data will be skipped.`;
+      logWarn`(Resolve errors for more complete output!)`;
+      errorless = false;
+    }
 
-    if (showInvalidPropertyAccesses) {
-        CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
+    if (errorless) {
+      logInfo`All data processed without any errors - nice!`;
+      logInfo`(This means all source files will be fully accounted for during page generation.)`;
     }
+  }
 
-    const {
-        aggregate: processDataAggregate,
-        result: wikiDataResult
-    } = await loadAndProcessDataDocuments({dataPath});
+  if (!WD.wikiInfo) {
+    logError`Can't proceed without wiki info file (${WIKI_INFO_FILE}) successfully loading`;
+    return;
+  }
 
-    Object.assign(wikiData, wikiDataResult);
+  let duplicateDirectoriesErrored = false;
 
-    {
-        const logThings = (thingDataProp, label) => logInfo` - ${wikiData[thingDataProp]?.length ?? color.red('(Missing!)')} ${color.normal(color.dim(label))}`;
-        try {
-            logInfo`Loaded data and processed objects:`;
-            logThings('albumData', 'albums');
-            logThings('trackData', 'tracks');
-            logThings('artistData', 'artists');
-            if (wikiData.flashData) {
-                logThings('flashData', 'flashes');
-                logThings('flashActData', 'flash acts');
-            }
-            logThings('groupData', 'groups');
-            logThings('groupCategoryData', 'group categories');
-            logThings('artTagData', 'art tags');
-            if (wikiData.newsData) {
-                logThings('newsData', 'news entries');
-            }
-            logThings('staticPageData', 'static pages');
-            if (wikiData.homepageLayout) {
-                logInfo` - ${1} homepage layout (${wikiData.homepageLayout.rows.length} rows)`;
-            }
-            if (wikiData.wikiInfo) {
-                logInfo` - ${1} wiki config file`;
-            }
-        } catch (error) {
-            console.error(`Error showing data summary:`, error);
-        }
-
-        let errorless = true;
-        try {
-            processDataAggregate.close();
-        } catch (error) {
-            niceShowAggregate(error);
-            logWarn`The above errors were detected while processing data files.`;
-            logWarn`If the remaining valid data is complete enough, the wiki will`;
-            logWarn`still build - but all errored data will be skipped.`;
-            logWarn`(Resolve errors for more complete output!)`;
-            errorless = false;
-        }
-
-        if (errorless) {
-            logInfo`All data processed without any errors - nice!`;
-            logInfo`(This means all source files will be fully accounted for during page generation.)`;
-        }
+  function filterAndShowDuplicateDirectories() {
+    const aggregate = filterDuplicateDirectories(wikiData);
+    let errorless = true;
+    try {
+      aggregate.close();
+    } catch (aggregate) {
+      niceShowAggregate(aggregate);
+      logWarn`The above duplicate directories were detected while reviewing data files.`;
+      logWarn`Each thing listed above will been totally excempt from this build of the site!`;
+      logWarn`Specify unique 'Directory' fields in data entries to resolve these.`;
+      logWarn`${`Note:`} This will probably result in reference errors below.`;
+      logWarn`${`. . .`} You should fix duplicate directories first!`;
+      logWarn`(Resolve errors for more complete output!)`;
+      duplicateDirectoriesErrored = true;
+      errorless = false;
     }
-
-    if (!WD.wikiInfo) {
-        logError`Can't proceed without wiki info file (${WIKI_INFO_FILE}) successfully loading`;
-        return;
+    if (errorless) {
+      logInfo`No duplicate directories found - nice!`;
     }
+  }
 
-    let duplicateDirectoriesErrored = false;
-
-    function filterAndShowDuplicateDirectories() {
-        const aggregate = filterDuplicateDirectories(wikiData);
-        let errorless = true;
-        try {
-            aggregate.close();
-        } catch (aggregate) {
-            niceShowAggregate(aggregate);
-            logWarn`The above duplicate directories were detected while reviewing data files.`;
-            logWarn`Each thing listed above will been totally excempt from this build of the site!`;
-            logWarn`Specify unique 'Directory' fields in data entries to resolve these.`;
-            logWarn`${`Note:`} This will probably result in reference errors below.`;
-            logWarn`${`. . .`} You should fix duplicate directories first!`;
-            logWarn`(Resolve errors for more complete output!)`;
-            duplicateDirectoriesErrored = true;
-            errorless = false;
-        }
-        if (errorless) {
-            logInfo`No duplicate directories found - nice!`;
-        }
+  function filterAndShowReferenceErrors() {
+    const aggregate = filterReferenceErrors(wikiData);
+    let errorless = true;
+    try {
+      aggregate.close();
+    } catch (error) {
+      niceShowAggregate(error);
+      logWarn`The above errors were detected while validating references in data files.`;
+      logWarn`If the remaining valid data is complete enough, the wiki will still build -`;
+      logWarn`but all errored references will be skipped.`;
+      if (duplicateDirectoriesErrored) {
+        logWarn`${`Note:`} Duplicate directories were found as well. Review those first,`;
+        logWarn`${`. . .`} as they may have caused some of the errors detected above.`;
+      }
+      logWarn`(Resolve errors for more complete output!)`;
+      errorless = false;
     }
-
-    function filterAndShowReferenceErrors() {
-        const aggregate = filterReferenceErrors(wikiData);
-        let errorless = true;
-        try {
-            aggregate.close();
-        } catch (error) {
-            niceShowAggregate(error);
-            logWarn`The above errors were detected while validating references in data files.`;
-            logWarn`If the remaining valid data is complete enough, the wiki will still build -`;
-            logWarn`but all errored references will be skipped.`;
-            if (duplicateDirectoriesErrored) {
-                logWarn`${`Note:`} Duplicate directories were found as well. Review those first,`;
-                logWarn`${`. . .`} as they may have caused some of the errors detected above.`;
-            }
-            logWarn`(Resolve errors for more complete output!)`;
-            errorless = false;
-        }
-        if (errorless) {
-            logInfo`All references validated without any errors - nice!`;
-            logInfo`(This means all references between things, such as leitmotif references`
-            logInfo` and artist credits, will be fully accounted for during page generation.)`;
-        }
+    if (errorless) {
+      logInfo`All references validated without any errors - nice!`;
+      logInfo`(This means all references between things, such as leitmotif references`;
+      logInfo` and artist credits, will be fully accounted for during page generation.)`;
     }
+  }
 
-    // Link data arrays so that all essential references between objects are
-    // complete, so properties (like dates!) are inherited where that's
-    // appropriate.
-    linkWikiDataArrays(wikiData);
+  // Link data arrays so that all essential references between objects are
+  // complete, so properties (like dates!) are inherited where that's
+  // appropriate.
+  linkWikiDataArrays(wikiData);
 
-    // Filter out any things with duplicate directories throughout the data,
-    // warning about them too.
-    filterAndShowDuplicateDirectories();
+  // Filter out any things with duplicate directories throughout the data,
+  // warning about them too.
+  filterAndShowDuplicateDirectories();
 
-    // Filter out any reference errors throughout the data, warning about them
-    // too.
-    filterAndShowReferenceErrors();
+  // Filter out any reference errors throughout the data, warning about them
+  // too.
+  filterAndShowReferenceErrors();
 
-    // Sort data arrays so that they're all in order! This may use properties
-    // which are only available after the initial linking.
-    sortWikiDataArrays(wikiData);
+  // Sort data arrays so that they're all in order! This may use properties
+  // which are only available after the initial linking.
+  sortWikiDataArrays(wikiData);
 
-    const internalDefaultLanguage = await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE));
+  const internalDefaultLanguage = await processLanguageFile(
+    path.join(__dirname, DEFAULT_STRINGS_FILE)
+  );
 
-    let languages;
-    if (langPath) {
-        const languageDataFiles = await findFiles(langPath, {
-            filter: f => path.extname(f) === '.json'
-        });
+  let languages;
+  if (langPath) {
+    const languageDataFiles = await findFiles(langPath, {
+      filter: (f) => path.extname(f) === ".json",
+    });
 
-        const results = await progressPromiseAll(`Reading & processing language files.`, languageDataFiles
-            .map(file => processLanguageFile(file)));
+    const results = await progressPromiseAll(
+      `Reading & processing language files.`,
+      languageDataFiles.map((file) => processLanguageFile(file))
+    );
 
-        languages = Object.fromEntries(results.map(language => [language.code, language]));
+    languages = Object.fromEntries(
+      results.map((language) => [language.code, language])
+    );
+  } else {
+    languages = {};
+  }
+
+  const customDefaultLanguage =
+    languages[WD.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code];
+  let finalDefaultLanguage;
+
+  if (customDefaultLanguage) {
+    logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
+    customDefaultLanguage.inheritedStrings = internalDefaultLanguage.strings;
+    finalDefaultLanguage = customDefaultLanguage;
+  } else if (WD.wikiInfo.defaultLanguage) {
+    logError`Wiki info file specified default language is ${WD.wikiInfo.defaultLanguage}, but no such language file exists!`;
+    if (langPath) {
+      logError`Check if an appropriate file exists in ${langPath}?`;
     } else {
-        languages = {};
+      logError`Be sure to specify ${"--lang"} or ${"HSMUSIC_LANG"} with the path to language files.`;
     }
-
-    const customDefaultLanguage = languages[WD.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code];
-    let finalDefaultLanguage;
-
-    if (customDefaultLanguage) {
-        logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
-        customDefaultLanguage.inheritedStrings = internalDefaultLanguage.strings;
-        finalDefaultLanguage = customDefaultLanguage;
-    } else if (WD.wikiInfo.defaultLanguage) {
-        logError`Wiki info file specified default language is ${WD.wikiInfo.defaultLanguage}, but no such language file exists!`;
-        if (langPath) {
-            logError`Check if an appropriate file exists in ${langPath}?`;
-        } else {
-            logError`Be sure to specify ${'--lang'} or ${'HSMUSIC_LANG'} with the path to language files.`;
-        }
-        return;
-    } else {
-        languages[internalDefaultLanguage.code] = internalDefaultLanguage;
-        finalDefaultLanguage = internalDefaultLanguage;
+    return;
+  } else {
+    languages[internalDefaultLanguage.code] = internalDefaultLanguage;
+    finalDefaultLanguage = internalDefaultLanguage;
+  }
+
+  for (const language of Object.values(languages)) {
+    if (language === finalDefaultLanguage) {
+      continue;
     }
 
-    for (const language of Object.values(languages)) {
-        if (language === finalDefaultLanguage) {
-            continue;
-        }
+    language.inheritedStrings = finalDefaultLanguage.strings;
+  }
+
+  logInfo`Loaded language strings: ${Object.keys(languages).join(", ")}`;
+
+  if (noBuild) {
+    logInfo`Not generating any site or page files this run (--no-build passed).`;
+  } else if (writeOneLanguage && !(writeOneLanguage in languages)) {
+    logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`;
+    return;
+  } else if (writeOneLanguage) {
+    logInfo`Writing only language ${writeOneLanguage} this run.`;
+  } else {
+    logInfo`Writing all languages.`;
+  }
+
+  {
+    const tagRefs = new Set(
+      [...WD.trackData, ...WD.albumData].flatMap(
+        (thing) => thing.artTagsByRef ?? []
+      )
+    );
 
-        language.inheritedStrings = finalDefaultLanguage.strings;
+    for (const ref of tagRefs) {
+      if (find.artTag(ref, WD.artTagData)) {
+        tagRefs.delete(ref);
+      }
     }
 
-    logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
-
-    if (noBuild) {
-        logInfo`Not generating any site or page files this run (--no-build passed).`;
-    } else if (writeOneLanguage && !(writeOneLanguage in languages)) {
-        logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`;
-        return;
-    } else if (writeOneLanguage) {
-        logInfo`Writing only language ${writeOneLanguage} this run.`;
-    } else {
-        logInfo`Writing all languages.`;
+    if (tagRefs.size) {
+      for (const ref of Array.from(tagRefs).sort()) {
+        console.log(`\x1b[33;1m- Missing tag: "${ref}"\x1b[0m`);
+      }
+      return;
     }
-
-    {
-        const tagRefs = new Set([...WD.trackData, ...WD.albumData].flatMap(thing => thing.artTagsByRef ?? []));
-
-        for (const ref of tagRefs) {
-            if (find.artTag(ref, WD.artTagData)) {
-                tagRefs.delete(ref);
-            }
+  }
+
+  WD.officialAlbumData = WD.albumData.filter((album) =>
+    album.groups.some((group) => group.directory === OFFICIAL_GROUP_DIRECTORY)
+  );
+  WD.fandomAlbumData = WD.albumData.filter((album) =>
+    album.groups.every((group) => group.directory !== OFFICIAL_GROUP_DIRECTORY)
+  );
+
+  const fileSizePreloader = new FileSizePreloader();
+
+  // File sizes of additional files need to be precalculated before we can
+  // actually reference 'em in site building, so get those loading right
+  // away. We actually need to keep track of two things here - the on-device
+  // file paths we're actually reading, and the corresponding on-site media
+  // paths that will be exposed in site build code. We'll build a mapping
+  // function between them so that when site code requests a site path,
+  // it'll get the size of the file at the corresponding device path.
+  const additionalFilePaths = [
+    ...WD.albumData.flatMap((album) =>
+      [
+        ...(album.additionalFiles ?? []),
+        ...album.tracks.flatMap((track) => track.additionalFiles ?? []),
+      ]
+        .flatMap((fileGroup) => fileGroup.files)
+        .map((file) => ({
+          device: path.join(
+            mediaPath,
+            urls
+              .from("media.root")
+              .toDevice("media.albumAdditionalFile", album.directory, file)
+          ),
+          media: urls
+            .from("media.root")
+            .to("media.albumAdditionalFile", album.directory, file),
+        }))
+    ),
+  ];
+
+  const getSizeOfAdditionalFile = (mediaPath) => {
+    const { device = null } =
+      additionalFilePaths.find(({ media }) => media === mediaPath) || {};
+    if (!device) return null;
+    return fileSizePreloader.getSizeOfPath(device);
+  };
+
+  logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`;
+
+  fileSizePreloader.loadPaths(
+    ...additionalFilePaths.map((path) => path.device)
+  );
+  await fileSizePreloader.waitUntilDoneLoading();
+
+  logInfo`Done preloading filesizes!`;
+
+  if (noBuild) return;
+
+  // Makes writing a little nicer on CPU theoretically, 8ut also costs in
+  // performance right now 'cuz it'll w8 for file writes to 8e completed
+  // 8efore moving on to more data processing. So, defaults to zero, which
+  // disa8les the queue feature altogether.
+  queueSize = +(miscOptions["queue-size"] ?? 0);
+
+  const buildDictionary = pageSpecs;
+
+  // NOT for ena8ling or disa8ling specific features of the site!
+  // This is only in charge of what general groups of files to 8uild.
+  // They're here to make development quicker when you're only working
+  // on some particular area(s) of the site rather than making changes
+  // across all of them.
+  const writeFlags = await parseOptions(process.argv.slice(2), {
+    all: { type: "flag" }, // Defaults to true if none 8elow specified.
+
+    // Kinda a hack t8h!
+    ...Object.fromEntries(
+      Object.keys(buildDictionary).map((key) => [key, { type: "flag" }])
+    ),
+
+    [parseOptions.handleUnknown]: () => {},
+  });
+
+  const writeAll = !Object.keys(writeFlags).length || writeFlags.all;
+
+  logInfo`Writing site pages: ${
+    writeAll ? "all" : Object.keys(writeFlags).join(", ")
+  }`;
+
+  await writeFavicon();
+  await writeSymlinks();
+  await writeSharedFilesAndPages({ language: finalDefaultLanguage, wikiData });
+
+  const buildSteps = writeAll
+    ? Object.entries(buildDictionary)
+    : Object.entries(buildDictionary).filter(([flag]) => writeFlags[flag]);
+
+  let writes;
+  {
+    let error = false;
+
+    const buildStepsWithTargets = buildSteps
+      .map(([flag, pageSpec]) => {
+        // Condition not met: skip this build step altogether.
+        if (pageSpec.condition && !pageSpec.condition({ wikiData })) {
+          return null;
         }
 
-        if (tagRefs.size) {
-            for (const ref of Array.from(tagRefs).sort()) {
-                console.log(`\x1b[33;1m- Missing tag: "${ref}"\x1b[0m`);
-            }
-            return;
+        // May still call writeTargetless if present.
+        if (!pageSpec.targets) {
+          return { flag, pageSpec, targets: [] };
         }
-    }
-
-    WD.officialAlbumData = WD.albumData.filter(album => album.groups.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY));
-    WD.fandomAlbumData = WD.albumData.filter(album => album.groups.every(group => group.directory !== OFFICIAL_GROUP_DIRECTORY));
-
-    const fileSizePreloader = new FileSizePreloader();
-
-    // File sizes of additional files need to be precalculated before we can
-    // actually reference 'em in site building, so get those loading right
-    // away. We actually need to keep track of two things here - the on-device
-    // file paths we're actually reading, and the corresponding on-site media
-    // paths that will be exposed in site build code. We'll build a mapping
-    // function between them so that when site code requests a site path,
-    // it'll get the size of the file at the corresponding device path.
-    const additionalFilePaths = [
-        ...WD.albumData.flatMap(album => (
-            [
-                ...album.additionalFiles ?? [],
-                ...album.tracks.flatMap(track => track.additionalFiles ?? [])
-            ]
-            .flatMap(fileGroup => fileGroup.files)
-            .map(file => ({
-                device: (path.join(mediaPath, urls
-                    .from('media.root')
-                    .toDevice('media.albumAdditionalFile', album.directory, file))),
-                media: (urls
-                    .from('media.root')
-                    .to('media.albumAdditionalFile', album.directory, file))
-            })))),
-    ];
-
-    const getSizeOfAdditionalFile = mediaPath => {
-        const { device = null } = additionalFilePaths.find(({ media }) => media === mediaPath) || {};
-        if (!device) return null;
-        return fileSizePreloader.getSizeOfPath(device);
-    };
-
-    logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`;
-
-    fileSizePreloader.loadPaths(...additionalFilePaths.map(path => path.device));
-    await fileSizePreloader.waitUntilDoneLoading();
-
-    logInfo`Done preloading filesizes!`;
-
-    if (noBuild) return;
-
-    // Makes writing a little nicer on CPU theoretically, 8ut also costs in
-    // performance right now 'cuz it'll w8 for file writes to 8e completed
-    // 8efore moving on to more data processing. So, defaults to zero, which
-    // disa8les the queue feature altogether.
-    queueSize = +(miscOptions['queue-size'] ?? 0);
-
-    const buildDictionary = pageSpecs;
-
-    // NOT for ena8ling or disa8ling specific features of the site!
-    // This is only in charge of what general groups of files to 8uild.
-    // They're here to make development quicker when you're only working
-    // on some particular area(s) of the site rather than making changes
-    // across all of them.
-    const writeFlags = await parseOptions(process.argv.slice(2), {
-        all: {type: 'flag'}, // Defaults to true if none 8elow specified.
-
-        // Kinda a hack t8h!
-        ...Object.fromEntries(Object.keys(buildDictionary)
-            .map(key => [key, {type: 'flag'}])),
-
-        [parseOptions.handleUnknown]: () => {}
-    });
-
-    const writeAll = !Object.keys(writeFlags).length || writeFlags.all;
-
-    logInfo`Writing site pages: ${writeAll ? 'all' : Object.keys(writeFlags).join(', ')}`;
-
-    await writeFavicon();
-    await writeSymlinks();
-    await writeSharedFilesAndPages({language: finalDefaultLanguage, wikiData});
-
-    const buildSteps = (writeAll
-        ? Object.entries(buildDictionary)
-        : (Object.entries(buildDictionary)
-            .filter(([ flag ]) => writeFlags[flag])));
-
-    let writes;
-    {
-        let error = false;
 
-        const buildStepsWithTargets = buildSteps.map(([ flag, pageSpec ]) => {
-            // Condition not met: skip this build step altogether.
-            if (pageSpec.condition && !pageSpec.condition({wikiData})) {
-                return null;
-            }
-
-            // May still call writeTargetless if present.
-            if (!pageSpec.targets) {
-                return {flag, pageSpec, targets: []};
-            }
-
-            if (!pageSpec.write) {
-                logError`${flag + '.targets'} is specified, but ${flag + '.write'} is missing!`;
-                error = true;
-                return null;
-            }
-
-            const targets = pageSpec.targets({wikiData});
-            if (!Array.isArray(targets)) {
-                logError`${flag + '.targets'} was called, but it didn't return an array! (${typeof targets})`;
-                error = true;
-                return null;
-            }
-
-            return {flag, pageSpec, targets};
-        }).filter(Boolean);
+        if (!pageSpec.write) {
+          logError`${flag + ".targets"} is specified, but ${
+            flag + ".write"
+          } is missing!`;
+          error = true;
+          return null;
+        }
 
-        if (error) {
-            return;
+        const targets = pageSpec.targets({ wikiData });
+        if (!Array.isArray(targets)) {
+          logError`${
+            flag + ".targets"
+          } was called, but it didn't return an array! (${typeof targets})`;
+          error = true;
+          return null;
         }
 
-        const validateWrites = (writes, fnName) => {
-            // Do a quick valid8tion! If one of the writeThingPages functions go
-            // wrong, this will stall out early and tell us which did.
+        return { flag, pageSpec, targets };
+      })
+      .filter(Boolean);
 
-            if (!Array.isArray(writes)) {
-                logError`${fnName} didn't return an array!`;
-                error = true;
-                return false;
-            }
+    if (error) {
+      return;
+    }
 
-            if (!(
-                writes.every(obj => typeof obj === 'object') &&
-                writes.every(obj => {
-                    const result = validateWriteObject(obj);
-                    if (result.error) {
-                        logError`Validating write object failed: ${result.error}`;
-                        return false;
-                    } else {
-                        return true;
-                    }
-                })
-            )) {
-                logError`${fnName} returned invalid entries!`;
-                error = true;
-                return false;
+    const validateWrites = (writes, fnName) => {
+      // Do a quick valid8tion! If one of the writeThingPages functions go
+      // wrong, this will stall out early and tell us which did.
+
+      if (!Array.isArray(writes)) {
+        logError`${fnName} didn't return an array!`;
+        error = true;
+        return false;
+      }
+
+      if (
+        !(
+          writes.every((obj) => typeof obj === "object") &&
+          writes.every((obj) => {
+            const result = validateWriteObject(obj);
+            if (result.error) {
+              logError`Validating write object failed: ${result.error}`;
+              return false;
+            } else {
+              return true;
             }
+          })
+        )
+      ) {
+        logError`${fnName} returned invalid entries!`;
+        error = true;
+        return false;
+      }
+
+      return true;
+    };
 
-            return true;
-        };
-
-        // return;
+    // return;
 
-        writes = buildStepsWithTargets.flatMap(({ flag, pageSpec, targets }) => {
-            const writes = targets.flatMap(target =>
-                pageSpec.write(target, {wikiData})?.slice() || []);
+    writes = buildStepsWithTargets.flatMap(({ flag, pageSpec, targets }) => {
+      const writes = targets.flatMap(
+        (target) => pageSpec.write(target, { wikiData })?.slice() || []
+      );
 
-            if (!validateWrites(writes, flag + '.write')) {
-                return [];
-            }
+      if (!validateWrites(writes, flag + ".write")) {
+        return [];
+      }
 
-            if (pageSpec.writeTargetless) {
-                const writes2 = pageSpec.writeTargetless({wikiData});
+      if (pageSpec.writeTargetless) {
+        const writes2 = pageSpec.writeTargetless({ wikiData });
 
-                if (!validateWrites(writes2, flag + '.writeTargetless')) {
-                    return [];
-                }
+        if (!validateWrites(writes2, flag + ".writeTargetless")) {
+          return [];
+        }
 
-                writes.push(...writes2);
-            }
+        writes.push(...writes2);
+      }
 
-            return writes;
-        });
+      return writes;
+    });
 
-        if (error) {
-            return;
-        }
+    if (error) {
+      return;
     }
+  }
 
-    const pageWrites = writes.filter(({ type }) => type === 'page');
-    const dataWrites = writes.filter(({ type }) => type === 'data');
-    const redirectWrites = writes.filter(({ type }) => type === 'redirect');
+  const pageWrites = writes.filter(({ type }) => type === "page");
+  const dataWrites = writes.filter(({ type }) => type === "data");
+  const redirectWrites = writes.filter(({ type }) => type === "redirect");
 
-    if (writes.length) {
-        logInfo`Total of ${writes.length} writes returned. (${pageWrites.length} page, ${dataWrites.length} data [currently skipped], ${redirectWrites.length} redirect)`;
-    } else {
-        logWarn`No writes returned at all, so exiting early. This is probably a bug!`;
-        return;
-    }
+  if (writes.length) {
+    logInfo`Total of ${writes.length} writes returned. (${pageWrites.length} page, ${dataWrites.length} data [currently skipped], ${redirectWrites.length} redirect)`;
+  } else {
+    logWarn`No writes returned at all, so exiting early. This is probably a bug!`;
+    return;
+  }
 
-    /*
+  /*
     await progressPromiseAll(`Writing data files shared across languages.`, queue(
         dataWrites.map(({path, data}) => () => {
             const bound = {};
@@ -1985,272 +2243,331 @@ async function main() {
     ));
     */
 
-    const perLanguageFn = async (language, i, entries) => {
-        const baseDirectory = (language === finalDefaultLanguage ? '' : language.code);
+  const perLanguageFn = async (language, i, entries) => {
+    const baseDirectory =
+      language === finalDefaultLanguage ? "" : language.code;
 
-        console.log(`\x1b[34;1m${
-            (`[${i + 1}/${entries.length}] ${language.code} (-> /${baseDirectory}) `
-                .padEnd(60, '-'))
-        }\x1b[0m`);
+    console.log(
+      `\x1b[34;1m${`[${i + 1}/${entries.length}] ${
+        language.code
+      } (-> /${baseDirectory}) `.padEnd(60, "-")}\x1b[0m`
+    );
 
-        await progressPromiseAll(`Writing ${language.code}`, queue([
-            ...pageWrites.map(({type, ...props}) => () => {
-                const { path, page } = props;
+    await progressPromiseAll(
+      `Writing ${language.code}`,
+      queue(
+        [
+          ...pageWrites.map(({ type, ...props }) => () => {
+            const { path, page } = props;
 
-                // TODO: This only supports one <>-style argument.
-                const pageSubKey = path[0];
-                const directory = path[1];
-
-                const localizedPaths = Object.fromEntries(Object.entries(languages)
-                    .filter(([ key, language ]) => key !== 'default' && !language.hidden)
-                    .map(([ key, language ]) => [language.code, writePage.paths(
-                        (language === finalDefaultLanguage ? '' : language.code),
-                        'localized.' + pageSubKey,
-                        directory
-                    )]));
-
-                const paths = writePage.paths(
-                    baseDirectory,
-                    'localized.' + pageSubKey,
+            // TODO: This only supports one <>-style argument.
+            const pageSubKey = path[0];
+            const directory = path[1];
+
+            const localizedPaths = Object.fromEntries(
+              Object.entries(languages)
+                .filter(
+                  ([key, language]) => key !== "default" && !language.hidden
+                )
+                .map(([key, language]) => [
+                  language.code,
+                  writePage.paths(
+                    language === finalDefaultLanguage ? "" : language.code,
+                    "localized." + pageSubKey,
                     directory
-                );
-
-                const to = writePage.to({
-                    baseDirectory,
-                    pageSubKey,
-                    paths
-                });
-
-                const absoluteTo = (targetFullKey, ...args) => {
-                    const [ groupKey, subKey ] = targetFullKey.split('.');
-                    const from = urls.from('shared.root');
-                    return '/' + (groupKey === 'localized' && baseDirectory
-                        ? from.to('localizedWithBaseDirectory.' + subKey, baseDirectory, ...args)
-                        : from.to(targetFullKey, ...args));
-                };
-
-                // TODO: Is there some nicer way to define these,
-                // may8e without totally re-8inding everything for
-                // each page?
-                const bound = {};
-
-                bound.link = withEntries(unbound_link, entries => entries
-                    .map(([ key, fn ]) => [key, bindOpts(fn, {to})]));
-
-                bound.linkAnythingMan = bindOpts(linkAnythingMan, {
-                    link: bound.link,
-                    wikiData
-                });
+                  ),
+                ])
+            );
+
+            const paths = writePage.paths(
+              baseDirectory,
+              "localized." + pageSubKey,
+              directory
+            );
+
+            const to = writePage.to({
+              baseDirectory,
+              pageSubKey,
+              paths,
+            });
 
-                bound.parseAttributes = bindOpts(parseAttributes, {
-                    to
-                });
+            const absoluteTo = (targetFullKey, ...args) => {
+              const [groupKey, subKey] = targetFullKey.split(".");
+              const from = urls.from("shared.root");
+              return (
+                "/" +
+                (groupKey === "localized" && baseDirectory
+                  ? from.to(
+                      "localizedWithBaseDirectory." + subKey,
+                      baseDirectory,
+                      ...args
+                    )
+                  : from.to(targetFullKey, ...args))
+              );
+            };
 
-                bound.find = bindFind(wikiData, {mode: 'warn'});
+            // TODO: Is there some nicer way to define these,
+            // may8e without totally re-8inding everything for
+            // each page?
+            const bound = {};
 
-                bound.transformInline = bindOpts(transformInline, {
-                    find: bound.find,
-                    link: bound.link,
-                    replacerSpec,
-                    language,
-                    to,
-                    wikiData
-                });
+            bound.link = withEntries(unbound_link, (entries) =>
+              entries.map(([key, fn]) => [key, bindOpts(fn, { to })])
+            );
 
-                bound.transformMultiline = bindOpts(transformMultiline, {
-                    transformInline: bound.transformInline,
-                    parseAttributes: bound.parseAttributes
-                });
-
-                bound.transformLyrics = bindOpts(transformLyrics, {
-                    transformInline: bound.transformInline,
-                    transformMultiline: bound.transformMultiline
-                });
+            bound.linkAnythingMan = bindOpts(linkAnythingMan, {
+              link: bound.link,
+              wikiData,
+            });
 
-                bound.iconifyURL = bindOpts(iconifyURL, {
-                    language,
-                    to
-                });
+            bound.parseAttributes = bindOpts(parseAttributes, {
+              to,
+            });
 
-                bound.fancifyURL = bindOpts(fancifyURL, {
-                    language
-                });
+            bound.find = bindFind(wikiData, { mode: "warn" });
 
-                bound.fancifyFlashURL = bindOpts(fancifyFlashURL, {
-                    [bindOpts.bindIndex]: 2,
-                    language
-                });
+            bound.transformInline = bindOpts(transformInline, {
+              find: bound.find,
+              link: bound.link,
+              replacerSpec,
+              language,
+              to,
+              wikiData,
+            });
 
-                bound.getLinkThemeString = getLinkThemeString;
+            bound.transformMultiline = bindOpts(transformMultiline, {
+              transformInline: bound.transformInline,
+              parseAttributes: bound.parseAttributes,
+            });
 
-                bound.getThemeString = getThemeString;
+            bound.transformLyrics = bindOpts(transformLyrics, {
+              transformInline: bound.transformInline,
+              transformMultiline: bound.transformMultiline,
+            });
 
-                bound.getArtistString = bindOpts(getArtistString, {
-                    iconifyURL: bound.iconifyURL,
-                    link: bound.link,
-                    language
-                });
+            bound.iconifyURL = bindOpts(iconifyURL, {
+              language,
+              to,
+            });
 
-                bound.getAlbumCover = bindOpts(getAlbumCover, {
-                    to
-                });
+            bound.fancifyURL = bindOpts(fancifyURL, {
+              language,
+            });
 
-                bound.getTrackCover = bindOpts(getTrackCover, {
-                    to
-                });
+            bound.fancifyFlashURL = bindOpts(fancifyFlashURL, {
+              [bindOpts.bindIndex]: 2,
+              language,
+            });
 
-                bound.getFlashCover = bindOpts(getFlashCover, {
-                    to
-                });
+            bound.getLinkThemeString = getLinkThemeString;
 
-                bound.getArtistAvatar = bindOpts(getArtistAvatar, {
-                    to
-                });
+            bound.getThemeString = getThemeString;
 
-                bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, {
-                    language
-                });
+            bound.getArtistString = bindOpts(getArtistString, {
+              iconifyURL: bound.iconifyURL,
+              link: bound.link,
+              language,
+            });
 
-                bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, {
-                    language
-                });
+            bound.getAlbumCover = bindOpts(getAlbumCover, {
+              to,
+            });
 
-                bound.generateChronologyLinks = bindOpts(generateChronologyLinks, {
-                    link: bound.link,
-                    linkAnythingMan: bound.linkAnythingMan,
-                    language,
-                    wikiData
-                });
+            bound.getTrackCover = bindOpts(getTrackCover, {
+              to,
+            });
 
-                bound.generateCoverLink = bindOpts(generateCoverLink, {
-                    [bindOpts.bindIndex]: 0,
-                    img,
-                    link: bound.link,
-                    language,
-                    to,
-                    wikiData
-                });
+            bound.getFlashCover = bindOpts(getFlashCover, {
+              to,
+            });
 
-                bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, {
-                    [bindOpts.bindIndex]: 2,
-                    link: bound.link,
-                    language
-                });
+            bound.getArtistAvatar = bindOpts(getArtistAvatar, {
+              to,
+            });
 
-                bound.generatePreviousNextLinks = bindOpts(generatePreviousNextLinks, {
-                    link: bound.link,
-                    language
-                });
+            bound.generateAdditionalFilesShortcut = bindOpts(
+              generateAdditionalFilesShortcut,
+              {
+                language,
+              }
+            );
+
+            bound.generateAdditionalFilesList = bindOpts(
+              generateAdditionalFilesList,
+              {
+                language,
+              }
+            );
+
+            bound.generateChronologyLinks = bindOpts(generateChronologyLinks, {
+              link: bound.link,
+              linkAnythingMan: bound.linkAnythingMan,
+              language,
+              wikiData,
+            });
 
-                bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, {
-                    language,
-                    wikiData,
-                });
+            bound.generateCoverLink = bindOpts(generateCoverLink, {
+              [bindOpts.bindIndex]: 0,
+              img,
+              link: bound.link,
+              language,
+              to,
+              wikiData,
+            });
 
-                bound.getGridHTML = bindOpts(getGridHTML, {
-                    [bindOpts.bindIndex]: 0,
-                    img,
-                    language
-                });
+            bound.generateInfoGalleryLinks = bindOpts(
+              generateInfoGalleryLinks,
+              {
+                [bindOpts.bindIndex]: 2,
+                link: bound.link,
+                language,
+              }
+            );
+
+            bound.generatePreviousNextLinks = bindOpts(
+              generatePreviousNextLinks,
+              {
+                link: bound.link,
+                language,
+              }
+            );
+
+            bound.generateTrackListDividedByGroups = bindOpts(
+              generateTrackListDividedByGroups,
+              {
+                language,
+                wikiData,
+              }
+            );
+
+            bound.getGridHTML = bindOpts(getGridHTML, {
+              [bindOpts.bindIndex]: 0,
+              img,
+              language,
+            });
 
-                bound.getAlbumGridHTML = bindOpts(getAlbumGridHTML, {
-                    [bindOpts.bindIndex]: 0,
-                    getAlbumCover: bound.getAlbumCover,
-                    getGridHTML: bound.getGridHTML,
-                    link: bound.link,
-                    language
-                });
+            bound.getAlbumGridHTML = bindOpts(getAlbumGridHTML, {
+              [bindOpts.bindIndex]: 0,
+              getAlbumCover: bound.getAlbumCover,
+              getGridHTML: bound.getGridHTML,
+              link: bound.link,
+              language,
+            });
 
-                bound.getFlashGridHTML = bindOpts(getFlashGridHTML, {
-                    [bindOpts.bindIndex]: 0,
-                    getFlashCover: bound.getFlashCover,
-                    getGridHTML: bound.getGridHTML,
-                    link: bound.link
-                });
+            bound.getFlashGridHTML = bindOpts(getFlashGridHTML, {
+              [bindOpts.bindIndex]: 0,
+              getFlashCover: bound.getFlashCover,
+              getGridHTML: bound.getGridHTML,
+              link: bound.link,
+            });
 
-                bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, {
-                    language
-                });
+            bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, {
+              language,
+            });
 
-                bound.getRevealStringFromWarnings = bindOpts(getRevealStringFromWarnings, {
-                    language
-                });
+            bound.getRevealStringFromWarnings = bindOpts(
+              getRevealStringFromWarnings,
+              {
+                language,
+              }
+            );
 
-                bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, {
-                    to
-                });
+            bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, {
+              to,
+            });
 
-                const pageInfo = page({
-                    ...bound,
+            const pageInfo = page({
+              ...bound,
 
-                    language,
+              language,
 
-                    absoluteTo,
-                    relativeTo: to,
-                    to,
-                    urls,
+              absoluteTo,
+              relativeTo: to,
+              to,
+              urls,
 
-                    getSizeOfAdditionalFile,
-                });
+              getSizeOfAdditionalFile,
+            });
 
-                const oEmbedJSON = writePage.oEmbedJSON(pageInfo, {
-                    language,
-                    wikiData,
-                });
+            const oEmbedJSON = writePage.oEmbedJSON(pageInfo, {
+              language,
+              wikiData,
+            });
 
-                const oEmbedJSONHref = (oEmbedJSON && wikiData.wikiInfo.canonicalBase) && (
-                    wikiData.wikiInfo.canonicalBase + urls.from('shared.root').to('shared.path', paths.pathname + OEMBED_JSON_FILE));
-
-                const html = writePage.html(pageInfo, {
-                    defaultLanguage: finalDefaultLanguage,
-                    language,
-                    languages,
-                    localizedPaths,
-                    oEmbedJSONHref,
-                    paths,
-                    to,
-                    transformMultiline: bound.transformMultiline,
-                    wikiData
-                });
+            const oEmbedJSONHref =
+              oEmbedJSON &&
+              wikiData.wikiInfo.canonicalBase &&
+              wikiData.wikiInfo.canonicalBase +
+                urls
+                  .from("shared.root")
+                  .to("shared.path", paths.pathname + OEMBED_JSON_FILE);
+
+            const html = writePage.html(pageInfo, {
+              defaultLanguage: finalDefaultLanguage,
+              language,
+              languages,
+              localizedPaths,
+              oEmbedJSONHref,
+              paths,
+              to,
+              transformMultiline: bound.transformMultiline,
+              wikiData,
+            });
 
-                return writePage.write({
-                    html,
-                    oEmbedJSON,
-                    paths,
-                });
-            }),
-            ...redirectWrites.map(({fromPath, toPath, title: titleFn}) => () => {
+            return writePage.write({
+              html,
+              oEmbedJSON,
+              paths,
+            });
+          }),
+          ...redirectWrites.map(
+            ({ fromPath, toPath, title: titleFn }) =>
+              () => {
                 const title = titleFn({
-                    language
+                  language,
                 });
 
                 // TODO: This only supports one <>-style argument.
-                const fromPaths = writePage.paths(baseDirectory, 'localized.' + fromPath[0], fromPath[1]);
-                const to = writePage.to({baseDirectory, pageSubKey: fromPath[0], paths: fromPaths});
-
-                const target = to('localized.' + toPath[0], ...toPath.slice(1));
-                const html = generateRedirectPage(title, target, {language});
-                return writePage.write({html, paths: fromPaths});
-            })
-        ], queueSize));
-    };
+                const fromPaths = writePage.paths(
+                  baseDirectory,
+                  "localized." + fromPath[0],
+                  fromPath[1]
+                );
+                const to = writePage.to({
+                  baseDirectory,
+                  pageSubKey: fromPath[0],
+                  paths: fromPaths,
+                });
 
-    await wrapLanguages(perLanguageFn, {
-        languages,
-        writeOneLanguage,
-    });
+                const target = to("localized." + toPath[0], ...toPath.slice(1));
+                const html = generateRedirectPage(title, target, { language });
+                return writePage.write({ html, paths: fromPaths });
+              }
+          ),
+        ],
+        queueSize
+      )
+    );
+  };
+
+  await wrapLanguages(perLanguageFn, {
+    languages,
+    writeOneLanguage,
+  });
 
-    // The single most important step.
-    logInfo`Written!`;
+  // The single most important step.
+  logInfo`Written!`;
 }
 
-main().catch(error => {
+main()
+  .catch((error) => {
     if (error instanceof AggregateError) {
-        showAggregate(error);
+      showAggregate(error);
     } else {
-        console.error(error);
+      console.error(error);
     }
-}).then(() => {
+  })
+  .then(() => {
     decorateTime.displayTime();
     CacheableObject.showInvalidAccesses();
-});
+  });
diff --git a/src/url-spec.js b/src/url-spec.js
index 5c599416..cd35abed 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -1,93 +1,92 @@
-import {withEntries} from './util/sugar.js';
+import { withEntries } from "./util/sugar.js";
 
 const urlSpec = {
-    data: {
-        prefix: 'data/',
+  data: {
+    prefix: "data/",
 
-        paths: {
-            root: '',
-            path: '<>',
+    paths: {
+      root: "",
+      path: "<>",
 
-            album: 'album/<>',
-            artist: 'artist/<>',
-            track: 'track/<>'
-        }
+      album: "album/<>",
+      artist: "artist/<>",
+      track: "track/<>",
     },
+  },
 
-    localized: {
-        // TODO: Implement this.
-        // prefix: '_languageCode',
+  localized: {
+    // TODO: Implement this.
+    // prefix: '_languageCode',
 
-        paths: {
-            root: '',
-            path: '<>',
+    paths: {
+      root: "",
+      path: "<>",
 
-            home: '',
+      home: "",
 
-            album: 'album/<>/',
-            albumCommentary: 'commentary/album/<>/',
+      album: "album/<>/",
+      albumCommentary: "commentary/album/<>/",
 
-            artist: 'artist/<>/',
-            artistGallery: 'artist/<>/gallery/',
+      artist: "artist/<>/",
+      artistGallery: "artist/<>/gallery/",
 
-            commentaryIndex: 'commentary/',
+      commentaryIndex: "commentary/",
 
-            flashIndex: 'flash/',
-            flash: 'flash/<>/',
+      flashIndex: "flash/",
+      flash: "flash/<>/",
 
-            groupInfo: 'group/<>/',
-            groupGallery: 'group/<>/gallery/',
+      groupInfo: "group/<>/",
+      groupGallery: "group/<>/gallery/",
 
-            listingIndex: 'list/',
-            listing: 'list/<>/',
+      listingIndex: "list/",
+      listing: "list/<>/",
 
-            newsIndex: 'news/',
-            newsEntry: 'news/<>/',
+      newsIndex: "news/",
+      newsEntry: "news/<>/",
 
-            staticPage: '<>/',
-            tag: 'tag/<>/',
-            track: 'track/<>/'
-        }
+      staticPage: "<>/",
+      tag: "tag/<>/",
+      track: "track/<>/",
     },
+  },
 
-    shared: {
-        paths: {
-            root: '',
-            path: '<>',
+  shared: {
+    paths: {
+      root: "",
+      path: "<>",
 
-            utilityRoot: 'util',
-            staticRoot: 'static',
+      utilityRoot: "util",
+      staticRoot: "static",
 
-            utilityFile: 'util/<>',
-            staticFile: 'static/<>'
-        }
+      utilityFile: "util/<>",
+      staticFile: "static/<>",
     },
-
-    media: {
-        prefix: 'media/',
-
-        paths: {
-            root: '',
-            path: '<>',
-
-            albumCover: 'album-art/<>/cover.<>',
-            albumWallpaper: 'album-art/<>/bg.<>',
-            albumBanner: 'album-art/<>/banner.<>',
-            trackCover: 'album-art/<>/<>.<>',
-            artistAvatar: 'artist-avatar/<>.<>',
-            flashArt: 'flash-art/<>.<>',
-            albumAdditionalFile: 'album-additional/<>/<>',
-        }
-    }
+  },
+
+  media: {
+    prefix: "media/",
+
+    paths: {
+      root: "",
+      path: "<>",
+
+      albumCover: "album-art/<>/cover.<>",
+      albumWallpaper: "album-art/<>/bg.<>",
+      albumBanner: "album-art/<>/banner.<>",
+      trackCover: "album-art/<>/<>.<>",
+      artistAvatar: "artist-avatar/<>.<>",
+      flashArt: "flash-art/<>.<>",
+      albumAdditionalFile: "album-additional/<>/<>",
+    },
+  },
 };
 
 // This gets automatically switched in place when working from a baseDirectory,
 // so it should never be referenced manually.
 urlSpec.localizedWithBaseDirectory = {
-    paths: withEntries(
-        urlSpec.localized.paths,
-        entries => entries.map(([key, path]) => [key, '<>/' + path])
-    )
+  paths: withEntries(urlSpec.localized.paths, (entries) =>
+    entries.map(([key, path]) => [key, "<>/" + path])
+  ),
 };
 
 export default urlSpec;
diff --git a/src/util/cli.js b/src/util/cli.js
index 0bbf3af4..e073bed8 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -5,47 +5,52 @@
 
 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');
-};
+    w("\n");
+  };
 
 export const logInfo = logColor(2);
 export const logWarn = logColor(33);
@@ -53,205 +58,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..4450a49f 100644
--- a/src/util/colors.js
+++ b/src/util/colors.js
@@ -3,23 +3,31 @@
 // 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..49a3a19a 100644
--- a/src/util/find.js
+++ b/src/util/find.js
@@ -1,126 +1,131 @@
-import {
-    color,
-    logError,
-    logWarn
-} from './cli.js';
+import { color, logError, logWarn } from "./cli.js";
 
-import { inspect } from 'util';
+import { inspect } from "util";
 
 function warnOrThrow(mode, message) {
-    switch (mode) {
-        case 'error':
-            throw new Error(message);
-        case 'warn':
-            logWarn(message);
-        default:
-            return null;
-    }
+  switch (mode) {
+    case "error":
+      throw new Error(message);
+    case "warn":
+      logWarn(message);
+    default:
+      return null;
+  }
 }
 
 function findHelper(keys, findFns = {}) {
-    // Note: This cache explicitly *doesn't* support mutable data arrays. If the
-    // data array is modified, make sure it's actually a new array object, not
-    // the original, or the cache here will break and act as though the data
-    // 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];
 
-    if (ref !== thing.name) {
-        warnOrThrow(mode, `Bad capitalization: ${color.red(ref)} -> ${color.green(thing.name)}`);
+    const found = key ? byDirectory(ref, data, mode) : byName(ref, data, mode);
+
+    if (!found) {
+      warnOrThrow(mode, `Didn't match anything for ${color.bright(fullRef)}`);
     }
 
-    return thing;
+    cacheForThisData[fullRef] = found;
+
+    return found;
+  };
+}
+
+function matchDirectory(ref, data, mode) {
+  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 +136,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
-                ? findFn(ref, thingData, {...opts1, ...opts2})
-                : findFn(ref, thingData, opts1))
-            : (ref, opts2) => (opts2
-                ? findFn(ref, thingData, opts2)
-                : findFn(ref, thingData)))];
-    }));
+  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),
+      ];
+    })
+  );
 }
diff --git a/src/util/html.js b/src/util/html.js
index a9b4bb9b..ceca5966 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -3,19 +3,19 @@
 // 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 +24,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..c17e2633 100644
--- a/src/util/io.js
+++ b/src/util/io.js
@@ -1,14 +1,14 @@
 // Utility functions for interacting with files and other external data
 // interfacey constructs.
 
-import { readdir } from 'fs/promises';
-import * as path from 'path';
+import { readdir } from "fs/promises";
+import * as path from "path";
 
-export async function findFiles(dataPath, {
-    filter = f => true,
-    joinParentDirectory = true,
-} = {}) {
-    return (await readdir(dataPath))
-        .filter(file => filter(file))
-        .map(file => joinParentDirectory ? path.join(dataPath, file) : file);
+export async function findFiles(
+  dataPath,
+  { filter = (f) => true, joinParentDirectory = true } = {}
+) {
+  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..0e3be3e5 100644
--- a/src/util/link.js
+++ b/src/util/link.js
@@ -9,108 +9,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});
+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 (link.globalOptions.appendIndexHTML) {
+      if (appendIndexHTMLRegex.test(href)) {
+        href += "index.html";
+      }
+    }
 
-        if (hash) {
-            href += (hash.startsWith('#') ? '' : '#') + hash;
-        }
+    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)
-    };
+    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
-    });
+const linkDirectory = (key, { expose = null, attr = null, ...conf } = {}) =>
+  linkHelper((thing, { to }) => to("localized." + key, thing.directory), {
+    attr: (thing) => ({
+      ...(attr ? attr(thing) : {}),
+      ...(expose ? { [expose]: thing.directory } : {}),
+    }),
+    ...conf,
+  });
 
-const linkPathname = (key, conf) => linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf);
-const linkIndex = (key, conf) => linkHelper((_, {to}) => to('localized.' + key), conf);
+const 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
-    },
+  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'}),
+  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({
+  // 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..c59e14aa 100644
--- a/src/util/magic-constants.js
+++ b/src/util/magic-constants.js
@@ -6,5 +6,5 @@
 // All such uses should eventually be replaced with better code in due time
 // (TM).
 
-export const OFFICIAL_GROUP_DIRECTORY = 'official';
-export const FANDOM_GROUP_DIRECTORY = 'fandom';
+export const OFFICIAL_GROUP_DIRECTORY = "official";
+export const FANDOM_GROUP_DIRECTORY = "fandom";
diff --git a/src/util/node-utils.js b/src/util/node-utils.js
index ad87cae3..889a276c 100644
--- a/src/util/node-utils.js
+++ b/src/util/node-utils.js
@@ -1,40 +1,43 @@
 // Utility functions which are only relevant to particular Node.js constructs.
 
-import { fileURLToPath } from 'url';
+import { fileURLToPath } from "url";
 
-import _commandExists from 'command-exists';
+import _commandExists from "command-exists";
 
 // This package throws an error instead of returning false when the command
 // 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)
+  // 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);
-        }
+  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);
-            }
-        })
-    })
+    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 +45,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..311f7633 100644
--- a/src/util/replacer.js
+++ b/src/util/replacer.js
@@ -1,429 +1,460 @@
-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;
-        }
+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;
+    }
+  }
 
-    return success;
+  return success;
 }
 
 // Syntax literals.
-const tagBeginning = '[[';
-const tagEnding = ']]';
-const tagReplacerValue = ':';
-const tagHash = '#';
-const tagArgument = '*';
-const tagArgumentValue = '=';
-const tagLabel = '|';
+const tagBeginning = "[[";
+const tagEnding = "]]";
+const tagReplacerValue = ":";
+const tagHash = "#";
+const tagArgument = "*";
+const tagArgumentValue = "=";
+const tagLabel = "|";
 
-const noPrecedingWhitespace = '(?<!\\s)';
+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 makeError = (i, message) => ({ i, type: "error", data: { message } });
+const endOfInput = (i, comment) =>
+  makeError(i, `Unexpected end of input (${comment}).`);
 
 // These are 8asically stored on the glo8al scope, which might seem odd
 // for a recursive function, 8ut the values are only ever used immediately
 // after they're set.
-let stopped,
-    stop_iMatch,
-    stop_iParse,
-    stop_literal;
+let stopped, stop_iMatch, 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;
-
-    stopped = false;
+  let nodes = [];
+  let escapeNext = false;
+  let string = "";
+  let iString = 0;
 
-    const pushTextNode = (isLast) => {
-        string = input.slice(iString, i);
+  stopped = false;
 
-        // 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();
-        }
+  const pushTextNode = (isLast) => {
+    string = input.slice(iString, i);
 
-        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 (regexpCache.hasOwnProperty(regexpSource)) {
+    regexp = regexpCache[regexpSource];
+  } else {
+    regexp = new RegExp(regexpSource);
+    regexpCache[regexpSource] = regexp;
+  }
+
+  // Skip whitespace at the start of parsing. This is run every time
+  // parseNodes is called (and thus parseOneTextNode too), so spaces
+  // at the start of syntax elements will always 8e skipped. We don't
+  // skip whitespace that shows up inside content (i.e. once we start
+  // parsing below), though!
+  const whitespaceOffset = input.slice(i).search(/[^\s]/);
+
+  // If the string is all whitespace, that's just zero content, so
+  // return the empty nodes array.
+  if (whitespaceOffset === -1) {
+    return nodes;
+  }
 
-    i += whitespaceOffset;
+  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_iMatch = closestMatchIndex;
+      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;
-};
+  return nodes;
+}
 
 export function parseInput(input) {
-    try {
-        return parseNodes(input, 0);
-    } catch (errorNode) {
-        if (errorNode.type !== 'error') {
-            throw errorNode;
-        }
+  try {
+    return parseNodes(input, 0);
+  } catch (errorNode) {
+    if (errorNode.type !== "error") {
+      throw errorNode;
+    }
 
-        const { i, data: { message } } = errorNode;
+    const {
+      i,
+      data: { message },
+    } = errorNode;
 
-        let lineStart = input.slice(0, i).lastIndexOf('\n');
-        if (lineStart >= 0) {
-            lineStart += 1;
-        } else {
-            lineStart = 0;
-        }
+    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;
-        }
+    let lineEnd = input.slice(i).indexOf("\n");
+    if (lineEnd >= 0) {
+      lineEnd += i;
+    } else {
+      lineEnd = input.length;
+    }
 
-        const line = input.slice(lineStart, lineEnd);
+    const line = input.slice(lineStart, lineEnd);
 
-        const cursor = i - lineStart;
+    const cursor = i - lineStart;
 
-        throw new SyntaxError(fixWS`
+    throw new SyntaxError(fixWS`
             Parse error (at pos ${i}): ${message}
             ${line}
-            ${'-'.repeat(cursor) + '^'}
+            ${"-".repeat(cursor) + "^"}
         `);
-    }
+  }
 }
 
 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;
-    }
-
-    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;
-    }
+  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;
+  }
+
+  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..57736cf4 100644
--- a/src/util/serialize.js
+++ b/src/util/serialize.js
@@ -1,71 +1,70 @@
 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)
-    };
+export function serializeImagePaths(original, { thumb }) {
+  return {
+    original,
+    medium: thumb.medium(original),
+    small: thumb.small(original),
+  };
 }
 
-export function serializeCover(thing, pathFunction, {
-    serializeImagePaths,
-    urls
-}) {
-    const coverPath = pathFunction(thing, {
-        to: urls.from('media.root').to
-    });
+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..70672bfd 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -6,69 +6,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 +88,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 +120,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);
-    };
-
-    aggregate.nest = (...args) => {
-        return aggregate.call(() => withAggregate(...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.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');
+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 +234,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 +276,174 @@ 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;
+  }
+
+  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 };
     });
-
-    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, lines) =>
+            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..8fc2aba7 100644
--- a/src/util/urls.js
+++ b/src/util/urls.js
@@ -8,117 +8,133 @@
 // actual path strings. More a8stract operations using wiki data o8jects is
 // the domain of link.js.
 
-import * as path from 'path';
-import { withEntries } from './sugar.js';
+import * as path from "path";
+import { withEntries } from "./sugar.js";
 
 export function generateURLs(urlSpec) {
-    const getValueForFullKey = (obj, fullKey, prop = null) => {
-        const [ groupKey, subKey ] = fullKey.split('.');
-        if (!groupKey || !subKey) {
-            throw new Error(`Expected group key and subkey (got ${fullKey})`);
-        }
-
-        if (!obj.hasOwnProperty(groupKey)) {
-            throw new Error(`Expected valid group key (got ${groupKey})`);
-        }
-
-        const group = obj[groupKey];
-
-        if (!group.hasOwnProperty(subKey)) {
-            throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`);
-        }
-
-        return {
-            value: group[subKey],
-            group
-        };
+  const 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,
     };
+  };
 
-    // 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;
+  // 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);
+  const generateTo = (fromPath, fromGroup) => {
+    const A = trimLeadingSlash(fromPath);
 
-        const rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length);
+    const rebasePrefix = "../".repeat(
+      (fromGroup.prefix || "").split("/").filter(Boolean).length
+    );
 
-        const pathHelper = (toPath, toGroup) => {
-            let B = trimLeadingSlash(toPath);
+    const pathHelper = (toPath, toGroup) => {
+      let B = trimLeadingSlash(toPath);
 
-            let argIndex = 0;
-            B = B.replaceAll('<>', () => `<${argIndex++}>`);
+      let argIndex = 0;
+      B = B.replaceAll("<>", () => `<${argIndex++}>`);
 
-            if (toGroup.prefix !== fromGroup.prefix) {
-                // TODO: Handle differing domains in prefixes.
-                B = rebasePrefix + (toGroup.prefix || '') + B;
-            }
+      if (toGroup.prefix !== fromGroup.prefix) {
+        // TODO: Handle differing domains in prefixes.
+        B = rebasePrefix + (toGroup.prefix || "") + B;
+      }
 
-            const suffix = (toPath.endsWith('/') ? '/' : '');
+      const suffix = toPath.endsWith("/") ? "/" : "";
 
-            return {
-                posix: path.posix.relative(A, B) + suffix,
-                device: path.relative(A, B) + suffix
-            };
-        };
-
-        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..f7610fdb 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -3,63 +3,64 @@
 // 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 +72,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 +138,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
@@ -249,20 +252,23 @@ export function sortByConditions(data, conditions) {
 // Expects thing properties:
 //  * directory (or override getDirectory)
 //  * name (or override getName)
-export function sortAlphabetically(data, {getDirectory, getName} = {}) {
-    sortByDirectory(data, {getDirectory});
-    sortByName(data, {getName});
-    return data;
+export function sortAlphabetically(data, { getDirectory, getName } = {}) {
+  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
@@ -273,44 +279,46 @@ export function sortChronologically(data, {getDirectory, getName, getDate} = {})
 // release date but can be overridden) above all else.
 //
 // 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]);
+export function sortAlbumsTracksChronologically(data, { getDate } = {}) {
+  // 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;
-    }
+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;
+  }
 }
 
 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 +339,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);
+export function getFlashCover(flash, { to }) {
+  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);
-    }
+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
+    );
+  }
 }
 
-export function getArtistAvatar(artist, {to}) {
-    return to('media.artistAvatar', artist.directory, artist.avatarFileExtension);
+export function getArtistAvatar(artist, { to }) {
+  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;
+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;
     });
 
-    // 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;
+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 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);
+  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}))
-    ];
+  return [
+    ...majorReleases.map((album) => ({ large: true, item: album })),
+    ...otherReleases.map((album) => ({ large: false, item: album })),
+  ];
 }