« 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--package-lock.json1622
-rw-r--r--package.json11
-rw-r--r--src/misc-templates.js30
-rw-r--r--src/static/site.css5
-rw-r--r--src/strings-default.json2
-rw-r--r--src/thing/album.js284
-rw-r--r--src/thing/art-tag.js37
-rw-r--r--src/thing/artist.js48
-rw-r--r--src/thing/cacheable-object.js271
-rw-r--r--src/thing/flash.js129
-rw-r--r--src/thing/group.js73
-rw-r--r--src/thing/homepage-layout.js99
-rw-r--r--src/thing/news-entry.js49
-rw-r--r--src/thing/structures.js31
-rw-r--r--src/thing/thing.js74
-rw-r--r--src/thing/track.js117
-rw-r--r--src/thing/validators.js314
-rwxr-xr-xsrc/upd8.js1473
-rw-r--r--src/util/cli.js37
-rw-r--r--src/util/find.js2
-rw-r--r--src/util/sugar.js200
-rw-r--r--test/cacheable-object.js274
-rw-r--r--test/data-validators.js277
23 files changed, 4642 insertions, 817 deletions
diff --git a/package-lock.json b/package-lock.json
index f97e7f0f..fb17557f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,10 +10,210 @@
             "license": "GPL-3.0",
             "dependencies": {
                 "fix-whitespace": "^1.0.4",
-                "he": "^1.2.0"
+                "he": "^1.2.0",
+                "js-yaml": "^4.1.0"
             },
             "bin": {
                 "hsmusic": "src/upd8.js"
+            },
+            "devDependencies": {
+                "tape": "^5.4.1"
+            }
+        },
+        "node_modules/argparse": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+            "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+        },
+        "node_modules/array.prototype.every": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/array.prototype.every/-/array.prototype.every-1.1.3.tgz",
+            "integrity": "sha512-vWnriJI//SOMOWtXbU/VXhJ/InfnNHPF6BLKn5WfY8xXy+NWql0fUy20GO3sdqBhCAO+qw8S/E5nJiZX+QFdCA==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3",
+                "es-abstract": "^1.19.0",
+                "is-string": "^1.0.7"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/available-typed-arrays": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz",
+            "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/balanced-match": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+            "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+            "dev": true
+        },
+        "node_modules/brace-expansion": {
+            "version": "1.1.11",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+            "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0",
+                "concat-map": "0.0.1"
+            }
+        },
+        "node_modules/call-bind": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+            "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+            "dev": true,
+            "dependencies": {
+                "function-bind": "^1.1.1",
+                "get-intrinsic": "^1.0.2"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "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/deep-equal": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.0.5.tgz",
+            "integrity": "sha512-nPiRgmbAtm1a3JsnLCf6/SLfXcjyN5v8L1TXzdCmHrXJ4hx+gW/w1YCcn7z8gJtSiDArZCgYtbao3QqLm/N1Sw==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.0",
+                "es-get-iterator": "^1.1.1",
+                "get-intrinsic": "^1.0.1",
+                "is-arguments": "^1.0.4",
+                "is-date-object": "^1.0.2",
+                "is-regex": "^1.1.1",
+                "isarray": "^2.0.5",
+                "object-is": "^1.1.4",
+                "object-keys": "^1.1.1",
+                "object.assign": "^4.1.2",
+                "regexp.prototype.flags": "^1.3.0",
+                "side-channel": "^1.0.3",
+                "which-boxed-primitive": "^1.0.1",
+                "which-collection": "^1.0.1",
+                "which-typed-array": "^1.1.2"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/define-properties": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
+            "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+            "dev": true,
+            "dependencies": {
+                "object-keys": "^1.0.12"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/defined": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
+            "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
+            "dev": true
+        },
+        "node_modules/dotignore": {
+            "version": "0.1.2",
+            "resolved": "https://registry.npmjs.org/dotignore/-/dotignore-0.1.2.tgz",
+            "integrity": "sha512-UGGGWfSauusaVJC+8fgV+NVvBXkCTmVv7sk6nojDZZvuOUNGUy0Zk4UpHQD6EDjS0jpBwcACvH4eofvyzBcRDw==",
+            "dev": true,
+            "dependencies": {
+                "minimatch": "^3.0.4"
+            },
+            "bin": {
+                "ignored": "bin/ignored"
+            }
+        },
+        "node_modules/es-abstract": {
+            "version": "1.19.1",
+            "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz",
+            "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "es-to-primitive": "^1.2.1",
+                "function-bind": "^1.1.1",
+                "get-intrinsic": "^1.1.1",
+                "get-symbol-description": "^1.0.0",
+                "has": "^1.0.3",
+                "has-symbols": "^1.0.2",
+                "internal-slot": "^1.0.3",
+                "is-callable": "^1.2.4",
+                "is-negative-zero": "^2.0.1",
+                "is-regex": "^1.1.4",
+                "is-shared-array-buffer": "^1.0.1",
+                "is-string": "^1.0.7",
+                "is-weakref": "^1.0.1",
+                "object-inspect": "^1.11.0",
+                "object-keys": "^1.1.1",
+                "object.assign": "^4.1.2",
+                "string.prototype.trimend": "^1.0.4",
+                "string.prototype.trimstart": "^1.0.4",
+                "unbox-primitive": "^1.0.1"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/es-get-iterator": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.2.tgz",
+            "integrity": "sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "get-intrinsic": "^1.1.0",
+                "has-symbols": "^1.0.1",
+                "is-arguments": "^1.1.0",
+                "is-map": "^2.0.2",
+                "is-set": "^2.0.2",
+                "is-string": "^1.0.5",
+                "isarray": "^2.0.5"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/es-to-primitive": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+            "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+            "dev": true,
+            "dependencies": {
+                "is-callable": "^1.1.4",
+                "is-date-object": "^1.0.1",
+                "is-symbol": "^1.0.2"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
         "node_modules/fix-whitespace": {
@@ -21,6 +221,153 @@
             "resolved": "https://registry.npmjs.org/fix-whitespace/-/fix-whitespace-1.0.4.tgz",
             "integrity": "sha512-TYJpw4orIgDpaINRkw1BVJQF8rPTNSUbW/s4mLYSApUt0MquGfI+iripYHibg9l9fe795VauuVCLTpDvy8KFWQ=="
         },
+        "node_modules/for-each": {
+            "version": "0.3.3",
+            "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
+            "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
+            "dev": true,
+            "dependencies": {
+                "is-callable": "^1.1.3"
+            }
+        },
+        "node_modules/foreach": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz",
+            "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=",
+            "dev": true
+        },
+        "node_modules/fs.realpath": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+            "dev": true
+        },
+        "node_modules/function-bind": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+            "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+            "dev": true
+        },
+        "node_modules/get-intrinsic": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+            "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+            "dev": true,
+            "dependencies": {
+                "function-bind": "^1.1.1",
+                "has": "^1.0.3",
+                "has-symbols": "^1.0.1"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/get-package-type": {
+            "version": "0.1.0",
+            "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+            "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+            "dev": true,
+            "engines": {
+                "node": ">=8.0.0"
+            }
+        },
+        "node_modules/get-symbol-description": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+            "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "get-intrinsic": "^1.1.1"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/glob": {
+            "version": "7.2.0",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
+            "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
+            "dev": true,
+            "dependencies": {
+                "fs.realpath": "^1.0.0",
+                "inflight": "^1.0.4",
+                "inherits": "2",
+                "minimatch": "^3.0.4",
+                "once": "^1.3.0",
+                "path-is-absolute": "^1.0.0"
+            },
+            "engines": {
+                "node": "*"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/has": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+            "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+            "dev": true,
+            "dependencies": {
+                "function-bind": "^1.1.1"
+            },
+            "engines": {
+                "node": ">= 0.4.0"
+            }
+        },
+        "node_modules/has-bigints": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
+            "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
+            "dev": true,
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/has-dynamic-import": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/has-dynamic-import/-/has-dynamic-import-2.0.1.tgz",
+            "integrity": "sha512-X3fbtsZmwb6W7fJGR9o7x65fZoodygCrZ3TVycvghP62yYQfS0t4RS0Qcz+j5tQYUKeSWS09tHkWW6WhFV3XhQ==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "get-intrinsic": "^1.1.1"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/has-symbols": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
+            "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/has-tostringtag": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+            "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+            "dev": true,
+            "dependencies": {
+                "has-symbols": "^1.0.2"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
         "node_modules/he": {
             "version": "1.2.0",
             "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
@@ -28,18 +375,1291 @@
             "bin": {
                 "he": "bin/he"
             }
+        },
+        "node_modules/inflight": {
+            "version": "1.0.6",
+            "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+            "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+            "dev": true,
+            "dependencies": {
+                "once": "^1.3.0",
+                "wrappy": "1"
+            }
+        },
+        "node_modules/inherits": {
+            "version": "2.0.4",
+            "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+            "dev": true
+        },
+        "node_modules/internal-slot": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+            "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+            "dev": true,
+            "dependencies": {
+                "get-intrinsic": "^1.1.0",
+                "has": "^1.0.3",
+                "side-channel": "^1.0.4"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/is-arguments": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
+            "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "has-tostringtag": "^1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-bigint": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+            "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+            "dev": true,
+            "dependencies": {
+                "has-bigints": "^1.0.1"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-boolean-object": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+            "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "has-tostringtag": "^1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-callable": {
+            "version": "1.2.4",
+            "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+            "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-core-module": {
+            "version": "2.8.1",
+            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz",
+            "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==",
+            "dev": true,
+            "dependencies": {
+                "has": "^1.0.3"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-date-object": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+            "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+            "dev": true,
+            "dependencies": {
+                "has-tostringtag": "^1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-map": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
+            "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==",
+            "dev": true,
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-negative-zero": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+            "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-number-object": {
+            "version": "1.0.6",
+            "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz",
+            "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==",
+            "dev": true,
+            "dependencies": {
+                "has-tostringtag": "^1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-regex": {
+            "version": "1.1.4",
+            "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+            "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "has-tostringtag": "^1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-set": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz",
+            "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==",
+            "dev": true,
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-shared-array-buffer": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz",
+            "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==",
+            "dev": true,
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-string": {
+            "version": "1.0.7",
+            "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+            "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+            "dev": true,
+            "dependencies": {
+                "has-tostringtag": "^1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-symbol": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+            "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+            "dev": true,
+            "dependencies": {
+                "has-symbols": "^1.0.2"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-typed-array": {
+            "version": "1.1.8",
+            "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.8.tgz",
+            "integrity": "sha512-HqH41TNZq2fgtGT8WHVFVJhBVGuY3AnP3Q36K8JKXUxSxRgk/d+7NjmwG2vo2mYmXK8UYZKu0qH8bVP5gEisjA==",
+            "dev": true,
+            "dependencies": {
+                "available-typed-arrays": "^1.0.5",
+                "call-bind": "^1.0.2",
+                "es-abstract": "^1.18.5",
+                "foreach": "^2.0.5",
+                "has-tostringtag": "^1.0.0"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-weakmap": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz",
+            "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==",
+            "dev": true,
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-weakref": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
+            "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/is-weakset": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz",
+            "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "get-intrinsic": "^1.1.1"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/isarray": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+            "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+            "dev": true
+        },
+        "node_modules/js-yaml": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+            "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+            "dependencies": {
+                "argparse": "^2.0.1"
+            },
+            "bin": {
+                "js-yaml": "bin/js-yaml.js"
+            }
+        },
+        "node_modules/minimatch": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+            "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+            "dev": true,
+            "dependencies": {
+                "brace-expansion": "^1.1.7"
+            },
+            "engines": {
+                "node": "*"
+            }
+        },
+        "node_modules/minimist": {
+            "version": "1.2.5",
+            "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+            "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
+            "dev": true
+        },
+        "node_modules/object-inspect": {
+            "version": "1.12.0",
+            "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+            "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+            "dev": true,
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/object-is": {
+            "version": "1.1.5",
+            "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
+            "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/object-keys": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+            "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/object.assign": {
+            "version": "4.1.2",
+            "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
+            "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.0",
+                "define-properties": "^1.1.3",
+                "has-symbols": "^1.0.1",
+                "object-keys": "^1.1.1"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/once": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+            "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+            "dev": true,
+            "dependencies": {
+                "wrappy": "1"
+            }
+        },
+        "node_modules/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,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "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/regexp.prototype.flags": {
+            "version": "1.4.1",
+            "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.1.tgz",
+            "integrity": "sha512-pMR7hBVUUGI7PMA37m2ofIdQCsomVnas+Jn5UPGAHQ+/LlwKm/aTLJHdasmHRzlfeZwHiAOaRSo2rbBDm3nNUQ==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/resolve": {
+            "version": "2.0.0-next.3",
+            "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz",
+            "integrity": "sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q==",
+            "dev": true,
+            "dependencies": {
+                "is-core-module": "^2.2.0",
+                "path-parse": "^1.0.6"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/resumer": {
+            "version": "0.0.0",
+            "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz",
+            "integrity": "sha1-8ej0YeQGS6Oegq883CqMiT0HZ1k=",
+            "dev": true,
+            "dependencies": {
+                "through": "~2.3.4"
+            }
+        },
+        "node_modules/side-channel": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+            "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.0",
+                "get-intrinsic": "^1.0.2",
+                "object-inspect": "^1.9.0"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/string.prototype.trim": {
+            "version": "1.2.5",
+            "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.5.tgz",
+            "integrity": "sha512-Lnh17webJVsD6ECeovpVN17RlAKjmz4rF9S+8Y45CkMc/ufVpTkU3vZIyIC7sllQ1FCvObZnnCdNs/HXTUOTlg==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3",
+                "es-abstract": "^1.19.1"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/string.prototype.trimend": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
+            "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/string.prototype.trimstart": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
+            "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
+            "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/tape": {
+            "version": "5.4.1",
+            "resolved": "https://registry.npmjs.org/tape/-/tape-5.4.1.tgz",
+            "integrity": "sha512-7bGaJ3WnQ/CX3xOWzlR+9lNptEWoD+11gyREP8k+SYrDu2a20EifKpTmZndXn25ZRxesYHSuNtE7Fb+THcjfGA==",
+            "dev": true,
+            "dependencies": {
+                "array.prototype.every": "^1.1.3",
+                "call-bind": "^1.0.2",
+                "deep-equal": "^2.0.5",
+                "defined": "^1.0.0",
+                "dotignore": "^0.1.2",
+                "for-each": "^0.3.3",
+                "get-package-type": "^0.1.0",
+                "glob": "^7.2.0",
+                "has": "^1.0.3",
+                "has-dynamic-import": "^2.0.1",
+                "inherits": "^2.0.4",
+                "is-regex": "^1.1.4",
+                "minimist": "^1.2.5",
+                "object-inspect": "^1.12.0",
+                "object-is": "^1.1.5",
+                "object-keys": "^1.1.1",
+                "object.assign": "^4.1.2",
+                "resolve": "^2.0.0-next.3",
+                "resumer": "^0.0.0",
+                "string.prototype.trim": "^1.2.5",
+                "through": "^2.3.8"
+            },
+            "bin": {
+                "tape": "bin/tape"
+            }
+        },
+        "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/unbox-primitive": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
+            "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+            "dev": true,
+            "dependencies": {
+                "function-bind": "^1.1.1",
+                "has-bigints": "^1.0.1",
+                "has-symbols": "^1.0.2",
+                "which-boxed-primitive": "^1.0.2"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/which-boxed-primitive": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+            "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+            "dev": true,
+            "dependencies": {
+                "is-bigint": "^1.0.1",
+                "is-boolean-object": "^1.1.0",
+                "is-number-object": "^1.0.4",
+                "is-string": "^1.0.5",
+                "is-symbol": "^1.0.3"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/which-collection": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz",
+            "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==",
+            "dev": true,
+            "dependencies": {
+                "is-map": "^2.0.1",
+                "is-set": "^2.0.1",
+                "is-weakmap": "^2.0.1",
+                "is-weakset": "^2.0.1"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/which-typed-array": {
+            "version": "1.1.7",
+            "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.7.tgz",
+            "integrity": "sha512-vjxaB4nfDqwKI0ws7wZpxIlde1XrLX5uB0ZjpfshgmapJMD7jJWhZI+yToJTqaFByF0eNBcYxbjmCzoRP7CfEw==",
+            "dev": true,
+            "dependencies": {
+                "available-typed-arrays": "^1.0.5",
+                "call-bind": "^1.0.2",
+                "es-abstract": "^1.18.5",
+                "foreach": "^2.0.5",
+                "has-tostringtag": "^1.0.0",
+                "is-typed-array": "^1.1.7"
+            },
+            "engines": {
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
+            }
+        },
+        "node_modules/wrappy": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+            "dev": true
         }
     },
     "dependencies": {
+        "argparse": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+            "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+        },
+        "array.prototype.every": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/array.prototype.every/-/array.prototype.every-1.1.3.tgz",
+            "integrity": "sha512-vWnriJI//SOMOWtXbU/VXhJ/InfnNHPF6BLKn5WfY8xXy+NWql0fUy20GO3sdqBhCAO+qw8S/E5nJiZX+QFdCA==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3",
+                "es-abstract": "^1.19.0",
+                "is-string": "^1.0.7"
+            }
+        },
+        "available-typed-arrays": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz",
+            "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==",
+            "dev": true
+        },
+        "balanced-match": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+            "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+            "dev": true
+        },
+        "brace-expansion": {
+            "version": "1.1.11",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+            "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+            "dev": true,
+            "requires": {
+                "balanced-match": "^1.0.0",
+                "concat-map": "0.0.1"
+            }
+        },
+        "call-bind": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+            "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+            "dev": true,
+            "requires": {
+                "function-bind": "^1.1.1",
+                "get-intrinsic": "^1.0.2"
+            }
+        },
+        "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
+        },
+        "deep-equal": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.0.5.tgz",
+            "integrity": "sha512-nPiRgmbAtm1a3JsnLCf6/SLfXcjyN5v8L1TXzdCmHrXJ4hx+gW/w1YCcn7z8gJtSiDArZCgYtbao3QqLm/N1Sw==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.0",
+                "es-get-iterator": "^1.1.1",
+                "get-intrinsic": "^1.0.1",
+                "is-arguments": "^1.0.4",
+                "is-date-object": "^1.0.2",
+                "is-regex": "^1.1.1",
+                "isarray": "^2.0.5",
+                "object-is": "^1.1.4",
+                "object-keys": "^1.1.1",
+                "object.assign": "^4.1.2",
+                "regexp.prototype.flags": "^1.3.0",
+                "side-channel": "^1.0.3",
+                "which-boxed-primitive": "^1.0.1",
+                "which-collection": "^1.0.1",
+                "which-typed-array": "^1.1.2"
+            }
+        },
+        "define-properties": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
+            "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+            "dev": true,
+            "requires": {
+                "object-keys": "^1.0.12"
+            }
+        },
+        "defined": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
+            "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
+            "dev": true
+        },
+        "dotignore": {
+            "version": "0.1.2",
+            "resolved": "https://registry.npmjs.org/dotignore/-/dotignore-0.1.2.tgz",
+            "integrity": "sha512-UGGGWfSauusaVJC+8fgV+NVvBXkCTmVv7sk6nojDZZvuOUNGUy0Zk4UpHQD6EDjS0jpBwcACvH4eofvyzBcRDw==",
+            "dev": true,
+            "requires": {
+                "minimatch": "^3.0.4"
+            }
+        },
+        "es-abstract": {
+            "version": "1.19.1",
+            "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz",
+            "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "es-to-primitive": "^1.2.1",
+                "function-bind": "^1.1.1",
+                "get-intrinsic": "^1.1.1",
+                "get-symbol-description": "^1.0.0",
+                "has": "^1.0.3",
+                "has-symbols": "^1.0.2",
+                "internal-slot": "^1.0.3",
+                "is-callable": "^1.2.4",
+                "is-negative-zero": "^2.0.1",
+                "is-regex": "^1.1.4",
+                "is-shared-array-buffer": "^1.0.1",
+                "is-string": "^1.0.7",
+                "is-weakref": "^1.0.1",
+                "object-inspect": "^1.11.0",
+                "object-keys": "^1.1.1",
+                "object.assign": "^4.1.2",
+                "string.prototype.trimend": "^1.0.4",
+                "string.prototype.trimstart": "^1.0.4",
+                "unbox-primitive": "^1.0.1"
+            }
+        },
+        "es-get-iterator": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.2.tgz",
+            "integrity": "sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "get-intrinsic": "^1.1.0",
+                "has-symbols": "^1.0.1",
+                "is-arguments": "^1.1.0",
+                "is-map": "^2.0.2",
+                "is-set": "^2.0.2",
+                "is-string": "^1.0.5",
+                "isarray": "^2.0.5"
+            }
+        },
+        "es-to-primitive": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+            "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+            "dev": true,
+            "requires": {
+                "is-callable": "^1.1.4",
+                "is-date-object": "^1.0.1",
+                "is-symbol": "^1.0.2"
+            }
+        },
         "fix-whitespace": {
             "version": "1.0.4",
             "resolved": "https://registry.npmjs.org/fix-whitespace/-/fix-whitespace-1.0.4.tgz",
             "integrity": "sha512-TYJpw4orIgDpaINRkw1BVJQF8rPTNSUbW/s4mLYSApUt0MquGfI+iripYHibg9l9fe795VauuVCLTpDvy8KFWQ=="
         },
