« get me outta code hell

initial working changes for big data restructure - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2022-01-18 19:45:09 -0400
committer(quasar) nebula <qznebula@protonmail.com>2022-01-18 19:45:09 -0400
commit859b8fb20525b44a94ab5072405c6c9d6df4da5b (patch)
treeb2e56fb20931d6f8702157e7a4cb113e39faab3c
parentb10d00e4f4cf191ed9cb914052422db4363de349 (diff)
initial working changes for big data restructure
-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.js255
-rw-r--r--src/thing/cacheable-object.js269
-rw-r--r--src/thing/structures.js31
-rw-r--r--src/thing/thing.js42
-rw-r--r--src/thing/validators.js208
-rwxr-xr-xsrc/upd8.js500
-rw-r--r--src/util/cli.js37
-rw-r--r--src/util/sugar.js146
-rw-r--r--test/cacheable-object.js274
-rw-r--r--test/data-validators.js207
15 files changed, 3387 insertions, 252 deletions
diff --git a/package-lock.json b/package-lock.json
index f97e7f0..fb17557 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 1017d4a..c48d868 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 578c4e5..090c437 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 65d4d34..c88343e 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 b80c99f..86fb73f 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 e99cfc3..7be092e 100644
--- a/src/thing/album.js
+++ b/src/thing/album.js
@@ -1,28 +1,267 @@
 import Thing from './thing.js';
 
 import {
-    validateDirectory,
-    validateReference
-} from './structures.js';
+    isBoolean,
+    isColor,
+    isCommentary,
+    isContributionList,
+    isDate,
+    isDimensions,
+    isDirectory,
+    isName,
+    isURL,
+    isString,
+    validateArrayItems,
+    validateReference,
+    validateReferenceList,
+} from './validators.js';
 
 import {
+    aggregateThrows,
     showAggregate,
     withAggregate
 } from '../util/sugar.js';
 
 export default class Album extends Thing {
+    /*
+    #name = 'Unnamed Album';
+
+    #color = null;
     #directory = null;
+    #urls = [];
+
+    #artists = [];
+    #coverArtists = [];
+    #trackCoverArtists = [];
+
+    #wallpaperArtists = [];
+    #wallpaperStyle = '';
+    #wallpaperFileExtension = 'jpg';
+
+    #bannerArtists = [];
+    #bannerStyle = '';
+    #bannerFileExtension = 'jpg';
+    #bannerDimensions = [0, 0];
+
+    #date = null;
+    #trackArtDate = null;
+    #coverArtDate = null;
+    #dateAddedToWiki = null;
+
+    #hasTrackArt = true;
+    #isMajorRelease = false;
+    #isListedOnHomepage = true;
+
+    #aka = '';
+    #groups = [];
+    #artTags = [];
+    #commentary = '';
+
     #tracks = [];
 
-    static updateError = {
+    static propertyError = {
+        name: Thing.extendPropertyError('name'),
         directory: Thing.extendPropertyError('directory'),
         tracks: Thing.extendPropertyError('tracks')
     };
+    */
+
+    static propertyDescriptors = {
+        // Update & expose
+
+        name: {
+            flags: {update: true, expose: true},
+
+            update: {
+                default: 'Unnamed Album',
+                validate: isName
+            }
+        },
+
+        color: {
+            flags: {update: true, expose: true},
+            update: {validate: isColor}
+        },
+
+        directory: {
+            flags: {update: true, expose: true},
+            update: {validate: isDirectory}
+        },
+
+        urls: {
+            flags: {update: true, expose: true},
+
+            update: {
+                validate: validateArrayItems(isURL)
+            }
+        },
+
+        date: {
+            flags: {update: true, expose: true},
+            update: {validate: isDate}
+        },
+
+        coverArtDate: {
+            flags: {update: true, expose: true},
+            update: {validate: isDate}
+        },
+
+        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')
+            }
+        },
+
+        artTagsByRef: {
+            flags: {update: true, expose: true},
+
+            update: {
+                validate: validateReferenceList('tag')
+            }
+        },
 