+        "for-each": {
+            "version": "0.3.3",
+            "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
+            "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
+            "dev": true,
+            "requires": {
+                "is-callable": "^1.1.3"
+            }
+        },
+        "foreach": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz",
+            "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=",
+            "dev": true
+        },
+        "fs.realpath": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+            "dev": true
+        },
+        "function-bind": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+            "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+            "dev": true
+        },
+        "get-intrinsic": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+            "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+            "dev": true,
+            "requires": {
+                "function-bind": "^1.1.1",
+                "has": "^1.0.3",
+                "has-symbols": "^1.0.1"
+            }
+        },
+        "get-package-type": {
+            "version": "0.1.0",
+            "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+            "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+            "dev": true
+        },
+        "get-symbol-description": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+            "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "get-intrinsic": "^1.1.1"
+            }
+        },
+        "glob": {
+            "version": "7.2.0",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
+            "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
+            "dev": true,
+            "requires": {
+                "fs.realpath": "^1.0.0",
+                "inflight": "^1.0.4",
+                "inherits": "2",
+                "minimatch": "^3.0.4",
+                "once": "^1.3.0",
+                "path-is-absolute": "^1.0.0"
+            }
+        },
+        "has": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+            "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+            "dev": true,
+            "requires": {
+                "function-bind": "^1.1.1"
+            }
+        },
+        "has-bigints": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
+            "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
+            "dev": true
+        },
+        "has-dynamic-import": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/has-dynamic-import/-/has-dynamic-import-2.0.1.tgz",
+            "integrity": "sha512-X3fbtsZmwb6W7fJGR9o7x65fZoodygCrZ3TVycvghP62yYQfS0t4RS0Qcz+j5tQYUKeSWS09tHkWW6WhFV3XhQ==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "get-intrinsic": "^1.1.1"
+            }
+        },
+        "has-symbols": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
+            "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+            "dev": true
+        },
+        "has-tostringtag": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+            "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+            "dev": true,
+            "requires": {
+                "has-symbols": "^1.0.2"
+            }
+        },
         "he": {
             "version": "1.2.0",
             "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
             "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
+        },
+        "inflight": {
+            "version": "1.0.6",
+            "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+            "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+            "dev": true,
+            "requires": {
+                "once": "^1.3.0",
+                "wrappy": "1"
+            }
+        },
+        "inherits": {
+            "version": "2.0.4",
+            "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+            "dev": true
+        },
+        "internal-slot": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+            "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+            "dev": true,
+            "requires": {
+                "get-intrinsic": "^1.1.0",
+                "has": "^1.0.3",
+                "side-channel": "^1.0.4"
+            }
+        },
+        "is-arguments": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
+            "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "has-tostringtag": "^1.0.0"
+            }
+        },
+        "is-bigint": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+            "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+            "dev": true,
+            "requires": {
+                "has-bigints": "^1.0.1"
+            }
+        },
+        "is-boolean-object": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+            "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "has-tostringtag": "^1.0.0"
+            }
+        },
+        "is-callable": {
+            "version": "1.2.4",
+            "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+            "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+            "dev": true
+        },
+        "is-core-module": {
+            "version": "2.8.1",
+            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz",
+            "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==",
+            "dev": true,
+            "requires": {
+                "has": "^1.0.3"
+            }
+        },
+        "is-date-object": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+            "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+            "dev": true,
+            "requires": {
+                "has-tostringtag": "^1.0.0"
+            }
+        },
+        "is-map": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
+            "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==",
+            "dev": true
+        },
+        "is-negative-zero": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+            "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+            "dev": true
+        },
+        "is-number-object": {
+            "version": "1.0.6",
+            "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz",
+            "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==",
+            "dev": true,
+            "requires": {
+                "has-tostringtag": "^1.0.0"
+            }
+        },
+        "is-regex": {
+            "version": "1.1.4",
+            "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+            "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "has-tostringtag": "^1.0.0"
+            }
+        },
+        "is-set": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz",
+            "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==",
+            "dev": true
+        },
+        "is-shared-array-buffer": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz",
+            "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==",
+            "dev": true
+        },
+        "is-string": {
+            "version": "1.0.7",
+            "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+            "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+            "dev": true,
+            "requires": {
+                "has-tostringtag": "^1.0.0"
+            }
+        },
+        "is-symbol": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+            "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+            "dev": true,
+            "requires": {
+                "has-symbols": "^1.0.2"
+            }
+        },
+        "is-typed-array": {
+            "version": "1.1.8",
+            "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.8.tgz",
+            "integrity": "sha512-HqH41TNZq2fgtGT8WHVFVJhBVGuY3AnP3Q36K8JKXUxSxRgk/d+7NjmwG2vo2mYmXK8UYZKu0qH8bVP5gEisjA==",
+            "dev": true,
+            "requires": {
+                "available-typed-arrays": "^1.0.5",
+                "call-bind": "^1.0.2",
+                "es-abstract": "^1.18.5",
+                "foreach": "^2.0.5",
+                "has-tostringtag": "^1.0.0"
+            }
+        },
+        "is-weakmap": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz",
+            "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==",
+            "dev": true
+        },
+        "is-weakref": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
+            "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2"
+            }
+        },
+        "is-weakset": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz",
+            "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "get-intrinsic": "^1.1.1"
+            }
+        },
+        "isarray": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+            "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+            "dev": true
+        },
+        "js-yaml": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+            "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+            "requires": {
+                "argparse": "^2.0.1"
+            }
+        },
+        "minimatch": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+            "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+            "dev": true,
+            "requires": {
+                "brace-expansion": "^1.1.7"
+            }
+        },
+        "minimist": {
+            "version": "1.2.5",
+            "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+            "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
+            "dev": true
+        },
+        "object-inspect": {
+            "version": "1.12.0",
+            "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+            "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+            "dev": true
+        },
+        "object-is": {
+            "version": "1.1.5",
+            "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
+            "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3"
+            }
+        },
+        "object-keys": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+            "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+            "dev": true
+        },
+        "object.assign": {
+            "version": "4.1.2",
+            "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
+            "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.0",
+                "define-properties": "^1.1.3",
+                "has-symbols": "^1.0.1",
+                "object-keys": "^1.1.1"
+            }
+        },
+        "once": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+            "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+            "dev": true,
+            "requires": {
+                "wrappy": "1"
+            }
+        },
+        "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-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
+        },
+        "regexp.prototype.flags": {
+            "version": "1.4.1",
+            "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.1.tgz",
+            "integrity": "sha512-pMR7hBVUUGI7PMA37m2ofIdQCsomVnas+Jn5UPGAHQ+/LlwKm/aTLJHdasmHRzlfeZwHiAOaRSo2rbBDm3nNUQ==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3"
+            }
+        },
+        "resolve": {
+            "version": "2.0.0-next.3",
+            "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz",
+            "integrity": "sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q==",
+            "dev": true,
+            "requires": {
+                "is-core-module": "^2.2.0",
+                "path-parse": "^1.0.6"
+            }
+        },
+        "resumer": {
+            "version": "0.0.0",
+            "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz",
+            "integrity": "sha1-8ej0YeQGS6Oegq883CqMiT0HZ1k=",
+            "dev": true,
+            "requires": {
+                "through": "~2.3.4"
+            }
+        },
+        "side-channel": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+            "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.0",
+                "get-intrinsic": "^1.0.2",
+                "object-inspect": "^1.9.0"
+            }
+        },
+        "string.prototype.trim": {
+            "version": "1.2.5",
+            "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.5.tgz",
+            "integrity": "sha512-Lnh17webJVsD6ECeovpVN17RlAKjmz4rF9S+8Y45CkMc/ufVpTkU3vZIyIC7sllQ1FCvObZnnCdNs/HXTUOTlg==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3",
+                "es-abstract": "^1.19.1"
+            }
+        },
+        "string.prototype.trimend": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
+            "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3"
+            }
+        },
+        "string.prototype.trimstart": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
+            "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
+            "dev": true,
+            "requires": {
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.1.3"
+            }
+        },
+        "tape": {
+            "version": "5.4.1",
+            "resolved": "https://registry.npmjs.org/tape/-/tape-5.4.1.tgz",
+            "integrity": "sha512-7bGaJ3WnQ/CX3xOWzlR+9lNptEWoD+11gyREP8k+SYrDu2a20EifKpTmZndXn25ZRxesYHSuNtE7Fb+THcjfGA==",
+            "dev": true,
+            "requires": {
+                "array.prototype.every": "^1.1.3",
+                "call-bind": "^1.0.2",
+                "deep-equal": "^2.0.5",
+                "defined": "^1.0.0",
+                "dotignore": "^0.1.2",
+                "for-each": "^0.3.3",
+                "get-package-type": "^0.1.0",
+                "glob": "^7.2.0",
+                "has": "^1.0.3",
+                "has-dynamic-import": "^2.0.1",
+                "inherits": "^2.0.4",
+                "is-regex": "^1.1.4",
+                "minimist": "^1.2.5",
+                "object-inspect": "^1.12.0",
+                "object-is": "^1.1.5",
+                "object-keys": "^1.1.1",
+                "object.assign": "^4.1.2",
+                "resolve": "^2.0.0-next.3",
+                "resumer": "^0.0.0",
+                "string.prototype.trim": "^1.2.5",
+                "through": "^2.3.8"
+            }
+        },
+        "through": {
+            "version": "2.3.8",
+            "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+            "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
+            "dev": true
+        },
+        "unbox-primitive": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
+            "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+            "dev": true,
+            "requires": {
+                "function-bind": "^1.1.1",
+                "has-bigints": "^1.0.1",
+                "has-symbols": "^1.0.2",
+                "which-boxed-primitive": "^1.0.2"
+            }
+        },
+        "which-boxed-primitive": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+            "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+            "dev": true,
+            "requires": {
+                "is-bigint": "^1.0.1",
+                "is-boolean-object": "^1.1.0",
+                "is-number-object": "^1.0.4",
+                "is-string": "^1.0.5",
+                "is-symbol": "^1.0.3"
+            }
+        },
+        "which-collection": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz",
+            "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==",
+            "dev": true,
+            "requires": {
+                "is-map": "^2.0.1",
+                "is-set": "^2.0.1",
+                "is-weakmap": "^2.0.1",
+                "is-weakset": "^2.0.1"
+            }
+        },
+        "which-typed-array": {
+            "version": "1.1.7",
+            "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.7.tgz",
+            "integrity": "sha512-vjxaB4nfDqwKI0ws7wZpxIlde1XrLX5uB0ZjpfshgmapJMD7jJWhZI+yToJTqaFByF0eNBcYxbjmCzoRP7CfEw==",
+            "dev": true,
+            "requires": {
+                "available-typed-arrays": "^1.0.5",
+                "call-bind": "^1.0.2",
+                "es-abstract": "^1.18.5",
+                "foreach": "^2.0.5",
+                "has-tostringtag": "^1.0.0",
+                "is-typed-array": "^1.1.7"
+            }
+        },
+        "wrappy": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+            "dev": true
         }
     }
 }
diff --git a/package.json b/package.json
index 1017d4aa..c48d868e 100644
--- a/package.json
+++ b/package.json
@@ -7,9 +7,16 @@
     "bin": {
         "hsmusic": "./src/upd8.js"
     },
+    "scripts": {
+        "test": "tape test/**/*.js"
+    },
     "dependencies": {
         "fix-whitespace": "^1.0.4",
-        "he": "^1.2.0"
+        "he": "^1.2.0",
+        "js-yaml": "^4.1.0"
     },
-    "license": "GPL-3.0"
+    "license": "GPL-3.0",
+    "devDependencies": {
+        "tape": "^5.4.1"
+    }
 }
diff --git a/src/misc-templates.js b/src/misc-templates.js
index 578c4e59..090c437d 100644
--- a/src/misc-templates.js
+++ b/src/misc-templates.js
@@ -375,3 +375,33 @@ export function generatePreviousNextLinks(current, {
         })
     ].filter(Boolean).join(', ');
 }
+
+// Footer stuff
+
+export function getFooterLocalizationLinks(pathname, {
+    languages,
+    paths,
+    strings,
+    to
+}) {
+    const { toPath } = paths;
+    const keySuffix = toPath[0].replace(/^localized\./, '.');
+    const toArgs = toPath.slice(1);
+
+    const links = Object.entries(languages)
+        .filter(([ code ]) => code !== 'default')
+        .map(([ code, strings ]) => strings)
+        .sort((
+            { json: { 'meta.languageName': a } },
+            { json: { 'meta.languageName': b } }
+        ) => a < b ? -1 : a > b ? 1 : 0)
+        .map(strings => html.tag('span', html.tag('a', {
+            href: (strings.code === languages.default.code
+                ? to('localizedDefaultLanguage' + keySuffix, ...toArgs)
+                : to('localizedWithBaseDirectory' + keySuffix, strings.code, ...toArgs))
+        }, strings.json['meta.languageName'])));
+
+    return html.tag('div',
+        {class: 'footer-localization-links'},
+        strings('misc.uiLanguage', {languages: links.join('\n')}));
+}
diff --git a/src/static/site.css b/src/static/site.css
index 65d4d343..c88343e5 100644
--- a/src/static/site.css
+++ b/src/static/site.css
@@ -173,6 +173,11 @@ footer > :last-child {
     margin-bottom: 0;
 }
 
+.footer-localization-links > span:not(:last-child)::after {
+    content: " \00b7 ";
+    font-weight: 800;
+}
+
 .nowrap {
     white-space: nowrap;
 }