+        tracksByRef: {
+            flags: {update: true, expose: true},
+
+            update: {
+                validate: validateReferenceList('track')
+            }
+        },
+
+        wallpaperStyle: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+
+        wallpaperFileExtension: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+
+        bannerStyle: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+
+        bannerFileExtension: {
+            flags: {update: true, expose: true},
+            update: {validate: isString}
+        },
+
+        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}
+        }
+    };
+
+    /*
     update(source) {
-        const err = this.constructor.updateError;
+        const err = this.constructor.propertyError;
+
+        withAggregate(aggregateThrows(Thing.UpdateError), ({ nest, filter, throws }) => {
+            if (source.name) {
+                nest(throws(err.name), ({ call }) => {
+                    if (call(validateName, source.name)) {
+                        this.#name = source.name;
+                    }
+                });
+            }
 
-        withAggregate(({ nest, filter, throws }) => {
+            if (source.color) {
+                nest(throws(err.color), ({ call }) => {
+                    if (call(validateColor, source.color)) {
+                        this.#color = source.color;
+                    }
+                });
+            }
 
             if (source.directory) {
                 nest(throws(err.directory), ({ call }) => {
@@ -37,10 +276,13 @@ export default class Album extends Thing {
         });
     }
 
+    get name() { return this.#name; }
     get directory() { return this.#directory; }
     get tracks() { return this.#tracks; }
+    */
 }
 
+/*
 const album = new Album();
 
 console.log('tracks (before):', album.tracks);
@@ -60,3 +302,4 @@ try {
 }
 
 console.log('tracks (after):', album.tracks);
+*/
diff --git a/src/thing/cacheable-object.js b/src/thing/cacheable-object.js
new file mode 100644
index 0000000..f478fd2
--- /dev/null
+++ b/src/thing/cacheable-object.js
@@ -0,0 +1,269 @@
+// 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 = (!flags.update && expose?.compute);
+        const transform = (flags.update && expose?.transform);
+
+        if (flags.update && !transform) {
+            return null;
+        } 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/structures.js b/src/thing/structures.js
index 89c9bd3..364ba14 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 c2465e3..dd3126c 100644
--- a/src/thing/thing.js
+++ b/src/thing/thing.js
@@ -6,46 +6,10 @@
 // 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});
-        }
-    }
-
-    static PropertyError = class extends AggregateError {
-        #key = this.constructor.key;
-        get key() { return this.#key; }
-
-        constructor(errors) {
-            super(errors, '');
-            this.message = `${errors.length} error(s) in property "${this.#key}"`;
-        }
-    };
-
-    static extendPropertyError(key) {
-        const cls = class extends this.PropertyError {
-            static #key = key;
-            static get key() { return this.#key; }
-        };
-
-        Object.defineProperty(cls, 'name', {value: `PropertyError:${key}`});
-        return cls;
-    }
-
-    // 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) {}
+export default class Thing extends CacheableObject {
+    static propertyDescriptors = Symbol('Thing property descriptors');
 
     // Called when collecting the full list of available things of that type
     // for wiki data; this method determine whether or not to include it.
diff --git a/src/thing/validators.js b/src/thing/validators.js
new file mode 100644
index 0000000..0573691
--- /dev/null
+++ b/src/thing/validators.js
@@ -0,0 +1,208 @@
+import { withAggregate } from '../util/sugar.js';
+
+// 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 isInteger(number) {
+    isNumber(number);
+
+    if (number % 1 !== 0)
+        throw new TypeError(`Expected integer`);
+
+    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;
+}
+
+export function validateArrayItems(itemValidator) {
+    return array => {
+        isArray(array);
+
+        withAggregate({message: 'Errors validating array items'}, ({ wrap }) => {
+            array.forEach(wrap(itemValidator));
+        });
+
+        return true;
+    };
+}
+
+// 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, and dash, got "${directory}"`);
+
+    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 hasTwoParts = ref.includes(':');
+        const [ typePart, directoryPart ] = ref.split(':');
+
+        if (hasTwoParts && typePart !== type)
+            throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:" (ref: ${ref})`);
+
+        if (hasTwoParts)
+            isDirectory(directoryPart);
+
+        isName(ref);
+
+        return true;
+    };
+}
+
+export function validateReferenceList(type = '') {
+    return validateArrayItems(validateReference(type));
+}
diff --git a/src/upd8.js b/src/upd8.js
index 6f538d1..fd5a21c 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
@@ -118,6 +89,8 @@ import find from './util/find.js';
 import * as html from './util/html.js';
 import unbound_link, {getLinkThemeString} from './util/link.js';
 