diff --git a/src/strings-default.json b/src/strings-default.json
index b80c99f6..86fb73fc 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -1,5 +1,6 @@
 {
     "meta.languageCode": "en",
+    "meta.languageName": "English",
     "count.tracks": "{TRACKS}",
     "count.tracks.withUnit.zero": "",
     "count.tracks.withUnit.one": "{TRACKS} track",
@@ -148,6 +149,7 @@
     "misc.contentWarnings": "cw: {WARNINGS}",
     "misc.contentWarnings.reveal": "click to show",
     "misc.albumGridDetails": "({TRACKS}, {TIME})",
+    "misc.uiLanguage": "UI Language: {LANGUAGES}",
     "homepage.title": "{TITLE}",
     "homepage.news.title": "News",
     "homepage.news.entry.viewRest": "(View rest of entry!)",
diff --git a/src/thing/album.js b/src/thing/album.js
index e99cfc36..8a9fde2c 100644
--- a/src/thing/album.js
+++ b/src/thing/album.js
@@ -1,62 +1,252 @@
+import CacheableObject from './cacheable-object.js';
 import Thing from './thing.js';
+import find from '../util/find.js';
 
 import {
-    validateDirectory,
-    validateReference
-} from './structures.js';
+    isBoolean,
+    isColor,
+    isCommentary,
+    isContributionList,
+    isDate,
+    isDimensions,
+    isDirectory,
+    isFileExtension,
+    isName,
+    isURL,
+    isString,
+    validateArrayItems,
+    validateInstanceOf,
+    validateReference,
+    validateReferenceList,
+} from './validators.js';
 
-import {
-    showAggregate,
-    withAggregate
-} from '../util/sugar.js';
+export class TrackGroup extends CacheableObject {
+    static propertyDescriptors = {
+        // Update & expose
 
-export default class Album extends Thing {
-    #directory = null;
-    #tracks = [];
+        name: {
+            flags: {update: true, expose: true},
+            update: {default: 'Unnamed Track Group', validate: isName}
+        },
+
+        color: {
+            flags: {update: true, expose: true},
+            update: {validate: isColor}
+        },
+
+        dateOriginallyReleased: {
+            flags: {update: true, expose: true},
+            update: {validate: isDate}
+        },
+
+        tracksByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: validateReferenceList('track')}
+        },
+
+        isDefaultTrackGroup: {
+            flags: {update: true, expose: true},
+            update: {validate: isBoolean}
+        },
+
+        // Update only
+
+        trackData: {
+            flags: {update: true},
+            update: {validate: validateArrayItems(item => isInstance(item, Track))}
+        },
 
-    static updateError = {
-        directory: Thing.extendPropertyError('directory'),
-        tracks: Thing.extendPropertyError('tracks')
+        // Expose only
+
+        tracks: {
+            flags: {expose: true},
+
+            expose: {
+                dependencies: ['tracksByRef', 'trackData'],
+                compute: ({ tracksByRef, trackData }) => (
+                    tracksByRef.map(ref => find.track(ref, {wikiData: {trackData}})))
+            }
+        }
     };
+}
+
+export default class Album extends Thing {
+    static [Thing.referenceType] = 'album';
+
+    static propertyDescriptors = {
+        // Update & expose
 
-    update(source) {
-        const err = this.constructor.updateError;
+        name: {
+            flags: {update: true, expose: true},
+            update: {default: 'Unnamed Album', validate: isName}
+        },
 
-        withAggregate(({ nest, filter, throws }) => {
+        color: {
+            flags: {update: true, expose: true},
+            update: {validate: isColor}
+        },
 
-            if (source.directory) {
-                nest(throws(err.directory), ({ call }) => {
-                    if (call(validateDirectory, source.directory)) {
-                        this.#directory = source.directory;
-                    }
-                });
+        directory: {
+            flags: {update: true, expose: true},
+            update: {validate: isDirectory},
+            expose: Thing.directoryExpose
+        },
+
+        urls: {
+            flags: {update: true, expose: true},
+
+            update: {
+                validate: validateArrayItems(isURL)
             }
+        },
 
-            if (source.tracks)
-                this.#tracks = filter(source.tracks, validateReference('track'), throws(err.tracks));
-        });
-    }
+        date: {
+            flags: {update: true, expose: true},
+            update: {validate: isDate}
+        },
 
-    get directory() { return this.#directory; }
-    get tracks() { return this.#tracks; }
-}
+        coverArtDate: {
+            flags: {update: true, expose: true},
+            update: {validate: isDate}
+        },
 
-const album = new Album();
-
-console.log('tracks (before):', album.tracks);
-
-try {
-    album.update({
-        directory: 'oh yes',
-        tracks: [
-            'lol',
-            123,
-            'track:oh-yeah',
-            'group:what-am-i-doing-here'
-        ]
-    });
-} catch (error) {
-    showAggregate(error);
-}
+        trackArtDate: {
+            flags: {update: true, expose: true},
+            update: {validate: isDate}
+        },
+
+        dateAddedToWiki: {
+            flags: {update: true, expose: true},
+
+            update: {validate: isDate}
+        },
+
+        artistContribsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: isContributionList}
+        },
+
+        coverArtistContribsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: isContributionList}
+        },
+
+        trackCoverArtistContribsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: isContributionList}
+        },
+
+        wallpaperArtistContribsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: isContributionList}
+        },
+
+        bannerArtistContribsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: isContributionList}
+        },
+
+        groupsByRef: {
+            flags: {update: true, expose: true},
+
+            update: {
+                validate: validateReferenceList('group')
+            }
+        },
 
-console.log('tracks (after):', album.tracks);
+        artTagsByRef: {
+            flags: {update: true, expose: true},
+
+            update: {
+                validate: validateReferenceList('tag')
+            }
+        },
+
+        trackGroups: {
+            flags: {update: true, expose: true},
+
+            update: {
+                validate: validateArrayItems(validateInstanceOf(TrackGroup))
+            }
+        },
+
+        wallpaperStyle: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+
+        wallpaperFileExtension: {
+            flags: {update: true, expose: true},
+            update: {validate: isFileExtension}
+        },
+
+        bannerStyle: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+
+        bannerFileExtension: {
+            flags: {update: true, expose: true},
+            update: {validate: isFileExtension}
+        },
+
+        bannerDimensions: {
+            flags: {update: true, expose: true},
+            update: {validate: isDimensions}
+        },
+
+        hasTrackArt: {
+            flags: {update: true, expose: true},
+
+            update: {
+                default: true,
+                validate: isBoolean
+            }
+        },
+
+        isMajorRelease: {
+            flags: {update: true, expose: true},
+
+            update: {
+                default: false,
+                validate: isBoolean
+            }
+        },
+
+        isListedOnHomepage: {
+            flags: {update: true, expose: true},
+
+            update: {
+                default: true,
+                validate: isBoolean
+            }
+        },
+
+        commentary: {
+            flags: {update: true, expose: true},
+            update: {validate: isCommentary}
+        },
+
+        // Expose only
+
+        /*
+        tracks: {
+            flags: {expose: true},
+
+            expose: {
+                dependencies: ['trackReferences', 'wikiData'],
+                compute: ({trackReferences, wikiData}) => (
+                    trackReferences.map(ref => find.track(ref, {wikiData})))
+            }
+        },
+        */
+
+        // Update only
+
+        /*
+        wikiData: {
+            flags: {update: true}
+        }
+        */
+    };
+}
diff --git a/src/thing/art-tag.js b/src/thing/art-tag.js
new file mode 100644
index 00000000..4b09d885
--- /dev/null
+++ b/src/thing/art-tag.js
@@ -0,0 +1,37 @@
+import Thing from './thing.js';
+
+import {
+    isBoolean,
+    isColor,
+    isDirectory,
+    isName,
+} from './validators.js';
+
+export default class ArtTag extends Thing {
+    static [Thing.referenceType] = 'tag';
+
+    static propertyDescriptors = {
+        // Update & expose
+
+        name: {
+            flags: {update: true, expose: true},
+            update: {validate: isName}
+        },
+
+        directory: {
+            flags: {update: true, expose: true},
+            update: {validate: isDirectory},
+            expose: Thing.directoryExpose
+        },
+
+        color: {
+            flags: {update: true, expose: true},
+            update: {validate: isColor}
+        },
+
+        isContentWarning: {
+            flags: {update: true, expose: true},
+            update: {validate: isBoolean, default: false}
+        },
+    };
+}
diff --git a/src/thing/artist.js b/src/thing/artist.js
new file mode 100644
index 00000000..bbb2a935
--- /dev/null
+++ b/src/thing/artist.js
@@ -0,0 +1,48 @@
+import Thing from './thing.js';
+
+import {
+    isDirectory,
+    isName,
+    isString,
+    isURL,
+    validateArrayItems,
+    validateReferenceList,
+} from './validators.js';
+
+export default class Artist extends Thing {
+    static [Thing.referenceType] = 'artist';
+
+    static propertyDescriptors = {
+        // Update & expose
+
+        name: {
+            flags: {update: true, expose: true},
+
+            update: {
+                default: 'Unnamed Artist',
+                validate: isName
+            }
+        },
+
+        directory: {
+            flags: {update: true, expose: true},
+            update: {validate: isDirectory},
+            expose: Thing.directoryExpose
+        },
+
+        urls: {
+            flags: {update: true, expose: true},
+            update: {validate: validateArrayItems(isURL)}
+        },
+
+        aliasRefs: {
+            flags: {update: true, expose: true},
+            update: {validate: validateReferenceList('artist')}
+        },
+
+        contextNotes: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+    };
+}
diff --git a/src/thing/cacheable-object.js b/src/thing/cacheable-object.js
new file mode 100644
index 00000000..3c14101c
--- /dev/null
+++ b/src/thing/cacheable-object.js
@@ -0,0 +1,271 @@
+// Generally extendable class for caching properties and handling dependencies,
+// with a few key properties:
+//
+// 1) The behavior of every property is defined by its descriptor, which is a
+//    static value stored on the subclass (all instances share the same property
+//    descriptors).
+//
+//  1a) Additional properties may not be added past the time of object
+//      construction, and attempts to do so (including externally setting a
+//      property name which has no corresponding descriptor) will throw a
+//      TypeError. (This is done via an Object.seal(this) call after a newly
+//      created instance defines its own properties according to the descriptor
+//      on its constructor class.)
+//
+// 2) Properties may have two flags set: update and expose. Properties which
+//    update are provided values from the external. Properties which expose
+//    provide values to the external, generally dependent on other update
+//    properties (within the same object).
+//
+//  2a) Properties may be flagged as both updating and exposing. This is so
+//      that the same name may be used for both "output" and "input".
+//
+// 3) Exposed properties have values which are computations dependent on other
+//    properties, as described by a `compute` function on the descriptor.
+//    Depended-upon properties are explicitly listed on the descriptor next to
+//    this function, and are only provided as arguments to the function once
+//    listed.
+//
+//  3a) An exposed property may depend only upon updating properties, not other
+//      exposed properties (within the same object). This is to force the
+//      general complexity of a single object to be fairly simple: inputs
+//      directly determine outputs, with the only in-between step being the
+//      `compute` function, no multiple-layer dependencies. Note that this is
+//      only true within a given object - externally, values provided to one
+//      object's `update` may be (and regularly are) the exposed values of
+//      another object.
+//
+//  3b) If a property both updates and exposes, it is automatically regarded as
+//      a dependancy. (That is, its exposed value will depend on the value it is
+//      updated with.) Rather than a required `compute` function, these have an
+//      optional `transform` function, which takes the update value as its first
+//      argument and then the usual key-value dependencies as its second. If no
+//      `transform` function is provided, the expose value is the same as the
+//      update value.
+//
+// 4) Exposed properties are cached; that is, if no depended-upon properties are
+//    updated, the value of an exposed property is not recomputed.
+//
+//  4a) The cache for an exposed property is invalidated as soon as any of its
+//      dependencies are updated, but the cache itself is lazy: the exposed
+//      value will not be recomputed until it is again accessed. (Likewise, an
+//      exposed value won't be computed for the first time until it is first
+//      accessed.)
+//
+// 5) Updating a property may optionally apply validation checks before passing,
+//    declared by a `validate` function on the `update` block. This function
+//    should either throw an error (e.g. TypeError) or return false if the value
+//    is invalid.
+//
+// 6) Objects do not expect all updating properties to be provided at once.
+//    Incomplete objects are deliberately supported and enabled.
+//
+//  6a) The default value for every updating property is null; undefined is not
+//      accepted as a property value under any circumstances (it always errors).
+//      However, this default may be overridden by specifying a `default` value
+//      on a property's `update` block. (This value will be checked against
+//      the property's validate function.) Note that a property may always be
+//      updated to null, even if the default is non-null. (Null always bypasses
+//      the validate check.)
+//
+//  6b) It's required by the external consumer of an object to determine whether
+//      or not the object is ready for use (within the larger program). This is
+//      convenienced by the static CacheableObject.listAccessibleProperties()
+//      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 { inspect as nodeInspect } from 'util';
+
+function inspect(value) {
+    return nodeInspect(value, {colors: ENABLE_COLOR});
+}
+
+export default class CacheableObject {
+    #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
+    // update() function from inside this constructor, it will error when
+    // writing to private members. Pretty bad!
+    //
+    // That means initial data must be provided by following up with update()
+    // after constructing the new instance of the Thing (sub)class.
+    */
+
+    constructor() {
+        this.#defineProperties();
+        this.#initializeUpdatingPropertyValues();
+    }
+
+    #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;
+            }
+        }
+    }
+
+    #defineProperties() {
+        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);
+    }
+
+    #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 ValueError(`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;
+                }
+            }
+
+            this.#propertyUpdateValues[property] = newValue;
+            this.#invalidateCachesDependentUpon(property);
+        };
+    }
+
+    #getUpdatePropertyValidateFunction(property) {
+        const descriptor = this.#getPropertyDescriptor(property);
+    }
+
+    #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];
+        }
+    }
+
+    #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`);
+        }
+
+        const dependencyKeys = expose.dependencies || [];
+        const dependencyGetters = dependencyKeys.map(key => () => [key, this.#propertyUpdateValues[key]]);
+        const getAllDependencies = () => Object.fromEntries(dependencyGetters.map(f => f()));
+
+        if (flags.update) {
+            return () => transform(this.#propertyUpdateValues[property], getAllDependencies());
+        } else {
+            return () => compute(getAllDependencies());
+        }
+    }
+
+    #getExposeCheckCacheValidFunction(property) {
+        const { flags, expose } = this.#getPropertyDescriptor(property);
+
+        let valid = false;
+
+        const invalidate = () => {
+            valid = false;
+        };
+
+        const dependencyKeys = new Set(expose?.dependencies);
+
+        if (flags.update) {
+            dependencyKeys.add(property);
+        }
+
+        for (const key of dependencyKeys) {
+            if (this.#propertyUpdateCacheInvalidators[key]) {
+                this.#propertyUpdateCacheInvalidators[key].push(invalidate);
+            } else {
+                this.#propertyUpdateCacheInvalidators[key] = [invalidate];
+            }
+        }
+
+        return () => {
+            if (!valid) {
+                valid = true;
+                return false;
+            } else {
+                return true;
+            }
+        };
+    }
+}
diff --git a/src/thing/flash.js b/src/thing/flash.js
new file mode 100644
index 00000000..4eac65ad
--- /dev/null
+++ b/src/thing/flash.js
@@ -0,0 +1,129 @@
+import Thing from './thing.js';
+
+import {
+    isColor,
+    isContributionList,
+    isDate,
+    isDirectory,
+    isFileExtension,
+    isName,
+    isNumber,
+    isString,
+    isURL,
+    oneOf,
+    validateArrayItems,
+    validateReferenceList,
+} from './validators.js';
+
+export default class Flash extends Thing {
+    static [Thing.referenceType] = 'flash';
+
+    static propertyDescriptors = {
+        // Update & expose
+
+        name: {
+            flags: {update: true, expose: true},
+
+            update: {
+                default: 'Unnamed Flash',
+                validate: isName
+            }
+        },
+
+        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)},
+
+            expose: {
+                transform: value => value.toString()
+            }
+        },
+
+        date: {
+            flags: {update: true, expose: true},
+            update: {validate: isDate}
+        },
+
+        coverArtFileExtension: {
+            flags: {update: true, expose: true},
+            update: {validate: isFileExtension}
+        },
+
+        featuredTracksByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: validateReferenceList('track')}
+        },
+
+        contributorContribsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: isContributionList}
+        },
+
+        urls: {
+            flags: {update: true, expose: true},
+            update: {validate: validateArrayItems(isURL)}
+        },
+    };
+}
+
+export class FlashAct extends Thing {
+    static [Thing.referenceType] = 'flash-act';
+
+    static propertyDescriptors = {
+        // Update & expose
+
+        name: {
+            flags: {update: true, expose: true},
+
+            update: {
+                default: 'Unnamed Flash Act',
+                validate: isName
+            }
+        },
+
+        color: {
+            flags: {update: true, expose: true},
+            update: {validate: isColor}
+        },
+
+        anchor: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+
+        jump: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+
+        jumpColor: {
+            flags: {update: true, expose: true},
+            update: {validate: isColor}
+        },
+
+        flashesByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: validateReferenceList('flash')}
+        },
+    };
+}
diff --git a/src/thing/group.js b/src/thing/group.js
new file mode 100644
index 00000000..3b92e957
--- /dev/null
+++ b/src/thing/group.js
@@ -0,0 +1,73 @@
+import CacheableObject from './cacheable-object.js';
+import Thing from './thing.js';
+
+import {
+    isColor,
+    isDirectory,
+    isName,
+    isString,
+    isURL,
+    validateArrayItems,
+    validateReferenceList,
+} from './validators.js';
+
+export class GroupCategory extends CacheableObject {
+    static propertyDescriptors = {
+        // Update & expose
+
+        name: {
+            flags: {update: true, expose: true},
+            update: {default: 'Unnamed Group Category', validate: isName}
+        },
+
+        color: {
+            flags: {update: true, expose: true},
+            update: {validate: isColor}
+        },
+
+        groupsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: validateReferenceList('group')}
+        },
+    };
+}
+
+export default class Group extends Thing {
+    static [Thing.referenceType] = 'group';
+
+    static propertyDescriptors = {
+        // Update & expose
+
+        name: {
+            flags: {update: true, expose: true},
+            update: {default: 'Unnamed Group', validate: isName}
+        },
+
+        directory: {
+            flags: {update: true, expose: true},
+            update: {validate: isDirectory},
+            expose: Thing.directoryExpose
+        },
+
+        description: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+
+        urls: {
+            flags: {update: true, expose: true},
+            update: {validate: validateArrayItems(isURL)}
+        },
+
+        // Expose only
+
+        descriptionShort: {
+            flags: {expose: true},
+
+            expose: {
+                dependencies: ['description'],
+                compute: ({ description }) => description.split('<hr class="split">')[0]
+            }
+        }
+    };
+}
diff --git a/src/thing/homepage-layout.js b/src/thing/homepage-layout.js
new file mode 100644
index 00000000..47173917
--- /dev/null
+++ b/src/thing/homepage-layout.js
@@ -0,0 +1,99 @@
+import CacheableObject from './cacheable-object.js';
+
+import {
+    isColor,
+    isCountingNumber,
+    isName,
+    isString,
+    oneOf,
+    validateArrayItems,
+    validateInstanceOf,
+    validateReference,
+    validateReferenceList,
+} from './validators.js';
+
+export class HomepageLayoutRow extends CacheableObject {
+    static propertyDescriptors = {
+        // Update & expose
+
+        name: {
+            flags: {update: true, expose: true},
+            update: {validate: isName}
+        },
+
+        type: {
+            flags: {update: true, expose: true},
+
+            update: {
+                validate(value) {
+                    throw new Error(`'type' property validator must be overridden`);
+                }
+            }
+        },
+
+        color: {
+            flags: {update: true, expose: true},
+            update: {validate: isColor}
+        },
+    };
+}
+
+export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
+    static propertyDescriptors = {
+        ...HomepageLayoutRow.propertyDescriptors,
+
+        // Update & expose
+
+        type: {
+            flags: {update: true, expose: true},
+            update: {
+                validate(value) {
+                    if (value !== 'albums') {
+                        throw new TypeError(`Expected 'albums'`);
+                    }
+
+                    return true;
+                }
+            }
+        },
+
+        sourceGroupByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: validateReference('group')}
+        },
+
+        sourceAlbumsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: validateReferenceList('album')}
+        },
+
+        countAlbumsFromGroup: {
+            flags: {update: true, expose: true},
+            update: {validate: isCountingNumber}
+        },
+
+        actionLinks: {
+            flags: {update: true, expose: true},
+            update: {validate: validateArrayItems(isString)}
+        },
+    }
+}
+
+export default class HomepageLayout extends CacheableObject {
+    static propertyDescriptors = {
+        // Update & expose
+
+        sidebarContent: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+
+        rows: {
+            flags: {update: true, expose: true},
+
+            update: {
+                validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow))
+            }
+        },
+    };
+}
diff --git a/src/thing/news-entry.js b/src/thing/news-entry.js
new file mode 100644
index 00000000..2db2f37c
--- /dev/null
+++ b/src/thing/news-entry.js
@@ -0,0 +1,49 @@
+import Thing from './thing.js';
+
+import {
+    isDate,
+    isDirectory,
+    isName,
+} from './validators.js';
+
+export default class NewsEntry extends Thing {
+    static [Thing.referenceType] = 'news-entry';
+
+    static propertyDescriptors = {
+        // Update & expose
+
+        name: {
+            flags: {update: true, expose: true},
+            update: {validate: isName}
+        },
+
+        directory: {
+            flags: {update: true, expose: true},
+            update: {validate: isDirectory},
+            expose: Thing.directoryExpose
+        },
+
+        date: {
+            flags: {update: true, expose: true},
+            update: {validate: isDate}
+        },
+
+        content: {
+            flags: {update: true, expose: true},
+        },
+
+        // Expose only
+
+        contentShort: {
+            flags: {expose: true},
+
+            expose: {
+                dependencies: ['content'],
+
+                compute({ content }) {
+                    return body.split('<hr class="split">')[0];
+                }
+            }
+        },
+    };
+}
diff --git a/src/thing/structures.js b/src/thing/structures.js
index 89c9bd39..364ba149 100644
--- a/src/thing/structures.js
+++ b/src/thing/structures.js
@@ -1,32 +1 @@
 // Generic structure utilities common across various Thing types.
-
-export function validateDirectory(directory) {
-    if (typeof directory !== 'string')
-        throw new TypeError(`Expected a string, got ${directory}`);
-
-    if (directory.length === 0)
-        throw new TypeError(`Expected directory to be non-zero length`);
-
-    if (directory.match(/[^a-zA-Z0-9\-]/))
-        throw new TypeError(`Expected only letters, numbers, and dash, got "${directory}"`);
-
-    return true;
-}
-
-export function validateReference(type = '') {
-    return ref => {
-        if (typeof ref !== 'string')
-            throw new TypeError(`Expected a string, got ${ref}`);
-
-        if (type) {
-            if (!ref.includes(':'))
-                throw new TypeError(`Expected ref to begin with "${type}:", but no type specified (ref: ${ref})`);
-
-            const typePart = ref.split(':')[0];
-            if (typePart !== type)
-                throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:" (ref: ${ref})`);
-        }
-
-        return true;
-    };
-}
diff --git a/src/thing/thing.js b/src/thing/thing.js
index c2465e32..54a278d1 100644
--- a/src/thing/thing.js
+++ b/src/thing/thing.js
@@ -1,66 +1,32 @@
 // Base class for Things. No, we will not come up with a better name.
 // Sorry not sorry! :)
-//
-// NB: Since these methods all involve processing a variety of input data, some
-// of which will pass and some of which may fail, any failures should be thrown
-// together as an AggregateError. See util/sugar.js for utility functions to
-// make writing code around this easier!
 