+import Album from './thing/album.js';
+
 import {
     fancifyFlashURL,
     fancifyURL,
@@ -129,6 +102,7 @@ import {
     getAlbumStylesheet,
     getArtistString,
     getFlashGridHTML,
+    getFooterLocalizationLinks,
     getGridHTML,
     getRevealStringFromTags,
     getRevealStringFromWarnings,
@@ -137,6 +111,7 @@ import {
 } from './misc-templates.js';
 
 import {
+    color,
     decorateTime,
     logWarn,
     logInfo,
@@ -185,10 +160,15 @@ import {
 import {
     bindOpts,
     call,
+    filterAggregateAsync,
     filterEmptyLines,
+    mapAggregateAsync,
+    openAggregate,
     queue,
+    showAggregate,
     splitArray,
     unique,
+    withAggregate,
     withEntries
 } from './util/sugar.js';
 
@@ -208,6 +188,7 @@ 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';
@@ -274,6 +255,11 @@ function splitLines(text) {
     return text.split(/\r\n|\r|\n/);
 }
 
+// REFERENCE CODE!
+// REFERENCE CODE!
+// REFERENCE CODE!
+// REFERENCE CODE!
+
 function* getSections(lines) {
     // ::::)
     const isSeparatorLine = line => /^-{8,}/.test(line);
@@ -285,9 +271,11 @@ function getBasicField(lines, name) {
     return line && line.slice(name.length + 1).trim();
 }
 
-function getDimensionsField(lines, name) {
-    const string = getBasicField(lines, name);
-    if (!string) return string;
+function parseDimensions(string) {
+    if (!string) {
+        return null;
+    }
+
     const parts = string.split(/[x,* ]+/g);
     if (parts.length !== 2) throw new Error(`Invalid dimensions: ${string} (expected width & height)`);
     const nums = parts.map(part => Number(part.trim()));
@@ -338,9 +326,7 @@ function getListField(lines, name) {
     return listLines.map(line => line.slice(2));
 };
 
-function getContributionField(section, name) {
-    let contributors = getListField(section, name);
-
+function parseContributors(contributors) {
     if (!contributors) {
         return null;
     }
@@ -737,10 +723,61 @@ function transformLyrics(text, {
     return outLines.join('\n');
 }
 
-function getCommentaryField(lines) {
-    const text = getMultilineField(lines, 'Commentary');
+// 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
+        };
+    };
+}
+
+function parseField(object, key, steps) {
+    let value = object[key];
+
+    for (const step of steps) {
+        try {
+            value = step(value);
+        } catch (error) {
+            throw parseField.stepError({
+                stepName: step.name,
+                stepError: error
+            });
+        }
+    }
+
+    return value;
+}
+
+parseField.stepError = parseErrorFactory('step failed');
+
+function assertFieldPresent(value) {
+    if (value === undefined || value === null) {
+        throw assertFieldPresent.missingField();
+    } else {
+        return value;
+    }
+}
+
+assertFieldPresent.missingField = parseErrorFactory('missing field');
+
+function assertValidDate(dateString, {optional = false} = {}) {
+    if (dateString && isNaN(Date.parse(dateString))) {
+        throw assertValidDate.invalidDate();
+    }
+    return value;
+}
+
+assertValidDate.invalidDate = parseErrorFactory('invalid date');
+
+function parseCommentary(text) {
     if (text) {
-        const lines = text.split('\n');
+        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)}..."`};
         }
@@ -748,8 +785,141 @@ function getCommentaryField(lines) {
     } else {
         return null;
     }
+}
+
+// General function for inputting a single document (usually loaded from YAML)
+// and outputting an instance of a provided Thing subclass.
+//
+// makeParseDocument is a factory function: the returned function will take a
+// document and apply the configuration passed to makeParseDocument in order to
+// construct a Thing subclass.
+function makeParseDocument(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 = {},
+
+    // 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
+}) {
+    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])));
+
+    return function(document, {file = null}) {
+        const unknownFields = Object.keys(document)
+            .filter(field => !knownFields.includes(field));
+
+        if (unknownFields.length) {
+            throw new makeParseDocument.UnknownFieldsError(unknownFields);
+        }
+
+        const fieldValues = {};
+
+        for (const [ field, value ] of Object.entries(document)) {
+            if (Object.hasOwn(fieldTransformations, field)) {
+                fieldValues[field] = fieldTransformations[field](value);
+            } else {
+                fieldValues[field] = value;
+            }
+        }
+
+        const sourceProperties = {};
+
+        for (const [ field, value ] of Object.entries(fieldValues)) {
+            const property = fieldPropertyMapping[field];
+            sourceProperties[property] = value;
+        }
+
+        const thing = Reflect.construct(thingClass, []);
+
+        const C = color;
+        const filePart = file ? `(file: ${C.bright(C.blue(path.relative(dataPath, file)))})` : '';
+        withAggregate({message: `Errors applying ${C.green(thingClass.name)} properties ${filePart}`}, ({ call }) => {
+            for (const [ property, value ] of Object.entries(sourceProperties)) {
+                call(() => {
+                    thing[property] = value;
+                });
+            }
+        });
+
+        return thing;
+    };
+}
+
+makeParseDocument.UnknownFieldsError = class UnknownFieldsError extends Error {
+    constructor(fields) {
+        super(`Unknown fields present: ${fields.join(', ')}`);
+        this.fields = fields;
+    }
 };
 
+processAlbumDataFile.parseDocument = makeParseDocument(Album, {
+    fieldTransformations: {
+        'Artists': parseContributors,
+        'Cover Artists': parseContributors,
+        'Default Track Cover Artists': parseContributors,
+        'Wallpaper Artists': parseContributors,
+        'Banner Artists': parseContributors,
+
+        'Date': value => new Date(value),
+        'Date Added': value => new Date(value),
+        'Cover Art Date': value => new Date(value),
+        'Default Track Cover Art Date': value => new Date(value),
+
+        'Banner Dimensions': parseDimensions,
+    },
+
+    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',
+    }
+});
+
 async function processAlbumDataFile(file) {
     let contents;
     try {
@@ -771,43 +941,54 @@ async function processAlbumDataFile(file) {
     // We'll just return more specific errors if it's missing necessary data
     // fields.
 
-    const contentLines = contents.split(/\r\n|\r|\n/);
+    const documents = yaml.loadAll(contents);
 
-    // In this line of code I defeat the purpose of using a generator in the
-    // first place. Sorry!!!!!!!!
-    const sections = Array.from(getSections(contentLines));
+    const albumDoc = documents[0];
 
-    const albumSection = sections[0];
-    const album = {};
+    return processAlbumDataFile.parseDocument(albumDoc, {file});
 
-    album.name = getBasicField(albumSection, 'Album');
+    // --------------------------------------------------------------
+
+    // const album = {};
+
+    album.name = parseField(albumDoc, 'Album', [
+        assertFieldPresent
+    ]);
 
     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?`};
     }
 
-    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;
+    // album.directory = albumDoc['Directory'];
+    // album.urls = albumDoc['URLs'] || [];
+
+    // album.artists = parseContributors(albumDoc['Artists']);
+
+    // album.date = albumDoc['Date'];
+    // album.trackArtDate = albumDoc['Track Art Date'] || album.date;
+    // album.coverArtDate = albumDoc['Cover Art Date'] || album.date;
+    // album.dateAdded = albumDoc['Date Added'];
+
+    // album.coverArtists = parseContributors(albumDoc['Cover Artists']);
+    // album.trackCoverArtists = parseContributors(albumDoc['Default Track Cover Artists']);
+    // album.hasTrackArt = albumDoc['Has Track Art'] ?? true;
+
+    // album.wallpaperArtists = parseContributors(albumDoc['Wallpaper Artists']);
+    // album.wallpaperStyle = albumDoc['Wallpaper Style'];
+    // album.wallpaperFileExtension = albumDoc['Wallpaper File Extension'] || 'jpg';
+
+    // album.bannerArtists = albumDoc['Banner Artists'];
+    // album.bannerStyle = albumDoc['Banner Style'];
+    // album.bannerFileExtension = albumDoc['Banner File Extension'] || 'jpg';
+    // album.bannerDimensions = parseDimensions(albumDoc['Banner Dimensions']);
+
+    // album.groups = albumDoc['Groups'] || [];
+    // album.artTags = albumDoc['Art Tags'] || [];
+
+    // album.commentary = parseCommentary(albumDoc['Commentary']);
+
+    // album.isMajorRelease = albumDoc['Major Release'] ?? false;
+    // album.isListedOnHomepage = albumDoc['Listed on Homepage'] ?? true;
 
     if (album.artists && album.artists.error) {
         return {error: `${album.artists.error} (in ${album.name})`};
@@ -829,10 +1010,7 @@ async function processAlbumDataFile(file) {
         return {error: `The album "${album.name}" is missing the "Cover Art" field.`};
     }
 
-    album.color = (
-        getBasicField(albumSection, 'Color') ||
-        getBasicField(albumSection, 'FG')
-    );
+    // album.color = albumDoc['Color'];
 
     if (!album.name) {
         return {error: `Expected "Album" (name) field!`};
@@ -879,24 +1057,20 @@ async function processAlbumDataFile(file) {
     let group = null;
     let trackIndex = 0;
 
-    for (const section of sections.slice(1)) {
+    for (const doc of documents.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) {
+        if (!doc) {
             continue;
         }
 
-        const groupName = getBasicField(section, 'Group');
+        const groupName = doc['Group'];
         if (groupName) {
             group = {
                 name: groupName,
-                color: (
-                    getBasicField(section, 'Color') ||
-                    getBasicField(section, 'FG') ||
-                    album.color
-                ),
-                originalDate: getBasicField(section, 'Original Date'),
+                color: doc['Color'] || album.color,
+                originalDate: doc['Original Date'],
                 startIndex: trackIndex,
                 tracks: []
             };
@@ -921,27 +1095,46 @@ async function processAlbumDataFile(file) {
 
         const track = {};
 
-        track.name = getBasicField(section, 'Track');
-        track.commentary = getCommentaryField(section);
-        track.lyrics = getMultilineField(section, 'Lyrics');
-        track.originalDate = getBasicField(section, 'Original Date');
-        track.coverArtDate = getBasicField(section, 'Cover Art Date') || track.originalDate || album.trackArtDate;
-        track.references = getListField(section, 'References') || [];
-        track.artists = getContributionField(section, 'Artists') || getContributionField(section, 'Artist');
-        track.coverArtists = getContributionField(section, 'Track Art');
-        track.artTags = getListField(section, 'Art Tags') || [];
-        track.contributors = getContributionField(section, 'Contributors') || [];
-        track.directory = getBasicField(section, 'Directory');
-        track.aka = getBasicField(section, 'AKA');
+        track.name = String(doc['Track']);
+
+        track.commentary = parseCommentary(doc['Commentary']);
+        track.lyrics = String(doc['Lyrics']);
+
+        track.originalDate = doc['Date First Released'];
+        track.coverArtDate = doc['Cover Art Date'] || track.originalDate || album.trackArtDate;
+
+        isNaN(Date.parse(track.originalDate))
+
+        if (track.originalDate) {
+            if (isNaN(Date.parse(track.originalDate))) {
+                return {error: `The track "${track.name}"'s has an invalid "Date First Released" 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;
+        }
+
+        track.coverArtDate = new Date(track.coverArtDate);
+
+        track.references = doc['References'] || [];
+        track.artists = parseContributors(doc['Artists']);
+        track.coverArtists = parseContributors(doc['Cover Artists']);
+        track.artTags = doc['Art Tags'] || [];
+        track.contributors = parseContributors(doc['Contributors']);
+        track.directory = doc['Directory'];
+        track.aka = doc['AKA'];
 
         if (!track.name) {
-            return {error: `A track section is missing the "Track" (name) field (in ${album.name}, previous: ${album.tracks[album.tracks.length - 1]?.name}).`};
+            return {error: `A track document is missing the "Track" (name) field (in ${album.name}, previous: ${album.tracks[album.tracks.length - 1]?.name}).`};
         }
 
-        let durationString = getBasicField(section, 'Duration') || '0:00';
-        track.duration = getDurationInSeconds(durationString);
+        track.duration = getDurationInSeconds(doc['Duration'] || '0:00');
 
-        if (track.contributors.error) {
+        if (track.contributors?.error) {
             return {error: `${track.contributors.error} (in ${track.name}, ${album.name})`};
         }
 
@@ -960,42 +1153,28 @@ async function processAlbumDataFile(file) {
             }
         }
 
-        if (!track.coverArtists) {
-            if (getBasicField(section, 'Track Art') !== 'none' && album.hasTrackArt) {
-                if (album.trackCoverArtists) {
-                    track.coverArtists = album.trackCoverArtists;
-                } else {
-                    return {error: `The track "${track.name}" is missing the "Track Art" field (in ${album.name}).`};
-                }
-            }
-        }
-
-        if (track.coverArtists && track.coverArtists.length && track.coverArtists[0] === 'none') {
+        if (doc['Has Cover Art'] === false) {
             track.coverArtists = null;
+        } else if (album.hasTrackArt && !track.coverArtists) {
+            if (album.trackCoverArtists) {
+                track.coverArtists = album.trackCoverArtists;
+            } else {
+                return {error: `The track "${track.name}" is missing the "Cover Artists" field (in ${album.name}).`};
+            }
         }
 
         if (!track.directory) {
-            track.directory = getKebabCase(track.name);
-        }
-
-        if (track.originalDate) {
-            if (isNaN(Date.parse(track.originalDate))) {
-                return {error: `The track "${track.name}"'s has an invalid "Original Date" field: "${track.originalDate}"`};
+            try {
+                track.directory = getKebabCase(track.name);
+            } catch (error) {
+                console.log('error:', track.name);
+                process.exit();
             }
-            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;
         }
 
-        track.coverArtDate = new Date(track.coverArtDate);
-
-        const hasURLs = getBooleanField(section, 'Has URLs') ?? true;
+        const hasURLs = doc['Has URLs'] ?? true;
 
-        track.urls = hasURLs && (getListField(section, 'URLs') || []).filter(Boolean);
+        track.urls = hasURLs && doc['URLs'] || [];
 
         if (hasURLs && !track.urls.length) {
             return {error: `The track "${track.name}" should have at least one URL specified.`};
@@ -1726,15 +1905,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 +1991,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);
@@ -2035,6 +2238,7 @@ writePage.paths = (baseDirectory, fullKey, directory = '', {
     const outputFile = path.join(outputDirectory, file);
 
     return {
+        toPath: [fullKey, directory],
         pathname,
         subdirectoryPrefix,
         outputDirectory, outputFile
@@ -2419,12 +2623,32 @@ 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');
+
+    class LoadDataFileError extends AggregateError {}
+
+    // WD.albumData = await progressPromiseAll(`Reading & processing album files.`, albumDataFiles.map(aggregate.wrapAsync(processAlbumDataFile)));
+
+    const processDataAggregate = openAggregate({message: `Errors processing data files`});
+
+    await processDataAggregate.callAsync(async () => {
+        const { aggregate, result } = await mapAggregateAsync(albumDataFiles, processAlbumDataFile, {
+            message: `Errors processing album files`,
+            promiseAll: array => progressPromiseAll(`Reading & processing album files.`, array)
+        });
+
+        WD.albumData = result;
 
-    // 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));
+        aggregate.close();
+    });
+
+    try {
+        processDataAggregate.close();
+    } catch (error) {
+        showAggregate(error);
+    }
+
+    process.exit();
 
     {
         const errors = WD.albumData.filter(obj => obj.error);
diff --git a/src/util/cli.js b/src/util/cli.js
index 7f84be7..b633572 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/sugar.js b/src/util/sugar.js
index 38c8047..64291f3 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
@@ -133,10 +135,25 @@ 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));
     };
@@ -183,6 +200,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 +220,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 +240,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,32 +260,57 @@ 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;
 
-    return {result, aggregate};
+        // Always keep results which match the overridden returnOnFail
+        // value, if provided.
+        if (value === aggregateOpts.returnOnFail) return true;
+
+        // Otherwise, filter according to the returned value of the wrapped
+        // function.
+        return value.output;
+    }
+
+    function mapFunction(value) {
+        // Then turn the results back into their corresponding input, or, if
+        // provided, the overridden returnOnFail value.
+        return (value === aggregateOpts.returnOnFail
+            ? value
+            : value.input);
+    }
+
+    function wrapperFunction(x, ...rest) {
+        return {
+            input: x,
+            output: fn(x, ...rest)
+        };
+    }
+
+    if (mode === 'sync') {
+        const result = array
+            .map(aggregate.wrap((input, index, array) => {
+                const output = fn(input, index, array);
+                return {input, output};
+            }))
+            .filter(filterFunction)
+            .map(mapFunction);
+
+        return {result, aggregate};
+    } else {
+        return promiseAll(array.map(aggregate.wrapAsync(async (input, index, array) => {
+            const output = await fn(input, index, array);
+            return {input, output};
+        }))).then(values => {
+            const result = values
+                .filter(filterFunction)
+                .map(mapFunction);
+
+            return {result, aggregate};
+        });
+    }
 }
 
 // Totally sugar function for opening an aggregate, running the provided
@@ -256,7 +330,17 @@ export function withAggregate(aggregateOpts, fn) {
 
 export function showAggregate(topError) {
     const recursive = error => {
-        const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'}`;
+        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()
+            : '(no stack trace)');
+
+        const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'} ${color.dim(tracePart)}`;
+
         if (error instanceof AggregateError) {
             return header + '\n' + (error.errors
                 .map(recursive)
@@ -268,5 +352,5 @@ export function showAggregate(topError) {
         }
     };
 
-    console.log(recursive(topError));
+    console.error(recursive(topError));
 }
diff --git a/test/cacheable-object.js b/test/cacheable-object.js
new file mode 100644
index 0000000..203d2af
--- /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 0000000..e6b8b43
--- /dev/null
+++ b/test/data-validators.js
@@ -0,0 +1,207 @@
+import _test from 'tape';
+import { showAggregate } from '../src/util/sugar.js';
+
+import {
+    // Basic types
+    isBoolean,
+    isNumber,
+    isString,
+    isStringNonEmpty,
+
+    // Complex types
+    isArray,
+    isObject,
+    validateArrayItems,
+
+    // Wiki data
+    isDimensions,
+    isDirectory,
+    validateReference,
+    validateReferenceList,
+} 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('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(5);
+    t.ok(isDirectory('savior-of-the-waking-world'));
+    t.ok(isDirectory('MeGaLoVania'));
+    t.throws(() => isDirectory(123), TypeError);
+    t.throws(() => isDirectory(''), TypeError);
+    t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), 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);
+});