-export default class Thing {
-    constructor(source, {
-        wikiData
-    } = {}) {
-        if (source) {
-            this.update(source);
-        }
+import CacheableObject from './cacheable-object.js';
 
-        if (wikiData && this.checkComplete()) {
-            this.postprocess({wikiData});
-        }
-    }
+import { getKebabCase } from '../util/wiki-data.js';
 
-    static PropertyError = class extends AggregateError {
-        #key = this.constructor.key;
-        get key() { return this.#key; }
+export default class Thing extends CacheableObject {
+    static referenceType = Symbol('Thing.referenceType');
 
-        constructor(errors) {
-            super(errors, '');
-            this.message = `${errors.length} error(s) in property "${this.#key}"`;
+    static directoryExpose = {
+        dependencies: ['name'],
+        transform(directory, { name }) {
+            if (directory === null && name === null)
+                return null;
+            else if (directory === null)
+                return getKebabCase(name);
+            else
+                return directory;
         }
     };
 
-    static extendPropertyError(key) {
-        const cls = class extends this.PropertyError {
-            static #key = key;
-            static get key() { return this.#key; }
-        };
+    static getReference(thing) {
+        if (!thing.constructor[Thing.referenceType])
+            throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`);
 
-        Object.defineProperty(cls, 'name', {value: `PropertyError:${key}`});
-        return cls;
-    }
+        if (!thing.directory)
+            throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
 
-    // Called when instantiating a thing, and when its data is updated for any
-    // reason. (Which currently includes no reasons, but hey, future-proofing!)
-    //
-    // Don't expect source to be a complete object, even on the first call - the
-    // method checkComplete() will prevent incomplete resources from being mixed
-    // with the rest.
-    update(source) {}
-
-    // Called when collecting the full list of available things of that type
-    // for wiki data; this method determine whether or not to include it.
-    //
-    // This should return whether or not the object is complete enough to be
-    // used across the wiki - not whether every optional attribute is provided!
-    // (That is, attributes required for postprocessing & basic page generation
-    // are all present.)
-    checkComplete() {}
-
-    // Called when adding the thing to the wiki data list, and when its source
-    // data is updated (provided checkComplete() passes).
-    //
-    // This should generate any cached object references, across other wiki
-    // data; for example, building an array of actual track objects
-    // corresponding to an album's track list ('track:cool-track' strings).
-    postprocess({wikiData}) {}
+        return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
+    }
 }
diff --git a/src/thing/track.js b/src/thing/track.js
new file mode 100644
index 00000000..75df109a
--- /dev/null
+++ b/src/thing/track.js
@@ -0,0 +1,117 @@
+import Thing from './thing.js';
+
+import {
+    isBoolean,
+    isColor,
+    isCommentary,
+    isContributionList,
+    isDate,
+    isDirectory,
+    isDuration,
+    isName,
+    isURL,
+    isString,
+    validateArrayItems,
+    validateReference,
+    validateReferenceList,
+} from './validators.js';
+
+export default class Track extends Thing {
+    static [Thing.referenceType] = 'track';
+
+    static propertyDescriptors = {
+        // Update & expose
+
+        name: {
+            flags: {update: true, expose: true},
+
+            update: {
+                default: 'Unnamed Track',
+                validate: isName
+            }
+        },
+
+        directory: {
+            flags: {update: true, expose: true},
+            update: {validate: isDirectory},
+            expose: Thing.directoryExpose
+        },
+
+        duration: {
+            flags: {update: true, expose: true},
+            update: {validate: isDuration}
+        },
+
+        urls: {
+            flags: {update: true, expose: true},
+
+            update: {
+                validate: validateArrayItems(isURL)
+            }
+        },
+
+        dateFirstReleased: {
+            flags: {update: true, expose: true},
+            update: {validate: isDate}
+        },
+
+        coverArtDate: {
+            flags: {update: true, expose: true},
+            update: {validate: isDate}
+        },
+
+        hasCoverArt: {
+            flags: {update: true, expose: true},
+            update: {default: true, validate: isBoolean}
+        },
+
+        hasURLs: {
+            flags: {update: true, expose: true},
+            update: {default: true, validate: isBoolean}
+        },
+
+        referencedTracksByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: validateReferenceList('track')}
+        },
+
+        artistContribsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: isContributionList}
+        },
+
+        contributorContribsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: isContributionList}
+        },
+
+        coverArtistContribsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: isContributionList}
+        },
+
+        artTagsByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: validateReferenceList('tag')}
+        },
+
+        originalReleaseTrackByRef: {
+            flags: {update: true, expose: true},
+            update: {validate: validateReference('track')}
+        },
+
+        commentary: {
+            flags: {update: true, expose: true},
+            update: {validate: isCommentary}
+        },
+
+        lyrics: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+
+        // Update only
+
+        // Expose only
+    };
+}
diff --git a/src/thing/validators.js b/src/thing/validators.js
new file mode 100644
index 00000000..49463473
--- /dev/null
+++ b/src/thing/validators.js
@@ -0,0 +1,314 @@
+import { withAggregate } from '../util/sugar.js';
+
+import { color, ENABLE_COLOR } from '../util/cli.js';
+
+import { inspect as nodeInspect } from 'util';
+
+function inspect(value) {
+    return nodeInspect(value, {colors: ENABLE_COLOR});
+}
+
+// Basic types (primitives)
+
+function 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}`);
+
+    return true;
+}
+
+export function isBoolean(value) {
+    return isType(value, 'boolean');
+}
+
+export function isNumber(value) {
+    return isType(value, 'number');
+}
+
+export function isPositive(number) {
+    isNumber(number);
+
+    if (number <= 0)
+        throw new TypeError(`Expected positive number`);
+
+    return true;
+}
+
+export function isNegative(number) {
+    isNumber(number);
+
+    if (number >= 0)
+        throw new TypeError(`Expected negative number`);
+
+    return true;
+}
+
+export function isPositiveOrZero(number) {
+    isNumber(number);
+
+    if (number < 0)
+        throw new TypeError(`Expected positive number or zero`);
+
+    return true;
+}
+
+export function isNegativeOrZero(number) {
+    isNumber(number);
+
+    if (number > 0)
+        throw new TypeError(`Expected negative number or zero`);
+
+    return true;
+}
+
+export function isInteger(number) {
+    isNumber(number);
+
+    if (number % 1 !== 0)
+        throw new TypeError(`Expected integer`);
+
+    return true;
+}
+
+export function isCountingNumber(number) {
+    isInteger(number);
+    isPositive(number);
+
+    return true;
+}
+
+export function isString(value) {
+    return isType(value, 'string');
+}
+
+export function isStringNonEmpty(value) {
+    isString(value);
+
+    if (value.trim().length === 0)
+        throw new TypeError(`Expected non-empty string`);
+
+    return true;
+}
+
+// Complex types (non-primitives)
+
+function isInstance(value, constructor) {
+    isObject(value);
+
+    if (!(value instanceof constructor))
+        throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`);
+
+    return true;
+}
+
+export function isDate(value) {
+    return isInstance(value, Date);
+}
+
+export function isObject(value) {
+    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`);
+
+    return true;
+}
+
+export function isArray(value) {
+    isObject(value);
+
+    if (!Array.isArray(value))
+        throw new TypeError(`Expected an array, got ${value}`);
+
+    return true;
+}
+
+function validateArrayItemsHelper(itemValidator) {
+    return (item, index) => {
+        try {
+            itemValidator(item);
+        } catch (error) {
+            error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`;
+            throw error;
+        }
+    };
+}
+
+export function validateArrayItems(itemValidator) {
+    const fn = validateArrayItemsHelper(itemValidator);
+
+    return array => {
+        isArray(array);
+
+        withAggregate({message: 'Errors validating array items'}, ({ wrap }) => {
+            array.forEach(wrap(fn));
+        });
+
+        return true;
+    };
+}
+
+export function validateInstanceOf(constructor) {
+    return object => isInstance(object, constructor);
+}
+
+// Wiki data (primitives & non-primitives)
+
+export function isColor(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 (/[^0-9a-fA-F]/.test(color.slice(1)))
+            throw new TypeError(`Expected hexadecimal digits`);
+
+        return true;
+    }
+
+    throw new TypeError(`Unknown color format`);
+}
+
+export function isCommentary(commentary) {
+    return isString(commentary);
+}
+
+const isArtistRef = validateReference('artist');
+
+export function isContribution(contrib) {
+    // TODO: Use better object validation for this (supporting aggregates etc)
+
+    isObject(contrib);
+
+    isArtistRef(contrib.who);
+
+    if (contrib.what !== null) {
+        isStringNonEmpty(contrib.what);
+    }
+
+    return true;
+}
+
+export const isContributionList = validateArrayItems(isContribution);
+
+export function isDimensions(dimensions) {
+    isArray(dimensions);
+
+    if (dimensions.length !== 2)
+        throw new TypeError(`Expected 2 item array`);
+
+    isPositive(dimensions[0]);
+    isInteger(dimensions[0]);
+    isPositive(dimensions[1]);
+    isInteger(dimensions[1]);
+
+    return true;
+}
+
+export function isDirectory(directory) {
+    isStringNonEmpty(directory);
+
+    if (directory.match(/[^a-zA-Z0-9_\-]/))
+        throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`);
+
+    return true;
+}
+
+export function isDuration(duration) {
+    isNumber(duration);
+    isPositiveOrZero(duration);
+
+    return true;
+}
+
+export function isFileExtension(string) {
+    isStringNonEmpty(string);
+
+    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`);
+
+    return true;
+}
+
+export function isName(name) {
+    return isString(name);
+}
+
+export function isURL(string) {
+    isStringNonEmpty(string);
+
+    new URL(string);
+
+    return true;
+}
+
+export function validateReference(type = 'track') {
+    return ref => {
+        isStringNonEmpty(ref);
+
+        const match = ref.trim().match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
+
+        if (!match)
+            throw new TypeError(`Malformed reference`);
+
+        const { groups: { typePart, directoryPart } } = match;
+
+        if (typePart && typePart !== type)
+            throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`);
+
+        if (typePart)
+            isDirectory(directoryPart);
+
+        isName(ref);
+
+        return true;
+    };
+}
+
+export function validateReferenceList(type = '') {
+    return validateArrayItems(validateReference(type));
+}
+
+// Compositional utilities
+
+export function oneOf(...checks) {
+    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`);
+                }
+
+                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/upd8.js b/src/upd8.js
index f78a21c4..0ea998a2 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -17,6 +17,9 @@
 //      going to 8e in. May8e JSON, 8ut more likely some weird custom format
 //      which will 8e a lot easier to edit.
 //
+//      Like three years later oh god: SURPISE! We went with the latter, but
+//      they're YAML now. Probably. Assuming that hasn't changed, yet.
+//
 //   3. Generate the page files! They're just static index.html files, and are
 //      what gh-pages (or wherever this is hosted) will show to clients.
 //      Hopefully pretty minimalistic HTML, 8ut like, shrug. They'll reference
@@ -28,40 +31,6 @@
 // Oh yeah, like. Just run this through some relatively recent version of
 // node.js and you'll 8e fine. ...Within the project root. O8viously.
 
-// HEY FUTURE ME!!!!!!!! Don't forget to implement artist pages! Those are,
-// like, the coolest idea you've had yet, so DO NOT FORGET. (Remem8er, link
-// from track listings, etc!) --- Thanks, past me. To futurerer me: an al8um
-// listing page (a list of all the al8ums)! Make sure to sort these 8y date -
-// we'll need a new field for al8ums.
-
-// ^^^^^^^^ DID THAT! 8ut also, artist images. Pro8a8ly stolen from the fandom
-// wiki (I found half those images anywayz).
-
-// TRACK ART CREDITS. This is a must.
-
-// 2020-08-23
-// ATTENTION ALL 8*TCHES AND OTHER GENDER TRUCKERS: AS IT TURNS OUT, THIS CODE
-// ****SUCKS****. I DON'T THINK ANYTHING WILL EVER REDEEM IT, 8UT THAT DOESN'T
-// MEAN WE CAN'T TAKE SOME ACTION TO MAKE WRITING IT A LITTLE LESS TERRI8LE.
-// We're gonna start defining STRUCTURES to make things suck less!!!!!!!!
-// No classes 8ecause those are a huge pain and like, pro8a8ly 8ad performance
-// or whatever -- just some standard structures that should 8e followed
-// wherever reasona8le. Only one I need today is the contri8 one 8ut let's put
-// any new general-purpose structures here too, ok?
-//
-// Contri8ution: {who, what, date, thing}. D8 and thing are the new fields.
-//
-// Use these wisely, which is to say all the time and instead of whatever
-// terri8le new pseudo structure you're trying to invent!!!!!!!!
-//
-// Upd8 2021-01-03: Soooooooo we didn't actually really end up using these,
-// lol? Well there's still only one anyway. Kinda ended up doing a 8ig refactor
-// of all the o8ject structures today. It's not *especially* relevant 8ut feels
-// worth mentioning? I'd get rid of this comment 8lock 8ut I like it too much!
-// Even though I haven't actually reread it, lol. 8ut yeah, hopefully in the
-// spirit of this "make things more consistent" attitude I 8rought up 8ack in
-// August, stuff's lookin' 8etter than ever now. W00t!
-
 import * as path from 'path';
 import { promisify } from 'util';
 import { fileURLToPath } from 'url';
@@ -75,6 +44,8 @@ import fixWS from 'fix-whitespace';
 // It stands for "HTML Entities", apparently. Cursed.
 import he from 'he';
 
+import yaml from 'js-yaml';
+
 import {
     // This is the dum8est name for a function possi8le. Like, SURE, fine, may8e
     // the UNIX people had some valid reason to go with the weird truncated
@@ -109,6 +80,8 @@ import {
     unlink
 } from 'fs/promises';
 
+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';
@@ -118,6 +91,18 @@ import find from './util/find.js';
 import * as html from './util/html.js';
 import unbound_link, {getLinkThemeString} from './util/link.js';
 
+import Album, { TrackGroup } from './thing/album.js';
+import Artist from './thing/artist.js';
+import ArtTag from './thing/art-tag.js';
+import Flash, { FlashAct } from './thing/flash.js';
+import Group, { GroupCategory } from './thing/group.js';
+import HomepageLayout, {
+    HomepageLayoutAlbumsRow,
+} from './thing/homepage-layout.js';
+import NewsEntry from './thing/news-entry.js';
+import Thing from './thing/thing.js';
+import Track from './thing/track.js';
+
 import {
     fancifyFlashURL,
     fancifyURL,
@@ -129,6 +114,7 @@ import {
     getAlbumStylesheet,
     getArtistString,
     getFlashGridHTML,
+    getFooterLocalizationLinks,
     getGridHTML,
     getRevealStringFromTags,
     getRevealStringFromWarnings,
@@ -137,12 +123,14 @@ import {
 } from './misc-templates.js';
 
 import {
+    color,
     decorateTime,
     logWarn,
     logInfo,
     logError,
     parseOptions,
-    progressPromiseAll
+    progressPromiseAll,
+    ENABLE_COLOR
 } from './util/cli.js';
 
 import {
@@ -184,11 +172,16 @@ import {
 
 import {
     bindOpts,
-    call,
+    filterAggregateAsync,
     filterEmptyLines,
+    mapAggregate,
+    mapAggregateAsync,
+    openAggregate,
     queue,
+    showAggregate,
     splitArray,
     unique,
+    withAggregate,
     withEntries
 } from './util/sugar.js';
 
@@ -208,13 +201,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 const CACHEBUST = 7;
 
+// MAKE THESE END IN YAML
 const WIKI_INFO_FILE = 'wiki-info.txt';
-const HOMEPAGE_INFO_FILE = 'homepage.txt';
-const ARTIST_DATA_FILE = 'artists.txt';
-const FLASH_DATA_FILE = 'flashes.txt';
-const NEWS_DATA_FILE = 'news.txt';
-const TAG_DATA_FILE = 'tags.txt';
-const GROUP_DATA_FILE = 'groups.txt';
+const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml';
+const ARTIST_DATA_FILE = 'artists.yaml';
+const FLASH_DATA_FILE = 'flashes.yaml';
+const NEWS_DATA_FILE = 'news.yaml';
+const ART_TAG_DATA_FILE = 'tags.yaml';
+const GROUP_DATA_FILE = 'groups.yaml';
 const STATIC_PAGE_DATA_FILE = 'static-pages.txt';
 const DEFAULT_STRINGS_FILE = 'strings-default.json';
 
@@ -235,6 +229,10 @@ const STATIC_DIRECTORY = 'static';
 // read from and processed to compose the majority of album and track data.
 const DATA_ALBUM_DIRECTORY = 'album';
 
+function inspect(value) {
+    return nodeInspect(value, {colors: ENABLE_COLOR});
+}
+
 // Shared varia8les! These are more efficient to access than a shared varia8le
 // (or at least I h8pe so), and are easier to pass across functions than a
 // 8unch of specific arguments.
@@ -274,20 +272,11 @@ function splitLines(text) {
     return text.split(/\r\n|\r|\n/);
 }
 
-function* getSections(lines) {
-    // ::::)
-    const isSeparatorLine = line => /^-{8,}/.test(line);
-    yield* splitArray(lines, isSeparatorLine);
-}
-
-function getBasicField(lines, name) {
-    const line = lines.find(line => line.startsWith(name + ':'));
-    return line && line.slice(name.length + 1).trim();
-}
+function parseDimensions(string) {
+    if (!string) {
+        return null;
+    }
 
-function getDimensionsField(lines, name) {
-    const string = getBasicField(lines, name);
-    if (!string) return string;
     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()));
@@ -295,52 +284,7 @@ function getDimensionsField(lines, name) {
     return nums;
 }
 
-function getBooleanField(lines, name) {
-    // The ?? oper8tor (which is just, hilariously named, lol) can 8e used to
-    // specify a default!
-    const value = getBasicField(lines, name);
-    switch (value) {
-        case 'yes':
-        case 'true':
-            return true;
-        case 'no':
-        case 'false':
-            return false;
-        default:
-            return null;
-    }
-}
-
-function getListField(lines, name) {
-    let startIndex = lines.findIndex(line => line.startsWith(name + ':'));
-    // If callers want to default to an empty array, they should stick
-    // "|| []" after the call.
-    if (startIndex === -1) {
-        return null;
-    }
-    // We increment startIndex 8ecause we don't want to include the
-    // "heading" line (e.g. "URLs:") in the actual data.
-    startIndex++;
-    let endIndex = lines.findIndex((line, index) => index >= startIndex && !line.startsWith('- '));
-    if (endIndex === -1) {
-        endIndex = lines.length;
-    }
-    if (endIndex === startIndex) {
-        // If there is no list that comes after the heading line, treat the
-        // heading line itself as the comma-separ8ted array value, using
-        // the 8asic field function to do that. (It's l8 and my 8rain is
-        // sleepy. Please excuse any unhelpful comments I may write, or may
-        // have already written, in this st8. Thanks!)
-        const value = getBasicField(lines, name);
-        return value && value.split(',').map(val => val.trim());
-    }
-    const listLines = lines.slice(startIndex, endIndex);
-    return listLines.map(line => line.slice(2));
-};
-
-function getContributionField(section, name) {
-    let contributors = getListField(section, name);
-
+function parseContributors(contributors) {
     if (!contributors) {
         return null;
     }
@@ -375,27 +319,6 @@ function getContributionField(section, name) {
     return contributors;
 };
 
-function getMultilineField(lines, name) {
-    // All this code is 8asically the same as the getListText - just with a
-    // different line prefix (four spaces instead of a dash and a space).
-    let startIndex = lines.findIndex(line => line.startsWith(name + ':'));
-    if (startIndex === -1) {
-        return null;
-    }
-    startIndex++;
-    let endIndex = lines.findIndex((line, index) => index >= startIndex && line.length && !line.startsWith('    '));
-    if (endIndex === -1) {
-        endIndex = lines.length;
-    }
-    // If there aren't any content lines, don't return anything!
-    if (endIndex === startIndex) {
-        return null;
-    }
-    // We also join the lines instead of returning an array.
-    const listLines = lines.slice(startIndex, endIndex);
-    return listLines.map(line => line.slice(4)).join('\n');
-};
-
 const replacerSpec = {
     'album': {
         find: 'album',
@@ -737,321 +660,380 @@ function transformLyrics(text, {
     return outLines.join('\n');
 }
 
-function getCommentaryField(lines) {
-    const text = getMultilineField(lines, 'Commentary');
-    if (text) {
-        const lines = text.split('\n');
-        if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) {
-            return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`};
-        }
-        return text;
-    } else {
-        return null;
-    }
-};
-
-async function processAlbumDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        // This function can return "error o8jects," which are really just
-        // ordinary o8jects with an error message attached. I'm not 8othering
-        // with error codes here or anywhere in this function; while this would
-        // normally 8e 8ad coding practice, it doesn't really matter here,
-        // 8ecause this isn't an API getting consumed 8y other services (e.g.
-        // translaction functions). If we return an error, the caller will just
-        // print the attached message in the output summary.
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    // We're pro8a8ly supposed to, like, search for a header somewhere in the
-    // al8um contents, to make sure it's trying to 8e the intended structure
-    // and is a valid utf-8 (or at least ASCII) file. 8ut like, whatever.
-    // We'll just return more specific errors if it's missing necessary data
-    // fields.
+// Use parseErrorFactory to declare different "types" of errors. By storing the
+// factory itself in an accessible location, the type of error may be detected
+// by comparing against its own factory property.
+function parseErrorFactory(annotation) {
+    return function factory(data = null) {
+        return {
+            error: true,
+            annotation,
+            data,
+            factory
+        };
+    };
+}
 
-    const contentLines = contents.split(/\r\n|\r|\n/);
+function parseField(object, key, steps) {
+    let value = object[key];
 
-    // In this line of code I defeat the purpose of using a generator in the
-    // first place. Sorry!!!!!!!!
-    const sections = Array.from(getSections(contentLines));
+    for (const step of steps) {
+        try {
+            value = step(value);
+        } catch (error) {
+            throw parseField.stepError({
+                stepName: step.name,
+                stepError: error
+            });
+        }
+    }
 
-    const albumSection = sections[0];
-    const album = {};
+    return value;
+}
 
-    album.name = getBasicField(albumSection, 'Album');
+parseField.stepError = parseErrorFactory('step failed');
 
-    if (!album.name) {
-        return {error: `The file "${path.relative(dataPath, file)}" is missing the "Album" field - maybe this is a misplaced file instead of album data?`};
+function assertFieldPresent(value) {
+    if (value === undefined || value === null) {
+        throw assertFieldPresent.missingField();
+    } else {
+        return value;
     }
+}
 
-    album.artists = getContributionField(albumSection, 'Artists') || getContributionField(albumSection, 'Artist');
-    album.wallpaperArtists = getContributionField(albumSection, 'Wallpaper Art');
-    album.wallpaperStyle = getMultilineField(albumSection, 'Wallpaper Style');
-    album.wallpaperFileExtension = getBasicField(albumSection, 'Wallpaper File Extension') || 'jpg';
-    album.bannerArtists = getContributionField(albumSection, 'Banner Art');
-    album.bannerStyle = getMultilineField(albumSection, 'Banner Style');
-    album.bannerFileExtension = getBasicField(albumSection, 'Banner File Extension') || 'jpg';
-    album.bannerDimensions = getDimensionsField(albumSection, 'Banner Dimensions');
-    album.date = getBasicField(albumSection, 'Date');
-    album.trackArtDate = getBasicField(albumSection, 'Track Art Date') || album.date;
-    album.coverArtDate = getBasicField(albumSection, 'Cover Art Date') || album.date;
-    album.dateAdded = getBasicField(albumSection, 'Date Added');
-    album.coverArtists = getContributionField(albumSection, 'Cover Art');
-    album.hasTrackArt = getBooleanField(albumSection, 'Has Track Art') ?? true;
-    album.trackCoverArtists = getContributionField(albumSection, 'Track Art');
-    album.artTags = getListField(albumSection, 'Art Tags') || [];
-    album.commentary = getCommentaryField(albumSection);
-    album.urls = getListField(albumSection, 'URLs') || [];
-    album.groups = getListField(albumSection, 'Groups') || [];
-    album.directory = getBasicField(albumSection, 'Directory');
-    album.isMajorRelease = getBooleanField(albumSection, 'Major Release') ?? false;
-    album.isListedOnHomepage = getBooleanField(albumSection, 'Listed on Homepage') ?? true;
+assertFieldPresent.missingField = parseErrorFactory('missing field');
 
-    if (album.artists && album.artists.error) {
-        return {error: `${album.artists.error} (in ${album.name})`};
+function assertValidDate(dateString, {optional = false} = {}) {
+    if (dateString && isNaN(Date.parse(dateString))) {
+        throw assertValidDate.invalidDate();
     }
+    return value;
+}
 
-    if (album.coverArtists && album.coverArtists.error) {
-        return {error: `${album.coverArtists.error} (in ${album.name})`};
-    }
+assertValidDate.invalidDate = parseErrorFactory('invalid date');
 
-    if (album.commentary && album.commentary.error) {
-        return {error: `${album.commentary.error} (in ${album.name})`};
+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 (album.trackCoverArtists && album.trackCoverArtists.error) {
-        return {error: `${album.trackCoverArtists.error} (in ${album.name})`};
-    }
+// General function for inputting a single document (usually loaded from YAML)
+// and outputting an instance of a provided Thing subclass.
+//
+// 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, {
+    // 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
+    // expects.
+    //
+    // Each key and value are a field name (not an update() property) and a
+    // function which takes the value for that field and returns the value which
+    // will be passed on to update().
+    fieldTransformations = {},
 
-    if (!album.coverArtists) {
-        return {error: `The album "${album.name}" is missing the "Cover Art" field.`};
-    }
+    // Mapping of Thing.update() source properties to field names.
+    //
+    // Note this is property -> field, not field -> property. This is a
+    // shorthand convenience because properties are generally typical
+    // camel-cased JS properties, while fields may contain whitespace and be
+    // more easily represented as quoted strings.
+    propertyFieldMapping,
+
+    // Completely ignored fields. These won't throw an unknown field error if
+    // 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;
+            }
+        };
+    };
 
-    album.color = (
-        getBasicField(albumSection, 'Color') ||
-        getBasicField(albumSection, 'FG')
-    );
+    return decorateErrorWithName(document => {
+        const documentEntries = Object.entries(document)
+            .filter(([ field ]) => !ignoredFields.includes(field));
 
-    if (!album.name) {
-        return {error: `Expected "Album" (name) field!`};
-    }
+        const unknownFields = documentEntries
+            .map(([ field ]) => field)
+            .filter(field => !knownFields.includes(field));
 
-    if (!album.date) {
-        return {error: `Expected "Date" field! (in ${album.name})`};
-    }
+        if (unknownFields.length) {
+            throw new makeProcessDocument.UnknownFieldsError(unknownFields);
+        }
 
-    if (!album.dateAdded) {
-        return {error: `Expected "Date Added" field! (in ${album.name})`};
-    }
+        const fieldValues = {};
 
-    if (isNaN(Date.parse(album.date))) {
-        return {error: `Invalid Date field: "${album.date}" (in ${album.name})`};
-    }
+        for (const [ field, value ] of documentEntries) {
+            if (Object.hasOwn(fieldTransformations, field)) {
+                fieldValues[field] = fieldTransformations[field](value);
+            } else {
+                fieldValues[field] = value;
+            }
+        }
 
-    if (isNaN(Date.parse(album.trackArtDate))) {
-        return {error: `Invalid Track Art Date field: "${album.trackArtDate}" (in ${album.name})`};
-    }
+        const sourceProperties = {};
 
-    if (isNaN(Date.parse(album.coverArtDate))) {
-        return {error: `Invalid Cover Art Date field: "${album.coverArtDate}" (in ${album.name})`};
-    }
+        for (const [ field, value ] of Object.entries(fieldValues)) {
+            const property = fieldPropertyMapping[field];
+            sourceProperties[property] = value;
+        }
 
-    if (isNaN(Date.parse(album.dateAdded))) {
-        return {error: `Invalid Date Added field: "${album.dateAdded}" (in ${album.name})`};
-    }
+        const thing = Reflect.construct(thingClass, []);
 
-    album.date = new Date(album.date);
-    album.trackArtDate = new Date(album.trackArtDate);
-    album.coverArtDate = new Date(album.coverArtDate);
-    album.dateAdded = new Date(album.dateAdded);
+        withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({ call }) => {
+            for (const [ property, value ] of Object.entries(sourceProperties)) {
+                call(() => (thing[property] = value));
+            }
+        });
 
-    if (!album.directory) {
-        album.directory = getKebabCase(album.name);
-    }
+        return thing;
+    });
+}
 
-    album.tracks = [];
+makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error {
+    constructor(fields) {
+        super(`Unknown fields present: ${fields.join(', ')}`);
+        this.fields = fields;
+    }
+};
 
-    // will be overwritten if a group section is found!
-    album.trackGroups = null;
+const processAlbumDocument = makeProcessDocument(Album, {
+    fieldTransformations: {
+        'Artists': parseContributors,
+        'Cover Artists': parseContributors,
+        'Default Track Cover Artists': parseContributors,
+        'Wallpaper Artists': parseContributors,
+        'Banner Artists': parseContributors,
 
-    let group = null;
-    let trackIndex = 0;
+        '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),
 
-    for (const section of sections.slice(1)) {
-        // Just skip empty sections. Sometimes I paste a 8unch of dividers,
-        // and this lets the empty sections doing that creates (temporarily)
-        // exist without raising an error.
-        if (!section.filter(Boolean).length) {
-            continue;
-        }
+        'Banner Dimensions': parseDimensions,
+    },
 
-        const groupName = getBasicField(section, 'Group');
-        if (groupName) {
-            group = {
-                name: groupName,
-                color: (
-                    getBasicField(section, 'Color') ||
-                    getBasicField(section, 'FG') ||
-                    album.color
-                ),
-                originalDate: getBasicField(section, 'Original Date'),
-                startIndex: trackIndex,
-                tracks: []
-            };
-            if (group.originalDate) {
-                if (isNaN(Date.parse(group.originalDate))) {
-                    return {error: `The track group "${group.name}" has an invalid "Original Date" field: "${group.originalDate}"`};
-                }
-                group.originalDate = new Date(group.originalDate);
-                group.date = group.originalDate;
+    propertyFieldMapping: {
+        name: 'Album',
+
+        color: 'Color',
+        directory: 'Directory',
+        urls: 'URLs',
+
+        artistContribsByRef: 'Artists',
+        coverArtistContribsByRef: 'Cover Artists',
+        trackCoverArtistContribsByRef: 'Default Track Cover Artists',
+
+        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',
+
+        hasTrackArt: 'Has Track Art',
+        isMajorRelease: 'Major Release',
+        isListedOnHomepage: 'Listed on Homepage',
+
+        aka: 'Also Released As',
+        groupsByRef: 'Groups',
+        artTagsByRef: 'Art Tags',
+        commentary: 'Commentary',
+    }
+});
+
+function processAlbumEntryDocuments(documents) {
+    // Slightly separate meanings: tracks is the array of Track objects (and
+    // only Track objects); trackGroups is the array of TrackGroup objects,
+    // organizing (by string reference) the Track objects within the Album.
+    // tracks is returned for collating with the rest of wiki data; trackGroups
+    // is directly set on the album object.
+    const tracks = [];
+    const trackGroups = [];
+
+    // We can't mutate an array once it's set as a property value, so prepare
+    // the tracks that will show up in a track list all the way before actually
+    // applying it.
+    let currentTracksByRef = null;
+    let currentTrackGroupDoc = null;
+
+    function closeCurrentTrackGroup() {
+        if (currentTracksByRef) {
+            let trackGroup;
+
+            if (currentTrackGroupDoc) {
+                trackGroup = processTrackGroupDocument(currentTrackGroupDoc);
             } else {
-                group.date = album.date;
-            }
-            if (album.trackGroups) {
-                album.trackGroups.push(group);
-            } else {
-                album.trackGroups = [group];
+                trackGroup = new TrackGroup();
+                trackGroup.isDefaultTrackGroup = true;
             }
+
+            trackGroup.tracksByRef = currentTracksByRef;
+            trackGroups.push(trackGroup);
+        }
+    }
+
+    for (const doc of documents) {
+        if (doc['Group']) {
+            closeCurrentTrackGroup();
+            currentTracksByRef = [];
+            currentTrackGroupDoc = doc;
             continue;
         }
 
-        trackIndex++;
+        const track = processTrackDocument(doc);
+        tracks.push(track);
 
-        const track = {};
+        const ref = Thing.getReference(track);
+        if (currentTracksByRef) {
+            currentTracksByRef.push(ref);
+        } else {
+            currentTracksByRef = [ref];
+        }
+    }
 
-        track.name = getBasicField(section, 'Track');
-        track.commentary = getCommentaryField(section);
-        track.lyrics = getMultilineField(section, 'Lyrics');
-        track.originalDate = getBasicField(section, 'Original Date');
-        track.coverArtDate = getBasicField(section, 'Cover Art Date') || track.originalDate || album.trackArtDate;
-        track.references = getListField(section, 'References') || [];
-        track.artists = getContributionField(section, 'Artists') || getContributionField(section, 'Artist');
-        track.coverArtists = getContributionField(section, 'Track Art');
-        track.artTags = getListField(section, 'Art Tags') || [];
-        track.contributors = getContributionField(section, 'Contributors') || [];
-        track.directory = getBasicField(section, 'Directory');
-        track.aka = getBasicField(section, 'AKA');
+    closeCurrentTrackGroup();
 
-        if (!track.name) {
-            return {error: `A track section is missing the "Track" (name) field (in ${album.name}, previous: ${album.tracks[album.tracks.length - 1]?.name}).`};
-        }
+    return {tracks, trackGroups};
+}
 
-        let durationString = getBasicField(section, 'Duration') || '0:00';
-        track.duration = getDurationInSeconds(durationString);
+const processTrackGroupDocument = makeProcessDocument(TrackGroup, {
+    fieldTransformations: {
+        'Date Originally Released': value => new Date(value),
+    },
 
-        if (track.contributors.error) {
-            return {error: `${track.contributors.error} (in ${track.name}, ${album.name})`};
-        }
+    propertyFieldMapping: {
+        name: 'Group',
+        color: 'Color',
+        dateOriginallyReleased: 'Date Originally Released',
+    }
+});
 
-        if (track.commentary && track.commentary.error) {
-            return {error: `${track.commentary.error} (in ${track.name}, ${album.name})`};
-        }
+const processTrackDocument = makeProcessDocument(Track, {
+    fieldTransformations: {
+        'Duration': getDurationInSeconds,
 
-        if (!track.artists) {
-            // If an al8um has an artist specified (usually 8ecause it's a solo
-            // al8um), let tracks inherit that artist. We won't display the
-            // "8y <artist>" string on the al8um listing.
-            if (album.artists) {
-                track.artists = album.artists;
-            } else {
-                return {error: `The track "${track.name}" is missing the "Artist" field (in ${album.name}).`};
-            }
-        }
+        'Date First Released': value => new Date(value),
+        'Cover Art Date': value => new Date(value),
 
-        if (!track.coverArtists) {
-            if (getBasicField(section, 'Track Art') !== 'none' && album.hasTrackArt) {
-                if (album.trackCoverArtists) {
-                    track.coverArtists = album.trackCoverArtists;
-                } else {
-                    return {error: `The track "${track.name}" is missing the "Track Art" field (in ${album.name}).`};
-                }
-            }
-        }
+        'Artists': parseContributors,
+        'Contributors': parseContributors,
+        'Cover Artists': parseContributors,
+    },
 
-        if (track.coverArtists && track.coverArtists.length && track.coverArtists[0] === 'none') {
-            track.coverArtists = null;
-        }
+    propertyFieldMapping: {
+        name: 'Track',
 
-        if (!track.directory) {
-            track.directory = getKebabCase(track.name);
-        }
+        directory: 'Directory',
+        duration: 'Duration',
+        urls: 'URLs',
 
-        if (track.originalDate) {
-            if (isNaN(Date.parse(track.originalDate))) {
-                return {error: `The track "${track.name}"'s has an invalid "Original Date" field: "${track.originalDate}"`};
-            }
-            track.originalDate = new Date(track.originalDate);
-            track.date = new Date(track.originalDate);
-        } else if (group && group.originalDate) {
-            track.originalDate = group.originalDate;
-            track.date = group.originalDate;
-        } else {
-            track.date = album.date;
-        }
+        coverArtDate: 'Cover Art Date',
+        dateFirstReleased: 'Date First Released',
+        hasCoverArt: 'Has Cover Art',
+        hasURLs: 'Has URLs',
 
-        track.coverArtDate = new Date(track.coverArtDate);
+        referencedTracksByRef: 'Referenced Tracks',
+        artistContribsByRef: 'Artists',
+        contributorContribsByRef: 'Contributors',
+        coverArtistContribsByRef: 'Cover Artists',
+        artTagsByRef: 'Art Tags',
+        originalReleaseTrackByRef: 'Originally Released As',
 
-        const hasURLs = getBooleanField(section, 'Has URLs') ?? true;
+        commentary: 'Commentary',
+        lyrics: 'Lyrics'
+    },
 
-        track.urls = hasURLs && (getListField(section, 'URLs') || []).filter(Boolean);
+    ignoredFields: ['Sampled Tracks']
+});
 
-        if (hasURLs && !track.urls.length) {
-            return {error: `The track "${track.name}" should have at least one URL specified.`};
-        }
+const processArtistDocument = makeProcessDocument(Artist, {
+    propertyFieldMapping: {
+        name: 'Artist',
 
-        // 8ack-reference the al8um o8ject! This is very useful for when
-        // we're outputting the track pages.
-        track.album = album;
+        directory: 'Directory',
+        urls: 'URLs',
 
-        if (group) {
-            track.color = group.color;
-            group.tracks.push(track);
-        } else {
-            track.color = album.color;
-        }
+        aliasRefs: 'Aliases',
 
-        album.tracks.push(track);
-    }
+        contextNotes: 'Context Notes'
+    },
 
-    return album;
-}
+    ignoredFields: ['Dead URLs']
+});
 
-async function processArtistDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
+const processFlashDocument = makeProcessDocument(Flash, {
+    fieldTransformations: {
+        'Date': value => new Date(value),
 
-    const contentLines = splitLines(contents);
-    const sections = Array.from(getSections(contentLines));
+        'Contributors': parseContributors,
+    },
 
-    return sections.filter(s => s.filter(Boolean).length).map(section => {
-        const name = getBasicField(section, 'Artist');
-        const urls = (getListField(section, 'URLs') || []).filter(Boolean);
-        const alias = getBasicField(section, 'Alias');
-        const hasAvatar = getBooleanField(section, 'Has Avatar') ?? false;
-        const note = getMultilineField(section, 'Note');
-        let directory = getBasicField(section, 'Directory');
+    propertyFieldMapping: {
+        name: 'Flash',
 
-        if (!name) {
-            return {error: 'Expected "Artist" (name) field!'};
-        }
+        directory: 'Directory',
+        page: 'Page',
+        date: 'Date',
+        coverArtFileExtension: 'Cover Art File Extension',
 
-        if (!directory) {
-            directory = getKebabCase(name);
-        }
+        featuredTracksByRef: 'Featured Tracks',
+        contributorContribsByRef: 'Contributors',
+        urls: 'URLs'
+    },
+});
 
-        if (alias) {
-            return {name, directory, alias};
-        } else {
-            return {name, directory, urls, note, hasAvatar};
-        }
-    });
-}
+const processFlashActDocument = makeProcessDocument(FlashAct, {
+    propertyFieldMapping: {
+        name: 'Act',
+        color: 'Color',
+        anchor: 'Anchor',
+        jump: 'Jump',
+        jumpColor: 'Jump Color'
+    }
+});
 
 async function processFlashDataFile(file) {
     let contents;
@@ -1113,101 +1095,43 @@ async function processFlashDataFile(file) {
     });
 }
 
-async function processNewsDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    const contentLines = splitLines(contents);
-    const sections = Array.from(getSections(contentLines));
-
-    return sections.map(section => {
-        const name = getBasicField(section, 'Name');
-        if (!name) {
-            return {error: 'Expected "Name" field!'};
-        }
-
-        const directory = getBasicField(section, 'Directory') || getBasicField(section, 'ID');
-        if (!directory) {
-            return {error: 'Expected "Directory" field!'};
-        }
-
-        let body = getMultilineField(section, 'Body');
-        if (!body) {
-            return {error: 'Expected "Body" field!'};
-        }
-
-        let date = getBasicField(section, 'Date');
-        if (!date) {
-            return {error: 'Expected "Date" field!'};
-        }
-
-        if (isNaN(Date.parse(date))) {
-            return {error: `Invalid date field: "${date}"`};
-        }
-
-        date = new Date(date);
-
-        let bodyShort = body.split('<hr class="split">')[0];
-
-        return {
-            name,
-            directory,
-            body,
-            bodyShort,
-            date
-        };
-    });
-}
+const processNewsEntryDocument = makeProcessDocument(NewsEntry, {
+    fieldTransformations: {
+        'Date': value => new Date(value)
+    },
 
-async function processTagDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        if (error.code === 'ENOENT') {
-            return [];
-        } else {
-            return {error: `Could not read ${file} (${error.code}).`};
-        }
+    propertyFieldMapping: {
+        name: 'Name',
+        directory: 'Directory',
+        date: 'Date',
+        content: 'Content',
     }
+});
 
-    const contentLines = splitLines(contents);
-    const sections = Array.from(getSections(contentLines));
-
-    return sections.map(section => {
-        let isCW = false;
-
-        let name = getBasicField(section, 'Tag');
-        if (!name) {
-            name = getBasicField(section, 'CW');
-            isCW = true;
-            if (!name) {
-                return {error: 'Expected "Tag" or "CW" field!'};
-            }
-        }
-
-        let color;
-        if (!isCW) {
-            color = getBasicField(section, 'Color');
-            if (!color) {
-                return {error: 'Expected "Color" field!'};
-            }
-        }
+const processArtTagDocument = makeProcessDocument(ArtTag, {
+    propertyFieldMapping: {
+        name: 'Tag',
+        directory: 'Directory',
+        color: 'Color',
+        isContentWarning: 'Is CW'
+    }
+});
 
-        const directory = getKebabCase(name);
+const processGroupDocument = makeProcessDocument(Group, {
+    propertyFieldMapping: {
+        name: 'Group',
+        directory: 'Directory',
+        description: 'Description',
+        urls: 'URLs',
+    }
+});
 
-        return {
-            name,
-            directory,
-            isCW,
-            color
-        };
-    });
-}
+const processGroupCategoryDocument = makeProcessDocument(GroupCategory, {
+    propertyFieldMapping: {
+        name: 'Category',
+        color: 'Color',
+    }
+});
 
 async function processGroupDataFile(file) {
     let contents;
@@ -1378,75 +1302,61 @@ async function processWikiInfoFile(file) {
     };
 }
 
-async function processHomepageInfoFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    const contentLines = splitLines(contents);
-    const sections = Array.from(getSections(contentLines));
-
-    const [ firstSection, ...rowSections ] = sections;
-
-    const sidebar = getMultilineField(firstSection, 'Sidebar');
+const processHomepageLayoutDocument = makeProcessDocument(HomepageLayout, {
+    propertyFieldMapping: {
+        sidebarContent: 'Sidebar Content'
+    },
 
-    const validRowTypes = ['albums'];
+    ignoredFields: ['Homepage']
+});
 
-    const rows = rowSections.map(section => {
-        const name = getBasicField(section, 'Row');
-        if (!name) {
-            return {error: 'Expected "Row" (name) field!'};
-        }
+const homepageLayoutRowBaseSpec = {
+};
 
-        const color = getBasicField(section, 'Color');
+const makeProcessHomepageLayoutRowDocument = (rowClass, spec) => makeProcessDocument(rowClass, {
+    ...spec,
 
-        const type = getBasicField(section, 'Type');
-        if (!type) {
-            return {error: 'Expected "Type" field!'};
-        }
+    propertyFieldMapping: {
+        name: 'Row',
+        color: 'Color',
+        type: 'Type',
+        ...spec.propertyFieldMapping,
+    }
+});
 
-        if (!validRowTypes.includes(type)) {
-            return {error: `Expected "Type" field to be one of: ${validRowTypes.join(', ')}`};
+const homepageLayoutRowTypeProcessMapping = {
+    albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, {
+        propertyFieldMapping: {
+            sourceGroupByRef: 'Group',
+            countAlbumsFromGroup: 'Count',
+            sourceAlbumsByRef: 'Albums',
+            actionLinks: 'Actions'
         }
+    })
+};
 
-        const row = {name, color, type};
-
-        switch (type) {
-            case 'albums': {
-                const group = getBasicField(section, 'Group') || null;
-                const albums = getListField(section, 'Albums') || [];
-
-                if (!group && !albums) {
-                    return {error: 'Expected "Group" and/or "Albums" field!'};
-                }
-
-                let groupCount = getBasicField(section, 'Count');
-                if (group && !groupCount) {
-                    return {error: 'Expected "Count" field!'};
-                }
-
-                if (groupCount) {
-                    if (isNaN(parseInt(groupCount))) {
-                        return {error: `Invalid Count field: "${groupCount}"`};
-                    }
+function processHomepageLayoutRowDocument(document) {
+    const type = document['Type'];
 
-                    groupCount = parseInt(groupCount);
-                }
+    const match = Object.entries(homepageLayoutRowTypeProcessMapping)
+        .find(([ key ]) => key === type);
 
-                const actions = getListField(section, 'Actions') || [];
-
-                return {...row, group, groupCount, albums, actions};
-            }
-        }
-    });
+    if (!match) {
+        throw new TypeError(`No processDocument function for row type ${type}!`);
+    }
 
-    return {sidebar, rows};
+    return match[1](document);
 }
 
 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]
@@ -1726,15 +1636,35 @@ writePage.to = ({
 }) => (targetFullKey, ...args) => {
     const [ groupKey, subKey ] = targetFullKey.split('.');
     let path = paths.subdirectoryPrefix;
+
+    let from;
+    let 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' && baseDirectory) {
-        path += urls.from('localizedWithBaseDirectory.' + pageSubKey).to(targetFullKey, ...args);
+    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.
-        path += urls.from('localized.' + pageSubKey).to(targetFullKey, ...args);
+        from = 'localized.' + pageSubKey;
+        to = targetFullKey;
     }
+
+    path += urls.from(from).to(to, ...args);
+
     return path;
 };
 
@@ -1792,8 +1722,12 @@ writePage.html = (pageFn, {
     footer.classes ??= [];
     footer.content ??= (wikiInfo.footer ? transformMultiline(wikiInfo.footer) : '');
 
+    footer.content += '\n' + getFooterLocalizationLinks(paths.pathname, {
+        languages, paths, strings, to
+    });
+
     const canonical = (wikiInfo.canonicalBase
-        ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathanme)
+        ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathname)
         : '');
 
     const collapseSidebars = (sidebarLeft.collapse !== false) && (sidebarRight.collapse !== false);
@@ -1876,10 +1810,10 @@ writePage.html = (pageFn, {
                     cur.toCurrentPage ? '' :
                     cur.toHome ? to('localized.home') :
                     cur.path ? to(...cur.path) :
-                    cur.href ? call(() => {
+                    cur.href ? (() => {
                         logWarn`Using legacy href format nav link in ${paths.pathname}`;
                         return cur.href;
-                    }) :
+                    })() :
                     null)
             };
             if (attributes.href === null) {
@@ -2035,6 +1969,7 @@ writePage.paths = (baseDirectory, fullKey, directory = '', {
     const outputFile = path.join(outputDirectory, file);
 
     return {
+        toPath: [fullKey, directory],
         pathname,
         subdirectoryPrefix,
         outputDirectory, outputFile
@@ -2353,6 +2288,7 @@ async function main() {
         logInfo`Writing all languages.`;
     }
 
+    /*
     WD.wikiInfo = await processWikiInfoFile(path.join(dataPath, WIKI_INFO_FILE));
     if (WD.wikiInfo.error) {
         console.log(`\x1b[31;1m${WD.wikiInfo.error}\x1b[0m`);
@@ -2377,23 +2313,7 @@ async function main() {
     } else {
         languages.default = defaultStrings;
     }
-
-    WD.homepageInfo = await processHomepageInfoFile(path.join(dataPath, HOMEPAGE_INFO_FILE));
-
-    if (WD.homepageInfo.error) {
-        console.log(`\x1b[31;1m${WD.homepageInfo.error}\x1b[0m`);
-        return;
-    }
-
-    {
-        const errors = WD.homepageInfo.rows.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-    }
+    */
 
     // 8ut wait, you might say, how do we know which al8um these data files
     // correspond to???????? You wouldn't dare suggest we parse the actual
@@ -2419,101 +2339,405 @@ async function main() {
     // avoiding that in our code 8ecause, again, we want to avoid assuming the
     // format of the returned paths here - they're only meant to 8e used for
     // reading as-is.
-    const albumDataFiles = await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY));
+    const albumDataFiles = await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY), f => path.extname(f) === '.yaml');
 
-    // Technically, we could do the data file reading and output writing at the
-    // same time, 8ut that kinda makes the code messy, so I'm not 8othering
-    // with it.
-    WD.albumData = await progressPromiseAll(`Reading & processing album files.`, albumDataFiles.map(processAlbumDataFile));
+    const documentModes = {
+        onePerFile: Symbol('Document mode: One per file'),
+        headerAndEntries: Symbol('Document mode: Header and entries'),
+        allInOne: Symbol('Document mode: All in one')
+    };
 
-    {
-        const errors = WD.albumData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
+    const dataSteps = [
+        {
+            title: `Process album files`,
+            files: albumDataFiles,
+
+            documentMode: documentModes.headerAndEntries,
+            processHeaderDocument: processAlbumDocument,
+            processEntryDocument(document) {
+                return ('Group' in document
+                    ? processTrackGroupDocument(document)
+                    : processTrackDocument(document));
+            },
+
+            // processEntryDocuments: processAlbumEntryDocuments,
+
+            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;
+
+                    function closeCurrentTrackGroup() {
+                        if (currentTracksByRef) {
+                            let trackGroup;
+
+                            if (currentTrackGroup) {
+                                trackGroup = currentTrackGroup;
+                            } else {
+                                trackGroup = new TrackGroup();
+                                trackGroup.name = `Default Track Group`;
+                                trackGroup.isDefaultTrackGroup = true;
+                            }
+
+                            trackGroup.tracksByRef = currentTracksByRef;
+                            trackGroups.push(trackGroup);
+                        }
+                    }
+
+                    for (const entry of entries) {
+                        if (entry instanceof TrackGroup) {
+                            closeCurrentTrackGroup();
+                            currentTracksByRef = [];
+                            currentTrackGroup = entry;
+                            continue;
+                        }
+
+                        trackData.push(entry);
+
+                        const ref = Thing.getReference(entry);
+                        if (currentTracksByRef) {
+                            currentTracksByRef.push(ref);
+                        } else {
+                            currentTracksByRef = [ref];
+                        }
+                    }
+
+                    closeCurrentTrackGroup();
+
+                    album.trackGroups = trackGroups;
+                    albumData.push(album);
+                }
+
+                sortByDate(albumData);
+                sortByDate(trackData);
+
+                Object.assign(wikiData, {albumData, trackData});
             }
-            return;
-        }
-    }
+        },
 
-    sortByDate(WD.albumData);
+        {
+            title: `Process artists file`,
+            files: [path.join(dataPath, ARTIST_DATA_FILE)],
 
-    WD.artistData = await processArtistDataFile(path.join(dataPath, ARTIST_DATA_FILE));
-    if (WD.artistData.error) {
-        console.log(`\x1b[31;1m${WD.artistData.error}\x1b[0m`);
-        return;
-    }
+            documentMode: documentModes.allInOne,
+            processDocument: processArtistDocument,
 
-    {
-        const errors = WD.artistData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
+            save(results) {
+                wikiData.artistData = results;
             }
-            return;
-        }
-    }
+        },
+
+        // TODO: WD.wikiInfo.features.flashesAndGames &&
+        {
+            title: `Process flashes file`,
+            files: [path.join(dataPath, FLASH_DATA_FILE)],
+
+            documentMode: documentModes.allInOne,
+            processDocument(document) {
+                return ('Act' in document
+                    ? processFlashActDocument(document)
+                    : processFlashDocument(document));
+            },
+
+            save(results) {
+                let flashAct;
+                let flashesByRef = [];
+
+                if (results[0] && !(results[0] instanceof FlashAct)) {
+                    throw new Error(`Expected an act at top of flash data file`);
+                }
 
-    WD.artistAliasData = WD.artistData.filter(x => x.alias);
-    WD.artistData = WD.artistData.filter(x => !x.alias);
+                for (const thing of results) {
+                    if (thing instanceof FlashAct) {
+                        if (flashAct) {
+                            Object.assign(flashAct, {flashesByRef});
+                        }
 
-    WD.trackData = getAllTracks(WD.albumData);
+                        flashAct = thing;
+                        flashesByRef = [];
+                    } else {
+                        flashesByRef.push(Thing.getReference(thing));
+                    }
+                }
 
-    if (WD.wikiInfo.features.flashesAndGames) {
-        WD.flashData = await processFlashDataFile(path.join(dataPath, FLASH_DATA_FILE));
-        if (WD.flashData.error) {
-            console.log(`\x1b[31;1m${WD.flashData.error}\x1b[0m`);
-            return;
-        }
+                if (flashAct) {
+                    Object.assign(flashAct, {flashesByRef});
+                }
 
-        const errors = WD.flashData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
+                wikiData.flashData = results.filter(x => x instanceof Flash);
+                wikiData.flashActData = results.filter(x => x instanceof FlashAct);
             }
-            return;
-        }
-    }
+        },
 
-    WD.flashActData = WD.flashData?.filter(x => x.act8r8k);
-    WD.flashData = WD.flashData?.filter(x => !x.act8r8k);
+        {
+            title: `Process groups file`,
+            files: [path.join(dataPath, GROUP_DATA_FILE)],
+
+            documentMode: documentModes.allInOne,
+            processDocument(document) {
+                return ('Category' in document
+                    ? processGroupCategoryDocument(document)
+                    : processGroupDocument(document));
+            },
+
+            save(results) {
+                let groupCategory;
+                let groupsByRef = [];
+
+                if (results[0] && !(results[0] instanceof GroupCategory)) {
+                    throw new Error(`Expected a category at top of group data file`);
+                }
 
-    WD.tagData = await processTagDataFile(path.join(dataPath, TAG_DATA_FILE));
-    if (WD.tagData.error) {
-        console.log(`\x1b[31;1m${WD.tagData.error}\x1b[0m`);
-        return;
+                for (const thing of results) {
+                    if (thing instanceof GroupCategory) {
+                        if (groupCategory) {
+                            Object.assign(groupCategory, {groupsByRef});
+                        }
+
+                        groupCategory = thing;
+                        groupsByRef = [];
+                    } else {
+                        groupsByRef.push(Thing.getReference(thing));
+                    }
+                }
+
+                if (groupCategory) {
+                    Object.assign(groupCategory, {groupsByRef});
+                }
+
+                wikiData.groupData = results.filter(x => x instanceof Group);
+                wikiData.groupCategoryData = results.filter(x => x instanceof GroupCategory);
+            }
+        },
+
+        {
+            title: `Process homepage layout file`,
+            files: [path.join(dataPath, HOMEPAGE_LAYOUT_DATA_FILE)],
+
+            documentMode: documentModes.headerAndEntries,
+            processHeaderDocument: processHomepageLayoutDocument,
+            processEntryDocument: processHomepageLayoutRowDocument,
+
+            save(results) {
+                if (!results[0]) {
+                    return;
+                }
+
+                const { header: homepageLayout, entries: rows } = results[0];
+                Object.assign(homepageLayout, {rows});
+                Object.assign(wikiData, {homepageLayout});
+            }
+        },
+
+        // TODO: WD.wikiInfo.features.news &&
+        {
+            title: `Process news data file`,
+            files: [path.join(dataPath, NEWS_DATA_FILE)],
+
+            documentMode: documentModes.allInOne,
+            processDocument: processNewsEntryDocument,
+
+            save(results) {
+                sortByDate(results);
+                results.reverse();
+
+                wikiData.newsData = results;
+            }
+        },
+
+        {
+            title: `Process art tags file`,
+            files: [path.join(dataPath, ART_TAG_DATA_FILE)],
+
+            documentMode: documentModes.allInOne,
+            processDocument: processArtTagDocument,
+
+            save(results) {
+                results.sort(sortByName);
+
+                wikiData.tagData = results;
+            }
+        },
+    ];
+
+    const processDataAggregate = openAggregate({message: `Errors processing data files`});
+
+    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;
+            }
+        };
     }
 
-    {
-        const errors = WD.tagData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
+    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;
         }
     }
 
-    WD.tagData.sort(sortByName);
+    for (const dataStep of dataSteps) {
+        await processDataAggregate.nestAsync(
+            {message: `Errors during data step: ${dataStep.title}`},
+            async ({call, callAsync, map, mapAsync, nest}) => {
+                const { documentMode } = dataStep;
 
-    WD.groupData = await processGroupDataFile(path.join(dataPath, GROUP_DATA_FILE));
-    if (WD.groupData.error) {
-        console.log(`\x1b[31;1m${WD.groupData.error}\x1b[0m`);
-        return;
+                if (!(Object.values(documentModes).includes(documentMode))) {
+                    throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
+                }
+
+                if (documentMode === documentModes.allInOne) {
+                    if (dataStep.files.length !== 1) {
+                        throw new Error(`Expected 1 file for all-in-one documentMode, not ${files.length}`);
+                    }
+
+                    const file = dataStep.files[0];
+
+                    const readResult = await callAsync(readFile, file);
+
+                    if (!readResult) {
+                        return;
+                    }
+
+                    const yamlResult = call(yaml.loadAll, readResult);
+
+                    if (!yamlResult) {
+                        return;
+                    }
+
+                    const {
+                        result: processResults,
+                        aggregate: processAggregate
+                    } = mapAggregate(
+                        yamlResult,
+                        decorateErrorWithIndex(dataStep.processDocument),
+                        {message: `Errors processing documents`}
+                    );
+
+                    call(processAggregate.close);
+
+                    dataStep.save(processResults);
+
+                    return;
+                }
+
+                const readResults = await mapAsync(
+                    dataStep.files,
+                    file => (readFile(file, 'utf-8')
+                        .then(contents => ({file, contents}))),
+                    {
+                        message: `Errors reading data files`,
+                        promiseAll: array => progressPromiseAll(`Data step: ${dataStep.title} (reading data files)`, array)
+                    });
+
+                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});
+                        });
+                    });
+                }
+
+                if (documentMode === documentModes.onePerFile) {
+                    throw new Error('TODO: onePerFile not yet implemented');
+                }
+
+                dataStep.save(processResults);
+            });
     }
 
     {
-        const errors = WD.groupData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
+        try {
+            logInfo`Loaded data and processed objects:`;
+            logInfo` - ${wikiData.albumData.length} albums`;
+            logInfo` - ${wikiData.trackData.length} tracks`;
+            logInfo` - ${wikiData.artistData.length} artists`;
+            if (wikiData.flashData)
+                logInfo` - ${wikiData.flashData.length} flashes (${wikiData.flashActData.length} acts)`;
+            logInfo` - ${wikiData.groupData.length} groups (${wikiData.groupCategoryData.length} categories)`;
+            logInfo` - ${wikiData.tagData.length} art tags`;
+            if (wikiData.newsData)
+                logInfo` - ${wikiData.newsData.length} news entries`;
+            if (wikiData.homepageLayout)
+                logInfo` - ${1} homepage layout (${wikiData.homepageLayout.rows.length} rows)`;
+        } catch (error) {
+            console.error(`Error showing data summary:`, error);
+        }
+
+        let errorless = true;
+        try {
+            processDataAggregate.close();
+        } catch (error) {
+            showAggregate(error, {pathToFile: f => path.relative(__dirname, f)});
+            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.)`;
         }
     }
 
-    WD.groupCategoryData = WD.groupData.filter(x => x.isCategory);
-    WD.groupData = WD.groupData.filter(x => x.isGroup);
+    process.exit();
 
     WD.staticPageData = await processStaticPageDataFile(path.join(dataPath, STATIC_PAGE_DATA_FILE));
     if (WD.staticPageData.error) {
@@ -2531,25 +2755,6 @@ async function main() {
         }
     }
 
-    if (WD.wikiInfo.features.news) {
-        WD.newsData = await processNewsDataFile(path.join(dataPath, NEWS_DATA_FILE));
-        if (WD.newsData.error) {
-            console.log(`\x1b[31;1m${WD.newsData.error}\x1b[0m`);
-            return;
-        }
-
-        const errors = WD.newsData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-
-        sortByDate(WD.newsData);
-        WD.newsData.reverse();
-    }
-
     {
         const tagNames = new Set([...WD.trackData, ...WD.albumData].flatMap(thing => thing.artTags));
 
diff --git a/src/util/cli.js b/src/util/cli.js
index 7f84be7c..b6335726 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -3,18 +3,47 @@
 // A 8unch of these depend on process.stdout 8eing availa8le, so they won't
 // work within the 8rowser.
 
+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));
+
+const C = n => (ENABLE_COLOR
+    ? text => `\x1b[${n}m${text}\x1b[0m`
+    : text => text);
+
+export const color = {
+    bright: C('1'),
+    dim: C('2'),
+    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);
-    w(`\x1b[${color}m`);
+    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) {
-            w(`\x1b[1m`);
+            wc(`\x1b[1m`);
             w(String(values[i]));
-            w(`\x1b[0;${color}m`);
+            wc(`\x1b[0;${color}m`);
         }
     }
-    w(`\x1b[0m\n`);
+    wc(`\x1b[0m`);
+    w('\n');
 };
 
 export const logInfo = logColor(2);
diff --git a/src/util/find.js b/src/util/find.js
index 5f69bbec..423046b3 100644
--- a/src/util/find.js
+++ b/src/util/find.js
@@ -7,7 +7,7 @@ function findHelper(keys, dataProp, findFns = {}) {
     const byDirectory = findFns.byDirectory || matchDirectory;
     const byName = findFns.byName || matchName;
 
-    const keyRefRegex = new RegExp(`^((${keys.join('|')}):)?(.*)$`);
+    const keyRefRegex = new RegExp(`^((${keys.join('|')}):(?:\S))?(.*)$`);
 
     return (fullRef, {wikiData}) => {
         if (!fullRef) return null;
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 38c8047f..219c3eec 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -6,6 +6,8 @@
 // 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';
+
 // 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
@@ -33,11 +35,6 @@ export const unique = arr => Array.from(new Set(arr));
 // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
 export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj)));
 
-// Nothin' more to it than what it says. Runs a function in-place. Provides an
-// altern8tive syntax to the usual IIFEs (e.g. (() => {})()) when you want to
-// open a scope and run some statements while inside an existing expression.
-export const call = fn => fn();
-
 export function queue(array, max = 50) {
     if (max === 0) {
         return array.map(fn => fn());
@@ -133,14 +130,33 @@ export function openAggregate({
         }
     };
 
+    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));
     };
 
+    aggregate.nestAsync = (...args) => {
+        return aggregate.callAsync(() => withAggregateAsync(...args));
+    };
+
     aggregate.map = (...args) => {
         const parent = aggregate;
         const { result, aggregate: child } = mapAggregate(...args);
@@ -148,6 +164,13 @@ export function openAggregate({
         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);
@@ -183,6 +206,19 @@ 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);
+}
+
+export function mapAggregateAsync(array, fn, {
+    promiseAll = Promise.all,
+    ...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({
@@ -190,10 +226,16 @@ export function mapAggregate(array, fn, aggregateOpts) {
         ...aggregateOpts
     });
 
-    const result = array.map(aggregate.wrap(fn))
-        .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
@@ -204,6 +246,19 @@ export function mapAggregate(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);
+}
+
+export async function filterAggregateAsync(array, fn, {
+    promiseAll = Promise.all,
+    ...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({
@@ -211,62 +266,121 @@ export function filterAggregate(array, fn, aggregateOpts) {
         ...aggregateOpts
     });
 
-    const result = array.map(aggregate.wrap((x, ...rest) => ({
-        input: x,
-        output: fn(x, ...rest)
-    })))
-        .filter(value => {
-            // Filter out results which match the failureSymbol, i.e. errored
-            // inputs.
-            if (value === failureSymbol) return false;
-
-            // Always keep results which match the overridden returnOnFail
-            // value, if provided.
-            if (value === aggregateOpts.returnOnFail) return true;
-
-            // Otherwise, filter according to the returned value of the wrapped
-            // function.
-            return value.output;
-        })
-        .map(value => {
-            // Then turn the results back into their corresponding input, or, if
-            // provided, the overridden returnOnFail value.
-            return (value === aggregateOpts.returnOnFail
-                ? value
-                : value.input);
-        });
+    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)
+        };
+    }
 
-    return {result, aggregate};
+    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);
+}
+
+export function withAggregateAsync(aggregateOpts, fn) {
+    return _withAggregate('async', aggregateOpts, fn);
+}
+
+export function _withAggregate(mode, aggregateOpts, fn) {
     if (typeof aggregateOpts === 'function') {
         fn = aggregateOpts;
         aggregateOpts = {};
     }
 
     const aggregate = openAggregate(aggregateOpts);
-    const result = fn(aggregate);
-    aggregate.close();
-    return result;
+
+    if (mode === 'sync') {
+        const result = fn(aggregate);
+        aggregate.close();
+        return result;
+    } else {
+        return fn(aggregate).then(result => {
+            aggregate.close();
+            return result;
+        });
+    }
 }
 
-export function showAggregate(topError) {
-    const recursive = error => {
-        const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'}`;
+export function showAggregate(topError, {pathToFile = p => p} = {}) {
+    const recursive = (error, {level}) => {
+        const stackLines = error.stack?.split('\n');
+        const stackLine = stackLines?.find(line =>
+            line.trim().startsWith('at')
+            && !line.includes('sugar')
+            && !line.includes('node:internal'));
+        const tracePart = (stackLine
+            ? '- ' + stackLine.trim().replace(/file:\/\/(.*\.js)/, (match, pathname) => pathToFile(pathname))
+            : '(no stack trace)');
+
+        const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'} ${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(recursive)
+                .map(error => recursive(error, {level: level + 1}))
                 .flatMap(str => str.split('\n'))
-                .map(line => ` | ` + line)
+                .map((line, i, lines) => (i === 0
+                    ? ` ${head} ${line}`
+                    : ` ${bar} ${line}`))
                 .join('\n'));
         } else {
             return header;
         }
     };
 
-    console.log(recursive(topError));
+    console.error(recursive(topError, {level: 0}));
 }
diff --git a/test/cacheable-object.js b/test/cacheable-object.js
new file mode 100644
index 00000000..203d2af0
--- /dev/null
+++ b/test/cacheable-object.js
@@ -0,0 +1,274 @@
+import test from 'tape';
+
+import CacheableObject from '../src/thing/cacheable-object.js';
+
+// Utility
+
+function newCacheableObject(PD) {
+    return new (class extends CacheableObject {
+        static propertyDescriptors = PD;
+    });
+}
+
+// Tests
+
+test(`CacheableObject simple separate update & expose`, t => {
+    const obj = newCacheableObject({
+        number: {
+            flags: {
+                update: true
+            }
+        },
+
+        timesTwo: {
+            flags: {
+                expose: true
+            },
+
+            expose: {
+                dependencies: ['number'],
+                compute: ({ number }) => number * 2
+            }
+        }
+    });
+
+    t.plan(1);
+    obj.number = 5;
+    t.equal(obj.timesTwo, 10);
+});
+
+test(`CacheableObject basic cache behavior`, t => {
+    let computeCount = 0;
+
+    const obj = newCacheableObject({
+        string: {
+            flags: {
+                update: true
+            }
+        },
+
+        karkat: {
+            flags: {
+                expose: true
+            },
+
+            expose: {
+                dependencies: ['string'],
+                compute: ({ string }) => {
+                    computeCount++;
+                    return string.toUpperCase();
+                }
+            }
+        }
+    });
+
+    t.plan(8);
+
+    t.is(computeCount, 0);
+
+    obj.string = 'hello world';
+    t.is(computeCount, 0);
+
+    obj.karkat;
+    t.is(computeCount, 1);
+
+    obj.karkat;
+    t.is(computeCount, 1);
+
+    obj.string = 'testing once again';
+    t.is(computeCount, 1);
+
+    obj.karkat;
+    t.is(computeCount, 2);
+
+    obj.string = 'testing once again';
+    t.is(computeCount, 2);
+
+    obj.karkat;
+    t.is(computeCount, 2);
+});
+
+test(`CacheableObject combined update & expose (no transform)`, t => {
+    const obj = newCacheableObject({
+        directory: {
+            flags: {
+                update: true,
+                expose: true
+            }
+        }
+    });
+
+    t.plan(2);
+
+    t.directory = 'the-world-revolving';
+    t.is(t.directory, 'the-world-revolving');
+
+    t.directory = 'chaos-king';
+    t.is(t.directory, 'chaos-king');
+});
+
+test(`CacheableObject combined update & expose (basic transform)`, t => {
+    const obj = newCacheableObject({
+        getsRepeated: {
+            flags: {
+                update: true,
+                expose: true
+            },
+
+            expose: {
+                transform: value => value.repeat(2)
+            }
+        }
+    });
+
+    t.plan(1);
+
+    obj.getsRepeated = 'dog';
+    t.is(obj.getsRepeated, 'dogdog');
+});
+
+test(`CacheableObject combined update & expose (transform with dependency)`, t => {
+    const obj = newCacheableObject({
+        customRepeat: {
+            flags: {
+                update: true,
+                expose: true
+            },
+
+            expose: {
+                dependencies: ['times'],
+                transform: (value, { times }) => value.repeat(times)
+            }
+        },
+
+        times: {
+            flags: {
+                update: true
+            }
+        }
+    });
+
+    t.plan(3);
+
+    obj.customRepeat = 'dog';
+    obj.times = 1;
+    t.is(obj.customRepeat, 'dog');
+
+    obj.times = 5;
+    t.is(obj.customRepeat, 'dogdogdogdogdog');
+
+    obj.customRepeat = 'cat';
+    t.is(obj.customRepeat, 'catcatcatcatcat');
+});
+
+test(`CacheableObject validate on update`, t => {
+    const mockError = new TypeError(`Expected a string, not ${typeof value}`);
+
+    const obj = newCacheableObject({
+        directory: {
+            flags: {
+                update: true,
+                expose: true
+            },
+
+            update: {
+                validate: value => {
+                    if (typeof value !== 'string') {
+                        throw mockError;
+                    }
+                    return true;
+                }
+            }
+        },
+
+        date: {
+            flags: {
+                update: true,
+                expose: true
+            },
+
+            update: {
+                validate: value => (value instanceof Date)
+            }
+        }
+    });
+
+    let thrownError;
+    t.plan(6);
+
+    obj.directory = 'megalovania';
+    t.is(obj.directory, 'megalovania');
+
+    try {
+        obj.directory = 25;
+    } catch (err) {
+        thrownError = err;
+    }
+
+    t.is(thrownError, mockError);
+    t.is(obj.directory, 'megalovania');
+
+    const date = new Date(`25 December 2009`);
+
+    obj.date = date;
+    t.is(obj.date, date);
+
+    try {
+        obj.date = `TWELFTH PERIGEE'S EVE`;
+    } catch (err) {
+        thrownError = err;
+    }
+
+    t.is(thrownError?.constructor, TypeError);
+    t.is(obj.date, date);
+});
+
+test(`CacheableObject default update property value`, t => {
+    const obj = newCacheableObject({
+        fruit: {
+            flags: {
+                update: true,
+                expose: true
+            },
+
+            update: {
+                default: 'potassium'
+            }
+        }
+    });
+
+    t.plan(1);
+    t.is(obj.fruit, 'potassium');
+});
+
+test(`CacheableObject default property throws if invalid`, t => {
+    const mockError = new TypeError(`Expected a string, not ${typeof value}`);
+
+    t.plan(1);
+
+    let thrownError;
+
+    try {
+        newCacheableObject({
+            string: {
+                flags: {
+                    update: true
+                },
+
+                update: {
+                    default: 123,
+                    validate: value => {
+                        if (typeof value !== 'string') {
+                            throw mockError;
+                        }
+                        return true;
+                    }
+                }
+            }
+        });
+    } catch (err) {
+        thrownError = err;
+    }
+
+    t.is(thrownError, mockError);
+});
diff --git a/test/data-validators.js b/test/data-validators.js
new file mode 100644
index 00000000..739333a3
--- /dev/null
+++ b/test/data-validators.js
@@ -0,0 +1,277 @@
+import _test from 'tape';
+import { showAggregate } from '../src/util/sugar.js';
+
+import {
+    // Basic types
+    isBoolean,
+    isCountingNumber,
+    isNumber,
+    isString,
+    isStringNonEmpty,
+
+    // Complex types
+    isArray,
+    isObject,
+    validateArrayItems,
+
+    // Wiki data
+    isDimensions,
+    isDirectory,
+    isDuration,
+    isFileExtension,
+    validateReference,
+    validateReferenceList,
+
+    // Compositional utilities
+    oneOf,
+} from '../src/thing/validators.js';
+
+function test(msg, fn) {
+    _test(msg, t => {
+        try {
+            fn(t);
+        } catch (error) {
+            if (error instanceof AggregateError) {
+                showAggregate(error);
+            }
+            throw error;
+        }
+    });
+}
+
+test.skip = _test.skip;
+
+// Basic types
+
+test('isBoolean', t => {
+    t.plan(4);
+    t.ok(isBoolean(true));
+    t.ok(isBoolean(false));
+    t.throws(() => isBoolean(1), TypeError);
+    t.throws(() => isBoolean('yes'), TypeError);
+});
+
+test('isNumber', t => {
+    t.plan(6);
+    t.ok(isNumber(123));
+    t.ok(isNumber(0.05));
+    t.ok(isNumber(0));
+    t.ok(isNumber(-10));
+    t.throws(() => isNumber('413'), TypeError);
+    t.throws(() => isNumber(true), TypeError);
+});
+
+test('isCountingNumber', t => {
+    t.plan(6);
+    t.ok(isCountingNumber(3));
+    t.ok(isCountingNumber(1));
+    t.throws(() => isCountingNumber(1.75), TypeError);
+    t.throws(() => isCountingNumber(0), TypeError);
+    t.throws(() => isCountingNumber(-1), TypeError);
+    t.throws(() => isCountingNumber('612'), TypeError);
+});
+
+test('isString', t => {
+    t.plan(3);
+    t.ok(isString('hello!'));
+    t.ok(isString(''));
+    t.throws(() => isString(100), TypeError);
+});
+
+test('isStringNonEmpty', t => {
+    t.plan(4);
+    t.ok(isStringNonEmpty('hello!'));
+    t.throws(() => isStringNonEmpty(''), TypeError);
+    t.throws(() => isStringNonEmpty('     '), TypeError);
+    t.throws(() => isStringNonEmpty(100), TypeError);
+});
+
+// Complex types
+
+test('isArray', t => {
+    t.plan(3);
+    t.ok(isArray([]));
+    t.throws(() => isArray({}), TypeError);
+    t.throws(() => isArray('1, 2, 3'), TypeError);
+});
+
+test.skip('isDate', t => {
+    // TODO
+});
+
+test('isObject', t => {
+    t.plan(3);
+    t.ok(isObject({}));
+    t.ok(isObject([]));
+    t.throws(() => isObject(null), TypeError);
+});
+
+test('validateArrayItems', t => {
+    t.plan(6);
+
+    t.ok(validateArrayItems(isNumber)([3, 4, 5]));
+    t.ok(validateArrayItems(validateArrayItems(isNumber))([[3, 4], [4, 5], [6, 7]]));
+
+    let caughtError = null;
+    try {
+        validateArrayItems(isNumber)([10, 20, 'one hundred million consorts', 30]);
+    } catch (err) {
+        caughtError = err;
+    }
+
+    t.isNot(caughtError, null);
+    t.true(caughtError instanceof AggregateError);
+    t.is(caughtError.errors.length, 1);
+    t.true(caughtError.errors[0] instanceof TypeError);
+});
+
+// Wiki data
+
+test.skip('isColor', t => {
+    // TODO
+});
+
+test.skip('isCommentary', t => {
+    // TODO
+});
+
+test.skip('isContribution', t => {
+    // TODO
+});
+
+test.skip('isContributionList', t => {
+    // TODO
+});
+
+test('isDimensions', t => {
+    t.plan(6);
+    t.ok(isDimensions([1, 1]));
+    t.ok(isDimensions([50, 50]));
+    t.ok(isDimensions([5000, 1]));
+    t.throws(() => isDimensions([1]), TypeError);
+    t.throws(() => isDimensions([413, 612, 1025]), TypeError);
+    t.throws(() => isDimensions('800x200'), TypeError);
+});
+
+test('isDirectory', t => {
+    t.plan(6);
+    t.ok(isDirectory('savior-of-the-waking-world'));
+    t.ok(isDirectory('MeGaLoVania'));
+    t.ok(isDirectory('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'));
+    t.throws(() => isDirectory(123), TypeError);
+    t.throws(() => isDirectory(''), TypeError);
+    t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), TypeError);
+});
+
+test('isDuration', t => {
+    t.plan(5);
+    t.ok(isDuration(60));
+    t.ok(isDuration(0.02));
+    t.ok(isDuration(0));
+    t.throws(() => isDuration(-1), TypeError);
+    t.throws(() => isDuration('10:25'), TypeError);
+});
+
+test('isFileExtension', t => {
+    t.plan(6);
+    t.ok(isFileExtension('png'));
+    t.ok(isFileExtension('jpg'));
+    t.ok(isFileExtension('sub_loc'));
+    t.throws(() => isFileExtension(''), TypeError);
+    t.throws(() => isFileExtension('.jpg'), TypeError);
+    t.throws(() => isFileExtension('just an image bro!!!!'), TypeError);
+});
+
+test.skip('isName', t => {
+    // TODO
+});
+
+test.skip('isURL', t => {
+    // TODO
+});
+
+test('validateReference', t => {
+    t.plan(16);
+
+    const typeless = validateReference();
+    const track = validateReference('track');
+    const album = validateReference('album');
+
+    t.ok(track('track:doctor'));
+    t.ok(track('track:MeGaLoVania'));
+    t.ok(track('Showtime (Imp Strife Mix)'));
+    t.throws(() => track('track:troll saint nic'), TypeError);
+    t.throws(() => track('track:'), TypeError);
+    t.throws(() => track('album:homestuck-vol-1'), TypeError);
+
+    t.ok(album('album:sburb'));
+    t.ok(album('album:the-wanderers'));
+    t.ok(album('Homestuck Vol. 8'));
+    t.throws(() => album('album:Hiveswap Friendsim'), TypeError);
+    t.throws(() => album('album:'), TypeError);
+    t.throws(() => album('track:showtime-piano-refrain'), TypeError);
+
+    t.ok(typeless('Hopes and Dreams'));
+    t.ok(typeless('track:snowdin-town'));
+    t.throws(() => typeless(''), TypeError);
+    t.throws(() => typeless('album:undertale-soundtrack'));
+});
+
+test('validateReferenceList', t => {
+    const track = validateReferenceList('track');
+    const artist = validateReferenceList('artist');
+
+    t.plan(9);
+
+    t.ok(track(['track:fallen-down', 'Once Upon a Time']));
+    t.ok(artist(['artist:toby-fox', 'Mark Hadley']));
+    t.ok(track(['track:amalgam']));
+    t.ok(track([]));
+
+    let caughtError = null;
+    try {
+        track(['Dog', 'album:vaporwave-2016', 'Cat', 'artist:john-madden']);
+    } catch (err) {
+        caughtError = err;
+    }
+
+    t.isNot(caughtError, null);
+    t.true(caughtError instanceof AggregateError);
+    t.is(caughtError.errors.length, 2);
+    t.true(caughtError.errors[0] instanceof TypeError);
+    t.true(caughtError.errors[1] instanceof TypeError);
+});
+
+test('oneOf', t => {
+    t.plan(11);
+
+    const isStringOrNumber = oneOf(isString, isNumber);
+
+    t.ok(isStringOrNumber('hello world'));
+    t.ok(isStringOrNumber(42));
+    t.throws(() => isStringOrNumber(false));
+
+    const mockError = new Error();
+    const neverSucceeds = () => {
+        throw mockError;
+    };
+
+    const isStringOrGetRekt = oneOf(isString, neverSucceeds);
+
+    t.ok(isStringOrGetRekt('phew!'));
+
+    let caughtError = null;
+    try {
+        isStringOrGetRekt(0xdeadbeef);
+    } catch (err) {
+        caughtError = err;
+    }
+
+    t.isNot(caughtError, null);
+    t.true(caughtError instanceof AggregateError);
+    t.is(caughtError.errors.length, 2);
+    t.true(caughtError.errors[0] instanceof TypeError);
+    t.is(caughtError.errors[0].check, isString);
+    t.is(caughtError.errors[1], mockError);
+    t.is(caughtError.errors[1].check, neverSucceeds);
+});