« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--data-tests/index.js (renamed from test/data-tests/index.js)0
-rw-r--r--data-tests/test-no-short-tracks.js (renamed from test/data-tests/test-no-short-tracks.js)0
-rw-r--r--data-tests/test-order-of-album-groups.js (renamed from test/data-tests/test-order-of-album-groups.js)0
-rw-r--r--package-lock.json8057
-rw-r--r--package.json12
-rw-r--r--src/content-function.js478
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js121
-rw-r--r--src/content/dependencies/generateAdditionalFilesShortcut.js33
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js57
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js99
-rw-r--r--src/content/dependencies/generateAlbumInfoPageContent.js307
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js120
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js76
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js88
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js98
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js85
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbedDescription.js50
-rw-r--r--src/content/dependencies/generateAlbumStyleRules.js61
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js126
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js75
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js818
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js105
-rw-r--r--src/content/dependencies/generateChronologyLinks.js88
-rw-r--r--src/content/dependencies/generateColorStyleRules.js41
-rw-r--r--src/content/dependencies/generateContentHeading.js27
-rw-r--r--src/content/dependencies/generateCoverArtwork.js83
-rw-r--r--src/content/dependencies/generateFooterLocalizationLinks.js56
-rw-r--r--src/content/dependencies/generatePageLayout.js506
-rw-r--r--src/content/dependencies/generatePreviousNextLinks.js36
-rw-r--r--src/content/dependencies/generateStaticPage.js39
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js45
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js132
-rw-r--r--src/content/dependencies/generateTrackInfoPageContent.js671
-rw-r--r--src/content/dependencies/generateTrackList.js55
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js53
-rw-r--r--src/content/dependencies/image.js207
-rw-r--r--src/content/dependencies/index.js235
-rw-r--r--src/content/dependencies/linkAlbum.js8
-rw-r--r--src/content/dependencies/linkAlbumAdditionalFile.js26
-rw-r--r--src/content/dependencies/linkAlbumCommentary.js8
-rw-r--r--src/content/dependencies/linkAlbumGallery.js8
-rw-r--r--src/content/dependencies/linkArtTag.js8
-rw-r--r--src/content/dependencies/linkArtist.js8
-rw-r--r--src/content/dependencies/linkArtistGallery.js8
-rw-r--r--src/content/dependencies/linkContribution.js74
-rw-r--r--src/content/dependencies/linkExternal.js93
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js46
-rw-r--r--src/content/dependencies/linkExternalFlash.js41
-rw-r--r--src/content/dependencies/linkFlash.js8
-rw-r--r--src/content/dependencies/linkGroup.js8
-rw-r--r--src/content/dependencies/linkGroupGallery.js8
-rw-r--r--src/content/dependencies/linkListing.js8
-rw-r--r--src/content/dependencies/linkNewsEntry.js8
-rw-r--r--src/content/dependencies/linkStaticPage.js8
-rw-r--r--src/content/dependencies/linkTemplate.js73
-rw-r--r--src/content/dependencies/linkThing.js91
-rw-r--r--src/content/dependencies/linkTrack.js8
-rw-r--r--src/content/dependencies/transformContent.js325
-rw-r--r--src/content/util/getChronologyRelations.js42
-rw-r--r--src/content/util/groupTracksByGroup.js23
-rw-r--r--src/data/things/album.js24
-rw-r--r--src/data/things/artist.js5
-rw-r--r--src/data/things/homepage-layout.js4
-rw-r--r--src/data/things/language.js6
-rw-r--r--src/data/things/thing.js16
-rw-r--r--src/data/things/validators.js49
-rw-r--r--src/data/yaml.js1
-rw-r--r--src/misc-templates.js873
-rw-r--r--src/page/album.js704
-rw-r--r--src/page/artist.js680
-rw-r--r--src/page/index.js54
-rw-r--r--src/page/static.js30
-rw-r--r--src/page/track.js550
-rw-r--r--src/static/client.js2
-rw-r--r--src/static/site4.css (renamed from src/static/site3.css)28
-rw-r--r--src/strings-default.json20
-rwxr-xr-xsrc/upd8.js1
-rw-r--r--src/util/html.js784
-rw-r--r--src/util/link.js168
-rw-r--r--src/util/replacer.js11
-rw-r--r--src/util/sugar.js106
-rw-r--r--src/util/transform-content.js3
-rw-r--r--src/write/bind-utilities.js168
-rw-r--r--src/write/build-modes/live-dev-server.js169
-rw-r--r--src/write/page-template.js150
-rw-r--r--tap-snapshots/test/snapshot/generateAdditionalFilesList.js.test.cjs29
-rw-r--r--tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs14
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumTrackListItem.js.test.cjs21
-rw-r--r--tap-snapshots/test/snapshot/generateContributionLinks.js.test.cjs52
-rw-r--r--tap-snapshots/test/snapshot/image.js.test.cjs60
-rw-r--r--tap-snapshots/test/snapshot/linkArtist.js.test.cjs14
-rw-r--r--tap-snapshots/test/snapshot/linkExternal.js.test.cjs43
-rw-r--r--tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs18
-rw-r--r--tap-snapshots/test/snapshot/linkTemplate.js.test.cjs14
-rw-r--r--test/lib/content-function.js148
-rw-r--r--test/lib/generic-mock.js314
-rw-r--r--test/lib/strict-match-error.js50
-rw-r--r--test/snapshot/generateAdditionalFilesList.js63
-rw-r--r--test/snapshot/generateAdditionalFilesShortcut.js36
-rw-r--r--test/snapshot/generateAlbumTrackListItem.js66
-rw-r--r--test/snapshot/generateContributionLinks.js55
-rw-r--r--test/snapshot/image.js92
-rw-r--r--test/snapshot/linkArtist.js30
-rw-r--r--test/snapshot/linkExternal.js57
-rw-r--r--test/snapshot/linkExternalFlash.js24
-rw-r--r--test/snapshot/linkTemplate.js33
-rw-r--r--test/unit/content/dependencies/generateContributionLinks.js109
-rw-r--r--test/unit/content/dependencies/linkArtist.js31
-rw-r--r--test/unit/data/things/cacheable-object.js (renamed from test/cacheable-object.js)72
-rw-r--r--test/unit/data/things/track.js (renamed from test/things.js)20
-rw-r--r--test/unit/data/things/validators.js (renamed from test/data-validators.js)145
-rw-r--r--test/unit/util/html.js906
113 files changed, 16305 insertions, 4896 deletions
diff --git a/.gitignore b/.gitignore
index fd4f2b0..6f9fe6b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
 node_modules
 .DS_Store
+.nyc_output
diff --git a/test/data-tests/index.js b/data-tests/index.js
index 1b9ec99..1b9ec99 100644
--- a/test/data-tests/index.js
+++ b/data-tests/index.js
diff --git a/test/data-tests/test-no-short-tracks.js b/data-tests/test-no-short-tracks.js
index 7635609..7635609 100644
--- a/test/data-tests/test-no-short-tracks.js
+++ b/data-tests/test-no-short-tracks.js
diff --git a/test/data-tests/test-order-of-album-groups.js b/data-tests/test-order-of-album-groups.js
index de2fcbe..de2fcbe 100644
--- a/test/data-tests/test-order-of-album-groups.js
+++ b/data-tests/test-order-of-album-groups.js
diff --git a/package-lock.json b/package-lock.json
index fcccac8..4985290 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,8 +11,10 @@
             "dependencies": {
                 "chroma-js": "^2.4.2",
                 "command-exists": "^1.2.9",
+                "eslint": "^8.37.0",
                 "he": "^1.2.0",
                 "js-yaml": "^4.1.0",
+                "marked": "^5.0.2",
                 "word-wrap": "^1.2.3"
             },
             "bin": {
@@ -20,20 +22,438 @@
             },
             "devDependencies": {
                 "chokidar": "^3.5.3",
-                "eslint": "^8.18.0",
-                "tape": "^5.4.1"
+                "tap": "^16.3.4",
+                "tcompare": "^6.0.0"
             }
         },
-        "node_modules/@eslint/eslintrc": {
-            "version": "1.3.0",
-            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.0.tgz",
-            "integrity": "sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==",
+        "node_modules/@ampproject/remapping": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
+            "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/gen-mapping": "^0.1.0",
+                "@jridgewell/trace-mapping": "^0.3.9"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@babel/code-frame": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
+            "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
+            "dev": true,
+            "dependencies": {
+                "@babel/highlight": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/compat-data": {
+            "version": "7.21.0",
+            "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.0.tgz",
+            "integrity": "sha512-gMuZsmsgxk/ENC3O/fRw5QY8A9/uxQbbCEypnLIiYYc/qVJtEV7ouxC3EllIIwNzMqAQee5tanFabWsUOutS7g==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/core": {
+            "version": "7.21.3",
+            "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.21.3.tgz",
+            "integrity": "sha512-qIJONzoa/qiHghnm0l1n4i/6IIziDpzqc36FBs4pzMhDUraHqponwJLiAKm1hGLP3OSB/TVNz6rMwVGpwxxySw==",
+            "dev": true,
+            "dependencies": {
+                "@ampproject/remapping": "^2.2.0",
+                "@babel/code-frame": "^7.18.6",
+                "@babel/generator": "^7.21.3",
+                "@babel/helper-compilation-targets": "^7.20.7",
+                "@babel/helper-module-transforms": "^7.21.2",
+                "@babel/helpers": "^7.21.0",
+                "@babel/parser": "^7.21.3",
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.21.3",
+                "@babel/types": "^7.21.3",
+                "convert-source-map": "^1.7.0",
+                "debug": "^4.1.0",
+                "gensync": "^1.0.0-beta.2",
+                "json5": "^2.2.2",
+                "semver": "^6.3.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/babel"
+            }
+        },
+        "node_modules/@babel/generator": {
+            "version": "7.21.3",
+            "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.3.tgz",
+            "integrity": "sha512-QS3iR1GYC/YGUnW7IdggFeN5c1poPUurnGttOV/bZgPGV+izC/D8HnD6DLwod0fsatNyVn1G3EVWMYIF0nHbeA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.21.3",
+                "@jridgewell/gen-mapping": "^0.3.2",
+                "@jridgewell/trace-mapping": "^0.3.17",
+                "jsesc": "^2.5.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": {
+            "version": "0.3.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+            "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/set-array": "^1.0.1",
+                "@jridgewell/sourcemap-codec": "^1.4.10",
+                "@jridgewell/trace-mapping": "^0.3.9"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@babel/helper-compilation-targets": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz",
+            "integrity": "sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/compat-data": "^7.20.5",
+                "@babel/helper-validator-option": "^7.18.6",
+                "browserslist": "^4.21.3",
+                "lru-cache": "^5.1.1",
+                "semver": "^6.3.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0"
+            }
+        },
+        "node_modules/@babel/helper-environment-visitor": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
+            "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-function-name": {
+            "version": "7.21.0",
+            "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz",
+            "integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/template": "^7.20.7",
+                "@babel/types": "^7.21.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-hoist-variables": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
+            "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-module-imports": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+            "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-module-transforms": {
+            "version": "7.21.2",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.21.2.tgz",
+            "integrity": "sha512-79yj2AR4U/Oqq/WOV7Lx6hUjau1Zfo4cI+JLAVYeMV5XIlbOhmjEk5ulbTc9fMpmlojzZHkUUxAiK+UKn+hNQQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-module-imports": "^7.18.6",
+                "@babel/helper-simple-access": "^7.20.2",
+                "@babel/helper-split-export-declaration": "^7.18.6",
+                "@babel/helper-validator-identifier": "^7.19.1",
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.21.2",
+                "@babel/types": "^7.21.2"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-simple-access": {
+            "version": "7.20.2",
+            "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz",
+            "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.20.2"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-split-export-declaration": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
+            "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-string-parser": {
+            "version": "7.19.4",
+            "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
+            "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-validator-identifier": {
+            "version": "7.19.1",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+            "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-validator-option": {
+            "version": "7.21.0",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
+            "integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helpers": {
+            "version": "7.21.0",
+            "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.21.0.tgz",
+            "integrity": "sha512-XXve0CBtOW0pd7MRzzmoyuSj0e3SEzj8pgyFxnTT1NJZL38BD1MK7yYrm8yefRPIDvNNe14xR4FdbHwpInD4rA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.21.0",
+                "@babel/types": "^7.21.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/highlight": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
+            "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-validator-identifier": "^7.18.6",
+                "chalk": "^2.0.0",
+                "js-tokens": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/highlight/node_modules/ansi-styles": {
+            "version": "3.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+            "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+            "dev": true,
+            "dependencies": {
+                "color-convert": "^1.9.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@babel/highlight/node_modules/chalk": {
+            "version": "2.4.2",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+            "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+            "dev": true,
+            "dependencies": {
+                "ansi-styles": "^3.2.1",
+                "escape-string-regexp": "^1.0.5",
+                "supports-color": "^5.3.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@babel/highlight/node_modules/color-convert": {
+            "version": "1.9.3",
+            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+            "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+            "dev": true,
+            "dependencies": {
+                "color-name": "1.1.3"
+            }
+        },
+        "node_modules/@babel/highlight/node_modules/color-name": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+            "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+            "dev": true
+        },
+        "node_modules/@babel/highlight/node_modules/escape-string-regexp": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+            "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.8.0"
+            }
+        },
+        "node_modules/@babel/highlight/node_modules/has-flag": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+            "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@babel/highlight/node_modules/supports-color": {
+            "version": "5.5.0",
+            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+            "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+            "dev": true,
+            "dependencies": {
+                "has-flag": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@babel/parser": {
+            "version": "7.21.3",
+            "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.3.tgz",
+            "integrity": "sha512-lobG0d7aOfQRXh8AyklEAgZGvA4FShxo6xQbUrrT/cNBPUdIDojlokwJsQyCC/eKia7ifqM0yP+2DRZ4WKw2RQ==",
+            "dev": true,
+            "bin": {
+                "parser": "bin/babel-parser.js"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@babel/template": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
+            "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/code-frame": "^7.18.6",
+                "@babel/parser": "^7.20.7",
+                "@babel/types": "^7.20.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/traverse": {
+            "version": "7.21.3",
+            "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.3.tgz",
+            "integrity": "sha512-XLyopNeaTancVitYZe2MlUEvgKb6YVVPXzofHgqHijCImG33b/uTurMS488ht/Hbsb2XK3U2BnSTxKVNGV3nGQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/code-frame": "^7.18.6",
+                "@babel/generator": "^7.21.3",
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-function-name": "^7.21.0",
+                "@babel/helper-hoist-variables": "^7.18.6",
+                "@babel/helper-split-export-declaration": "^7.18.6",
+                "@babel/parser": "^7.21.3",
+                "@babel/types": "^7.21.3",
+                "debug": "^4.1.0",
+                "globals": "^11.1.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/traverse/node_modules/globals": {
+            "version": "11.12.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+            "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@babel/types": {
+            "version": "7.21.3",
+            "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.3.tgz",
+            "integrity": "sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg==",
             "dev": true,
             "dependencies": {
+                "@babel/helper-string-parser": "^7.19.4",
+                "@babel/helper-validator-identifier": "^7.19.1",
+                "to-fast-properties": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@eslint-community/eslint-utils": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+            "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+            "dependencies": {
+                "eslint-visitor-keys": "^3.3.0"
+            },
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "peerDependencies": {
+                "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+            }
+        },
+        "node_modules/@eslint-community/regexpp": {
+            "version": "4.5.0",
+            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz",
+            "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==",
+            "engines": {
+                "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+            }
+        },
+        "node_modules/@eslint/eslintrc": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz",
+            "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==",
+            "dependencies": {
                 "ajv": "^6.12.4",
                 "debug": "^4.3.2",
-                "espree": "^9.3.2",
-                "globals": "^13.15.0",
+                "espree": "^9.5.1",
+                "globals": "^13.19.0",
                 "ignore": "^5.2.0",
                 "import-fresh": "^3.2.1",
                 "js-yaml": "^4.1.0",
@@ -42,33 +462,188 @@
             },
             "engines": {
                 "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
+            }
+        },
+        "node_modules/@eslint/js": {
+            "version": "8.37.0",
+            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.37.0.tgz",
+            "integrity": "sha512-x5vzdtOOGgFVDCUs81QRB2+liax8rFg3+7hqM+QhBG0/G3F1ZsoYl97UrqgHgQ9KKT7G6c4V+aTUCgu/n22v1A==",
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
             }
         },
         "node_modules/@humanwhocodes/config-array": {
-            "version": "0.9.5",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
-            "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==",
-            "dev": true,
+            "version": "0.11.8",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
+            "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
             "dependencies": {
                 "@humanwhocodes/object-schema": "^1.2.1",
                 "debug": "^4.1.1",
-                "minimatch": "^3.0.4"
+                "minimatch": "^3.0.5"
             },
             "engines": {
                 "node": ">=10.10.0"
             }
         },
+        "node_modules/@humanwhocodes/module-importer": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+            "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+            "engines": {
+                "node": ">=12.22"
+            },
+            "funding": {
+                "type": "github",
+                "url": "https://github.com/sponsors/nzakas"
+            }
+        },
         "node_modules/@humanwhocodes/object-schema": {
             "version": "1.2.1",
             "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
-            "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+            "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
+        },
+        "node_modules/@istanbuljs/load-nyc-config": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+            "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+            "dev": true,
+            "dependencies": {
+                "camelcase": "^5.3.1",
+                "find-up": "^4.1.0",
+                "get-package-type": "^0.1.0",
+                "js-yaml": "^3.13.1",
+                "resolve-from": "^5.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
+            "version": "1.0.10",
+            "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+            "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+            "dev": true,
+            "dependencies": {
+                "sprintf-js": "~1.0.2"
+            }
+        },
+        "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
+            "version": "3.14.1",
+            "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+            "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+            "dev": true,
+            "dependencies": {
+                "argparse": "^1.0.7",
+                "esprima": "^4.0.0"
+            },
+            "bin": {
+                "js-yaml": "bin/js-yaml.js"
+            }
+        },
+        "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+            "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/@istanbuljs/schema": {
+            "version": "0.1.3",
+            "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+            "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/@jridgewell/gen-mapping": {
+            "version": "0.1.1",
+            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
+            "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/set-array": "^1.0.0",
+                "@jridgewell/sourcemap-codec": "^1.4.10"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@jridgewell/resolve-uri": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
+            "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@jridgewell/set-array": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+            "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@jridgewell/sourcemap-codec": {
+            "version": "1.4.14",
+            "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
+            "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
             "dev": true
         },
-        "node_modules/acorn": {
-            "version": "8.7.1",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
-            "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==",
+        "node_modules/@jridgewell/trace-mapping": {
+            "version": "0.3.17",
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz",
+            "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==",
             "dev": true,
+            "dependencies": {
+                "@jridgewell/resolve-uri": "3.1.0",
+                "@jridgewell/sourcemap-codec": "1.4.14"
+            }
+        },
+        "node_modules/@nodelib/fs.scandir": {
+            "version": "2.1.5",
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+            "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+            "dependencies": {
+                "@nodelib/fs.stat": "2.0.5",
+                "run-parallel": "^1.1.9"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/@nodelib/fs.stat": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+            "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/@nodelib/fs.walk": {
+            "version": "1.2.8",
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+            "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+            "dependencies": {
+                "@nodelib/fs.scandir": "2.1.5",
+                "fastq": "^1.6.0"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/acorn": {
+            "version": "8.8.2",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
+            "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
             "bin": {
                 "acorn": "bin/acorn"
             },
@@ -80,16 +655,27 @@
             "version": "5.3.2",
             "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
             "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
-            "dev": true,
             "peerDependencies": {
                 "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
             }
         },
+        "node_modules/aggregate-error": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+            "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+            "dev": true,
+            "dependencies": {
+                "clean-stack": "^2.0.0",
+                "indent-string": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/ajv": {
             "version": "6.12.6",
             "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
             "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
-            "dev": true,
             "dependencies": {
                 "fast-deep-equal": "^3.1.1",
                 "fast-json-stable-stringify": "^2.0.0",
@@ -105,7 +691,6 @@
             "version": "5.0.1",
             "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
             "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             }
@@ -114,7 +699,6 @@
             "version": "4.3.0",
             "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
             "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-            "dev": true,
             "dependencies": {
                 "color-convert": "^2.0.1"
             },
@@ -138,46 +722,42 @@
                 "node": ">= 8"
             }
         },
-        "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==",
+        "node_modules/append-transform": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz",
+            "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3",
-                "es-abstract": "^1.19.0",
-                "is-string": "^1.0.7"
+                "default-require-extensions": "^3.0.0"
             },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=8"
             }
         },
-        "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==",
+        "node_modules/archy": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
+            "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==",
+            "dev": true
+        },
+        "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/async-hook-domain": {
+            "version": "2.0.4",
+            "resolved": "https://registry.npmjs.org/async-hook-domain/-/async-hook-domain-2.0.4.tgz",
+            "integrity": "sha512-14LjCmlK1PK8eDtTezR6WX8TMaYNIzBIsd2D1sGoGjgx0BuNMMoSdk7i/drlbtamy0AWv9yv2tkB+ASdmeqFIw==",
             "dev": true,
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=10"
             }
         },
         "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
+            "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
         },
         "node_modules/binary-extensions": {
             "version": "2.2.0",
@@ -188,11 +768,19 @@
                 "node": ">=8"
             }
         },
+        "node_modules/bind-obj-methods": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/bind-obj-methods/-/bind-obj-methods-3.0.0.tgz",
+            "integrity": "sha512-nLEaaz3/sEzNSyPWRsN9HNsqwk1AUyECtGj+XwGdIi3xABnEqecvXtIJ0wehQXuuER5uZ/5fTs2usONgYjG+iw==",
+            "dev": true,
+            "engines": {
+                "node": ">=10"
+            }
+        },
         "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"
@@ -210,33 +798,92 @@
                 "node": ">=8"
             }
         },
-        "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==",
+        "node_modules/browserslist": {
+            "version": "4.21.5",
+            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz",
+            "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==",
             "dev": true,
+            "funding": [
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/browserslist"
+                },
+                {
+                    "type": "tidelift",
+                    "url": "https://tidelift.com/funding/github/npm/browserslist"
+                }
+            ],
             "dependencies": {
-                "function-bind": "^1.1.1",
-                "get-intrinsic": "^1.0.2"
+                "caniuse-lite": "^1.0.30001449",
+                "electron-to-chromium": "^1.4.284",
+                "node-releases": "^2.0.8",
+                "update-browserslist-db": "^1.0.10"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "bin": {
+                "browserslist": "cli.js"
+            },
+            "engines": {
+                "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+            }
+        },
+        "node_modules/buffer-from": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+            "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+            "dev": true
+        },
+        "node_modules/caching-transform": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz",
+            "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==",
+            "dev": true,
+            "dependencies": {
+                "hasha": "^5.0.0",
+                "make-dir": "^3.0.0",
+                "package-hash": "^4.0.0",
+                "write-file-atomic": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=8"
             }
         },
         "node_modules/callsites": {
             "version": "3.1.0",
             "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
             "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/camelcase": {
+            "version": "5.3.1",
+            "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+            "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
             "dev": true,
             "engines": {
                 "node": ">=6"
             }
         },
+        "node_modules/caniuse-lite": {
+            "version": "1.0.30001469",
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001469.tgz",
+            "integrity": "sha512-Rcp7221ScNqQPP3W+lVOYDyjdR6dC+neEQCttoNr5bAyz54AboB4iwpnWgyi8P4YUsPybVzT4LgWiBbI3drL4g==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/browserslist"
+                },
+                {
+                    "type": "tidelift",
+                    "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+                }
+            ]
+        },
         "node_modules/chalk": {
             "version": "4.1.2",
             "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
             "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-            "dev": true,
             "dependencies": {
                 "ansi-styles": "^4.1.0",
                 "supports-color": "^7.1.0"
@@ -292,11 +939,30 @@
             "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz",
             "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A=="
         },
+        "node_modules/clean-stack": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+            "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+            "dev": true,
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/cliui": {
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+            "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+            "dev": true,
+            "dependencies": {
+                "string-width": "^4.2.0",
+                "strip-ansi": "^6.0.0",
+                "wrap-ansi": "^7.0.0"
+            }
+        },
         "node_modules/color-convert": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
             "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-            "dev": true,
             "dependencies": {
                 "color-name": "~1.1.4"
             },
@@ -307,25 +973,43 @@
         "node_modules/color-name": {
             "version": "1.1.4",
             "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
+            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+        },
+        "node_modules/color-support": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+            "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+            "dev": true,
+            "bin": {
+                "color-support": "bin.js"
+            }
         },
         "node_modules/command-exists": {
             "version": "1.2.9",
             "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
             "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="
         },
+        "node_modules/commondir": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+            "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+            "dev": true
+        },
         "node_modules/concat-map": {
             "version": "0.0.1",
             "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
-            "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+            "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+        },
+        "node_modules/convert-source-map": {
+            "version": "1.9.0",
+            "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+            "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
             "dev": true
         },
         "node_modules/cross-spawn": {
             "version": "7.0.3",
             "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
             "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
-            "dev": true,
             "dependencies": {
                 "path-key": "^3.1.0",
                 "shebang-command": "^2.0.0",
@@ -339,7 +1023,6 @@
             "version": "4.3.4",
             "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
             "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
-            "dev": true,
             "dependencies": {
                 "ms": "2.1.2"
             },
@@ -352,61 +1035,48 @@
                 }
             }
         },
-        "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/decamelize": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+            "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
             }
         },
         "node_modules/deep-is": {
             "version": "0.1.4",
             "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
-            "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
-            "dev": true
+            "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
         },
-        "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==",
+        "node_modules/default-require-extensions": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz",
+            "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==",
             "dev": true,
             "dependencies": {
-                "object-keys": "^1.0.12"
+                "strip-bom": "^4.0.0"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "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/diff": {
+            "version": "4.0.2",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+            "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.3.1"
+            }
         },
         "node_modules/doctrine": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
             "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
-            "dev": true,
             "dependencies": {
                 "esutils": "^2.0.2"
             },
@@ -414,93 +1084,37 @@
                 "node": ">=6.0.0"
             }
         },
-        "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/electron-to-chromium": {
+            "version": "1.4.340",
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.340.tgz",
+            "integrity": "sha512-zx8hqumOqltKsv/MF50yvdAlPF9S/4PXbyfzJS6ZGhbddGkRegdwImmfSVqCkEziYzrIGZ/TlrzBND4FysfkDg==",
+            "dev": true
         },
-        "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/emoji-regex": {
+            "version": "8.0.0",
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+            "dev": true
         },
-        "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==",
+        "node_modules/es6-error": {
+            "version": "4.1.1",
+            "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
+            "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+            "dev": true
+        },
+        "node_modules/escalade": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
             "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": ">=6"
             }
         },
         "node_modules/escape-string-regexp": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
             "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
-            "dev": true,
             "engines": {
                 "node": ">=10"
             },
@@ -509,13 +1123,17 @@
             }
         },
         "node_modules/eslint": {
-            "version": "8.18.0",
-            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.18.0.tgz",
-            "integrity": "sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA==",
-            "dev": true,
+            "version": "8.37.0",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.37.0.tgz",
+            "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==",
             "dependencies": {
-                "@eslint/eslintrc": "^1.3.0",
-                "@humanwhocodes/config-array": "^0.9.2",
+                "@eslint-community/eslint-utils": "^4.2.0",
+                "@eslint-community/regexpp": "^4.4.0",
+                "@eslint/eslintrc": "^2.0.2",
+                "@eslint/js": "8.37.0",
+                "@humanwhocodes/config-array": "^0.11.8",
+                "@humanwhocodes/module-importer": "^1.0.1",
+                "@nodelib/fs.walk": "^1.2.8",
                 "ajv": "^6.10.0",
                 "chalk": "^4.0.0",
                 "cross-spawn": "^7.0.2",
@@ -523,20 +1141,22 @@
                 "doctrine": "^3.0.0",
                 "escape-string-regexp": "^4.0.0",
                 "eslint-scope": "^7.1.1",
-                "eslint-utils": "^3.0.0",
-                "eslint-visitor-keys": "^3.3.0",
-                "espree": "^9.3.2",
-                "esquery": "^1.4.0",
+                "eslint-visitor-keys": "^3.4.0",
+                "espree": "^9.5.1",
+                "esquery": "^1.4.2",
                 "esutils": "^2.0.2",
                 "fast-deep-equal": "^3.1.3",
                 "file-entry-cache": "^6.0.1",
-                "functional-red-black-tree": "^1.0.1",
-                "glob-parent": "^6.0.1",
-                "globals": "^13.15.0",
+                "find-up": "^5.0.0",
+                "glob-parent": "^6.0.2",
+                "globals": "^13.19.0",
+                "grapheme-splitter": "^1.0.4",
                 "ignore": "^5.2.0",
                 "import-fresh": "^3.0.0",
                 "imurmurhash": "^0.1.4",
                 "is-glob": "^4.0.0",
+                "is-path-inside": "^3.0.3",
+                "js-sdsl": "^4.1.4",
                 "js-yaml": "^4.1.0",
                 "json-stable-stringify-without-jsonify": "^1.0.1",
                 "levn": "^0.4.1",
@@ -544,11 +1164,9 @@
                 "minimatch": "^3.1.2",
                 "natural-compare": "^1.4.0",
                 "optionator": "^0.9.1",
-                "regexpp": "^3.2.0",
                 "strip-ansi": "^6.0.1",
                 "strip-json-comments": "^3.1.0",
-                "text-table": "^0.2.0",
-                "v8-compile-cache": "^2.0.3"
+                "text-table": "^0.2.0"
             },
             "bin": {
                 "eslint": "bin/eslint.js"
@@ -564,7 +1182,6 @@
             "version": "7.1.1",
             "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
             "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
-            "dev": true,
             "dependencies": {
                 "esrecurse": "^4.3.0",
                 "estraverse": "^5.2.0"
@@ -573,61 +1190,107 @@
                 "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
             }
         },
-        "node_modules/eslint-utils": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
-            "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
-            "dev": true,
+        "node_modules/eslint-visitor-keys": {
+            "version": "3.4.0",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz",
+            "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==",
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
+            }
+        },
+        "node_modules/eslint/node_modules/find-up": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+            "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
             "dependencies": {
-                "eslint-visitor-keys": "^2.0.0"
+                "locate-path": "^6.0.0",
+                "path-exists": "^4.0.0"
             },
             "engines": {
-                "node": "^10.0.0 || ^12.0.0 || >= 14.0.0"
+                "node": ">=10"
             },
             "funding": {
-                "url": "https://github.com/sponsors/mysticatea"
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/eslint/node_modules/locate-path": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+            "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+            "dependencies": {
+                "p-locate": "^5.0.0"
             },
-            "peerDependencies": {
-                "eslint": ">=5"
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/eslint-utils/node_modules/eslint-visitor-keys": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
-            "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
-            "dev": true,
+        "node_modules/eslint/node_modules/p-limit": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+            "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+            "dependencies": {
+                "yocto-queue": "^0.1.0"
+            },
             "engines": {
                 "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/eslint-visitor-keys": {
-            "version": "3.3.0",
-            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
-            "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
-            "dev": true,
+        "node_modules/eslint/node_modules/p-locate": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+            "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+            "dependencies": {
+                "p-limit": "^3.0.2"
+            },
             "engines": {
-                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
         "node_modules/espree": {
-            "version": "9.3.2",
-            "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.2.tgz",
-            "integrity": "sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==",
-            "dev": true,
+            "version": "9.5.1",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
+            "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==",
             "dependencies": {
-                "acorn": "^8.7.1",
+                "acorn": "^8.8.0",
                 "acorn-jsx": "^5.3.2",
-                "eslint-visitor-keys": "^3.3.0"
+                "eslint-visitor-keys": "^3.4.0"
             },
             "engines": {
                 "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
             }
         },
-        "node_modules/esquery": {
-            "version": "1.4.0",
-            "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
-            "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+        "node_modules/esprima": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+            "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
             "dev": true,
+            "bin": {
+                "esparse": "bin/esparse.js",
+                "esvalidate": "bin/esvalidate.js"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/esquery": {
+            "version": "1.5.0",
+            "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
+            "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
             "dependencies": {
                 "estraverse": "^5.1.0"
             },
@@ -639,7 +1302,6 @@
             "version": "4.3.0",
             "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
             "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
-            "dev": true,
             "dependencies": {
                 "estraverse": "^5.2.0"
             },
@@ -651,7 +1313,6 @@
             "version": "5.3.0",
             "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
             "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
-            "dev": true,
             "engines": {
                 "node": ">=4.0"
             }
@@ -660,34 +1321,43 @@
             "version": "2.0.3",
             "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
             "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
-            "dev": true,
             "engines": {
                 "node": ">=0.10.0"
             }
         },
+        "node_modules/events-to-array": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/events-to-array/-/events-to-array-1.1.2.tgz",
+            "integrity": "sha512-inRWzRY7nG+aXZxBzEqYKB3HPgwflZRopAjDCHv0whhRx+MTUr1ei0ICZUypdyE0HRm4L2d5VEcIqLD6yl+BFA==",
+            "dev": true
+        },
         "node_modules/fast-deep-equal": {
             "version": "3.1.3",
             "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
-            "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-            "dev": true
+            "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
         },
         "node_modules/fast-json-stable-stringify": {
             "version": "2.1.0",
             "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
-            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
-            "dev": true
+            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
         },
         "node_modules/fast-levenshtein": {
             "version": "2.0.6",
             "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
-            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
-            "dev": true
+            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
+        },
+        "node_modules/fastq": {
+            "version": "1.15.0",
+            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
+            "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
+            "dependencies": {
+                "reusify": "^1.0.4"
+            }
         },
         "node_modules/file-entry-cache": {
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
             "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
-            "dev": true,
             "dependencies": {
                 "flat-cache": "^3.0.4"
             },
@@ -707,11 +1377,46 @@
                 "node": ">=8"
             }
         },
+        "node_modules/find-cache-dir": {
+            "version": "3.3.2",
+            "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+            "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+            "dev": true,
+            "dependencies": {
+                "commondir": "^1.0.1",
+                "make-dir": "^3.0.2",
+                "pkg-dir": "^4.1.0"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
+            }
+        },
+        "node_modules/find-up": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+            "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+            "dev": true,
+            "dependencies": {
+                "locate-path": "^5.0.0",
+                "path-exists": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/findit": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/findit/-/findit-2.0.0.tgz",
+            "integrity": "sha512-ENZS237/Hr8bjczn5eKuBohLgaD0JyUd0arxretR1f9RO46vZHA1b2y0VorgGV3WaOT3c+78P8h7v4JGJ1i/rg==",
+            "dev": true
+        },
         "node_modules/flat-cache": {
             "version": "3.0.4",
             "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
             "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
-            "dev": true,
             "dependencies": {
                 "flatted": "^3.1.0",
                 "rimraf": "^3.0.2"
@@ -723,29 +1428,51 @@
         "node_modules/flatted": {
             "version": "3.2.5",
             "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
-            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
-            "dev": true
+            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
         },
-        "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==",
+        "node_modules/foreground-child": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
+            "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==",
             "dev": true,
             "dependencies": {
-                "is-callable": "^1.1.3"
+                "cross-spawn": "^7.0.0",
+                "signal-exit": "^3.0.2"
+            },
+            "engines": {
+                "node": ">=8.0.0"
             }
         },
-        "node_modules/foreach": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz",
-            "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=",
+        "node_modules/fromentries": {
+            "version": "1.3.2",
+            "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz",
+            "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/feross"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/feross"
+                },
+                {
+                    "type": "consulting",
+                    "url": "https://feross.org/support"
+                }
+            ]
+        },
+        "node_modules/fs-exists-cached": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/fs-exists-cached/-/fs-exists-cached-1.0.0.tgz",
+            "integrity": "sha512-kSxoARUDn4F2RPXX48UXnaFKwVU7Ivd/6qpzZL29MCDmr9sTvybv4gFCp+qaI4fM9m0z9fgz/yJvi56GAz+BZg==",
             "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
+            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
         },
         "node_modules/fsevents": {
             "version": "2.3.2",
@@ -761,30 +1488,28 @@
                 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
             }
         },
-        "node_modules/function-bind": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
-            "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+        "node_modules/function-loop": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/function-loop/-/function-loop-2.0.1.tgz",
+            "integrity": "sha512-ktIR+O6i/4h+j/ZhZJNdzeI4i9lEPeEK6UPR2EVyTVBqOwcU3Za9xYKLH64ZR9HmcROyRrOkizNyjjtWJzDDkQ==",
             "dev": true
         },
-        "node_modules/functional-red-black-tree": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
-            "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
-            "dev": true
+        "node_modules/gensync": {
+            "version": "1.0.0-beta.2",
+            "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+            "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
         },
-        "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==",
+        "node_modules/get-caller-file": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+            "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
             "dev": true,
-            "dependencies": {
-                "function-bind": "^1.1.1",
-                "has": "^1.0.3",
-                "has-symbols": "^1.0.1"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": "6.* || 8.* || >= 10.*"
             }
         },
         "node_modules/get-package-type": {
@@ -796,32 +1521,15 @@
                 "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,
+            "version": "7.2.3",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+            "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
             "dependencies": {
                 "fs.realpath": "^1.0.0",
                 "inflight": "^1.0.4",
                 "inherits": "2",
-                "minimatch": "^3.0.4",
+                "minimatch": "^3.1.1",
                 "once": "^1.3.0",
                 "path-is-absolute": "^1.0.0"
             },
@@ -836,7 +1544,6 @@
             "version": "6.0.2",
             "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
             "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
-            "dev": true,
             "dependencies": {
                 "is-glob": "^4.0.3"
             },
@@ -845,10 +1552,9 @@
             }
         },
         "node_modules/globals": {
-            "version": "13.15.0",
-            "resolved": "https://registry.npmjs.org/globals/-/globals-13.15.0.tgz",
-            "integrity": "sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==",
-            "dev": true,
+            "version": "13.20.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
+            "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
             "dependencies": {
                 "type-fest": "^0.20.2"
             },
@@ -859,74 +1565,48 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "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/graceful-fs": {
+            "version": "4.2.11",
+            "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+            "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+            "dev": true
         },
-        "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/grapheme-splitter": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+            "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
         },
         "node_modules/has-flag": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
             "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             }
         },
-        "node_modules/has-symbols": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
-            "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+        "node_modules/hasha": {
+            "version": "5.2.2",
+            "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz",
+            "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==",
             "dev": true,
+            "dependencies": {
+                "is-stream": "^2.0.0",
+                "type-fest": "^0.8.0"
+            },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=8"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "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==",
+        "node_modules/hasha/node_modules/type-fest": {
+            "version": "0.8.1",
+            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+            "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
             "dev": true,
-            "dependencies": {
-                "has-symbols": "^1.0.2"
-            },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=8"
             }
         },
         "node_modules/he": {
@@ -937,11 +1617,16 @@
                 "he": "bin/he"
             }
         },
+        "node_modules/html-escaper": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+            "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+            "dev": true
+        },
         "node_modules/ignore": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
-            "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
-            "dev": true,
+            "version": "5.2.4",
+            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
+            "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
             "engines": {
                 "node": ">= 4"
             }
@@ -950,7 +1635,6 @@
             "version": "3.3.0",
             "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
             "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
-            "dev": true,
             "dependencies": {
                 "parent-module": "^1.0.0",
                 "resolve-from": "^4.0.0"
@@ -966,16 +1650,23 @@
             "version": "0.1.4",
             "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
             "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
-            "dev": true,
             "engines": {
                 "node": ">=0.8.19"
             }
         },
+        "node_modules/indent-string": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+            "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "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"
@@ -984,50 +1675,7 @@
         "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"
-            }
+            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
         },
         "node_modules/is-binary-path": {
             "version": "2.1.0",
@@ -1041,75 +1689,27 @@
                 "node": ">=8"
             }
         },
-        "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-extglob": {
             "version": "2.1.1",
             "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
             "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
-            "dev": true,
             "engines": {
                 "node": ">=0.10.0"
             }
         },
+        "node_modules/is-fullwidth-code-point": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/is-glob": {
             "version": "4.0.3",
             "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
             "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
-            "dev": true,
             "dependencies": {
                 "is-extglob": "^2.1.1"
             },
@@ -1117,27 +1717,6 @@
                 "node": ">=0.10.0"
             }
         },
-        "node_modules/is-map": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
-            "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": {
             "version": "7.0.0",
             "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -1147,148 +1726,165 @@
                 "node": ">=0.12.0"
             }
         },
-        "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"
-            },
+        "node_modules/is-path-inside": {
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+            "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=8"
             }
         },
-        "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==",
+        "node_modules/is-stream": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+            "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
             "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
-            },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=8"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "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==",
+        "node_modules/is-typedarray": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+            "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
+            "dev": true
+        },
+        "node_modules/is-windows": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+            "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
             "dev": true,
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">=0.10.0"
             }
         },
-        "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==",
+        "node_modules/isexe": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
+        },
+        "node_modules/istanbul-lib-coverage": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
+            "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
             "dev": true,
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">=8"
             }
         },
-        "node_modules/is-string": {
-            "version": "1.0.7",
-            "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
-            "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+        "node_modules/istanbul-lib-hook": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz",
+            "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==",
             "dev": true,
             "dependencies": {
-                "has-tostringtag": "^1.0.0"
+                "append-transform": "^2.0.0"
             },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=8"
             }
         },
-        "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==",
+        "node_modules/istanbul-lib-instrument": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz",
+            "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==",
             "dev": true,
             "dependencies": {
-                "has-symbols": "^1.0.2"
+                "@babel/core": "^7.7.5",
+                "@istanbuljs/schema": "^0.1.2",
+                "istanbul-lib-coverage": "^3.0.0",
+                "semver": "^6.3.0"
             },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=8"
             }
         },
-        "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==",
+        "node_modules/istanbul-lib-processinfo": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz",
+            "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==",
             "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"
+                "archy": "^1.0.0",
+                "cross-spawn": "^7.0.3",
+                "istanbul-lib-coverage": "^3.2.0",
+                "p-map": "^3.0.0",
+                "rimraf": "^3.0.0",
+                "uuid": "^8.3.2"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=8"
+            }
+        },
+        "node_modules/istanbul-lib-report": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+            "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==",
+            "dev": true,
+            "dependencies": {
+                "istanbul-lib-coverage": "^3.0.0",
+                "make-dir": "^3.0.0",
+                "supports-color": "^7.1.0"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">=8"
             }
         },
-        "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==",
+        "node_modules/istanbul-lib-source-maps": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
+            "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
             "dev": true,
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "dependencies": {
+                "debug": "^4.1.1",
+                "istanbul-lib-coverage": "^3.0.0",
+                "source-map": "^0.6.1"
+            },
+            "engines": {
+                "node": ">=10"
             }
         },
-        "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==",
+        "node_modules/istanbul-reports": {
+            "version": "3.1.5",
+            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz",
+            "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2"
+                "html-escaper": "^2.0.0",
+                "istanbul-lib-report": "^3.0.0"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">=8"
             }
         },
-        "node_modules/is-weakset": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz",
-            "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==",
+        "node_modules/jackspeak": {
+            "version": "1.4.2",
+            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-1.4.2.tgz",
+            "integrity": "sha512-GHeGTmnuaHnvS+ZctRB01bfxARuu9wW83ENbuiweu07SFcVlZrJpcshSre/keGT7YGBhLHg/+rXCNSrsEHKU4Q==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
+                "cliui": "^7.0.4"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">=8"
             }
         },
-        "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-sdsl": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
+            "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==",
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/js-sdsl"
+            }
         },
-        "node_modules/isexe": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+        "node_modules/js-tokens": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+            "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
             "dev": true
         },
         "node_modules/js-yaml": {
@@ -1302,23 +1898,44 @@
                 "js-yaml": "bin/js-yaml.js"
             }
         },
+        "node_modules/jsesc": {
+            "version": "2.5.2",
+            "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+            "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+            "dev": true,
+            "bin": {
+                "jsesc": "bin/jsesc"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
         "node_modules/json-schema-traverse": {
             "version": "0.4.1",
             "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
-            "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
-            "dev": true
+            "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
         },
         "node_modules/json-stable-stringify-without-jsonify": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
-            "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
-            "dev": true
+            "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="
+        },
+        "node_modules/json5": {
+            "version": "2.2.3",
+            "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+            "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+            "dev": true,
+            "bin": {
+                "json5": "lib/cli.js"
+            },
+            "engines": {
+                "node": ">=6"
+            }
         },
         "node_modules/levn": {
             "version": "0.4.1",
             "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
             "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
-            "dev": true,
             "dependencies": {
                 "prelude-ls": "^1.2.1",
                 "type-check": "~0.4.0"
@@ -1327,17 +1944,113 @@
                 "node": ">= 0.8.0"
             }
         },
+        "node_modules/libtap": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/libtap/-/libtap-1.4.0.tgz",
+            "integrity": "sha512-STLFynswQ2A6W14JkabgGetBNk6INL1REgJ9UeNKw5llXroC2cGLgKTqavv0sl8OLVztLLipVKMcQ7yeUcqpmg==",
+            "dev": true,
+            "dependencies": {
+                "async-hook-domain": "^2.0.4",
+                "bind-obj-methods": "^3.0.0",
+                "diff": "^4.0.2",
+                "function-loop": "^2.0.1",
+                "minipass": "^3.1.5",
+                "own-or": "^1.0.0",
+                "own-or-env": "^1.0.2",
+                "signal-exit": "^3.0.4",
+                "stack-utils": "^2.0.4",
+                "tap-parser": "^11.0.0",
+                "tap-yaml": "^1.0.0",
+                "tcompare": "^5.0.6",
+                "trivial-deferred": "^1.0.1"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/libtap/node_modules/tcompare": {
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz",
+            "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^4.0.2"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/locate-path": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+            "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+            "dev": true,
+            "dependencies": {
+                "p-locate": "^4.1.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/lodash.flattendeep": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
+            "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==",
+            "dev": true
+        },
         "node_modules/lodash.merge": {
             "version": "4.6.2",
             "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
-            "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+            "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
+        },
+        "node_modules/lru-cache": {
+            "version": "5.1.1",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+            "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+            "dev": true,
+            "dependencies": {
+                "yallist": "^3.0.2"
+            }
+        },
+        "node_modules/lru-cache/node_modules/yallist": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+            "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
             "dev": true
         },
+        "node_modules/make-dir": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+            "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+            "dev": true,
+            "dependencies": {
+                "semver": "^6.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/marked": {
+            "version": "5.0.2",
+            "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz",
+            "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ==",
+            "bin": {
+                "marked": "bin/marked.js"
+            },
+            "engines": {
+                "node": ">= 18"
+            }
+        },
         "node_modules/minimatch": {
             "version": "3.1.2",
             "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
             "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
-            "dev": true,
             "dependencies": {
                 "brace-expansion": "^1.1.7"
             },
@@ -1345,22 +2058,56 @@
                 "node": "*"
             }
         },
-        "node_modules/minimist": {
-            "version": "1.2.6",
-            "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
-            "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
-            "dev": true
+        "node_modules/minipass": {
+            "version": "3.3.6",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+            "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+            "dev": true,
+            "dependencies": {
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/mkdirp": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+            "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+            "dev": true,
+            "bin": {
+                "mkdirp": "bin/cmd.js"
+            },
+            "engines": {
+                "node": ">=10"
+            }
         },
         "node_modules/ms": {
             "version": "2.1.2",
             "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-            "dev": true
+            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
         },
         "node_modules/natural-compare": {
             "version": "1.4.0",
             "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
-            "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+            "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
+        },
+        "node_modules/node-preload": {
+            "version": "0.2.1",
+            "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz",
+            "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==",
+            "dev": true,
+            "dependencies": {
+                "process-on-spawn": "^1.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/node-releases": {
+            "version": "2.0.10",
+            "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz",
+            "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==",
             "dev": true
         },
         "node_modules/normalize-path": {
@@ -1372,72 +2119,77 @@
                 "node": ">=0.10.0"
             }
         },
-        "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==",
+        "node_modules/nyc": {
+            "version": "15.1.0",
+            "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz",
+            "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "@istanbuljs/load-nyc-config": "^1.0.0",
+                "@istanbuljs/schema": "^0.1.2",
+                "caching-transform": "^4.0.0",
+                "convert-source-map": "^1.7.0",
+                "decamelize": "^1.2.0",
+                "find-cache-dir": "^3.2.0",
+                "find-up": "^4.1.0",
+                "foreground-child": "^2.0.0",
+                "get-package-type": "^0.1.0",
+                "glob": "^7.1.6",
+                "istanbul-lib-coverage": "^3.0.0",
+                "istanbul-lib-hook": "^3.0.0",
+                "istanbul-lib-instrument": "^4.0.0",
+                "istanbul-lib-processinfo": "^2.0.2",
+                "istanbul-lib-report": "^3.0.0",
+                "istanbul-lib-source-maps": "^4.0.0",
+                "istanbul-reports": "^3.0.2",
+                "make-dir": "^3.0.0",
+                "node-preload": "^0.2.1",
+                "p-map": "^3.0.0",
+                "process-on-spawn": "^1.0.0",
+                "resolve-from": "^5.0.0",
+                "rimraf": "^3.0.0",
+                "signal-exit": "^3.0.2",
+                "spawn-wrap": "^2.0.0",
+                "test-exclude": "^6.0.0",
+                "yargs": "^15.0.2"
             },
-            "engines": {
-                "node": ">= 0.4"
+            "bin": {
+                "nyc": "bin/nyc.js"
             },
-            "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": ">=8.9"
             }
         },
-        "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==",
+        "node_modules/nyc/node_modules/resolve-from": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+            "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
             "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": ">=8"
             }
         },
         "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/opener": {
+            "version": "1.5.2",
+            "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
+            "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
+            "dev": true,
+            "bin": {
+                "opener": "bin/opener-bin.js"
+            }
+        },
         "node_modules/optionator": {
             "version": "0.9.1",
             "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
             "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
-            "dev": true,
             "dependencies": {
                 "deep-is": "^0.1.3",
                 "fast-levenshtein": "^2.0.6",
@@ -1450,11 +2202,88 @@
                 "node": ">= 0.8.0"
             }
         },
+        "node_modules/own-or": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/own-or/-/own-or-1.0.0.tgz",
+            "integrity": "sha512-NfZr5+Tdf6MB8UI9GLvKRs4cXY8/yB0w3xtt84xFdWy8hkGjn+JFc60VhzS/hFRfbyxFcGYMTjnF4Me+RbbqrA==",
+            "dev": true
+        },
+        "node_modules/own-or-env": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/own-or-env/-/own-or-env-1.0.2.tgz",
+            "integrity": "sha512-NQ7v0fliWtK7Lkb+WdFqe6ky9XAzYmlkXthQrBbzlYbmFKoAYbDDcwmOm6q8kOuwSRXW8bdL5ORksploUJmWgw==",
+            "dev": true,
+            "dependencies": {
+                "own-or": "^1.0.0"
+            }
+        },
+        "node_modules/p-limit": {
+            "version": "2.3.0",
+            "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+            "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+            "dev": true,
+            "dependencies": {
+                "p-try": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=6"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/p-locate": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+            "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+            "dev": true,
+            "dependencies": {
+                "p-limit": "^2.2.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/p-map": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz",
+            "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==",
+            "dev": true,
+            "dependencies": {
+                "aggregate-error": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/p-try": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+            "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/package-hash": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz",
+            "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==",
+            "dev": true,
+            "dependencies": {
+                "graceful-fs": "^4.1.15",
+                "hasha": "^5.0.0",
+                "lodash.flattendeep": "^4.4.0",
+                "release-zalgo": "^1.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/parent-module": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
             "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
-            "dev": true,
             "dependencies": {
                 "callsites": "^3.0.0"
             },
@@ -1462,11 +2291,18 @@
                 "node": ">=6"
             }
         },
+        "node_modules/path-exists": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+            "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "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"
             }
@@ -1475,15 +2311,14 @@
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
             "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             }
         },
-        "node_modules/path-parse": {
-            "version": "1.0.7",
-            "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
-            "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+        "node_modules/picocolors": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+            "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
             "dev": true
         },
         "node_modules/picomatch": {
@@ -1498,24 +2333,65 @@
                 "url": "https://github.com/sponsors/jonschlinkert"
             }
         },
+        "node_modules/pkg-dir": {
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+            "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+            "dev": true,
+            "dependencies": {
+                "find-up": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/prelude-ls": {
             "version": "1.2.1",
             "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
             "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
-            "dev": true,
             "engines": {
                 "node": ">= 0.8.0"
             }
         },
+        "node_modules/process-on-spawn": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz",
+            "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==",
+            "dev": true,
+            "dependencies": {
+                "fromentries": "^1.2.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/punycode": {
             "version": "2.1.1",
             "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
             "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
-            "dev": true,
             "engines": {
                 "node": ">=6"
             }
         },
+        "node_modules/queue-microtask": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+            "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/feross"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/feross"
+                },
+                {
+                    "type": "consulting",
+                    "url": "https://feross.org/support"
+                }
+            ]
+        },
         "node_modules/readdirp": {
             "version": "3.6.0",
             "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -1528,70 +2404,54 @@
                 "node": ">=8.10.0"
             }
         },
-        "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==",
+        "node_modules/release-zalgo": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz",
+            "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "es6-error": "^4.0.1"
             },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=4"
             }
         },
-        "node_modules/regexpp": {
-            "version": "3.2.0",
-            "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
-            "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+        "node_modules/require-directory": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+            "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
             "dev": true,
             "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/mysticatea"
+                "node": ">=0.10.0"
             }
         },
-        "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/require-main-filename": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+            "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+            "dev": true
         },
         "node_modules/resolve-from": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
             "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
-            "dev": true,
             "engines": {
                 "node": ">=4"
             }
         },
-        "node_modules/resumer": {
-            "version": "0.0.0",
-            "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz",
-            "integrity": "sha1-8ej0YeQGS6Oegq883CqMiT0HZ1k=",
-            "dev": true,
-            "dependencies": {
-                "through": "~2.3.4"
+        "node_modules/reusify": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+            "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+            "engines": {
+                "iojs": ">=1.0.0",
+                "node": ">=0.10.0"
             }
         },
         "node_modules/rimraf": {
             "version": "3.0.2",
             "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
             "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
-            "dev": true,
             "dependencies": {
                 "glob": "^7.1.3"
             },
@@ -1602,11 +2462,47 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
+        "node_modules/run-parallel": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+            "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/feross"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/feross"
+                },
+                {
+                    "type": "consulting",
+                    "url": "https://feross.org/support"
+                }
+            ],
+            "dependencies": {
+                "queue-microtask": "^1.2.2"
+            }
+        },
+        "node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
+        "node_modules/set-blocking": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+            "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+            "dev": true
+        },
         "node_modules/shebang-command": {
             "version": "2.0.0",
             "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
             "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
-            "dev": true,
             "dependencies": {
                 "shebang-regex": "^3.0.0"
             },
@@ -1618,73 +2514,97 @@
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
             "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             }
         },
-        "node_modules/side-channel": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
-            "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+        "node_modules/signal-exit": {
+            "version": "3.0.7",
+            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+            "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+            "dev": true
+        },
+        "node_modules/source-map": {
+            "version": "0.6.1",
+            "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+            "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/source-map-support": {
+            "version": "0.5.21",
+            "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+            "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
             "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"
+                "buffer-from": "^1.0.0",
+                "source-map": "^0.6.0"
             }
         },
-        "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==",
+        "node_modules/spawn-wrap": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz",
+            "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3",
-                "es-abstract": "^1.19.1"
+                "foreground-child": "^2.0.0",
+                "is-windows": "^1.0.2",
+                "make-dir": "^3.0.0",
+                "rimraf": "^3.0.0",
+                "signal-exit": "^3.0.2",
+                "which": "^2.0.1"
             },
             "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "node": ">=8"
             }
         },
-        "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==",
+        "node_modules/sprintf-js": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+            "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+            "dev": true
+        },
+        "node_modules/stack-utils": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
+            "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "escape-string-regexp": "^2.0.0"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">=10"
             }
         },
-        "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==",
+        "node_modules/stack-utils/node_modules/escape-string-regexp": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+            "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/string-width": {
+            "version": "4.2.3",
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+            "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "emoji-regex": "^8.0.0",
+                "is-fullwidth-code-point": "^3.0.0",
+                "strip-ansi": "^6.0.1"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">=8"
             }
         },
         "node_modules/strip-ansi": {
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
             "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-            "dev": true,
             "dependencies": {
                 "ansi-regex": "^5.0.1"
             },
@@ -1692,11 +2612,19 @@
                 "node": ">=8"
             }
         },
+        "node_modules/strip-bom": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
+            "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/strip-json-comments": {
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
             "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             },
@@ -1708,7 +2636,1221 @@
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
             "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+            "dependencies": {
+                "has-flag": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap": {
+            "version": "16.3.4",
+            "resolved": "https://registry.npmjs.org/tap/-/tap-16.3.4.tgz",
+            "integrity": "sha512-SAexdt2ZF4XBgye6TPucFI2y7VE0qeFXlXucJIV1XDPCs+iJodk0MYacr1zR6Ycltzz7PYg8zrblDXKbAZM2LQ==",
+            "bundleDependencies": [
+                "ink",
+                "treport",
+                "@types/react",
+                "@isaacs/import-jsx",
+                "react"
+            ],
+            "dev": true,
+            "dependencies": {
+                "@isaacs/import-jsx": "^4.0.1",
+                "@types/react": "^17.0.52",
+                "chokidar": "^3.3.0",
+                "findit": "^2.0.0",
+                "foreground-child": "^2.0.0",
+                "fs-exists-cached": "^1.0.0",
+                "glob": "^7.2.3",
+                "ink": "^3.2.0",
+                "isexe": "^2.0.0",
+                "istanbul-lib-processinfo": "^2.0.3",
+                "jackspeak": "^1.4.2",
+                "libtap": "^1.4.0",
+                "minipass": "^3.3.4",
+                "mkdirp": "^1.0.4",
+                "nyc": "^15.1.0",
+                "opener": "^1.5.1",
+                "react": "^17.0.2",
+                "rimraf": "^3.0.0",
+                "signal-exit": "^3.0.6",
+                "source-map-support": "^0.5.16",
+                "tap-mocha-reporter": "^5.0.3",
+                "tap-parser": "^11.0.2",
+                "tap-yaml": "^1.0.2",
+                "tcompare": "^5.0.7",
+                "treport": "^3.0.4",
+                "which": "^2.0.2"
+            },
+            "bin": {
+                "tap": "bin/run.js"
+            },
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            },
+            "peerDependencies": {
+                "coveralls": "^3.1.1",
+                "flow-remove-types": ">=2.112.0",
+                "ts-node": ">=8.5.2",
+                "typescript": ">=3.7.2"
+            },
+            "peerDependenciesMeta": {
+                "coveralls": {
+                    "optional": true
+                },
+                "flow-remove-types": {
+                    "optional": true
+                },
+                "ts-node": {
+                    "optional": true
+                },
+                "typescript": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/tap-mocha-reporter": {
+            "version": "5.0.3",
+            "resolved": "https://registry.npmjs.org/tap-mocha-reporter/-/tap-mocha-reporter-5.0.3.tgz",
+            "integrity": "sha512-6zlGkaV4J+XMRFkN0X+yuw6xHbE9jyCZ3WUKfw4KxMyRGOpYSRuuQTRJyWX88WWuLdVTuFbxzwXhXuS2XE6o0g==",
+            "dev": true,
+            "dependencies": {
+                "color-support": "^1.1.0",
+                "debug": "^4.1.1",
+                "diff": "^4.0.1",
+                "escape-string-regexp": "^2.0.0",
+                "glob": "^7.0.5",
+                "tap-parser": "^11.0.0",
+                "tap-yaml": "^1.0.0",
+                "unicode-length": "^2.0.2"
+            },
+            "bin": {
+                "tap-mocha-reporter": "index.js"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/tap-mocha-reporter/node_modules/escape-string-regexp": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+            "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap-parser": {
+            "version": "11.0.2",
+            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-11.0.2.tgz",
+            "integrity": "sha512-6qGlC956rcORw+fg7Fv1iCRAY8/bU9UabUAhs3mXRH6eRmVZcNPLheSXCYaVaYeSwx5xa/1HXZb1537YSvwDZg==",
+            "dev": true,
+            "dependencies": {
+                "events-to-array": "^1.0.1",
+                "minipass": "^3.1.6",
+                "tap-yaml": "^1.0.0"
+            },
+            "bin": {
+                "tap-parser": "bin/cmd.js"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/tap-yaml": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-1.0.2.tgz",
+            "integrity": "sha512-GegASpuqBnRNdT1U+yuUPZ8rEU64pL35WPBpCISWwff4dErS2/438barz7WFJl4Nzh3Y05tfPidZnH+GaV1wMg==",
+            "dev": true,
+            "dependencies": {
+                "yaml": "^1.10.2"
+            }
+        },
+        "node_modules/tap/node_modules/@ampproject/remapping": {
+            "version": "2.1.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "Apache-2.0",
+            "dependencies": {
+                "@jridgewell/trace-mapping": "^0.3.0"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/code-frame": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/highlight": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/compat-data": {
+            "version": "7.17.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/core": {
+            "version": "7.17.8",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@ampproject/remapping": "^2.1.0",
+                "@babel/code-frame": "^7.16.7",
+                "@babel/generator": "^7.17.7",
+                "@babel/helper-compilation-targets": "^7.17.7",
+                "@babel/helper-module-transforms": "^7.17.7",
+                "@babel/helpers": "^7.17.8",
+                "@babel/parser": "^7.17.8",
+                "@babel/template": "^7.16.7",
+                "@babel/traverse": "^7.17.3",
+                "@babel/types": "^7.17.0",
+                "convert-source-map": "^1.7.0",
+                "debug": "^4.1.0",
+                "gensync": "^1.0.0-beta.2",
+                "json5": "^2.1.2",
+                "semver": "^6.3.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/babel"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/generator": {
+            "version": "7.17.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/types": "^7.17.0",
+                "jsesc": "^2.5.1",
+                "source-map": "^0.5.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-annotate-as-pure": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/types": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-compilation-targets": {
+            "version": "7.17.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/compat-data": "^7.17.7",
+                "@babel/helper-validator-option": "^7.16.7",
+                "browserslist": "^4.17.5",
+                "semver": "^6.3.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-environment-visitor": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/types": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-function-name": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-get-function-arity": "^7.16.7",
+                "@babel/template": "^7.16.7",
+                "@babel/types": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-get-function-arity": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/types": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-hoist-variables": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/types": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-module-imports": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/types": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-module-transforms": {
+            "version": "7.17.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-environment-visitor": "^7.16.7",
+                "@babel/helper-module-imports": "^7.16.7",
+                "@babel/helper-simple-access": "^7.17.7",
+                "@babel/helper-split-export-declaration": "^7.16.7",
+                "@babel/helper-validator-identifier": "^7.16.7",
+                "@babel/template": "^7.16.7",
+                "@babel/traverse": "^7.17.3",
+                "@babel/types": "^7.17.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-plugin-utils": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-simple-access": {
+            "version": "7.17.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/types": "^7.17.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-split-export-declaration": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/types": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-validator-identifier": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helper-validator-option": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/helpers": {
+            "version": "7.17.8",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/template": "^7.16.7",
+                "@babel/traverse": "^7.17.3",
+                "@babel/types": "^7.17.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/highlight": {
+            "version": "7.16.10",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-validator-identifier": "^7.16.7",
+                "chalk": "^2.0.0",
+                "js-tokens": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/parser": {
+            "version": "7.17.8",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "bin": {
+                "parser": "bin/babel-parser.js"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/plugin-proposal-object-rest-spread": {
+            "version": "7.17.3",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/compat-data": "^7.17.0",
+                "@babel/helper-compilation-targets": "^7.16.7",
+                "@babel/helper-plugin-utils": "^7.16.7",
+                "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+                "@babel/plugin-transform-parameters": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/plugin-syntax-jsx": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/plugin-syntax-object-rest-spread": {
+            "version": "7.8.3",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/plugin-transform-destructuring": {
+            "version": "7.17.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/plugin-transform-parameters": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/plugin-transform-react-jsx": {
+            "version": "7.17.3",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-annotate-as-pure": "^7.16.7",
+                "@babel/helper-module-imports": "^7.16.7",
+                "@babel/helper-plugin-utils": "^7.16.7",
+                "@babel/plugin-syntax-jsx": "^7.16.7",
+                "@babel/types": "^7.17.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/template": {
+            "version": "7.16.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/code-frame": "^7.16.7",
+                "@babel/parser": "^7.16.7",
+                "@babel/types": "^7.16.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/traverse": {
+            "version": "7.17.3",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/code-frame": "^7.16.7",
+                "@babel/generator": "^7.17.3",
+                "@babel/helper-environment-visitor": "^7.16.7",
+                "@babel/helper-function-name": "^7.16.7",
+                "@babel/helper-hoist-variables": "^7.16.7",
+                "@babel/helper-split-export-declaration": "^7.16.7",
+                "@babel/parser": "^7.17.3",
+                "@babel/types": "^7.17.0",
+                "debug": "^4.1.0",
+                "globals": "^11.1.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@babel/types": {
+            "version": "7.17.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-validator-identifier": "^7.16.7",
+                "to-fast-properties": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/@isaacs/import-jsx": {
+            "version": "4.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/core": "^7.5.5",
+                "@babel/plugin-proposal-object-rest-spread": "^7.5.5",
+                "@babel/plugin-transform-destructuring": "^7.5.0",
+                "@babel/plugin-transform-react-jsx": "^7.3.0",
+                "caller-path": "^3.0.1",
+                "find-cache-dir": "^3.2.0",
+                "make-dir": "^3.0.2",
+                "resolve-from": "^3.0.0",
+                "rimraf": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/tap/node_modules/@jridgewell/resolve-uri": {
+            "version": "3.0.5",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/tap/node_modules/@jridgewell/sourcemap-codec": {
+            "version": "1.4.11",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/@jridgewell/trace-mapping": {
+            "version": "0.3.4",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@jridgewell/resolve-uri": "^3.0.3",
+                "@jridgewell/sourcemap-codec": "^1.4.10"
+            }
+        },
+        "node_modules/tap/node_modules/@types/prop-types": {
+            "version": "15.7.4",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/@types/react": {
+            "version": "17.0.52",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@types/prop-types": "*",
+                "@types/scheduler": "*",
+                "csstype": "^3.0.2"
+            }
+        },
+        "node_modules/tap/node_modules/@types/scheduler": {
+            "version": "0.16.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/@types/yoga-layout": {
+            "version": "1.9.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/ansi-escapes": {
+            "version": "4.3.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "type-fest": "^0.21.3"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/tap/node_modules/ansi-escapes/node_modules/type-fest": {
+            "version": "0.21.3",
+            "dev": true,
+            "inBundle": true,
+            "license": "(MIT OR CC0-1.0)",
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/tap/node_modules/ansi-regex": {
+            "version": "5.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/ansi-styles": {
+            "version": "3.2.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "color-convert": "^1.9.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/tap/node_modules/ansicolors": {
+            "version": "0.3.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/astral-regex": {
+            "version": "2.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/auto-bind": {
+            "version": "4.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/tap/node_modules/balanced-match": {
+            "version": "1.0.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/brace-expansion": {
+            "version": "1.1.11",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "balanced-match": "^1.0.0",
+                "concat-map": "0.0.1"
+            }
+        },
+        "node_modules/tap/node_modules/browserslist": {
+            "version": "4.20.2",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/browserslist"
+                },
+                {
+                    "type": "tidelift",
+                    "url": "https://tidelift.com/funding/github/npm/browserslist"
+                }
+            ],
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "caniuse-lite": "^1.0.30001317",
+                "electron-to-chromium": "^1.4.84",
+                "escalade": "^3.1.1",
+                "node-releases": "^2.0.2",
+                "picocolors": "^1.0.0"
+            },
+            "bin": {
+                "browserslist": "cli.js"
+            },
+            "engines": {
+                "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+            }
+        },
+        "node_modules/tap/node_modules/caller-callsite": {
+            "version": "4.1.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "callsites": "^3.1.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/caller-path": {
+            "version": "3.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "caller-callsite": "^4.1.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/callsites": {
+            "version": "3.1.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/tap/node_modules/caniuse-lite": {
+            "version": "1.0.30001319",
             "dev": true,
+            "funding": [
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/browserslist"
+                },
+                {
+                    "type": "tidelift",
+                    "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+                }
+            ],
+            "inBundle": true,
+            "license": "CC-BY-4.0"
+        },
+        "node_modules/tap/node_modules/cardinal": {
+            "version": "2.1.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "ansicolors": "~0.3.2",
+                "redeyed": "~2.1.0"
+            },
+            "bin": {
+                "cdl": "bin/cdl.js"
+            }
+        },
+        "node_modules/tap/node_modules/chalk": {
+            "version": "2.4.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "ansi-styles": "^3.2.1",
+                "escape-string-regexp": "^1.0.5",
+                "supports-color": "^5.3.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/tap/node_modules/ci-info": {
+            "version": "2.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/cli-boxes": {
+            "version": "2.2.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/tap/node_modules/cli-cursor": {
+            "version": "3.1.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "restore-cursor": "^3.1.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/cli-truncate": {
+            "version": "2.1.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "slice-ansi": "^3.0.0",
+                "string-width": "^4.2.0"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/tap/node_modules/code-excerpt": {
+            "version": "3.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "convert-to-spaces": "^1.0.1"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/tap/node_modules/color-convert": {
+            "version": "1.9.3",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "color-name": "1.1.3"
+            }
+        },
+        "node_modules/tap/node_modules/color-name": {
+            "version": "1.1.3",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/commondir": {
+            "version": "1.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/concat-map": {
+            "version": "0.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/convert-source-map": {
+            "version": "1.8.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "safe-buffer": "~5.1.1"
+            }
+        },
+        "node_modules/tap/node_modules/convert-to-spaces": {
+            "version": "1.0.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">= 4"
+            }
+        },
+        "node_modules/tap/node_modules/csstype": {
+            "version": "3.0.11",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/debug": {
+            "version": "4.3.4",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "ms": "2.1.2"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/tap/node_modules/electron-to-chromium": {
+            "version": "1.4.89",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC"
+        },
+        "node_modules/tap/node_modules/emoji-regex": {
+            "version": "8.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/escalade": {
+            "version": "3.1.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/tap/node_modules/escape-string-regexp": {
+            "version": "1.0.5",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=0.8.0"
+            }
+        },
+        "node_modules/tap/node_modules/esprima": {
+            "version": "4.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "BSD-2-Clause",
+            "bin": {
+                "esparse": "bin/esparse.js",
+                "esvalidate": "bin/esvalidate.js"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/tap/node_modules/events-to-array": {
+            "version": "1.1.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC"
+        },
+        "node_modules/tap/node_modules/find-cache-dir": {
+            "version": "3.3.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "commondir": "^1.0.1",
+                "make-dir": "^3.0.2",
+                "pkg-dir": "^4.1.0"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
+            }
+        },
+        "node_modules/tap/node_modules/find-up": {
+            "version": "4.1.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "locate-path": "^5.0.0",
+                "path-exists": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/fs.realpath": {
+            "version": "1.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC"
+        },
+        "node_modules/tap/node_modules/gensync": {
+            "version": "1.0.0-beta.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/tap/node_modules/glob": {
+            "version": "7.2.3",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC",
+            "dependencies": {
+                "fs.realpath": "^1.0.0",
+                "inflight": "^1.0.4",
+                "inherits": "2",
+                "minimatch": "^3.1.1",
+                "once": "^1.3.0",
+                "path-is-absolute": "^1.0.0"
+            },
+            "engines": {
+                "node": "*"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/tap/node_modules/globals": {
+            "version": "11.12.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/tap/node_modules/has-flag": {
+            "version": "3.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/tap/node_modules/indent-string": {
+            "version": "4.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/inflight": {
+            "version": "1.0.6",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC",
+            "dependencies": {
+                "once": "^1.3.0",
+                "wrappy": "1"
+            }
+        },
+        "node_modules/tap/node_modules/inherits": {
+            "version": "2.0.4",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC"
+        },
+        "node_modules/tap/node_modules/ink": {
+            "version": "3.2.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "ansi-escapes": "^4.2.1",
+                "auto-bind": "4.0.0",
+                "chalk": "^4.1.0",
+                "cli-boxes": "^2.2.0",
+                "cli-cursor": "^3.1.0",
+                "cli-truncate": "^2.1.0",
+                "code-excerpt": "^3.0.0",
+                "indent-string": "^4.0.0",
+                "is-ci": "^2.0.0",
+                "lodash": "^4.17.20",
+                "patch-console": "^1.0.0",
+                "react-devtools-core": "^4.19.1",
+                "react-reconciler": "^0.26.2",
+                "scheduler": "^0.20.2",
+                "signal-exit": "^3.0.2",
+                "slice-ansi": "^3.0.0",
+                "stack-utils": "^2.0.2",
+                "string-width": "^4.2.2",
+                "type-fest": "^0.12.0",
+                "widest-line": "^3.1.0",
+                "wrap-ansi": "^6.2.0",
+                "ws": "^7.5.5",
+                "yoga-layout-prebuilt": "^1.9.6"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "peerDependencies": {
+                "@types/react": ">=16.8.0",
+                "react": ">=16.8.0"
+            },
+            "peerDependenciesMeta": {
+                "@types/react": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/tap/node_modules/ink/node_modules/ansi-styles": {
+            "version": "4.3.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "color-convert": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            }
+        },
+        "node_modules/tap/node_modules/ink/node_modules/chalk": {
+            "version": "4.1.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "ansi-styles": "^4.1.0",
+                "supports-color": "^7.1.0"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/chalk?sponsor=1"
+            }
+        },
+        "node_modules/tap/node_modules/ink/node_modules/color-convert": {
+            "version": "2.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "color-name": "~1.1.4"
+            },
+            "engines": {
+                "node": ">=7.0.0"
+            }
+        },
+        "node_modules/tap/node_modules/ink/node_modules/color-name": {
+            "version": "1.1.4",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/ink/node_modules/has-flag": {
+            "version": "4.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/ink/node_modules/supports-color": {
+            "version": "7.2.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
             "dependencies": {
                 "has-flag": "^4.0.0"
             },
@@ -1716,49 +3858,845 @@
                 "node": ">=8"
             }
         },
-        "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==",
+        "node_modules/tap/node_modules/is-ci": {
+            "version": "2.0.0",
             "dev": true,
+            "inBundle": true,
+            "license": "MIT",
             "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"
+                "ci-info": "^2.0.0"
+            },
+            "bin": {
+                "is-ci": "bin.js"
+            }
+        },
+        "node_modules/tap/node_modules/is-fullwidth-code-point": {
+            "version": "3.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/js-tokens": {
+            "version": "4.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/jsesc": {
+            "version": "2.5.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "bin": {
+                "jsesc": "bin/jsesc"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/tap/node_modules/json5": {
+            "version": "2.2.3",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "bin": {
+                "json5": "lib/cli.js"
+            },
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/tap/node_modules/locate-path": {
+            "version": "5.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "p-locate": "^4.1.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/lodash": {
+            "version": "4.17.21",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/loose-envify": {
+            "version": "1.4.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "js-tokens": "^3.0.0 || ^4.0.0"
             },
             "bin": {
-                "tape": "bin/tape"
+                "loose-envify": "cli.js"
+            }
+        },
+        "node_modules/tap/node_modules/make-dir": {
+            "version": "3.1.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "semver": "^6.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/tap/node_modules/mimic-fn": {
+            "version": "2.1.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/tap/node_modules/minimatch": {
+            "version": "3.1.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC",
+            "dependencies": {
+                "brace-expansion": "^1.1.7"
+            },
+            "engines": {
+                "node": "*"
+            }
+        },
+        "node_modules/tap/node_modules/minipass": {
+            "version": "3.3.4",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC",
+            "dependencies": {
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/ms": {
+            "version": "2.1.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/node-releases": {
+            "version": "2.0.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/object-assign": {
+            "version": "4.1.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/tap/node_modules/once": {
+            "version": "1.4.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC",
+            "dependencies": {
+                "wrappy": "1"
+            }
+        },
+        "node_modules/tap/node_modules/onetime": {
+            "version": "5.1.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "mimic-fn": "^2.1.0"
+            },
+            "engines": {
+                "node": ">=6"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/tap/node_modules/p-limit": {
+            "version": "2.3.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "p-try": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=6"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/tap/node_modules/p-locate": {
+            "version": "4.1.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "p-limit": "^2.2.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/p-try": {
+            "version": "2.2.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/tap/node_modules/patch-console": {
+            "version": "1.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/tap/node_modules/path-exists": {
+            "version": "4.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/path-is-absolute": {
+            "version": "1.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/tap/node_modules/picocolors": {
+            "version": "1.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC"
+        },
+        "node_modules/tap/node_modules/pkg-dir": {
+            "version": "4.2.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "find-up": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/punycode": {
+            "version": "2.1.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/tap/node_modules/react": {
+            "version": "17.0.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "loose-envify": "^1.1.0",
+                "object-assign": "^4.1.1"
+            },
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/tap/node_modules/react-devtools-core": {
+            "version": "4.24.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "shell-quote": "^1.6.1",
+                "ws": "^7"
+            }
+        },
+        "node_modules/tap/node_modules/react-reconciler": {
+            "version": "0.26.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "loose-envify": "^1.1.0",
+                "object-assign": "^4.1.1",
+                "scheduler": "^0.20.2"
+            },
+            "engines": {
+                "node": ">=0.10.0"
+            },
+            "peerDependencies": {
+                "react": "^17.0.2"
+            }
+        },
+        "node_modules/tap/node_modules/redeyed": {
+            "version": "2.1.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "esprima": "~4.0.0"
+            }
+        },
+        "node_modules/tap/node_modules/resolve-from": {
+            "version": "3.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/tap/node_modules/restore-cursor": {
+            "version": "3.1.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "onetime": "^5.1.0",
+                "signal-exit": "^3.0.2"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/rimraf": {
+            "version": "3.0.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC",
+            "dependencies": {
+                "glob": "^7.1.3"
+            },
+            "bin": {
+                "rimraf": "bin.js"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/tap/node_modules/safe-buffer": {
+            "version": "5.1.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/scheduler": {
+            "version": "0.20.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "loose-envify": "^1.1.0",
+                "object-assign": "^4.1.1"
+            }
+        },
+        "node_modules/tap/node_modules/semver": {
+            "version": "6.3.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC",
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
+        "node_modules/tap/node_modules/shell-quote": {
+            "version": "1.7.3",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/signal-exit": {
+            "version": "3.0.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC"
+        },
+        "node_modules/tap/node_modules/slice-ansi": {
+            "version": "3.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "ansi-styles": "^4.0.0",
+                "astral-regex": "^2.0.0",
+                "is-fullwidth-code-point": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/slice-ansi/node_modules/ansi-styles": {
+            "version": "4.3.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "color-convert": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            }
+        },
+        "node_modules/tap/node_modules/slice-ansi/node_modules/color-convert": {
+            "version": "2.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "color-name": "~1.1.4"
+            },
+            "engines": {
+                "node": ">=7.0.0"
+            }
+        },
+        "node_modules/tap/node_modules/slice-ansi/node_modules/color-name": {
+            "version": "1.1.4",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/source-map": {
+            "version": "0.5.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "BSD-3-Clause",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/tap/node_modules/stack-utils": {
+            "version": "2.0.5",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "escape-string-regexp": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/tap/node_modules/stack-utils/node_modules/escape-string-regexp": {
+            "version": "2.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/string-width": {
+            "version": "4.2.3",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "emoji-regex": "^8.0.0",
+                "is-fullwidth-code-point": "^3.0.0",
+                "strip-ansi": "^6.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/strip-ansi": {
+            "version": "6.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "ansi-regex": "^5.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/supports-color": {
+            "version": "5.5.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "has-flag": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/tap/node_modules/tap-parser": {
+            "version": "11.0.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "events-to-array": "^1.0.1",
+                "minipass": "^3.1.6",
+                "tap-yaml": "^1.0.0"
+            },
+            "bin": {
+                "tap-parser": "bin/cmd.js"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/tap/node_modules/tap-yaml": {
+            "version": "1.0.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC",
+            "dependencies": {
+                "yaml": "^1.10.2"
+            }
+        },
+        "node_modules/tap/node_modules/tcompare": {
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz",
+            "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^4.0.2"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/tap/node_modules/to-fast-properties": {
+            "version": "2.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/tap/node_modules/treport": {
+            "version": "3.0.4",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC",
+            "dependencies": {
+                "@isaacs/import-jsx": "^4.0.1",
+                "cardinal": "^2.1.1",
+                "chalk": "^3.0.0",
+                "ink": "^3.2.0",
+                "ms": "^2.1.2",
+                "tap-parser": "^11.0.0",
+                "tap-yaml": "^1.0.0",
+                "unicode-length": "^2.0.2"
+            },
+            "peerDependencies": {
+                "react": "^17.0.2"
+            }
+        },
+        "node_modules/tap/node_modules/treport/node_modules/ansi-styles": {
+            "version": "4.3.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "color-convert": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            }
+        },
+        "node_modules/tap/node_modules/treport/node_modules/chalk": {
+            "version": "3.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "ansi-styles": "^4.1.0",
+                "supports-color": "^7.1.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/treport/node_modules/color-convert": {
+            "version": "2.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "color-name": "~1.1.4"
+            },
+            "engines": {
+                "node": ">=7.0.0"
+            }
+        },
+        "node_modules/tap/node_modules/treport/node_modules/color-name": {
+            "version": "1.1.4",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/treport/node_modules/has-flag": {
+            "version": "4.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/treport/node_modules/supports-color": {
+            "version": "7.2.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "has-flag": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/type-fest": {
+            "version": "0.12.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "(MIT OR CC0-1.0)",
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/tap/node_modules/unicode-length": {
+            "version": "2.0.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "punycode": "^2.0.0",
+                "strip-ansi": "^3.0.1"
+            }
+        },
+        "node_modules/tap/node_modules/unicode-length/node_modules/ansi-regex": {
+            "version": "2.1.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/tap/node_modules/unicode-length/node_modules/strip-ansi": {
+            "version": "3.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "ansi-regex": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/tap/node_modules/widest-line": {
+            "version": "3.1.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "string-width": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/wrap-ansi": {
+            "version": "6.2.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "ansi-styles": "^4.0.0",
+                "string-width": "^4.1.0",
+                "strip-ansi": "^6.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tap/node_modules/wrap-ansi/node_modules/ansi-styles": {
+            "version": "4.3.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "color-convert": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            }
+        },
+        "node_modules/tap/node_modules/wrap-ansi/node_modules/color-convert": {
+            "version": "2.0.1",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "color-name": "~1.1.4"
+            },
+            "engines": {
+                "node": ">=7.0.0"
+            }
+        },
+        "node_modules/tap/node_modules/wrap-ansi/node_modules/color-name": {
+            "version": "1.1.4",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT"
+        },
+        "node_modules/tap/node_modules/wrappy": {
+            "version": "1.0.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC"
+        },
+        "node_modules/tap/node_modules/ws": {
+            "version": "7.5.7",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=8.3.0"
+            },
+            "peerDependencies": {
+                "bufferutil": "^4.0.1",
+                "utf-8-validate": "^5.0.2"
+            },
+            "peerDependenciesMeta": {
+                "bufferutil": {
+                    "optional": true
+                },
+                "utf-8-validate": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/tap/node_modules/yallist": {
+            "version": "4.0.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC"
+        },
+        "node_modules/tap/node_modules/yaml": {
+            "version": "1.10.2",
+            "dev": true,
+            "inBundle": true,
+            "license": "ISC",
+            "engines": {
+                "node": ">= 6"
+            }
+        },
+        "node_modules/tap/node_modules/yoga-layout-prebuilt": {
+            "version": "1.10.0",
+            "dev": true,
+            "inBundle": true,
+            "license": "MIT",
+            "dependencies": {
+                "@types/yoga-layout": "1.9.2"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tcompare": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-6.0.0.tgz",
+            "integrity": "sha512-JeX89lSVkxTzYND0LxzFCGrXm/TqGEQ0heu1JTwplnpaYQNky6hIaO4lQBOrs+/P787i3CoK9T/O3/oEcnJXvA==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^5.1.0"
+            },
+            "engines": {
+                "node": ">=16"
+            }
+        },
+        "node_modules/tcompare/node_modules/diff": {
+            "version": "5.1.0",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
+            "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.3.1"
+            }
+        },
+        "node_modules/test-exclude": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+            "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+            "dev": true,
+            "dependencies": {
+                "@istanbuljs/schema": "^0.1.2",
+                "glob": "^7.1.4",
+                "minimatch": "^3.0.4"
+            },
+            "engines": {
+                "node": ">=8"
             }
         },
         "node_modules/text-table": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
-            "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
-            "dev": true
+            "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="
         },
-        "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/to-fast-properties": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+            "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
         },
         "node_modules/to-regex-range": {
             "version": "5.0.1",
@@ -1772,11 +4710,19 @@
                 "node": ">=8.0"
             }
         },
+        "node_modules/trivial-deferred": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/trivial-deferred/-/trivial-deferred-1.1.2.tgz",
+            "integrity": "sha512-vDPiDBC3hyP6O4JrJYMImW3nl3c03Tsj9fEXc7Qc/XKa1O7gf5ZtFfIR/E0dun9SnDHdwjna1Z2rSzYgqpxh/g==",
+            "dev": true,
+            "engines": {
+                "node": ">= 8"
+            }
+        },
         "node_modules/type-check": {
             "version": "0.4.0",
             "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
             "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
-            "dev": true,
             "dependencies": {
                 "prelude-ls": "^1.2.1"
             },
@@ -1788,7 +4734,6 @@
             "version": "0.20.2",
             "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
             "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
-            "dev": true,
             "engines": {
                 "node": ">=10"
             },
@@ -1796,41 +4741,71 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/unbox-primitive": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
-            "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+        "node_modules/typedarray-to-buffer": {
+            "version": "3.1.5",
+            "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+            "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
             "dev": true,
             "dependencies": {
-                "function-bind": "^1.1.1",
-                "has-bigints": "^1.0.1",
-                "has-symbols": "^1.0.2",
-                "which-boxed-primitive": "^1.0.2"
+                "is-typedarray": "^1.0.0"
+            }
+        },
+        "node_modules/unicode-length": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/unicode-length/-/unicode-length-2.1.0.tgz",
+            "integrity": "sha512-4bV582zTV9Q02RXBxSUMiuN/KHo5w4aTojuKTNT96DIKps/SIawFp7cS5Mu25VuY1AioGXrmYyzKZUzh8OqoUw==",
+            "dev": true,
+            "dependencies": {
+                "punycode": "^2.0.0"
+            }
+        },
+        "node_modules/update-browserslist-db": {
+            "version": "1.0.10",
+            "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz",
+            "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/browserslist"
+                },
+                {
+                    "type": "tidelift",
+                    "url": "https://tidelift.com/funding/github/npm/browserslist"
+                }
+            ],
+            "dependencies": {
+                "escalade": "^3.1.1",
+                "picocolors": "^1.0.0"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "bin": {
+                "browserslist-lint": "cli.js"
+            },
+            "peerDependencies": {
+                "browserslist": ">= 4.21.0"
             }
         },
         "node_modules/uri-js": {
             "version": "4.4.1",
             "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
             "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
-            "dev": true,
             "dependencies": {
                 "punycode": "^2.1.0"
             }
         },
-        "node_modules/v8-compile-cache": {
-            "version": "2.3.0",
-            "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
-            "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
-            "dev": true
+        "node_modules/uuid": {
+            "version": "8.3.2",
+            "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+            "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+            "dev": true,
+            "bin": {
+                "uuid": "dist/bin/uuid"
+            }
         },
         "node_modules/which": {
             "version": "2.0.2",
             "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
             "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
-            "dev": true,
             "dependencies": {
                 "isexe": "^2.0.0"
             },
@@ -1841,83 +4816,476 @@
                 "node": ">= 8"
             }
         },
-        "node_modules/which-boxed-primitive": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
-            "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+        "node_modules/which-module": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
+            "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==",
+            "dev": true
+        },
+        "node_modules/word-wrap": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+            "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/wrap-ansi": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+            "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
             "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"
+                "ansi-styles": "^4.0.0",
+                "string-width": "^4.1.0",
+                "strip-ansi": "^6.0.0"
+            },
+            "engines": {
+                "node": ">=10"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
             }
         },
-        "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==",
+        "node_modules/wrappy": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+        },
+        "node_modules/write-file-atomic": {
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+            "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
             "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"
+                "imurmurhash": "^0.1.4",
+                "is-typedarray": "^1.0.0",
+                "signal-exit": "^3.0.2",
+                "typedarray-to-buffer": "^3.1.5"
+            }
+        },
+        "node_modules/y18n": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+            "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+            "dev": true
+        },
+        "node_modules/yallist": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+            "dev": true
+        },
+        "node_modules/yaml": {
+            "version": "1.10.2",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+            "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 6"
             }
         },
-        "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==",
+        "node_modules/yargs": {
+            "version": "15.4.1",
+            "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+            "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
             "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"
+                "cliui": "^6.0.0",
+                "decamelize": "^1.2.0",
+                "find-up": "^4.1.0",
+                "get-caller-file": "^2.0.1",
+                "require-directory": "^2.1.1",
+                "require-main-filename": "^2.0.0",
+                "set-blocking": "^2.0.0",
+                "string-width": "^4.2.0",
+                "which-module": "^2.0.0",
+                "y18n": "^4.0.0",
+                "yargs-parser": "^18.1.2"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=8"
+            }
+        },
+        "node_modules/yargs-parser": {
+            "version": "18.1.3",
+            "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+            "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+            "dev": true,
+            "dependencies": {
+                "camelcase": "^5.0.0",
+                "decamelize": "^1.2.0"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">=6"
             }
         },
-        "node_modules/word-wrap": {
-            "version": "1.2.3",
-            "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
-            "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+        "node_modules/yargs/node_modules/cliui": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+            "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+            "dev": true,
+            "dependencies": {
+                "string-width": "^4.2.0",
+                "strip-ansi": "^6.0.0",
+                "wrap-ansi": "^6.2.0"
+            }
+        },
+        "node_modules/yargs/node_modules/wrap-ansi": {
+            "version": "6.2.0",
+            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+            "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+            "dev": true,
+            "dependencies": {
+                "ansi-styles": "^4.0.0",
+                "string-width": "^4.1.0",
+                "strip-ansi": "^6.0.0"
+            },
             "engines": {
-                "node": ">=0.10.0"
+                "node": ">=8"
             }
         },
-        "node_modules/wrappy": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
-            "dev": true
+        "node_modules/yocto-queue": {
+            "version": "0.1.0",
+            "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+            "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
         }
     },
     "dependencies": {
-        "@eslint/eslintrc": {
-            "version": "1.3.0",
-            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.0.tgz",
-            "integrity": "sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==",
+        "@ampproject/remapping": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
+            "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==",
+            "dev": true,
+            "requires": {
+                "@jridgewell/gen-mapping": "^0.1.0",
+                "@jridgewell/trace-mapping": "^0.3.9"
+            }
+        },
+        "@babel/code-frame": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
+            "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
+            "dev": true,
+            "requires": {
+                "@babel/highlight": "^7.18.6"
+            }
+        },
+        "@babel/compat-data": {
+            "version": "7.21.0",
+            "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.0.tgz",
+            "integrity": "sha512-gMuZsmsgxk/ENC3O/fRw5QY8A9/uxQbbCEypnLIiYYc/qVJtEV7ouxC3EllIIwNzMqAQee5tanFabWsUOutS7g==",
+            "dev": true
+        },
+        "@babel/core": {
+            "version": "7.21.3",
+            "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.21.3.tgz",
+            "integrity": "sha512-qIJONzoa/qiHghnm0l1n4i/6IIziDpzqc36FBs4pzMhDUraHqponwJLiAKm1hGLP3OSB/TVNz6rMwVGpwxxySw==",
+            "dev": true,
+            "requires": {
+                "@ampproject/remapping": "^2.2.0",
+                "@babel/code-frame": "^7.18.6",
+                "@babel/generator": "^7.21.3",
+                "@babel/helper-compilation-targets": "^7.20.7",
+                "@babel/helper-module-transforms": "^7.21.2",
+                "@babel/helpers": "^7.21.0",
+                "@babel/parser": "^7.21.3",
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.21.3",
+                "@babel/types": "^7.21.3",
+                "convert-source-map": "^1.7.0",
+                "debug": "^4.1.0",
+                "gensync": "^1.0.0-beta.2",
+                "json5": "^2.2.2",
+                "semver": "^6.3.0"
+            }
+        },
+        "@babel/generator": {
+            "version": "7.21.3",
+            "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.3.tgz",
+            "integrity": "sha512-QS3iR1GYC/YGUnW7IdggFeN5c1poPUurnGttOV/bZgPGV+izC/D8HnD6DLwod0fsatNyVn1G3EVWMYIF0nHbeA==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.21.3",
+                "@jridgewell/gen-mapping": "^0.3.2",
+                "@jridgewell/trace-mapping": "^0.3.17",
+                "jsesc": "^2.5.1"
+            },
+            "dependencies": {
+                "@jridgewell/gen-mapping": {
+                    "version": "0.3.2",
+                    "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+                    "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+                    "dev": true,
+                    "requires": {
+                        "@jridgewell/set-array": "^1.0.1",
+                        "@jridgewell/sourcemap-codec": "^1.4.10",
+                        "@jridgewell/trace-mapping": "^0.3.9"
+                    }
+                }
+            }
+        },
+        "@babel/helper-compilation-targets": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz",
+            "integrity": "sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==",
+            "dev": true,
+            "requires": {
+                "@babel/compat-data": "^7.20.5",
+                "@babel/helper-validator-option": "^7.18.6",
+                "browserslist": "^4.21.3",
+                "lru-cache": "^5.1.1",
+                "semver": "^6.3.0"
+            }
+        },
+        "@babel/helper-environment-visitor": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
+            "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
+            "dev": true
+        },
+        "@babel/helper-function-name": {
+            "version": "7.21.0",
+            "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz",
+            "integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==",
+            "dev": true,
+            "requires": {
+                "@babel/template": "^7.20.7",
+                "@babel/types": "^7.21.0"
+            }
+        },
+        "@babel/helper-hoist-variables": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
+            "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.18.6"
+            }
+        },
+        "@babel/helper-module-imports": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+            "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.18.6"
+            }
+        },
+        "@babel/helper-module-transforms": {
+            "version": "7.21.2",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.21.2.tgz",
+            "integrity": "sha512-79yj2AR4U/Oqq/WOV7Lx6hUjau1Zfo4cI+JLAVYeMV5XIlbOhmjEk5ulbTc9fMpmlojzZHkUUxAiK+UKn+hNQQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-module-imports": "^7.18.6",
+                "@babel/helper-simple-access": "^7.20.2",
+                "@babel/helper-split-export-declaration": "^7.18.6",
+                "@babel/helper-validator-identifier": "^7.19.1",
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.21.2",
+                "@babel/types": "^7.21.2"
+            }
+        },
+        "@babel/helper-simple-access": {
+            "version": "7.20.2",
+            "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz",
+            "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.20.2"
+            }
+        },
+        "@babel/helper-split-export-declaration": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
+            "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.18.6"
+            }
+        },
+        "@babel/helper-string-parser": {
+            "version": "7.19.4",
+            "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
+            "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
+            "dev": true
+        },
+        "@babel/helper-validator-identifier": {
+            "version": "7.19.1",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+            "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
+            "dev": true
+        },
+        "@babel/helper-validator-option": {
+            "version": "7.21.0",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
+            "integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==",
+            "dev": true
+        },
+        "@babel/helpers": {
+            "version": "7.21.0",
+            "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.21.0.tgz",
+            "integrity": "sha512-XXve0CBtOW0pd7MRzzmoyuSj0e3SEzj8pgyFxnTT1NJZL38BD1MK7yYrm8yefRPIDvNNe14xR4FdbHwpInD4rA==",
             "dev": true,
             "requires": {
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.21.0",
+                "@babel/types": "^7.21.0"
+            }
+        },
+        "@babel/highlight": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
+            "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-validator-identifier": "^7.18.6",
+                "chalk": "^2.0.0",
+                "js-tokens": "^4.0.0"
+            },
+            "dependencies": {
+                "ansi-styles": {
+                    "version": "3.2.1",
+                    "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+                    "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+                    "dev": true,
+                    "requires": {
+                        "color-convert": "^1.9.0"
+                    }
+                },
+                "chalk": {
+                    "version": "2.4.2",
+                    "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+                    "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+                    "dev": true,
+                    "requires": {
+                        "ansi-styles": "^3.2.1",
+                        "escape-string-regexp": "^1.0.5",
+                        "supports-color": "^5.3.0"
+                    }
+                },
+                "color-convert": {
+                    "version": "1.9.3",
+                    "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+                    "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+                    "dev": true,
+                    "requires": {
+                        "color-name": "1.1.3"
+                    }
+                },
+                "color-name": {
+                    "version": "1.1.3",
+                    "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+                    "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+                    "dev": true
+                },
+                "escape-string-regexp": {
+                    "version": "1.0.5",
+                    "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+                    "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+                    "dev": true
+                },
+                "has-flag": {
+                    "version": "3.0.0",
+                    "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+                    "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+                    "dev": true
+                },
+                "supports-color": {
+                    "version": "5.5.0",
+                    "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+                    "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+                    "dev": true,
+                    "requires": {
+                        "has-flag": "^3.0.0"
+                    }
+                }
+            }
+        },
+        "@babel/parser": {
+            "version": "7.21.3",
+            "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.3.tgz",
+            "integrity": "sha512-lobG0d7aOfQRXh8AyklEAgZGvA4FShxo6xQbUrrT/cNBPUdIDojlokwJsQyCC/eKia7ifqM0yP+2DRZ4WKw2RQ==",
+            "dev": true
+        },
+        "@babel/template": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
+            "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==",
+            "dev": true,
+            "requires": {
+                "@babel/code-frame": "^7.18.6",
+                "@babel/parser": "^7.20.7",
+                "@babel/types": "^7.20.7"
+            }
+        },
+        "@babel/traverse": {
+            "version": "7.21.3",
+            "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.3.tgz",
+            "integrity": "sha512-XLyopNeaTancVitYZe2MlUEvgKb6YVVPXzofHgqHijCImG33b/uTurMS488ht/Hbsb2XK3U2BnSTxKVNGV3nGQ==",
+            "dev": true,
+            "requires": {
+                "@babel/code-frame": "^7.18.6",
+                "@babel/generator": "^7.21.3",
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-function-name": "^7.21.0",
+                "@babel/helper-hoist-variables": "^7.18.6",
+                "@babel/helper-split-export-declaration": "^7.18.6",
+                "@babel/parser": "^7.21.3",
+                "@babel/types": "^7.21.3",
+                "debug": "^4.1.0",
+                "globals": "^11.1.0"
+            },
+            "dependencies": {
+                "globals": {
+                    "version": "11.12.0",
+                    "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+                    "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+                    "dev": true
+                }
+            }
+        },
+        "@babel/types": {
+            "version": "7.21.3",
+            "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.3.tgz",
+            "integrity": "sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-string-parser": "^7.19.4",
+                "@babel/helper-validator-identifier": "^7.19.1",
+                "to-fast-properties": "^2.0.0"
+            }
+        },
+        "@eslint-community/eslint-utils": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+            "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+            "requires": {
+                "eslint-visitor-keys": "^3.3.0"
+            }
+        },
+        "@eslint-community/regexpp": {
+            "version": "4.5.0",
+            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz",
+            "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ=="
+        },
+        "@eslint/eslintrc": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz",
+            "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==",
+            "requires": {
                 "ajv": "^6.12.4",
                 "debug": "^4.3.2",
-                "espree": "^9.3.2",
-                "globals": "^13.15.0",
+                "espree": "^9.5.1",
+                "globals": "^13.19.0",
                 "ignore": "^5.2.0",
                 "import-fresh": "^3.2.1",
                 "js-yaml": "^4.1.0",
@@ -1925,41 +5293,163 @@
                 "strip-json-comments": "^3.1.1"
             }
         },
+        "@eslint/js": {
+            "version": "8.37.0",
+            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.37.0.tgz",
+            "integrity": "sha512-x5vzdtOOGgFVDCUs81QRB2+liax8rFg3+7hqM+QhBG0/G3F1ZsoYl97UrqgHgQ9KKT7G6c4V+aTUCgu/n22v1A=="
+        },
         "@humanwhocodes/config-array": {
-            "version": "0.9.5",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
-            "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==",
-            "dev": true,
+            "version": "0.11.8",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
+            "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
             "requires": {
                 "@humanwhocodes/object-schema": "^1.2.1",
                 "debug": "^4.1.1",
-                "minimatch": "^3.0.4"
+                "minimatch": "^3.0.5"
             }
         },
+        "@humanwhocodes/module-importer": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+            "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="
+        },
         "@humanwhocodes/object-schema": {
             "version": "1.2.1",
             "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
-            "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+            "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
+        },
+        "@istanbuljs/load-nyc-config": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+            "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+            "dev": true,
+            "requires": {
+                "camelcase": "^5.3.1",
+                "find-up": "^4.1.0",
+                "get-package-type": "^0.1.0",
+                "js-yaml": "^3.13.1",
+                "resolve-from": "^5.0.0"
+            },
+            "dependencies": {
+                "argparse": {
+                    "version": "1.0.10",
+                    "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+                    "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+                    "dev": true,
+                    "requires": {
+                        "sprintf-js": "~1.0.2"
+                    }
+                },
+                "js-yaml": {
+                    "version": "3.14.1",
+                    "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+                    "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+                    "dev": true,
+                    "requires": {
+                        "argparse": "^1.0.7",
+                        "esprima": "^4.0.0"
+                    }
+                },
+                "resolve-from": {
+                    "version": "5.0.0",
+                    "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+                    "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+                    "dev": true
+                }
+            }
+        },
+        "@istanbuljs/schema": {
+            "version": "0.1.3",
+            "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+            "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
             "dev": true
         },
-        "acorn": {
-            "version": "8.7.1",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
-            "integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==",
+        "@jridgewell/gen-mapping": {
+            "version": "0.1.1",
+            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
+            "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==",
+            "dev": true,
+            "requires": {
+                "@jridgewell/set-array": "^1.0.0",
+                "@jridgewell/sourcemap-codec": "^1.4.10"
+            }
+        },
+        "@jridgewell/resolve-uri": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
+            "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
+            "dev": true
+        },
+        "@jridgewell/set-array": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+            "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+            "dev": true
+        },
+        "@jridgewell/sourcemap-codec": {
+            "version": "1.4.14",
+            "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
+            "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
             "dev": true
         },
+        "@jridgewell/trace-mapping": {
+            "version": "0.3.17",
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz",
+            "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==",
+            "dev": true,
+            "requires": {
+                "@jridgewell/resolve-uri": "3.1.0",
+                "@jridgewell/sourcemap-codec": "1.4.14"
+            }
+        },
+        "@nodelib/fs.scandir": {
+            "version": "2.1.5",
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+            "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+            "requires": {
+                "@nodelib/fs.stat": "2.0.5",
+                "run-parallel": "^1.1.9"
+            }
+        },
+        "@nodelib/fs.stat": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+            "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="
+        },
+        "@nodelib/fs.walk": {
+            "version": "1.2.8",
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+            "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+            "requires": {
+                "@nodelib/fs.scandir": "2.1.5",
+                "fastq": "^1.6.0"
+            }
+        },
+        "acorn": {
+            "version": "8.8.2",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
+            "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw=="
+        },
         "acorn-jsx": {
             "version": "5.3.2",
             "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
             "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
-            "dev": true,
             "requires": {}
         },
+        "aggregate-error": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+            "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+            "dev": true,
+            "requires": {
+                "clean-stack": "^2.0.0",
+                "indent-string": "^4.0.0"
+            }
+        },
         "ajv": {
             "version": "6.12.6",
             "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
             "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
-            "dev": true,
             "requires": {
                 "fast-deep-equal": "^3.1.1",
                 "fast-json-stable-stringify": "^2.0.0",
@@ -1970,14 +5460,12 @@
         "ansi-regex": {
             "version": "5.0.1",
             "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
-            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-            "dev": true
+            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
         },
         "ansi-styles": {
             "version": "4.3.0",
             "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
             "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-            "dev": true,
             "requires": {
                 "color-convert": "^2.0.1"
             }
@@ -1992,34 +5480,36 @@
                 "picomatch": "^2.0.4"
             }
         },
+        "append-transform": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz",
+            "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==",
+            "dev": true,
+            "requires": {
+                "default-require-extensions": "^3.0.0"
+            }
+        },
+        "archy": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
+            "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==",
+            "dev": true
+        },
         "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==",
+        "async-hook-domain": {
+            "version": "2.0.4",
+            "resolved": "https://registry.npmjs.org/async-hook-domain/-/async-hook-domain-2.0.4.tgz",
+            "integrity": "sha512-14LjCmlK1PK8eDtTezR6WX8TMaYNIzBIsd2D1sGoGjgx0BuNMMoSdk7i/drlbtamy0AWv9yv2tkB+ASdmeqFIw==",
             "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
+            "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
         },
         "binary-extensions": {
             "version": "2.2.0",
@@ -2027,11 +5517,16 @@
             "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
             "dev": true
         },
+        "bind-obj-methods": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/bind-obj-methods/-/bind-obj-methods-3.0.0.tgz",
+            "integrity": "sha512-nLEaaz3/sEzNSyPWRsN9HNsqwk1AUyECtGj+XwGdIi3xABnEqecvXtIJ0wehQXuuER5uZ/5fTs2usONgYjG+iw==",
+            "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"
@@ -2046,27 +5541,57 @@
                 "fill-range": "^7.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==",
+        "browserslist": {
+            "version": "4.21.5",
+            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz",
+            "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==",
+            "dev": true,
+            "requires": {
+                "caniuse-lite": "^1.0.30001449",
+                "electron-to-chromium": "^1.4.284",
+                "node-releases": "^2.0.8",
+                "update-browserslist-db": "^1.0.10"
+            }
+        },
+        "buffer-from": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+            "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+            "dev": true
+        },
+        "caching-transform": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz",
+            "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==",
             "dev": true,
             "requires": {
-                "function-bind": "^1.1.1",
-                "get-intrinsic": "^1.0.2"
+                "hasha": "^5.0.0",
+                "make-dir": "^3.0.0",
+                "package-hash": "^4.0.0",
+                "write-file-atomic": "^3.0.0"
             }
         },
         "callsites": {
             "version": "3.1.0",
             "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
-            "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+            "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
+        },
+        "camelcase": {
+            "version": "5.3.1",
+            "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+            "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+            "dev": true
+        },
+        "caniuse-lite": {
+            "version": "1.0.30001469",
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001469.tgz",
+            "integrity": "sha512-Rcp7221ScNqQPP3W+lVOYDyjdR6dC+neEQCttoNr5bAyz54AboB4iwpnWgyi8P4YUsPybVzT4LgWiBbI3drL4g==",
             "dev": true
         },
         "chalk": {
             "version": "4.1.2",
             "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
             "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-            "dev": true,
             "requires": {
                 "ansi-styles": "^4.1.0",
                 "supports-color": "^7.1.0"
@@ -2104,11 +5629,27 @@
             "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz",
             "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A=="
         },
+        "clean-stack": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+            "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+            "dev": true
+        },
+        "cliui": {
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+            "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+            "dev": true,
+            "requires": {
+                "string-width": "^4.2.0",
+                "strip-ansi": "^6.0.0",
+                "wrap-ansi": "^7.0.0"
+            }
+        },
         "color-convert": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
             "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-            "dev": true,
             "requires": {
                 "color-name": "~1.1.4"
             }
@@ -2116,7 +5657,12 @@
         "color-name": {
             "version": "1.1.4",
             "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+        },
+        "color-support": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+            "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
             "dev": true
         },
         "command-exists": {
@@ -2124,17 +5670,27 @@
             "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
             "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="
         },
+        "commondir": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+            "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+            "dev": true
+        },
         "concat-map": {
             "version": "0.0.1",
             "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
-            "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+            "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+        },
+        "convert-source-map": {
+            "version": "1.9.0",
+            "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+            "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
             "dev": true
         },
         "cross-spawn": {
             "version": "7.0.3",
             "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
             "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
-            "dev": true,
             "requires": {
                 "path-key": "^3.1.0",
                 "shebang-command": "^2.0.0",
@@ -2145,142 +5701,85 @@
             "version": "4.3.4",
             "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
             "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
-            "dev": true,
             "requires": {
                 "ms": "2.1.2"
             }
         },
-        "deep-equal": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.0.5.tgz",
-            "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"
-            }
+        "decamelize": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+            "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+            "dev": true
         },
         "deep-is": {
             "version": "0.1.4",
             "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
-            "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
-            "dev": true
+            "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
         },
-        "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==",
+        "default-require-extensions": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz",
+            "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==",
             "dev": true,
             "requires": {
-                "object-keys": "^1.0.12"
+                "strip-bom": "^4.0.0"
             }
         },
-        "defined": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
-            "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
+        "diff": {
+            "version": "4.0.2",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+            "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
             "dev": true
         },
         "doctrine": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
             "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
-            "dev": true,
             "requires": {
                 "esutils": "^2.0.2"
             }
         },
-        "dotignore": {
-            "version": "0.1.2",
-            "resolved": "https://registry.npmjs.org/dotignore/-/dotignore-0.1.2.tgz",
-            "integrity": "sha512-UGGGWfSauusaVJC+8fgV+NVvBXkCTmVv7sk6nojDZZvuOUNGUy0Zk4UpHQD6EDjS0jpBwcACvH4eofvyzBcRDw==",
-            "dev": true,
-            "requires": {
-                "minimatch": "^3.0.4"
-            }
+        "electron-to-chromium": {
+            "version": "1.4.340",
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.340.tgz",
+            "integrity": "sha512-zx8hqumOqltKsv/MF50yvdAlPF9S/4PXbyfzJS6ZGhbddGkRegdwImmfSVqCkEziYzrIGZ/TlrzBND4FysfkDg==",
+            "dev": true
         },
-        "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"
-            }
+        "emoji-regex": {
+            "version": "8.0.0",
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+            "dev": true
         },
-        "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"
-            }
+        "es6-error": {
+            "version": "4.1.1",
+            "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
+            "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+            "dev": true
+        },
+        "escalade": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+            "dev": true
         },
         "escape-string-regexp": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
-            "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
-            "dev": true
+            "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
         },
         "eslint": {
-            "version": "8.18.0",
-            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.18.0.tgz",
-            "integrity": "sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA==",
-            "dev": true,
+            "version": "8.37.0",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.37.0.tgz",
+            "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==",
             "requires": {
-                "@eslint/eslintrc": "^1.3.0",
-                "@humanwhocodes/config-array": "^0.9.2",
+                "@eslint-community/eslint-utils": "^4.2.0",
+                "@eslint-community/regexpp": "^4.4.0",
+                "@eslint/eslintrc": "^2.0.2",
+                "@eslint/js": "8.37.0",
+                "@humanwhocodes/config-array": "^0.11.8",
+                "@humanwhocodes/module-importer": "^1.0.1",
+                "@nodelib/fs.walk": "^1.2.8",
                 "ajv": "^6.10.0",
                 "chalk": "^4.0.0",
                 "cross-spawn": "^7.0.2",
@@ -2288,20 +5787,22 @@
                 "doctrine": "^3.0.0",
                 "escape-string-regexp": "^4.0.0",
                 "eslint-scope": "^7.1.1",
-                "eslint-utils": "^3.0.0",
-                "eslint-visitor-keys": "^3.3.0",
-                "espree": "^9.3.2",
-                "esquery": "^1.4.0",
+                "eslint-visitor-keys": "^3.4.0",
+                "espree": "^9.5.1",
+                "esquery": "^1.4.2",
                 "esutils": "^2.0.2",
                 "fast-deep-equal": "^3.1.3",
                 "file-entry-cache": "^6.0.1",
-                "functional-red-black-tree": "^1.0.1",
-                "glob-parent": "^6.0.1",
-                "globals": "^13.15.0",
+                "find-up": "^5.0.0",
+                "glob-parent": "^6.0.2",
+                "globals": "^13.19.0",
+                "grapheme-splitter": "^1.0.4",
                 "ignore": "^5.2.0",
                 "import-fresh": "^3.0.0",
                 "imurmurhash": "^0.1.4",
                 "is-glob": "^4.0.0",
+                "is-path-inside": "^3.0.3",
+                "js-sdsl": "^4.1.4",
                 "js-yaml": "^4.1.0",
                 "json-stable-stringify-without-jsonify": "^1.0.1",
                 "levn": "^0.4.1",
@@ -2309,62 +5810,80 @@
                 "minimatch": "^3.1.2",
                 "natural-compare": "^1.4.0",
                 "optionator": "^0.9.1",
-                "regexpp": "^3.2.0",
                 "strip-ansi": "^6.0.1",
                 "strip-json-comments": "^3.1.0",
-                "text-table": "^0.2.0",
-                "v8-compile-cache": "^2.0.3"
+                "text-table": "^0.2.0"
+            },
+            "dependencies": {
+                "find-up": {
+                    "version": "5.0.0",
+                    "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+                    "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+                    "requires": {
+                        "locate-path": "^6.0.0",
+                        "path-exists": "^4.0.0"
+                    }
+                },
+                "locate-path": {
+                    "version": "6.0.0",
+                    "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+                    "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+                    "requires": {
+                        "p-locate": "^5.0.0"
+                    }
+                },
+                "p-limit": {
+                    "version": "3.1.0",
+                    "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+                    "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+                    "requires": {
+                        "yocto-queue": "^0.1.0"
+                    }
+                },
+                "p-locate": {
+                    "version": "5.0.0",
+                    "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+                    "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+                    "requires": {
+                        "p-limit": "^3.0.2"
+                    }
+                }
             }
         },
         "eslint-scope": {
             "version": "7.1.1",
             "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
             "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
-            "dev": true,
             "requires": {
                 "esrecurse": "^4.3.0",
                 "estraverse": "^5.2.0"
             }
         },
-        "eslint-utils": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
-            "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
-            "dev": true,
-            "requires": {
-                "eslint-visitor-keys": "^2.0.0"
-            },
-            "dependencies": {
-                "eslint-visitor-keys": {
-                    "version": "2.1.0",
-                    "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
-                    "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
-                    "dev": true
-                }
-            }
-        },
         "eslint-visitor-keys": {
-            "version": "3.3.0",
-            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
-            "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
-            "dev": true
+            "version": "3.4.0",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz",
+            "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ=="
         },
         "espree": {
-            "version": "9.3.2",
-            "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.2.tgz",
-            "integrity": "sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==",
-            "dev": true,
+            "version": "9.5.1",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
+            "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==",
             "requires": {
-                "acorn": "^8.7.1",
+                "acorn": "^8.8.0",
                 "acorn-jsx": "^5.3.2",
-                "eslint-visitor-keys": "^3.3.0"
+                "eslint-visitor-keys": "^3.4.0"
             }
         },
+        "esprima": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+            "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+            "dev": true
+        },
         "esquery": {
-            "version": "1.4.0",
-            "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
-            "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
-            "dev": true,
+            "version": "1.5.0",
+            "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
+            "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
             "requires": {
                 "estraverse": "^5.1.0"
             }
@@ -2373,7 +5892,6 @@
             "version": "4.3.0",
             "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
             "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
-            "dev": true,
             "requires": {
                 "estraverse": "^5.2.0"
             }
@@ -2381,38 +5899,46 @@
         "estraverse": {
             "version": "5.3.0",
             "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
-            "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
-            "dev": true
+            "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="
         },
         "esutils": {
             "version": "2.0.3",
             "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
-            "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+            "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
+        },
+        "events-to-array": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/events-to-array/-/events-to-array-1.1.2.tgz",
+            "integrity": "sha512-inRWzRY7nG+aXZxBzEqYKB3HPgwflZRopAjDCHv0whhRx+MTUr1ei0ICZUypdyE0HRm4L2d5VEcIqLD6yl+BFA==",
             "dev": true
         },
         "fast-deep-equal": {
             "version": "3.1.3",
             "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
-            "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-            "dev": true
+            "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
         },
         "fast-json-stable-stringify": {
             "version": "2.1.0",
             "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
-            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
-            "dev": true
+            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
         },
         "fast-levenshtein": {
             "version": "2.0.6",
             "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
-            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
-            "dev": true
+            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
+        },
+        "fastq": {
+            "version": "1.15.0",
+            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
+            "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
+            "requires": {
+                "reusify": "^1.0.4"
+            }
         },
         "file-entry-cache": {
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
             "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
-            "dev": true,
             "requires": {
                 "flat-cache": "^3.0.4"
             }
@@ -2426,11 +5952,37 @@
                 "to-regex-range": "^5.0.1"
             }
         },
+        "find-cache-dir": {
+            "version": "3.3.2",
+            "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+            "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+            "dev": true,
+            "requires": {
+                "commondir": "^1.0.1",
+                "make-dir": "^3.0.2",
+                "pkg-dir": "^4.1.0"
+            }
+        },
+        "find-up": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+            "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+            "dev": true,
+            "requires": {
+                "locate-path": "^5.0.0",
+                "path-exists": "^4.0.0"
+            }
+        },
+        "findit": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/findit/-/findit-2.0.0.tgz",
+            "integrity": "sha512-ENZS237/Hr8bjczn5eKuBohLgaD0JyUd0arxretR1f9RO46vZHA1b2y0VorgGV3WaOT3c+78P8h7v4JGJ1i/rg==",
+            "dev": true
+        },
         "flat-cache": {
             "version": "3.0.4",
             "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
             "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
-            "dev": true,
             "requires": {
                 "flatted": "^3.1.0",
                 "rimraf": "^3.0.2"
@@ -2439,29 +5991,34 @@
         "flatted": {
             "version": "3.2.5",
             "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
-            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
-            "dev": true
+            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
         },
-        "for-each": {
-            "version": "0.3.3",
-            "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
-            "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
+        "foreground-child": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
+            "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==",
             "dev": true,
             "requires": {
-                "is-callable": "^1.1.3"
+                "cross-spawn": "^7.0.0",
+                "signal-exit": "^3.0.2"
             }
         },
-        "foreach": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz",
-            "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=",
+        "fromentries": {
+            "version": "1.3.2",
+            "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz",
+            "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==",
+            "dev": true
+        },
+        "fs-exists-cached": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/fs-exists-cached/-/fs-exists-cached-1.0.0.tgz",
+            "integrity": "sha512-kSxoARUDn4F2RPXX48UXnaFKwVU7Ivd/6qpzZL29MCDmr9sTvybv4gFCp+qaI4fM9m0z9fgz/yJvi56GAz+BZg==",
             "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
+            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
         },
         "fsevents": {
             "version": "2.3.2",
@@ -2470,28 +6027,23 @@
             "dev": true,
             "optional": true
         },
-        "function-bind": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
-            "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+        "function-loop": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/function-loop/-/function-loop-2.0.1.tgz",
+            "integrity": "sha512-ktIR+O6i/4h+j/ZhZJNdzeI4i9lEPeEK6UPR2EVyTVBqOwcU3Za9xYKLH64ZR9HmcROyRrOkizNyjjtWJzDDkQ==",
             "dev": true
         },
-        "functional-red-black-tree": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
-            "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
+        "gensync": {
+            "version": "1.0.0-beta.2",
+            "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+            "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
             "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-caller-file": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+            "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+            "dev": true
         },
         "get-package-type": {
             "version": "0.1.0",
@@ -2499,26 +6051,15 @@
             "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,
+            "version": "7.2.3",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+            "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
             "requires": {
                 "fs.realpath": "^1.0.0",
                 "inflight": "^1.0.4",
                 "inherits": "2",
-                "minimatch": "^3.0.4",
+                "minimatch": "^3.1.1",
                 "once": "^1.3.0",
                 "path-is-absolute": "^1.0.0"
             }
@@ -2527,64 +6068,50 @@
             "version": "6.0.2",
             "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
             "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
-            "dev": true,
             "requires": {
                 "is-glob": "^4.0.3"
             }
         },
         "globals": {
-            "version": "13.15.0",
-            "resolved": "https://registry.npmjs.org/globals/-/globals-13.15.0.tgz",
-            "integrity": "sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==",
-            "dev": true,
+            "version": "13.20.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
+            "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
             "requires": {
                 "type-fest": "^0.20.2"
             }
         },
-        "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==",
+        "graceful-fs": {
+            "version": "4.2.11",
+            "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+            "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
             "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"
-            }
+        "grapheme-splitter": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+            "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
         },
         "has-flag": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true
+            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
         },
-        "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==",
+        "hasha": {
+            "version": "5.2.2",
+            "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz",
+            "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==",
             "dev": true,
             "requires": {
-                "has-symbols": "^1.0.2"
+                "is-stream": "^2.0.0",
+                "type-fest": "^0.8.0"
+            },
+            "dependencies": {
+                "type-fest": {
+                    "version": "0.8.1",
+                    "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+                    "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+                    "dev": true
+                }
             }
         },
         "he": {
@@ -2592,17 +6119,21 @@
             "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
             "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
         },
-        "ignore": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
-            "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
+        "html-escaper": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+            "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
             "dev": true
         },
+        "ignore": {
+            "version": "5.2.4",
+            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
+            "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ=="
+        },
         "import-fresh": {
             "version": "3.3.0",
             "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
             "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
-            "dev": true,
             "requires": {
                 "parent-module": "^1.0.0",
                 "resolve-from": "^4.0.0"
@@ -2611,14 +6142,18 @@
         "imurmurhash": {
             "version": "0.1.4",
             "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
-            "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+            "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="
+        },
+        "indent-string": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+            "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
             "dev": true
         },
         "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"
@@ -2627,38 +6162,7 @@
         "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"
-            }
+            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
         },
         "is-binary-path": {
             "version": "2.1.0",
@@ -2669,170 +6173,150 @@
                 "binary-extensions": "^2.0.0"
             }
         },
-        "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-extglob": {
             "version": "2.1.1",
             "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
-            "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+            "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
+        },
+        "is-fullwidth-code-point": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
             "dev": true
         },
         "is-glob": {
             "version": "4.0.3",
             "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
             "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
-            "dev": true,
             "requires": {
                 "is-extglob": "^2.1.1"
             }
         },
-        "is-map": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
-            "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": {
             "version": "7.0.0",
             "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
             "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
             "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==",
+        "is-path-inside": {
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+            "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="
+        },
+        "is-stream": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+            "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+            "dev": true
+        },
+        "is-typedarray": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+            "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
+            "dev": true
+        },
+        "is-windows": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+            "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
+            "dev": true
+        },
+        "isexe": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
+        },
+        "istanbul-lib-coverage": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
+            "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
+            "dev": true
+        },
+        "istanbul-lib-hook": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz",
+            "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==",
             "dev": true,
             "requires": {
-                "has-tostringtag": "^1.0.0"
+                "append-transform": "^2.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==",
+        "istanbul-lib-instrument": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz",
+            "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
+                "@babel/core": "^7.7.5",
+                "@istanbuljs/schema": "^0.1.2",
+                "istanbul-lib-coverage": "^3.0.0",
+                "semver": "^6.3.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==",
+        "istanbul-lib-processinfo": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz",
+            "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==",
             "dev": true,
             "requires": {
-                "has-tostringtag": "^1.0.0"
+                "archy": "^1.0.0",
+                "cross-spawn": "^7.0.3",
+                "istanbul-lib-coverage": "^3.2.0",
+                "p-map": "^3.0.0",
+                "rimraf": "^3.0.0",
+                "uuid": "^8.3.2"
             }
         },
-        "is-symbol": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
-            "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+        "istanbul-lib-report": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+            "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==",
             "dev": true,
             "requires": {
-                "has-symbols": "^1.0.2"
+                "istanbul-lib-coverage": "^3.0.0",
+                "make-dir": "^3.0.0",
+                "supports-color": "^7.1.0"
             }
         },
-        "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==",
+        "istanbul-lib-source-maps": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
+            "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
             "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"
+                "debug": "^4.1.1",
+                "istanbul-lib-coverage": "^3.0.0",
+                "source-map": "^0.6.1"
             }
         },
-        "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==",
+        "istanbul-reports": {
+            "version": "3.1.5",
+            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz",
+            "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2"
+                "html-escaper": "^2.0.0",
+                "istanbul-lib-report": "^3.0.0"
             }
         },
-        "is-weakset": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz",
-            "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==",
+        "jackspeak": {
+            "version": "1.4.2",
+            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-1.4.2.tgz",
+            "integrity": "sha512-GHeGTmnuaHnvS+ZctRB01bfxARuu9wW83ENbuiweu07SFcVlZrJpcshSre/keGT7YGBhLHg/+rXCNSrsEHKU4Q==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
+                "cliui": "^7.0.4"
             }
         },
-        "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-sdsl": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
+            "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg=="
         },
-        "isexe": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+        "js-tokens": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+            "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
             "dev": true
         },
         "js-yaml": {
@@ -2843,59 +6327,166 @@
                 "argparse": "^2.0.1"
             }
         },
+        "jsesc": {
+            "version": "2.5.2",
+            "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+            "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+            "dev": true
+        },
         "json-schema-traverse": {
             "version": "0.4.1",
             "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
-            "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
-            "dev": true
+            "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
         },
         "json-stable-stringify-without-jsonify": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
-            "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+            "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="
+        },
+        "json5": {
+            "version": "2.2.3",
+            "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+            "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
             "dev": true
         },
         "levn": {
             "version": "0.4.1",
             "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
             "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
-            "dev": true,
             "requires": {
                 "prelude-ls": "^1.2.1",
                 "type-check": "~0.4.0"
             }
         },
+        "libtap": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/libtap/-/libtap-1.4.0.tgz",
+            "integrity": "sha512-STLFynswQ2A6W14JkabgGetBNk6INL1REgJ9UeNKw5llXroC2cGLgKTqavv0sl8OLVztLLipVKMcQ7yeUcqpmg==",
+            "dev": true,
+            "requires": {
+                "async-hook-domain": "^2.0.4",
+                "bind-obj-methods": "^3.0.0",
+                "diff": "^4.0.2",
+                "function-loop": "^2.0.1",
+                "minipass": "^3.1.5",
+                "own-or": "^1.0.0",
+                "own-or-env": "^1.0.2",
+                "signal-exit": "^3.0.4",
+                "stack-utils": "^2.0.4",
+                "tap-parser": "^11.0.0",
+                "tap-yaml": "^1.0.0",
+                "tcompare": "^5.0.6",
+                "trivial-deferred": "^1.0.1"
+            },
+            "dependencies": {
+                "tcompare": {
+                    "version": "5.0.7",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz",
+                    "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^4.0.2"
+                    }
+                }
+            }
+        },
+        "locate-path": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+            "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+            "dev": true,
+            "requires": {
+                "p-locate": "^4.1.0"
+            }
+        },
+        "lodash.flattendeep": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
+            "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==",
+            "dev": true
+        },
         "lodash.merge": {
             "version": "4.6.2",
             "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
-            "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
-            "dev": true
+            "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
+        },
+        "lru-cache": {
+            "version": "5.1.1",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+            "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+            "dev": true,
+            "requires": {
+                "yallist": "^3.0.2"
+            },
+            "dependencies": {
+                "yallist": {
+                    "version": "3.1.1",
+                    "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+                    "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+                    "dev": true
+                }
+            }
+        },
+        "make-dir": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+            "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+            "dev": true,
+            "requires": {
+                "semver": "^6.0.0"
+            }
+        },
+        "marked": {
+            "version": "5.0.2",
+            "resolved": "https://registry.npmjs.org/marked/-/marked-5.0.2.tgz",
+            "integrity": "sha512-TXksm9GwqXCRNbFUZmMtqNLvy3K2cQHuWmyBDLOrY1e6i9UvZpOTJXoz7fBjYkJkaUFzV9hBFxMuZSyQt8R6KQ=="
         },
         "minimatch": {
             "version": "3.1.2",
             "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
             "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
-            "dev": true,
             "requires": {
                 "brace-expansion": "^1.1.7"
             }
         },
-        "minimist": {
-            "version": "1.2.6",
-            "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
-            "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
+        "minipass": {
+            "version": "3.3.6",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+            "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+            "dev": true,
+            "requires": {
+                "yallist": "^4.0.0"
+            }
+        },
+        "mkdirp": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+            "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
             "dev": true
         },
         "ms": {
             "version": "2.1.2",
             "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-            "dev": true
+            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
         },
         "natural-compare": {
             "version": "1.4.0",
             "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
-            "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+            "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
+        },
+        "node-preload": {
+            "version": "0.2.1",
+            "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz",
+            "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==",
+            "dev": true,
+            "requires": {
+                "process-on-spawn": "^1.0.0"
+            }
+        },
+        "node-releases": {
+            "version": "2.0.10",
+            "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz",
+            "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==",
             "dev": true
         },
         "normalize-path": {
@@ -2904,54 +6495,67 @@
             "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
             "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==",
+        "nyc": {
+            "version": "15.1.0",
+            "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz",
+            "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==",
             "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"
+                "@istanbuljs/load-nyc-config": "^1.0.0",
+                "@istanbuljs/schema": "^0.1.2",
+                "caching-transform": "^4.0.0",
+                "convert-source-map": "^1.7.0",
+                "decamelize": "^1.2.0",
+                "find-cache-dir": "^3.2.0",
+                "find-up": "^4.1.0",
+                "foreground-child": "^2.0.0",
+                "get-package-type": "^0.1.0",
+                "glob": "^7.1.6",
+                "istanbul-lib-coverage": "^3.0.0",
+                "istanbul-lib-hook": "^3.0.0",
+                "istanbul-lib-instrument": "^4.0.0",
+                "istanbul-lib-processinfo": "^2.0.2",
+                "istanbul-lib-report": "^3.0.0",
+                "istanbul-lib-source-maps": "^4.0.0",
+                "istanbul-reports": "^3.0.2",
+                "make-dir": "^3.0.0",
+                "node-preload": "^0.2.1",
+                "p-map": "^3.0.0",
+                "process-on-spawn": "^1.0.0",
+                "resolve-from": "^5.0.0",
+                "rimraf": "^3.0.0",
+                "signal-exit": "^3.0.2",
+                "spawn-wrap": "^2.0.0",
+                "test-exclude": "^6.0.0",
+                "yargs": "^15.0.2"
+            },
+            "dependencies": {
+                "resolve-from": {
+                    "version": "5.0.0",
+                    "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+                    "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+                    "dev": true
+                }
             }
         },
         "once": {
             "version": "1.4.0",
             "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
             "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
-            "dev": true,
             "requires": {
                 "wrappy": "1"
             }
         },
+        "opener": {
+            "version": "1.5.2",
+            "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
+            "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
+            "dev": true
+        },
         "optionator": {
             "version": "0.9.1",
             "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
             "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
-            "dev": true,
             "requires": {
                 "deep-is": "^0.1.3",
                 "fast-levenshtein": "^2.0.6",
@@ -2961,31 +6565,93 @@
                 "word-wrap": "^1.2.3"
             }
         },
+        "own-or": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/own-or/-/own-or-1.0.0.tgz",
+            "integrity": "sha512-NfZr5+Tdf6MB8UI9GLvKRs4cXY8/yB0w3xtt84xFdWy8hkGjn+JFc60VhzS/hFRfbyxFcGYMTjnF4Me+RbbqrA==",
+            "dev": true
+        },
+        "own-or-env": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/own-or-env/-/own-or-env-1.0.2.tgz",
+            "integrity": "sha512-NQ7v0fliWtK7Lkb+WdFqe6ky9XAzYmlkXthQrBbzlYbmFKoAYbDDcwmOm6q8kOuwSRXW8bdL5ORksploUJmWgw==",
+            "dev": true,
+            "requires": {
+                "own-or": "^1.0.0"
+            }
+        },
+        "p-limit": {
+            "version": "2.3.0",
+            "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+            "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+            "dev": true,
+            "requires": {
+                "p-try": "^2.0.0"
+            }
+        },
+        "p-locate": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+            "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+            "dev": true,
+            "requires": {
+                "p-limit": "^2.2.0"
+            }
+        },
+        "p-map": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz",
+            "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==",
+            "dev": true,
+            "requires": {
+                "aggregate-error": "^3.0.0"
+            }
+        },
+        "p-try": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+            "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+            "dev": true
+        },
+        "package-hash": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz",
+            "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==",
+            "dev": true,
+            "requires": {
+                "graceful-fs": "^4.1.15",
+                "hasha": "^5.0.0",
+                "lodash.flattendeep": "^4.4.0",
+                "release-zalgo": "^1.0.0"
+            }
+        },
         "parent-module": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
             "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
-            "dev": true,
             "requires": {
                 "callsites": "^3.0.0"
             }
         },
+        "path-exists": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+            "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
+        },
         "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
+            "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
         },
         "path-key": {
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
-            "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
-            "dev": true
+            "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
         },
-        "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==",
+        "picocolors": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+            "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
             "dev": true
         },
         "picomatch": {
@@ -2994,17 +6660,38 @@
             "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
             "dev": true
         },
+        "pkg-dir": {
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+            "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+            "dev": true,
+            "requires": {
+                "find-up": "^4.0.0"
+            }
+        },
         "prelude-ls": {
             "version": "1.2.1",
             "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
-            "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
-            "dev": true
+            "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
+        },
+        "process-on-spawn": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz",
+            "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==",
+            "dev": true,
+            "requires": {
+                "fromentries": "^1.2.0"
+            }
         },
         "punycode": {
             "version": "2.1.1",
             "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
-            "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
-            "dev": true
+            "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
+        },
+        "queue-microtask": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+            "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
         },
         "readdirp": {
             "version": "3.6.0",
@@ -3015,61 +6702,69 @@
                 "picomatch": "^2.2.1"
             }
         },
-        "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==",
+        "release-zalgo": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz",
+            "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "es6-error": "^4.0.1"
             }
         },
-        "regexpp": {
-            "version": "3.2.0",
-            "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
-            "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+        "require-directory": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+            "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
             "dev": true
         },
-        "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"
-            }
+        "require-main-filename": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+            "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+            "dev": true
         },
         "resolve-from": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
-            "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
-            "dev": true
+            "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
         },
-        "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"
-            }
+        "reusify": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+            "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="
         },
         "rimraf": {
             "version": "3.0.2",
             "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
             "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
-            "dev": true,
             "requires": {
                 "glob": "^7.1.3"
             }
         },
+        "run-parallel": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+            "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+            "requires": {
+                "queue-microtask": "^1.2.2"
+            }
+        },
+        "semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true
+        },
+        "set-blocking": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+            "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+            "dev": true
+        },
         "shebang-command": {
             "version": "2.0.0",
             "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
             "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
-            "dev": true,
             "requires": {
                 "shebang-regex": "^3.0.0"
             }
@@ -3077,114 +6772,1453 @@
         "shebang-regex": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
-            "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+            "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
+        },
+        "signal-exit": {
+            "version": "3.0.7",
+            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+            "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
             "dev": true
         },
-        "side-channel": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
-            "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+        "source-map": {
+            "version": "0.6.1",
+            "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+            "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+            "dev": true
+        },
+        "source-map-support": {
+            "version": "0.5.21",
+            "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+            "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.0",
-                "get-intrinsic": "^1.0.2",
-                "object-inspect": "^1.9.0"
+                "buffer-from": "^1.0.0",
+                "source-map": "^0.6.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==",
+        "spawn-wrap": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz",
+            "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3",
-                "es-abstract": "^1.19.1"
+                "foreground-child": "^2.0.0",
+                "is-windows": "^1.0.2",
+                "make-dir": "^3.0.0",
+                "rimraf": "^3.0.0",
+                "signal-exit": "^3.0.2",
+                "which": "^2.0.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==",
+        "sprintf-js": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+            "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+            "dev": true
+        },
+        "stack-utils": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
+            "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "escape-string-regexp": "^2.0.0"
+            },
+            "dependencies": {
+                "escape-string-regexp": {
+                    "version": "2.0.0",
+                    "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+                    "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+                    "dev": true
+                }
             }
         },
-        "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==",
+        "string-width": {
+            "version": "4.2.3",
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+            "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "emoji-regex": "^8.0.0",
+                "is-fullwidth-code-point": "^3.0.0",
+                "strip-ansi": "^6.0.1"
             }
         },
         "strip-ansi": {
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
             "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-            "dev": true,
             "requires": {
                 "ansi-regex": "^5.0.1"
             }
         },
+        "strip-bom": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
+            "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
+            "dev": true
+        },
         "strip-json-comments": {
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
-            "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
-            "dev": true
+            "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="
         },
         "supports-color": {
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
             "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-            "dev": true,
             "requires": {
                 "has-flag": "^4.0.0"
             }
         },
-        "tape": {
-            "version": "5.4.1",
-            "resolved": "https://registry.npmjs.org/tape/-/tape-5.4.1.tgz",
-            "integrity": "sha512-7bGaJ3WnQ/CX3xOWzlR+9lNptEWoD+11gyREP8k+SYrDu2a20EifKpTmZndXn25ZRxesYHSuNtE7Fb+THcjfGA==",
+        "tap": {
+            "version": "16.3.4",
+            "resolved": "https://registry.npmjs.org/tap/-/tap-16.3.4.tgz",
+            "integrity": "sha512-SAexdt2ZF4XBgye6TPucFI2y7VE0qeFXlXucJIV1XDPCs+iJodk0MYacr1zR6Ycltzz7PYg8zrblDXKbAZM2LQ==",
             "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"
+                "@isaacs/import-jsx": "^4.0.1",
+                "@types/react": "^17.0.52",
+                "chokidar": "^3.3.0",
+                "findit": "^2.0.0",
+                "foreground-child": "^2.0.0",
+                "fs-exists-cached": "^1.0.0",
+                "glob": "^7.2.3",
+                "ink": "^3.2.0",
+                "isexe": "^2.0.0",
+                "istanbul-lib-processinfo": "^2.0.3",
+                "jackspeak": "^1.4.2",
+                "libtap": "^1.4.0",
+                "minipass": "^3.3.4",
+                "mkdirp": "^1.0.4",
+                "nyc": "^15.1.0",
+                "opener": "^1.5.1",
+                "react": "^17.0.2",
+                "rimraf": "^3.0.0",
+                "signal-exit": "^3.0.6",
+                "source-map-support": "^0.5.16",
+                "tap-mocha-reporter": "^5.0.3",
+                "tap-parser": "^11.0.2",
+                "tap-yaml": "^1.0.2",
+                "tcompare": "^5.0.7",
+                "treport": "^3.0.4",
+                "which": "^2.0.2"
+            },
+            "dependencies": {
+                "@ampproject/remapping": {
+                    "version": "2.1.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@jridgewell/trace-mapping": "^0.3.0"
+                    }
+                },
+                "@babel/code-frame": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/highlight": "^7.16.7"
+                    }
+                },
+                "@babel/compat-data": {
+                    "version": "7.17.7",
+                    "bundled": true,
+                    "dev": true
+                },
+                "@babel/core": {
+                    "version": "7.17.8",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@ampproject/remapping": "^2.1.0",
+                        "@babel/code-frame": "^7.16.7",
+                        "@babel/generator": "^7.17.7",
+                        "@babel/helper-compilation-targets": "^7.17.7",
+                        "@babel/helper-module-transforms": "^7.17.7",
+                        "@babel/helpers": "^7.17.8",
+                        "@babel/parser": "^7.17.8",
+                        "@babel/template": "^7.16.7",
+                        "@babel/traverse": "^7.17.3",
+                        "@babel/types": "^7.17.0",
+                        "convert-source-map": "^1.7.0",
+                        "debug": "^4.1.0",
+                        "gensync": "^1.0.0-beta.2",
+                        "json5": "^2.1.2",
+                        "semver": "^6.3.0"
+                    }
+                },
+                "@babel/generator": {
+                    "version": "7.17.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/types": "^7.17.0",
+                        "jsesc": "^2.5.1",
+                        "source-map": "^0.5.0"
+                    }
+                },
+                "@babel/helper-annotate-as-pure": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/types": "^7.16.7"
+                    }
+                },
+                "@babel/helper-compilation-targets": {
+                    "version": "7.17.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/compat-data": "^7.17.7",
+                        "@babel/helper-validator-option": "^7.16.7",
+                        "browserslist": "^4.17.5",
+                        "semver": "^6.3.0"
+                    }
+                },
+                "@babel/helper-environment-visitor": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/types": "^7.16.7"
+                    }
+                },
+                "@babel/helper-function-name": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/helper-get-function-arity": "^7.16.7",
+                        "@babel/template": "^7.16.7",
+                        "@babel/types": "^7.16.7"
+                    }
+                },
+                "@babel/helper-get-function-arity": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/types": "^7.16.7"
+                    }
+                },
+                "@babel/helper-hoist-variables": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/types": "^7.16.7"
+                    }
+                },
+                "@babel/helper-module-imports": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/types": "^7.16.7"
+                    }
+                },
+                "@babel/helper-module-transforms": {
+                    "version": "7.17.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/helper-environment-visitor": "^7.16.7",
+                        "@babel/helper-module-imports": "^7.16.7",
+                        "@babel/helper-simple-access": "^7.17.7",
+                        "@babel/helper-split-export-declaration": "^7.16.7",
+                        "@babel/helper-validator-identifier": "^7.16.7",
+                        "@babel/template": "^7.16.7",
+                        "@babel/traverse": "^7.17.3",
+                        "@babel/types": "^7.17.0"
+                    }
+                },
+                "@babel/helper-plugin-utils": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true
+                },
+                "@babel/helper-simple-access": {
+                    "version": "7.17.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/types": "^7.17.0"
+                    }
+                },
+                "@babel/helper-split-export-declaration": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/types": "^7.16.7"
+                    }
+                },
+                "@babel/helper-validator-identifier": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true
+                },
+                "@babel/helper-validator-option": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true
+                },
+                "@babel/helpers": {
+                    "version": "7.17.8",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/template": "^7.16.7",
+                        "@babel/traverse": "^7.17.3",
+                        "@babel/types": "^7.17.0"
+                    }
+                },
+                "@babel/highlight": {
+                    "version": "7.16.10",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/helper-validator-identifier": "^7.16.7",
+                        "chalk": "^2.0.0",
+                        "js-tokens": "^4.0.0"
+                    }
+                },
+                "@babel/parser": {
+                    "version": "7.17.8",
+                    "bundled": true,
+                    "dev": true
+                },
+                "@babel/plugin-proposal-object-rest-spread": {
+                    "version": "7.17.3",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/compat-data": "^7.17.0",
+                        "@babel/helper-compilation-targets": "^7.16.7",
+                        "@babel/helper-plugin-utils": "^7.16.7",
+                        "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+                        "@babel/plugin-transform-parameters": "^7.16.7"
+                    }
+                },
+                "@babel/plugin-syntax-jsx": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/helper-plugin-utils": "^7.16.7"
+                    }
+                },
+                "@babel/plugin-syntax-object-rest-spread": {
+                    "version": "7.8.3",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/helper-plugin-utils": "^7.8.0"
+                    }
+                },
+                "@babel/plugin-transform-destructuring": {
+                    "version": "7.17.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/helper-plugin-utils": "^7.16.7"
+                    }
+                },
+                "@babel/plugin-transform-parameters": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/helper-plugin-utils": "^7.16.7"
+                    }
+                },
+                "@babel/plugin-transform-react-jsx": {
+                    "version": "7.17.3",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/helper-annotate-as-pure": "^7.16.7",
+                        "@babel/helper-module-imports": "^7.16.7",
+                        "@babel/helper-plugin-utils": "^7.16.7",
+                        "@babel/plugin-syntax-jsx": "^7.16.7",
+                        "@babel/types": "^7.17.0"
+                    }
+                },
+                "@babel/template": {
+                    "version": "7.16.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/code-frame": "^7.16.7",
+                        "@babel/parser": "^7.16.7",
+                        "@babel/types": "^7.16.7"
+                    }
+                },
+                "@babel/traverse": {
+                    "version": "7.17.3",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/code-frame": "^7.16.7",
+                        "@babel/generator": "^7.17.3",
+                        "@babel/helper-environment-visitor": "^7.16.7",
+                        "@babel/helper-function-name": "^7.16.7",
+                        "@babel/helper-hoist-variables": "^7.16.7",
+                        "@babel/helper-split-export-declaration": "^7.16.7",
+                        "@babel/parser": "^7.17.3",
+                        "@babel/types": "^7.17.0",
+                        "debug": "^4.1.0",
+                        "globals": "^11.1.0"
+                    }
+                },
+                "@babel/types": {
+                    "version": "7.17.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/helper-validator-identifier": "^7.16.7",
+                        "to-fast-properties": "^2.0.0"
+                    }
+                },
+                "@isaacs/import-jsx": {
+                    "version": "4.0.1",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@babel/core": "^7.5.5",
+                        "@babel/plugin-proposal-object-rest-spread": "^7.5.5",
+                        "@babel/plugin-transform-destructuring": "^7.5.0",
+                        "@babel/plugin-transform-react-jsx": "^7.3.0",
+                        "caller-path": "^3.0.1",
+                        "find-cache-dir": "^3.2.0",
+                        "make-dir": "^3.0.2",
+                        "resolve-from": "^3.0.0",
+                        "rimraf": "^3.0.0"
+                    }
+                },
+                "@jridgewell/resolve-uri": {
+                    "version": "3.0.5",
+                    "bundled": true,
+                    "dev": true
+                },
+                "@jridgewell/sourcemap-codec": {
+                    "version": "1.4.11",
+                    "bundled": true,
+                    "dev": true
+                },
+                "@jridgewell/trace-mapping": {
+                    "version": "0.3.4",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@jridgewell/resolve-uri": "^3.0.3",
+                        "@jridgewell/sourcemap-codec": "^1.4.10"
+                    }
+                },
+                "@types/prop-types": {
+                    "version": "15.7.4",
+                    "bundled": true,
+                    "dev": true
+                },
+                "@types/react": {
+                    "version": "17.0.52",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@types/prop-types": "*",
+                        "@types/scheduler": "*",
+                        "csstype": "^3.0.2"
+                    }
+                },
+                "@types/scheduler": {
+                    "version": "0.16.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "@types/yoga-layout": {
+                    "version": "1.9.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "ansi-escapes": {
+                    "version": "4.3.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "type-fest": "^0.21.3"
+                    },
+                    "dependencies": {
+                        "type-fest": {
+                            "version": "0.21.3",
+                            "bundled": true,
+                            "dev": true
+                        }
+                    }
+                },
+                "ansi-regex": {
+                    "version": "5.0.1",
+                    "bundled": true,
+                    "dev": true
+                },
+                "ansi-styles": {
+                    "version": "3.2.1",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "color-convert": "^1.9.0"
+                    }
+                },
+                "ansicolors": {
+                    "version": "0.3.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "astral-regex": {
+                    "version": "2.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "auto-bind": {
+                    "version": "4.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "balanced-match": {
+                    "version": "1.0.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "brace-expansion": {
+                    "version": "1.1.11",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0",
+                        "concat-map": "0.0.1"
+                    }
+                },
+                "browserslist": {
+                    "version": "4.20.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "caniuse-lite": "^1.0.30001317",
+                        "electron-to-chromium": "^1.4.84",
+                        "escalade": "^3.1.1",
+                        "node-releases": "^2.0.2",
+                        "picocolors": "^1.0.0"
+                    }
+                },
+                "caller-callsite": {
+                    "version": "4.1.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "callsites": "^3.1.0"
+                    }
+                },
+                "caller-path": {
+                    "version": "3.0.1",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "caller-callsite": "^4.1.0"
+                    }
+                },
+                "callsites": {
+                    "version": "3.1.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "caniuse-lite": {
+                    "version": "1.0.30001319",
+                    "bundled": true,
+                    "dev": true
+                },
+                "cardinal": {
+                    "version": "2.1.1",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "ansicolors": "~0.3.2",
+                        "redeyed": "~2.1.0"
+                    }
+                },
+                "chalk": {
+                    "version": "2.4.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "ansi-styles": "^3.2.1",
+                        "escape-string-regexp": "^1.0.5",
+                        "supports-color": "^5.3.0"
+                    }
+                },
+                "ci-info": {
+                    "version": "2.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "cli-boxes": {
+                    "version": "2.2.1",
+                    "bundled": true,
+                    "dev": true
+                },
+                "cli-cursor": {
+                    "version": "3.1.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "restore-cursor": "^3.1.0"
+                    }
+                },
+                "cli-truncate": {
+                    "version": "2.1.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "slice-ansi": "^3.0.0",
+                        "string-width": "^4.2.0"
+                    }
+                },
+                "code-excerpt": {
+                    "version": "3.0.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "convert-to-spaces": "^1.0.1"
+                    }
+                },
+                "color-convert": {
+                    "version": "1.9.3",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "color-name": "1.1.3"
+                    }
+                },
+                "color-name": {
+                    "version": "1.1.3",
+                    "bundled": true,
+                    "dev": true
+                },
+                "commondir": {
+                    "version": "1.0.1",
+                    "bundled": true,
+                    "dev": true
+                },
+                "concat-map": {
+                    "version": "0.0.1",
+                    "bundled": true,
+                    "dev": true
+                },
+                "convert-source-map": {
+                    "version": "1.8.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "safe-buffer": "~5.1.1"
+                    }
+                },
+                "convert-to-spaces": {
+                    "version": "1.0.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "csstype": {
+                    "version": "3.0.11",
+                    "bundled": true,
+                    "dev": true
+                },
+                "debug": {
+                    "version": "4.3.4",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "ms": "2.1.2"
+                    }
+                },
+                "electron-to-chromium": {
+                    "version": "1.4.89",
+                    "bundled": true,
+                    "dev": true
+                },
+                "emoji-regex": {
+                    "version": "8.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "escalade": {
+                    "version": "3.1.1",
+                    "bundled": true,
+                    "dev": true
+                },
+                "escape-string-regexp": {
+                    "version": "1.0.5",
+                    "bundled": true,
+                    "dev": true
+                },
+                "esprima": {
+                    "version": "4.0.1",
+                    "bundled": true,
+                    "dev": true
+                },
+                "events-to-array": {
+                    "version": "1.1.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "find-cache-dir": {
+                    "version": "3.3.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "commondir": "^1.0.1",
+                        "make-dir": "^3.0.2",
+                        "pkg-dir": "^4.1.0"
+                    }
+                },
+                "find-up": {
+                    "version": "4.1.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "locate-path": "^5.0.0",
+                        "path-exists": "^4.0.0"
+                    }
+                },
+                "fs.realpath": {
+                    "version": "1.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "gensync": {
+                    "version": "1.0.0-beta.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "glob": {
+                    "version": "7.2.3",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "fs.realpath": "^1.0.0",
+                        "inflight": "^1.0.4",
+                        "inherits": "2",
+                        "minimatch": "^3.1.1",
+                        "once": "^1.3.0",
+                        "path-is-absolute": "^1.0.0"
+                    }
+                },
+                "globals": {
+                    "version": "11.12.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "has-flag": {
+                    "version": "3.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "indent-string": {
+                    "version": "4.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "inflight": {
+                    "version": "1.0.6",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "once": "^1.3.0",
+                        "wrappy": "1"
+                    }
+                },
+                "inherits": {
+                    "version": "2.0.4",
+                    "bundled": true,
+                    "dev": true
+                },
+                "ink": {
+                    "version": "3.2.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "ansi-escapes": "^4.2.1",
+                        "auto-bind": "4.0.0",
+                        "chalk": "^4.1.0",
+                        "cli-boxes": "^2.2.0",
+                        "cli-cursor": "^3.1.0",
+                        "cli-truncate": "^2.1.0",
+                        "code-excerpt": "^3.0.0",
+                        "indent-string": "^4.0.0",
+                        "is-ci": "^2.0.0",
+                        "lodash": "^4.17.20",
+                        "patch-console": "^1.0.0",
+                        "react-devtools-core": "^4.19.1",
+                        "react-reconciler": "^0.26.2",
+                        "scheduler": "^0.20.2",
+                        "signal-exit": "^3.0.2",
+                        "slice-ansi": "^3.0.0",
+                        "stack-utils": "^2.0.2",
+                        "string-width": "^4.2.2",
+                        "type-fest": "^0.12.0",
+                        "widest-line": "^3.1.0",
+                        "wrap-ansi": "^6.2.0",
+                        "ws": "^7.5.5",
+                        "yoga-layout-prebuilt": "^1.9.6"
+                    },
+                    "dependencies": {
+                        "ansi-styles": {
+                            "version": "4.3.0",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "color-convert": "^2.0.1"
+                            }
+                        },
+                        "chalk": {
+                            "version": "4.1.2",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "ansi-styles": "^4.1.0",
+                                "supports-color": "^7.1.0"
+                            }
+                        },
+                        "color-convert": {
+                            "version": "2.0.1",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "color-name": "~1.1.4"
+                            }
+                        },
+                        "color-name": {
+                            "version": "1.1.4",
+                            "bundled": true,
+                            "dev": true
+                        },
+                        "has-flag": {
+                            "version": "4.0.0",
+                            "bundled": true,
+                            "dev": true
+                        },
+                        "supports-color": {
+                            "version": "7.2.0",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "has-flag": "^4.0.0"
+                            }
+                        }
+                    }
+                },
+                "is-ci": {
+                    "version": "2.0.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "ci-info": "^2.0.0"
+                    }
+                },
+                "is-fullwidth-code-point": {
+                    "version": "3.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "js-tokens": {
+                    "version": "4.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "jsesc": {
+                    "version": "2.5.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "json5": {
+                    "version": "2.2.3",
+                    "bundled": true,
+                    "dev": true
+                },
+                "locate-path": {
+                    "version": "5.0.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "p-locate": "^4.1.0"
+                    }
+                },
+                "lodash": {
+                    "version": "4.17.21",
+                    "bundled": true,
+                    "dev": true
+                },
+                "loose-envify": {
+                    "version": "1.4.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "js-tokens": "^3.0.0 || ^4.0.0"
+                    }
+                },
+                "make-dir": {
+                    "version": "3.1.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "semver": "^6.0.0"
+                    }
+                },
+                "mimic-fn": {
+                    "version": "2.1.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "minimatch": {
+                    "version": "3.1.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^1.1.7"
+                    }
+                },
+                "minipass": {
+                    "version": "3.3.4",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "yallist": "^4.0.0"
+                    }
+                },
+                "ms": {
+                    "version": "2.1.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "node-releases": {
+                    "version": "2.0.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "object-assign": {
+                    "version": "4.1.1",
+                    "bundled": true,
+                    "dev": true
+                },
+                "once": {
+                    "version": "1.4.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "wrappy": "1"
+                    }
+                },
+                "onetime": {
+                    "version": "5.1.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "mimic-fn": "^2.1.0"
+                    }
+                },
+                "p-limit": {
+                    "version": "2.3.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "p-try": "^2.0.0"
+                    }
+                },
+                "p-locate": {
+                    "version": "4.1.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "p-limit": "^2.2.0"
+                    }
+                },
+                "p-try": {
+                    "version": "2.2.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "patch-console": {
+                    "version": "1.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "path-exists": {
+                    "version": "4.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "path-is-absolute": {
+                    "version": "1.0.1",
+                    "bundled": true,
+                    "dev": true
+                },
+                "picocolors": {
+                    "version": "1.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "pkg-dir": {
+                    "version": "4.2.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "find-up": "^4.0.0"
+                    }
+                },
+                "punycode": {
+                    "version": "2.1.1",
+                    "bundled": true,
+                    "dev": true
+                },
+                "react": {
+                    "version": "17.0.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "loose-envify": "^1.1.0",
+                        "object-assign": "^4.1.1"
+                    }
+                },
+                "react-devtools-core": {
+                    "version": "4.24.1",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "shell-quote": "^1.6.1",
+                        "ws": "^7"
+                    }
+                },
+                "react-reconciler": {
+                    "version": "0.26.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "loose-envify": "^1.1.0",
+                        "object-assign": "^4.1.1",
+                        "scheduler": "^0.20.2"
+                    }
+                },
+                "redeyed": {
+                    "version": "2.1.1",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "esprima": "~4.0.0"
+                    }
+                },
+                "resolve-from": {
+                    "version": "3.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "restore-cursor": {
+                    "version": "3.1.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "onetime": "^5.1.0",
+                        "signal-exit": "^3.0.2"
+                    }
+                },
+                "rimraf": {
+                    "version": "3.0.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "glob": "^7.1.3"
+                    }
+                },
+                "safe-buffer": {
+                    "version": "5.1.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "scheduler": {
+                    "version": "0.20.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "loose-envify": "^1.1.0",
+                        "object-assign": "^4.1.1"
+                    }
+                },
+                "semver": {
+                    "version": "6.3.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "shell-quote": {
+                    "version": "1.7.3",
+                    "bundled": true,
+                    "dev": true
+                },
+                "signal-exit": {
+                    "version": "3.0.7",
+                    "bundled": true,
+                    "dev": true
+                },
+                "slice-ansi": {
+                    "version": "3.0.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "ansi-styles": "^4.0.0",
+                        "astral-regex": "^2.0.0",
+                        "is-fullwidth-code-point": "^3.0.0"
+                    },
+                    "dependencies": {
+                        "ansi-styles": {
+                            "version": "4.3.0",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "color-convert": "^2.0.1"
+                            }
+                        },
+                        "color-convert": {
+                            "version": "2.0.1",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "color-name": "~1.1.4"
+                            }
+                        },
+                        "color-name": {
+                            "version": "1.1.4",
+                            "bundled": true,
+                            "dev": true
+                        }
+                    }
+                },
+                "source-map": {
+                    "version": "0.5.7",
+                    "bundled": true,
+                    "dev": true
+                },
+                "stack-utils": {
+                    "version": "2.0.5",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "escape-string-regexp": "^2.0.0"
+                    },
+                    "dependencies": {
+                        "escape-string-regexp": {
+                            "version": "2.0.0",
+                            "bundled": true,
+                            "dev": true
+                        }
+                    }
+                },
+                "string-width": {
+                    "version": "4.2.3",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "emoji-regex": "^8.0.0",
+                        "is-fullwidth-code-point": "^3.0.0",
+                        "strip-ansi": "^6.0.1"
+                    }
+                },
+                "strip-ansi": {
+                    "version": "6.0.1",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "ansi-regex": "^5.0.1"
+                    }
+                },
+                "supports-color": {
+                    "version": "5.5.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "has-flag": "^3.0.0"
+                    }
+                },
+                "tap-parser": {
+                    "version": "11.0.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "events-to-array": "^1.0.1",
+                        "minipass": "^3.1.6",
+                        "tap-yaml": "^1.0.0"
+                    }
+                },
+                "tap-yaml": {
+                    "version": "1.0.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "yaml": "^1.10.2"
+                    }
+                },
+                "tcompare": {
+                    "version": "5.0.7",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz",
+                    "integrity": "sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^4.0.2"
+                    }
+                },
+                "to-fast-properties": {
+                    "version": "2.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "treport": {
+                    "version": "3.0.4",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@isaacs/import-jsx": "^4.0.1",
+                        "cardinal": "^2.1.1",
+                        "chalk": "^3.0.0",
+                        "ink": "^3.2.0",
+                        "ms": "^2.1.2",
+                        "tap-parser": "^11.0.0",
+                        "tap-yaml": "^1.0.0",
+                        "unicode-length": "^2.0.2"
+                    },
+                    "dependencies": {
+                        "ansi-styles": {
+                            "version": "4.3.0",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "color-convert": "^2.0.1"
+                            }
+                        },
+                        "chalk": {
+                            "version": "3.0.0",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "ansi-styles": "^4.1.0",
+                                "supports-color": "^7.1.0"
+                            }
+                        },
+                        "color-convert": {
+                            "version": "2.0.1",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "color-name": "~1.1.4"
+                            }
+                        },
+                        "color-name": {
+                            "version": "1.1.4",
+                            "bundled": true,
+                            "dev": true
+                        },
+                        "has-flag": {
+                            "version": "4.0.0",
+                            "bundled": true,
+                            "dev": true
+                        },
+                        "supports-color": {
+                            "version": "7.2.0",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "has-flag": "^4.0.0"
+                            }
+                        }
+                    }
+                },
+                "type-fest": {
+                    "version": "0.12.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "unicode-length": {
+                    "version": "2.0.2",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "punycode": "^2.0.0",
+                        "strip-ansi": "^3.0.1"
+                    },
+                    "dependencies": {
+                        "ansi-regex": {
+                            "version": "2.1.1",
+                            "bundled": true,
+                            "dev": true
+                        },
+                        "strip-ansi": {
+                            "version": "3.0.1",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "ansi-regex": "^2.0.0"
+                            }
+                        }
+                    }
+                },
+                "widest-line": {
+                    "version": "3.1.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "string-width": "^4.0.0"
+                    }
+                },
+                "wrap-ansi": {
+                    "version": "6.2.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "ansi-styles": "^4.0.0",
+                        "string-width": "^4.1.0",
+                        "strip-ansi": "^6.0.0"
+                    },
+                    "dependencies": {
+                        "ansi-styles": {
+                            "version": "4.3.0",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "color-convert": "^2.0.1"
+                            }
+                        },
+                        "color-convert": {
+                            "version": "2.0.1",
+                            "bundled": true,
+                            "dev": true,
+                            "requires": {
+                                "color-name": "~1.1.4"
+                            }
+                        },
+                        "color-name": {
+                            "version": "1.1.4",
+                            "bundled": true,
+                            "dev": true
+                        }
+                    }
+                },
+                "wrappy": {
+                    "version": "1.0.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "ws": {
+                    "version": "7.5.7",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {}
+                },
+                "yallist": {
+                    "version": "4.0.0",
+                    "bundled": true,
+                    "dev": true
+                },
+                "yaml": {
+                    "version": "1.10.2",
+                    "bundled": true,
+                    "dev": true
+                },
+                "yoga-layout-prebuilt": {
+                    "version": "1.10.0",
+                    "bundled": true,
+                    "dev": true,
+                    "requires": {
+                        "@types/yoga-layout": "1.9.2"
+                    }
+                }
+            }
+        },
+        "tap-mocha-reporter": {
+            "version": "5.0.3",
+            "resolved": "https://registry.npmjs.org/tap-mocha-reporter/-/tap-mocha-reporter-5.0.3.tgz",
+            "integrity": "sha512-6zlGkaV4J+XMRFkN0X+yuw6xHbE9jyCZ3WUKfw4KxMyRGOpYSRuuQTRJyWX88WWuLdVTuFbxzwXhXuS2XE6o0g==",
+            "dev": true,
+            "requires": {
+                "color-support": "^1.1.0",
+                "debug": "^4.1.1",
+                "diff": "^4.0.1",
+                "escape-string-regexp": "^2.0.0",
+                "glob": "^7.0.5",
+                "tap-parser": "^11.0.0",
+                "tap-yaml": "^1.0.0",
+                "unicode-length": "^2.0.2"
+            },
+            "dependencies": {
+                "escape-string-regexp": {
+                    "version": "2.0.0",
+                    "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+                    "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+                    "dev": true
+                }
+            }
+        },
+        "tap-parser": {
+            "version": "11.0.2",
+            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-11.0.2.tgz",
+            "integrity": "sha512-6qGlC956rcORw+fg7Fv1iCRAY8/bU9UabUAhs3mXRH6eRmVZcNPLheSXCYaVaYeSwx5xa/1HXZb1537YSvwDZg==",
+            "dev": true,
+            "requires": {
+                "events-to-array": "^1.0.1",
+                "minipass": "^3.1.6",
+                "tap-yaml": "^1.0.0"
+            }
+        },
+        "tap-yaml": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-1.0.2.tgz",
+            "integrity": "sha512-GegASpuqBnRNdT1U+yuUPZ8rEU64pL35WPBpCISWwff4dErS2/438barz7WFJl4Nzh3Y05tfPidZnH+GaV1wMg==",
+            "dev": true,
+            "requires": {
+                "yaml": "^1.10.2"
+            }
+        },
+        "tcompare": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-6.0.0.tgz",
+            "integrity": "sha512-JeX89lSVkxTzYND0LxzFCGrXm/TqGEQ0heu1JTwplnpaYQNky6hIaO4lQBOrs+/P787i3CoK9T/O3/oEcnJXvA==",
+            "dev": true,
+            "requires": {
+                "diff": "^5.1.0"
+            },
+            "dependencies": {
+                "diff": {
+                    "version": "5.1.0",
+                    "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
+                    "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
+                    "dev": true
+                }
+            }
+        },
+        "test-exclude": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+            "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+            "dev": true,
+            "requires": {
+                "@istanbuljs/schema": "^0.1.2",
+                "glob": "^7.1.4",
+                "minimatch": "^3.0.4"
             }
         },
         "text-table": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
-            "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
-            "dev": true
+            "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="
         },
-        "through": {
-            "version": "2.3.8",
-            "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
-            "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
+        "to-fast-properties": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+            "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
             "dev": true
         },
         "to-regex-range": {
@@ -3196,11 +8230,16 @@
                 "is-number": "^7.0.0"
             }
         },
+        "trivial-deferred": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/trivial-deferred/-/trivial-deferred-1.1.2.tgz",
+            "integrity": "sha512-vDPiDBC3hyP6O4JrJYMImW3nl3c03Tsj9fEXc7Qc/XKa1O7gf5ZtFfIR/E0dun9SnDHdwjna1Z2rSzYgqpxh/g==",
+            "dev": true
+        },
         "type-check": {
             "version": "0.4.0",
             "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
             "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
-            "dev": true,
             "requires": {
                 "prelude-ls": "^1.2.1"
             }
@@ -3208,94 +8247,172 @@
         "type-fest": {
             "version": "0.20.2",
             "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
-            "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
-            "dev": true
+            "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="
         },
-        "unbox-primitive": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
-            "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+        "typedarray-to-buffer": {
+            "version": "3.1.5",
+            "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+            "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+            "dev": true,
+            "requires": {
+                "is-typedarray": "^1.0.0"
+            }
+        },
+        "unicode-length": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/unicode-length/-/unicode-length-2.1.0.tgz",
+            "integrity": "sha512-4bV582zTV9Q02RXBxSUMiuN/KHo5w4aTojuKTNT96DIKps/SIawFp7cS5Mu25VuY1AioGXrmYyzKZUzh8OqoUw==",
             "dev": true,
             "requires": {
-                "function-bind": "^1.1.1",
-                "has-bigints": "^1.0.1",
-                "has-symbols": "^1.0.2",
-                "which-boxed-primitive": "^1.0.2"
+                "punycode": "^2.0.0"
+            }
+        },
+        "update-browserslist-db": {
+            "version": "1.0.10",
+            "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz",
+            "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==",
+            "dev": true,
+            "requires": {
+                "escalade": "^3.1.1",
+                "picocolors": "^1.0.0"
             }
         },
         "uri-js": {
             "version": "4.4.1",
             "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
             "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
-            "dev": true,
             "requires": {
                 "punycode": "^2.1.0"
             }
         },
-        "v8-compile-cache": {
-            "version": "2.3.0",
-            "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
-            "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+        "uuid": {
+            "version": "8.3.2",
+            "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+            "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
             "dev": true
         },
         "which": {
             "version": "2.0.2",
             "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
             "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
-            "dev": true,
             "requires": {
                 "isexe": "^2.0.0"
             }
         },
-        "which-boxed-primitive": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
-            "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+        "which-module": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
+            "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==",
+            "dev": true
+        },
+        "word-wrap": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+            "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ=="
+        },
+        "wrap-ansi": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+            "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
             "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"
+                "ansi-styles": "^4.0.0",
+                "string-width": "^4.1.0",
+                "strip-ansi": "^6.0.0"
             }
         },
-        "which-collection": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz",
-            "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==",
+        "wrappy": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+        },
+        "write-file-atomic": {
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+            "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
             "dev": true,
             "requires": {
-                "is-map": "^2.0.1",
-                "is-set": "^2.0.1",
-                "is-weakmap": "^2.0.1",
-                "is-weakset": "^2.0.1"
+                "imurmurhash": "^0.1.4",
+                "is-typedarray": "^1.0.0",
+                "signal-exit": "^3.0.2",
+                "typedarray-to-buffer": "^3.1.5"
             }
         },
-        "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==",
+        "y18n": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+            "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+            "dev": true
+        },
+        "yallist": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+            "dev": true
+        },
+        "yaml": {
+            "version": "1.10.2",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+            "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+            "dev": true
+        },
+        "yargs": {
+            "version": "15.4.1",
+            "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+            "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
             "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"
+                "cliui": "^6.0.0",
+                "decamelize": "^1.2.0",
+                "find-up": "^4.1.0",
+                "get-caller-file": "^2.0.1",
+                "require-directory": "^2.1.1",
+                "require-main-filename": "^2.0.0",
+                "set-blocking": "^2.0.0",
+                "string-width": "^4.2.0",
+                "which-module": "^2.0.0",
+                "y18n": "^4.0.0",
+                "yargs-parser": "^18.1.2"
+            },
+            "dependencies": {
+                "cliui": {
+                    "version": "6.0.0",
+                    "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+                    "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+                    "dev": true,
+                    "requires": {
+                        "string-width": "^4.2.0",
+                        "strip-ansi": "^6.0.0",
+                        "wrap-ansi": "^6.2.0"
+                    }
+                },
+                "wrap-ansi": {
+                    "version": "6.2.0",
+                    "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+                    "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+                    "dev": true,
+                    "requires": {
+                        "ansi-styles": "^4.0.0",
+                        "string-width": "^4.1.0",
+                        "strip-ansi": "^6.0.0"
+                    }
+                }
             }
         },
-        "word-wrap": {
-            "version": "1.2.3",
-            "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
-            "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ=="
+        "yargs-parser": {
+            "version": "18.1.3",
+            "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+            "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+            "dev": true,
+            "requires": {
+                "camelcase": "^5.0.0",
+                "decamelize": "^1.2.0"
+            }
         },
-        "wrappy": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
-            "dev": true
+        "yocto-queue": {
+            "version": "0.1.0",
+            "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+            "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
         }
     }
 }
diff --git a/package.json b/package.json
index 348ca89..67e00fd 100644
--- a/package.json
+++ b/package.json
@@ -8,20 +8,26 @@
         "hsmusic": "./src/upd8.js"
     },
     "scripts": {
-        "test": "tape test/**/*.js",
+        "test": "tap 'test/snapshot/*.js' 'test/unit/**/*.js'",
         "dev": "eslint src && node src/upd8.js"
     },
     "dependencies": {
         "chroma-js": "^2.4.2",
         "command-exists": "^1.2.9",
+        "eslint": "^8.37.0",
         "he": "^1.2.0",
         "js-yaml": "^4.1.0",
+        "marked": "^5.0.2",
         "word-wrap": "^1.2.3"
     },
     "license": "GPL-3.0",
     "devDependencies": {
         "chokidar": "^3.5.3",
-        "eslint": "^8.18.0",
-        "tape": "^5.4.1"
+        "tap": "^16.3.4",
+        "tcompare": "^6.0.0"
+    },
+    "tap": {
+        "coverage": false,
+        "coverage-report": false
     }
 }
diff --git a/src/content-function.js b/src/content-function.js
new file mode 100644
index 0000000..3536d5a
--- /dev/null
+++ b/src/content-function.js
@@ -0,0 +1,478 @@
+import {annotateFunction, empty} from './util/sugar.js';
+
+export default function contentFunction({
+  contentDependencies = [],
+  extraDependencies = [],
+
+  sprawl,
+  relations,
+  data,
+  generate,
+}) {
+  return expectDependencies({
+    sprawl,
+    relations,
+    data,
+    generate,
+
+    expectedContentDependencyKeys: contentDependencies,
+    expectedExtraDependencyKeys: extraDependencies,
+    fulfilledDependencies: {},
+  });
+}
+
+contentFunction.identifyingSymbol = Symbol(`Is a content function?`);
+
+export function expectDependencies({
+  sprawl,
+  relations,
+  data,
+  generate,
+
+  expectedContentDependencyKeys,
+  expectedExtraDependencyKeys,
+  fulfilledDependencies,
+}) {
+  if (!generate) {
+    throw new Error(`Expected generate function`);
+  }
+
+  const hasSprawlFunction = !!sprawl;
+  const hasRelationsFunction = !!relations;
+  const hasDataFunction = !!data;
+
+  if (hasSprawlFunction && !expectedExtraDependencyKeys.includes('wikiData')) {
+    throw new Error(`Content functions which sprawl must specify wikiData in extraDependencies`);
+  }
+
+  const fulfilledDependencyKeys = Object.keys(fulfilledDependencies);
+
+  const invalidatingDependencyKeys = Object.entries(fulfilledDependencies)
+    .filter(([key, value]) => value?.fulfilled === false)
+    .map(([key]) => key);
+
+  const missingContentDependencyKeys = expectedContentDependencyKeys
+    .filter(key => !fulfilledDependencyKeys.includes(key));
+
+  const missingExtraDependencyKeys = expectedExtraDependencyKeys
+    .filter(key => !fulfilledDependencyKeys.includes(key));
+
+  let wrappedGenerate;
+
+  if (!empty(invalidatingDependencyKeys)) {
+    wrappedGenerate = function() {
+      throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${invalidatingDependencyKeys.join(', ')}`);
+    };
+
+    annotateFunction(wrappedGenerate, {name: generate, trait: 'invalidated'});
+    wrappedGenerate.fulfilled = false;
+  } else if (empty(missingContentDependencyKeys) && empty(missingExtraDependencyKeys)) {
+    wrappedGenerate = function(arg1, arg2) {
+      if (hasDataFunction && !arg1) {
+        throw new Error(`Expected data`);
+      }
+
+      if (hasDataFunction && hasRelationsFunction && !arg2) {
+        throw new Error(`Expected relations`);
+      }
+
+      if (hasRelationsFunction && !arg1) {
+        throw new Error(`Expected relations`);
+      }
+
+      if (hasDataFunction && hasRelationsFunction) {
+        return generate(arg1, arg2, fulfilledDependencies);
+      } else if (hasDataFunction || hasRelationsFunction) {
+        return generate(arg1, fulfilledDependencies);
+      } else {
+        return generate(fulfilledDependencies);
+      }
+    };
+
+    annotateFunction(wrappedGenerate, {name: generate, trait: 'fulfilled'});
+    wrappedGenerate.fulfilled = true;
+
+    wrappedGenerate.fulfill = function() {
+      throw new Error(`All dependencies already fulfilled`);
+    };
+  } else {
+    wrappedGenerate = function() {
+      throw new Error(`Dependencies still needed: ${missingContentDependencyKeys.concat(missingExtraDependencyKeys).join(', ')}`);
+    };
+
+    annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'});
+    wrappedGenerate.fulfilled = false;
+  }
+
+  wrappedGenerate[contentFunction.identifyingSymbol] = true;
+
+  if (hasSprawlFunction) {
+    wrappedGenerate.sprawl = sprawl;
+  }
+
+  if (hasRelationsFunction) {
+    wrappedGenerate.relations = relations;
+  }
+
+  if (hasDataFunction) {
+    wrappedGenerate.data = data;
+  }
+
+  wrappedGenerate.fulfill ??= function fulfill(dependencies) {
+    return expectDependencies({
+      sprawl,
+      relations,
+      data,
+      generate,
+
+      expectedContentDependencyKeys,
+      expectedExtraDependencyKeys,
+
+      fulfilledDependencies: fulfillDependencies({
+        name: generate.name,
+        dependencies,
+
+        expectedContentDependencyKeys,
+        expectedExtraDependencyKeys,
+        fulfilledDependencies,
+      }),
+    });
+  };
+
+  Object.assign(wrappedGenerate, {
+    contentDependencies: expectedContentDependencyKeys,
+    extraDependencies: expectedExtraDependencyKeys,
+  });
+
+  return wrappedGenerate;
+}
+
+export function fulfillDependencies({
+  name,
+  dependencies,
+  expectedContentDependencyKeys,
+  expectedExtraDependencyKeys,
+  fulfilledDependencies,
+}) {
+  const newFulfilledDependencies = {...fulfilledDependencies};
+  const fulfilledDependencyKeys = Object.keys(fulfilledDependencies);
+
+  const errors = [];
+  let bail = false;
+
+  for (let [key, value] of Object.entries(dependencies)) {
+    if (fulfilledDependencyKeys.includes(key)) {
+      errors.push(new Error(`Dependency ${key} is already fulfilled`));
+      bail = true;
+      continue;
+    }
+
+    const isContentKey = expectedContentDependencyKeys.includes(key);
+    const isExtraKey = expectedExtraDependencyKeys.includes(key);
+
+    if (!isContentKey && !isExtraKey) {
+      errors.push(new Error(`Dependency ${key} is not expected`));
+      bail = true;
+      continue;
+    }
+
+    if (value === undefined) {
+      errors.push(new Error(`Dependency ${key} was provided undefined`));
+      bail = true;
+      continue;
+    }
+
+    if (isContentKey && !value?.[contentFunction.identifyingSymbol]) {
+      errors.push(new Error(`Content dependency ${key} is not a content function (got ${value})`));
+      bail = true;
+      continue;
+    }
+
+    if (isExtraKey && value?.[contentFunction.identifyingSymbol]) {
+      errors.push(new Error(`Extra dependency ${key} is a content function`));
+      bail = true;
+      continue;
+    }
+
+    if (!bail) {
+      newFulfilledDependencies[key] = value;
+    }
+  }
+
+  if (!empty(errors)) {
+    throw new AggregateError(errors, `Errors fulfilling dependencies for ${name}`);
+  }
+
+  return newFulfilledDependencies;
+}
+
+export function getRelationsTree(dependencies, contentFunctionName, wikiData, ...args) {
+  const relationIdentifier = Symbol('Relation');
+
+  function recursive(contentFunctionName, ...args) {
+    const contentFunction = dependencies[contentFunctionName];
+    if (!contentFunction) {
+      throw new Error(`Couldn't find dependency ${contentFunctionName}`);
+    }
+
+    if (!contentFunction.relations) {
+      return null;
+    }
+
+    const listedDependencies = new Set(contentFunction.contentDependencies);
+
+    // TODO: Evaluating a sprawl might belong somewhere better than here, lol...
+    const sprawl =
+      (contentFunction.sprawl
+        ? contentFunction.sprawl(wikiData, ...args)
+        : null)
+
+    const relationSlots = {};
+
+    const relationSymbolMessage = (() => {
+      let num = 1;
+      return name => `#${num++} ${name}`;
+    })();
+
+    const relationFunction = (name, ...args) => {
+      if (!listedDependencies.has(name)) {
+        throw new Error(`Called relation('${name}') but ${contentFunctionName} doesn't list that dependency`);
+      }
+
+      const relationSymbol = Symbol(relationSymbolMessage(name));
+      relationSlots[relationSymbol] = {name, args};
+      return {[relationIdentifier]: relationSymbol};
+    };
+
+    const relationsLayout =
+      (sprawl
+        ? contentFunction.relations(relationFunction, sprawl, ...args)
+        : contentFunction.relations(relationFunction, ...args))
+
+    const relationsTree = Object.fromEntries(
+      Object.getOwnPropertySymbols(relationSlots)
+        .map(symbol => [symbol, relationSlots[symbol]])
+        .map(([symbol, {name, args}]) => [
+          symbol,
+          recursive(name, ...args),
+        ]));
+
+    return {
+      layout: relationsLayout,
+      slots: relationSlots,
+      tree: relationsTree,
+    };
+  }
+
+  const relationsTree = recursive(contentFunctionName, ...args);
+
+  return {
+    root: {
+      name: contentFunctionName,
+      args,
+      relations: relationsTree?.layout,
+    },
+
+    relationIdentifier,
+    relationsTree,
+  };
+}
+
+export function flattenRelationsTree({
+  root,
+  relationIdentifier,
+  relationsTree,
+}) {
+  const flatRelationSlots = {};
+
+  function recursive({layout, slots, tree}) {
+    for (const slot of Object.getOwnPropertySymbols(slots)) {
+      if (tree[slot]) {
+        recursive(tree[slot]);
+      }
+
+      flatRelationSlots[slot] = {
+        name: slots[slot].name,
+        args: slots[slot].args,
+        relations: tree[slot]?.layout ?? null,
+      };
+    }
+  }
+
+  if (relationsTree) {
+    recursive(relationsTree);
+  }
+
+  return {
+    root,
+    relationIdentifier,
+    flatRelationSlots,
+  };
+}
+
+export function fillRelationsLayoutFromSlotResults(relationIdentifier, results, layout) {
+  function recursive(object) {
+    if (typeof object !== 'object' || object === null) {
+      return object;
+    }
+
+    if (Array.isArray(object)) {
+      return object.map(recursive);
+    }
+
+    if (relationIdentifier in object) {
+      return results[object[relationIdentifier]];
+    }
+
+    if (object.constructor !== Object) {
+      throw new Error(`Expected primitive, array, relation, or normal {key: value} style Object`);
+    }
+
+    return Object.fromEntries(
+      Object.entries(object)
+        .map(([key, value]) => [key, recursive(value)]));
+  }
+
+  return recursive(layout);
+}
+
+export function getNeededContentDependencyNames(contentDependencies, name) {
+  const set = new Set();
+
+  function recursive(name) {
+    const contentFunction = contentDependencies[name];
+    for (const dependencyName of contentFunction?.contentDependencies ?? []) {
+      recursive(dependencyName);
+    }
+    set.add(name);
+  }
+
+  recursive(name);
+
+  return set;
+}
+
+export function quickEvaluate({
+  contentDependencies: allContentDependencies,
+  extraDependencies: allExtraDependencies,
+
+  name,
+  args = [],
+  multiple = null,
+  postprocess = null,
+}) {
+  if (multiple !== null) {
+    return multiple.map(opts =>
+      quickEvaluate({
+        contentDependencies: allContentDependencies,
+        extraDependencies: allExtraDependencies,
+
+        ...opts,
+        name: opts.name ?? name,
+        args: opts.args ?? args,
+        postprocess: opts.postprocess ?? postprocess,
+      }));
+  }
+
+  const treeInfo = getRelationsTree(allContentDependencies, name, allExtraDependencies.wikiData ?? {}, ...args);
+  const flatTreeInfo = flattenRelationsTree(treeInfo);
+  const {root, relationIdentifier, flatRelationSlots} = flatTreeInfo;
+
+  const neededContentDependencyNames =
+    getNeededContentDependencyNames(allContentDependencies, name);
+
+  // Content functions aren't recursive, so by following the set above
+  // sequentually, we will always provide fulfilled content functions as the
+  // dependencies for later content functions.
+  const fulfilledContentDependencies = {};
+  for (const name of neededContentDependencyNames) {
+    const unfulfilledContentFunction = allContentDependencies[name];
+    if (!unfulfilledContentFunction) continue;
+
+    const {contentDependencies, extraDependencies} = unfulfilledContentFunction;
+
+    if (empty(contentDependencies) && empty(extraDependencies)) {
+      fulfilledContentDependencies[name] = unfulfilledContentFunction;
+      continue;
+    }
+
+    const fulfillments = {};
+
+    for (const dependencyName of contentDependencies ?? []) {
+      if (dependencyName in fulfilledContentDependencies) {
+        fulfillments[dependencyName] =
+          fulfilledContentDependencies[dependencyName];
+      }
+    }
+
+    for (const dependencyName of extraDependencies ?? []) {
+      if (dependencyName in allExtraDependencies) {
+        fulfillments[dependencyName] =
+          allExtraDependencies[dependencyName];
+      }
+    }
+
+    fulfilledContentDependencies[name] =
+      unfulfilledContentFunction.fulfill(fulfillments);
+  }
+
+  // There might still be unfulfilled content functions if dependencies weren't
+  // provided as part of allContentDependencies or allExtraDependencies.
+  // Catch and report these early, together in an aggregate error.
+  const unfulfilledErrors = [];
+  const unfulfilledNames = [];
+  for (const name of neededContentDependencyNames) {
+    const contentFunction = fulfilledContentDependencies[name];
+    if (!contentFunction) continue;
+    if (!contentFunction.fulfilled) {
+      try {
+        contentFunction();
+      } catch (error) {
+        error.message = `(${name}) ${error.message}`;
+        unfulfilledErrors.push(error);
+        unfulfilledNames.push(name);
+      }
+    }
+  }
+
+  if (!empty(unfulfilledErrors)) {
+    throw new AggregateError(unfulfilledErrors, `Content functions unfulfilled (${unfulfilledNames.join(', ')})`);
+  }
+
+  const slotResults = {};
+
+  function runContentFunction({name, args, relations: flatRelations}) {
+    const contentFunction = fulfilledContentDependencies[name];
+
+    if (!contentFunction) {
+      throw new Error(`Content function ${name} unfulfilled or not listed`);
+    }
+
+    const sprawl =
+      contentFunction.sprawl?.(allExtraDependencies.wikiData, ...args);
+
+    const relations =
+      fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, flatRelations);
+
+    const data =
+      (sprawl
+        ? contentFunction.data?.(sprawl, ...args)
+        : contentFunction.data?.(...args));
+
+    const generateArgs = [data, relations].filter(Boolean);
+
+    return contentFunction(...generateArgs);
+  }
+
+  for (const slot of Object.getOwnPropertySymbols(flatRelationSlots)) {
+    slotResults[slot] = runContentFunction(flatRelationSlots[slot]);
+  }
+
+  const topLevelResult = runContentFunction(root);
+
+  if (postprocess !== null) {
+    return postprocess(topLevelResult);
+  } else {
+    return topLevelResult;
+  }
+}
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
new file mode 100644
index 0000000..eb9fc8b
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -0,0 +1,121 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: [
+    'html',
+    'language',
+  ],
+
+  data(additionalFiles, {fileSize = true} = {}) {
+    return {
+      // Additional files are already a serializable format.
+      additionalFiles,
+      showFileSizes: fileSize,
+    };
+  },
+
+  generate(data, {
+    html,
+    language,
+  }) {
+    const fileKeys = data.additionalFiles.flatMap(({files}) => files);
+    const validateFileMapping = (v, validateValue) => {
+      return value => {
+        v.isObject(value);
+
+        // It's OK to skip values for files, but if keys are provided for files
+        // which don't exist, that's an error.
+
+        const unexpectedKeys =
+          Object.keys(value).filter(key => !fileKeys.includes(key))
+
+        if (!empty(unexpectedKeys)) {
+          throw new TypeError(`Unexpected file keys: ${unexpectedKeys.join(', ')}`);
+        }
+
+        const valueErrors = [];
+        for (const [fileKey, fileValue] of Object.entries(value)) {
+          if (fileValue === null) {
+            continue;
+          }
+
+          try {
+            validateValue(fileValue);
+          } catch (error) {
+            error.message = `(${fileKey}) ` + error.message;
+            valueErrors.push(error);
+          }
+        }
+
+        if (!empty(valueErrors)) {
+          throw new AggregateError(valueErrors, `Errors validating values`);
+        }
+      };
+    };
+
+    return html.template({
+      annotation: 'generateAdditionalFilesList',
+
+      slots: {
+        fileLinks: {
+          validate: v => validateFileMapping(v, v.isHTML),
+        },
+
+        fileSizes: {
+          validate: v => validateFileMapping(v, v.isWholeNumber),
+        },
+      },
+
+      content(slots) {
+        if (!slots.fileSizes) {
+          return html.blank();
+        }
+
+        const filesWithLinks = new Set(
+          Object.entries(slots.fileLinks)
+            .filter(([key, value]) => value)
+            .map(([key]) => key));
+
+        if (filesWithLinks.size === 0) {
+          return html.blank();
+        }
+
+        const filteredFileGroups = data.additionalFiles
+          .map(({title, description, files}) => ({
+            title,
+            description,
+            files: files.filter(f => filesWithLinks.has(f)),
+          }))
+          .filter(({files}) => !empty(files));
+
+        if (empty(filteredFileGroups)) {
+          return html.blank();
+        }
+
+        return html.tag('dl',
+          filteredFileGroups.flatMap(({title, description, files}) => [
+            html.tag('dt',
+              (description
+                ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
+                    title,
+                    description,
+                  })
+                : language.$('releaseInfo.additionalFiles.entry', {title}))),
+
+            html.tag('dd',
+              html.tag('ul',
+                files.map(file =>
+                  html.tag('li',
+                    (slots.fileSizes[file]
+                      ? language.$('releaseInfo.additionalFiles.file.withSize', {
+                          file: slots.fileLinks[file],
+                          size: language.formatFileSize(slots.fileSizes[file]),
+                        })
+                      : language.$('releaseInfo.additionalFiles.file', {
+                          file: slots.fileLinks[file],
+                        })))))),
+          ]));
+      },
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js
new file mode 100644
index 0000000..7dfe07b
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesShortcut.js
@@ -0,0 +1,33 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: [
+    'html',
+    'language',
+  ],
+
+  data(additionalFiles) {
+    return {
+      titles: additionalFiles.map(fileGroup => fileGroup.title),
+    };
+  },
+
+  generate(data, {
+    html,
+    language,
+  }) {
+    if (empty(data.titles)) {
+      return html.blank();
+    }
+
+    return language.$('releaseInfo.additionalFiles.shortcut', {
+      anchorLink:
+        html.tag('a',
+          {href: '#additional-files'},
+          language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
+
+      titles:
+        language.formatUnitList(data.titles),
+    });
+  },
+}
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
new file mode 100644
index 0000000..5fd4e05
--- /dev/null
+++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js
@@ -0,0 +1,57 @@
+export default {
+  contentDependencies: [
+    'generateAdditionalFilesList',
+    'linkAlbumAdditionalFile',
+  ],
+
+  extraDependencies: [
+    'getSizeOfAdditionalFile',
+    'urls',
+  ],
+
+  data(album, additionalFiles, {fileSize = true} = {}) {
+    return {
+      albumDirectory: album.directory,
+      fileLocations: additionalFiles.flatMap(({files}) => files),
+      showFileSizes: fileSize,
+    };
+  },
+
+  relations(relation, album, additionalFiles, {fileSize = true} = {}) {
+    return {
+      additionalFilesList:
+        relation('generateAdditionalFilesList', additionalFiles, {
+          fileSize,
+        }),
+
+      additionalFileLinks:
+        Object.fromEntries(
+          additionalFiles
+            .flatMap(({files}) => files)
+            .map(file => [
+              file,
+              relation('linkAlbumAdditionalFile', album, file),
+            ])),
+    };
+  },
+
+  generate(data, relations, {
+    getSizeOfAdditionalFile,
+    urls,
+  }) {
+    return relations.additionalFilesList
+      .slots({
+        fileLinks: relations.additionalFileLinks,
+        fileSizes:
+          Object.fromEntries(data.fileLocations.map(file => [
+            file,
+            (data.showFileSizes
+              ? getSizeOfAdditionalFile(
+                  urls
+                    .from('media.root')
+                    .to('media.albumAdditionalFile', data.albumDirectory, file))
+              : 0),
+          ])),
+      });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
new file mode 100644
index 0000000..749dd2a
--- /dev/null
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -0,0 +1,99 @@
+import getChronologyRelations from '../util/getChronologyRelations.js';
+import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateAlbumInfoPageContent',
+    'generateAlbumNavAccent',
+    'generateAlbumSidebar',
+    'generateAlbumSocialEmbed',
+    'generateAlbumStyleRules',
+    'generateChronologyLinks',
+    'generateColorStyleRules',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkArtist',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['language'],
+
+  relations(relation, album) {
+    return {
+      layout: relation('generatePageLayout'),
+
+      coverArtistChronologyContributions: getChronologyRelations(album, {
+        contributions: album.coverArtistContribs,
+
+        linkArtist: artist => relation('linkArtist', artist),
+
+        linkThing: trackOrAlbum =>
+          (trackOrAlbum.album
+            ? relation('linkTrack', trackOrAlbum)
+            : relation('linkAlbum', trackOrAlbum)),
+
+        getThings: artist =>
+          sortAlbumsTracksChronologically([
+            ...artist.albumsAsCoverArtist,
+            ...artist.tracksAsCoverArtist,
+          ]),
+      }),
+
+      albumNavAccent: relation('generateAlbumNavAccent', album, null),
+      chronologyLinks: relation('generateChronologyLinks'),
+
+      content: relation('generateAlbumInfoPageContent', album),
+      sidebar: relation('generateAlbumSidebar', album, null),
+      socialEmbed: relation('generateAlbumSocialEmbed', album),
+      albumStyleRules: relation('generateAlbumStyleRules', album),
+      colorStyleRules: relation('generateColorStyleRules', album.color),
+    };
+  },
+
+  data(album) {
+    return {
+      name: album.name,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.layout
+      .slots({
+        title: language.$('albumPage.title', {album: data.name}),
+        headingMode: 'sticky',
+
+        colorStyleRules: [relations.colorStyleRules],
+        additionalStyleRules: [relations.albumStyleRules],
+
+        cover: relations.content.cover,
+        mainContent: relations.content.main.content,
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {
+            auto: 'current',
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: true,
+                showExtraLinks: true,
+              }),
+          },
+        ],
+
+        navContent:
+          relations.chronologyLinks.slots({
+            chronologyInfoSets: [
+              {
+                headingString: 'misc.chronology.heading.coverArt',
+                contributions: relations.coverArtistChronologyContributions,
+              },
+            ],
+          }),
+
+        ...relations.sidebar,
+
+        // socialEmbed: relations.socialEmbed,
+      });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumInfoPageContent.js b/src/content/dependencies/generateAlbumInfoPageContent.js
new file mode 100644
index 0000000..5d2817e
--- /dev/null
+++ b/src/content/dependencies/generateAlbumInfoPageContent.js
@@ -0,0 +1,307 @@
+import {accumulateSum, empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalFilesShortcut',
+    'generateAlbumAdditionalFilesList',
+    'generateAlbumTrackList',
+    'generateContentHeading',
+    'generateCoverArtwork',
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+    'linkContribution',
+    'linkExternal',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    const relations = {};
+    const sections = relations.sections = {};
+
+    const contributionLinksRelation = contribs =>
+      contribs.map(contrib =>
+        relation('linkContribution', contrib.who, contrib.what));
+
+    // Section: Release info
+
+    const releaseInfo = sections.releaseInfo = {};
+
+    if (!empty(album.artistContribs)) {
+      releaseInfo.artistContributionLinks =
+        contributionLinksRelation(album.artistContribs);
+    }
+
+    if (album.hasCoverArt) {
+      relations.cover =
+        relation('generateCoverArtwork', album.artTags);
+      releaseInfo.coverArtistContributionLinks =
+        contributionLinksRelation(album.coverArtistContribs);
+    } else {
+      relations.cover = null;
+    }
+
+    if (album.hasWallpaperArt) {
+      releaseInfo.wallpaperArtistContributionLinks =
+        contributionLinksRelation(album.wallpaperArtistContribs);
+    }
+
+    if (album.hasBannerArt) {
+      releaseInfo.bannerArtistContributionLinks =
+        contributionLinksRelation(album.bannerArtistContribs);
+    }
+
+    // Section: Listen on
+
+    if (!empty(album.urls)) {
+      const listen = sections.listen = {};
+
+      listen.externalLinks =
+        album.urls.map(url =>
+          relation('linkExternal', url, {type: 'album'}));
+    }
+
+    // Section: Extra links
+
+    const extra = sections.extra = {};
+
+    if (album.tracks.some(t => t.hasUniqueCoverArt)) {
+      extra.galleryLink =
+        relation('linkAlbumGallery', album);
+    }
+
+    if (album.commentary || album.tracks.some(t => t.commentary)) {
+      extra.commentaryLink =
+        relation('linkAlbumCommentary', album);
+    }
+
+    if (!empty(album.additionalFiles)) {
+      extra.additionalFilesShortcut =
+        relation('generateAdditionalFilesShortcut', album.additionalFiles);
+    }
+
+    // Section: Track list
+
+    relations.trackList =
+      relation('generateAlbumTrackList', album);
+
+    // Section: Additional files
+
+    if (!empty(album.additionalFiles)) {
+      const additionalFiles = sections.additionalFiles = {};
+
+      additionalFiles.heading =
+        relation('generateContentHeading');
+
+      additionalFiles.additionalFilesList =
+        relation('generateAlbumAdditionalFilesList', album, album.additionalFiles);
+    }
+
+    // Section: Artist commentary
+
+    if (album.commentary) {
+      const artistCommentary = sections.artistCommentary = {};
+
+      artistCommentary.heading =
+        relation('generateContentHeading');
+
+      artistCommentary.content =
+        relation('transformContent', album.commentary);
+    }
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    data.date = album.date;
+    data.duration = accumulateSum(album.tracks, track => track.duration);
+    data.durationApproximate = album.tracks.length > 1;
+
+    data.hasCoverArt = album.hasCoverArt;
+
+    if (album.hasCoverArt) {
+      data.coverArtDirectory = album.directory;
+      data.coverArtFileExtension = album.coverArtFileExtension;
+
+      if (album.coverArtDate && +album.coverArtDate !== +album.date) {
+        data.coverArtDate = album.coverArtDate;
+      }
+    }
+
+    if (!empty(album.additionalFiles)) {
+      data.numAdditionalFiles = album.additionalFiles.length;
+    }
+
+    data.dateAddedToWiki = album.dateAddedToWiki;
+
+    return data;
+  },
+
+  generate(data, relations, {
+    html,
+    language,
+  }) {
+    const content = {};
+
+    const {sections: sec} = relations;
+
+    const formatContributions =
+      (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) =>
+        contributionLinks &&
+          language.$(stringKey, {
+            artists:
+              language.formatConjunctionList(
+                contributionLinks.map(link =>
+                  link.slots({showContribution, showIcons}))),
+          });
+
+    if (data.hasCoverArt) {
+      content.cover = relations.cover
+        .slots({
+          path: ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension],
+          alt: language.$('misc.alt.trackCover')
+        });
+    } else {
+      content.cover = null;
+    }
+
+    content.main = {
+      headingMode: 'sticky',
+      content: html.tags([
+        html.tag('p',
+          {
+            [html.onlyIfContent]: true,
+            [html.joinChildren]: html.tag('br'),
+          },
+          [
+            formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks),
+            formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks),
+            formatContributions('releaseInfo.wallpaperArtBy', sec.releaseInfo.wallpaperArtistContributionLinks),
+            formatContributions('releaseInfo.bannerArtBy', sec.releaseInfo.bannerArtistContributionLinks),
+
+            data.date &&
+              language.$('releaseInfo.released', {
+                date: language.formatDate(data.date),
+              }),
+
+            data.coverArtDate &&
+              language.$('releaseInfo.artReleased', {
+                date: language.formatDate(data.coverArtDate),
+              }),
+
+            data.duration &&
+              language.$('releaseInfo.duration', {
+                duration:
+                  language.formatDuration(data.duration, {
+                    approximate: data.durationApproximate,
+                  }),
+              }),
+          ]),
+
+        sec.listen &&
+          html.tag('p',
+            language.$('releaseInfo.listenOn', {
+              links: language.formatDisjunctionList(sec.listen.externalLinks),
+            })),
+
+        html.tag('p',
+          {
+            [html.onlyIfContent]: true,
+            [html.joinChildren]: html.tag('br'),
+          },
+          [
+            sec.extra.additionalFilesShortcut,
+
+            sec.extra.galleryLink && sec.extra.commentaryLink &&
+              language.$('releaseInfo.viewGalleryOrCommentary', {
+                gallery:
+                  sec.extra.galleryLink
+                    .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')),
+                commentary:
+                  sec.extra.commentaryLink
+                    .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')),
+              }),
+
+            sec.extra.galleryLink && !sec.extra.commentaryLink &&
+              language.$('releaseInfo.viewGallery', {
+                link:
+                  sec.extra.galleryLink
+                    .slot('content', language.$('releaseInfo.viewGallery.link')),
+              }),
+
+            !sec.extra.galleryLink && sec.extra.commentaryLink &&
+              language.$('releaseInfo.viewCommentary', {
+                link:
+                  sec.extra.commentaryLink
+                    .slot('content', language.$('releaseInfo.viewCommentary.link')),
+              }),
+          ]),
+
+        relations.trackList,
+
+        html.tag('p',
+          {
+            [html.onlyIfContent]: true,
+            [html.joinChildren]: '<br>',
+          },
+          [
+            data.dateAddedToWiki &&
+              language.$('releaseInfo.addedToWiki', {
+                date: language.formatDate(data.dateAddedToWiki),
+              }),
+          ]),
+
+        sec.additionalFiles && [
+          sec.additionalFiles.heading
+            .slots({
+              id: 'additional-files',
+              title:
+                language.$('releaseInfo.additionalFiles.heading', {
+                  additionalFiles:
+                    language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
+                }),
+            }),
+
+          sec.additionalFiles.additionalFilesList,
+        ],
+
+        sec.artistCommentary && [
+          sec.artistCommentary.heading
+            .slots({
+              id: 'artist-commentary',
+              title: language.$('releaseInfo.artistCommentary')
+            }),
+
+          html.tag('blockquote',
+            sec.artistCommentary.content
+              .slot('mode', 'multiline')),
+        ],
+      ]),
+    };
+
+    return content;
+  },
+};
+
+/*
+  banner: !empty(album.bannerArtistContribs) && {
+    dimensions: album.bannerDimensions,
+    path: [
+      'media.albumBanner',
+      album.directory,
+      album.bannerFileExtension,
+    ],
+    alt: language.$('misc.alt.albumBanner'),
+    position: 'top',
+  },
+
+  secondaryNav: generateAlbumSecondaryNav(album, null, {
+    getLinkThemeString,
+    html,
+    language,
+    link,
+  }),
+*/
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
new file mode 100644
index 0000000..9d1d87c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -0,0 +1,120 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generatePreviousNextLinks',
+    'linkTrack',
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album, track) {
+    const relations = {};
+
+    relations.previousNextLinks =
+      relation('generatePreviousNextLinks');
+
+    relations.previousTrackLink = null;
+    relations.nextTrackLink = null;
+
+    if (track) {
+      const index = album.tracks.indexOf(track);
+
+      if (index > 0) {
+        relations.previousTrackLink =
+          relation('linkTrack', album.tracks[index - 1]);
+      }
+
+      if (index < album.tracks.length - 1) {
+        relations.nextTrackLink =
+          relation('linkTrack', album.tracks[index + 1]);
+      }
+    }
+
+    if (album.tracks.some(t => t.hasUniqueCoverArt)) {
+      relations.albumGalleryLink =
+        relation('linkAlbumGallery', album);
+    }
+
+    if (album.commentary || album.tracks.some(t => t.commentary)) {
+      relations.albumCommentaryLink =
+        relation('linkAlbumCommentary', album);
+    }
+
+    return relations;
+  },
+
+  data(album, track) {
+    return {
+      hasMultipleTracks: album.tracks.length > 1,
+      isTrackPage: !!track,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return html.template({
+      annotation: `generateAlbumNavAccent`,
+
+      slots: {
+        showTrackNavigation: {type: 'boolean', default: false},
+        showExtraLinks: {type: 'boolean', default: false},
+
+        currentExtra: {
+          validate: v => v.is('gallery', 'commentary'),
+        },
+      },
+
+      content(slots) {
+        const {content: extraLinks = []} =
+          slots.showExtraLinks &&
+            {content: [
+              relations.albumGalleryLink?.slots({
+                attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+                content: language.$('albumPage.nav.gallery'),
+              }),
+
+              relations.albumCommentaryLink?.slots({
+                attributes: {class: slots.currentExtra === 'commentary' && 'current'},
+                content: language.$('albumPage.nav.commentary'),
+              }),
+            ]};
+
+        const {content: previousNextLinks = []} =
+          slots.showTrackNavigation &&
+          data.isTrackPage &&
+          data.hasMultipleTracks &&
+            relations.previousNextLinks.slots({
+              previousLink: relations.previousTrackLink,
+              nextLink: relations.nextTrackLink,
+            });
+
+        const randomLink =
+          slots.showTrackNavigation &&
+          data.hasMultipleTracks &&
+            html.tag('a',
+              {
+                href: '#',
+                'data-random': 'track-in-album',
+                id: 'random-button',
+              },
+              (data.isTrackPage
+                ? language.$('trackPage.nav.random')
+                : language.$('albumPage.nav.randomTrack')));
+
+        const allLinks = [
+          ...previousNextLinks,
+          ...extraLinks,
+          randomLink,
+        ].filter(Boolean);
+
+        if (empty(allLinks)) {
+          return html.blank();
+        }
+
+        return `(${language.formatUnitList(allLinks)})`
+      },
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
new file mode 100644
index 0000000..bf6b091
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -0,0 +1,76 @@
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarGroupBox',
+    'generateAlbumSidebarTrackSection',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+
+  relations(relation, album, track) {
+    const relations = {};
+
+    relations.albumLink =
+      relation('linkAlbum', album);
+
+    relations.groupBoxes =
+      album.groups.map(group =>
+        relation('generateAlbumSidebarGroupBox', album, group));
+
+    relations.trackSections =
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection', album, track, trackSection));
+
+    return relations;
+  },
+
+  data(album, track) {
+    return {isAlbumPage: !track};
+  },
+
+  generate(data, relations, {html}) {
+    const trackListBox = {
+      content:
+        html.tags([
+          html.tag('h1', relations.albumLink),
+          relations.trackSections,
+        ]),
+    };
+
+    if (data.isAlbumPage) {
+      const groupBoxes =
+        relations.groupBoxes
+          .map(content => content.slot('isAlbumPage', true))
+          .map(content => ({content}));
+
+      return {
+        leftSidebarMultiple: [
+          ...groupBoxes,
+          trackListBox,
+        ],
+      };
+    }
+
+    const conjoinedGroupBox = {
+      content:
+        relations.groupBoxes
+          .flatMap((content, i, {length}) => [
+            content,
+            i < length - 1 &&
+              html.tag('hr', {
+                style: `border-color: var(--primary-color); border-style: none none dotted none`
+              }),
+          ])
+          .filter(Boolean),
+    };
+
+    return {
+      // leftSidebarStickyMode: 'column',
+      leftSidebarMultiple: [
+        trackListBox,
+        conjoinedGroupBox,
+      ],
+    };
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
new file mode 100644
index 0000000..1c27af9
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -0,0 +1,88 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'linkAlbum',
+    'linkExternal',
+    'linkGroup',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album, group) {
+    const relations = {};
+
+    relations.groupLink =
+      relation('linkGroup', group);
+
+    relations.externalLinks =
+      group.urls.map(url =>
+        relation('linkExternal', url));
+
+    const albums = group.albums.filter(album => album.date);
+    const index = albums.indexOf(album);
+    const previousAlbum = (index > 0) && albums[index - 1];
+    const nextAlbum = (index < albums.length - 1) && albums[index + 1];
+
+    if (group.descriptionShort) {
+      relations.description =
+        relation('transformContent', group.descriptionShort);
+    }
+
+    if (previousAlbum) {
+      relations.previousAlbumLink =
+        relation('linkAlbum', previousAlbum);
+    }
+
+    if (nextAlbum) {
+      relations.nextAlbumLink =
+        relation('linkAlbum', nextAlbum);
+    }
+
+    return relations;
+  },
+
+  generate(relations, {html, language}) {
+    return html.template({
+      annotation: `generateAlbumSidebarGroupBox`,
+
+      slots: {
+        isAlbumPage: {type: 'boolean', default: false},
+      },
+
+      content(slots) {
+        return html.tags([
+          html.tag('h1',
+            language.$('albumSidebar.groupBox.title', {
+              group: relations.groupLink,
+            })),
+
+          slots.isAlbumPage &&
+            relations.description
+              ?.slot('mode', 'multiline'),
+
+          !empty(relations.externalLinks) &&
+            html.tag('p',
+              language.$('releaseInfo.visitOn', {
+                links: language.formatDisjunctionList(relations.externalLinks),
+              })),
+
+          slots.isAlbumPage &&
+          relations.nextAlbumLink &&
+            html.tag('p', {class: 'group-chronology-link'},
+              language.$('albumSidebar.groupBox.next', {
+                album: relations.nextAlbumLink,
+              })),
+
+          slots.isAlbumPage &&
+          relations.previousAlbumLink &&
+            html.tag('p', {class: 'group-chronology-link'},
+              language.$('albumSidebar.groupBox.previous', {
+                album: relations.previousAlbumLink,
+              })),
+        ]);
+      },
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
new file mode 100644
index 0000000..2aca6da
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -0,0 +1,98 @@
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['getColors', 'html', 'language'],
+
+  relations(relation, album, track, trackSection) {
+    const relations = {};
+
+    relations.trackLinks =
+      trackSection.tracks.map(track =>
+        relation('linkTrack', track));
+
+    return relations;
+  },
+
+  data(album, track, trackSection) {
+    const data = {};
+
+    data.hasTrackNumbers = album.hasTrackNumbers;
+    data.isTrackPage = !!track;
+
+    data.name = trackSection.name;
+    data.color = trackSection.color;
+    data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
+
+    data.firstTrackNumber = trackSection.startIndex + 1;
+    data.lastTrackNumber = trackSection.startIndex + trackSection.tracks.length;
+
+    if (track) {
+      const index = trackSection.tracks.indexOf(track);
+      if (index !== -1) {
+        data.includesCurrentTrack = true;
+        data.currentTrackIndex = index;
+      }
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {getColors, html, language}) {
+    const sectionName =
+      html.tag('span', {class: 'group-name'},
+        (data.isDefaultTrackSection
+          ? language.$('albumSidebar.trackList.fallbackSectionName')
+          : data.name));
+
+    let style;
+    if (data.color) {
+      const {primary} = getColors(data.color);
+      style = `--primary-color: ${primary}`;
+    }
+
+    const trackListItems =
+      relations.trackLinks.map((trackLink, index) =>
+        html.tag('li',
+          {
+            class:
+              data.includesCurrentTrack &&
+              index === data.currentTrackIndex &&
+              'current',
+          },
+          language.$('albumSidebar.trackList.item', {
+            track: trackLink,
+          })));
+
+    return html.tag('details',
+      {
+        class: data.includesCurrentTrack && 'current',
+
+        open: (
+          // Leave sidebar track sections collapsed on album info page,
+          // since there's already a view of the full track listing
+          // in the main content area.
+          data.isTrackPage &&
+
+          // Only expand the track section which includes the track
+          // currently being viewed by default.
+          data.includesCurrentTrack),
+      },
+      [
+        html.tag('summary', {style},
+          html.tag('span',
+            (data.hasTrackNumbers
+              ? language.$('albumSidebar.trackList.group.withRange', {
+                  group: sectionName,
+                  range: `${data.firstTrackNumber}&ndash;${data.lastTrackNumber}`
+                })
+              : language.$('albumSidebar.trackList.group', {
+                  group: sectionName,
+                })))),
+
+        (data.hasTrackNumbers
+          ? html.tag('ol',
+              {start: data.firstTrackNumber},
+              trackListItems)
+          : html.tag('ul', trackListItems)),
+      ]);
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js
new file mode 100644
index 0000000..656bd99
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -0,0 +1,85 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateAlbumSocialEmbedDescription',
+  ],
+
+  extraDependencies: [
+    'absoluteTo',
+    'language',
+    'urls',
+  ],
+
+  relations(relation, album) {
+    const relations = {};
+
+    relations.description =
+      relation('generateAlbumSocialEmbedDescription', album);
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    data.hasHeading = !empty(album.groups);
+
+    if (data.hasHeading) {
+      const firstGroup = album.groups[0];
+      data.headingGroupName = firstGroup.directory;
+      data.headingGroupDirectory = firstGroup.directory;
+    }
+
+    data.hasImage = album.hasCoverArt;
+
+    if (data.hasImage) {
+      data.coverArtDirectory = album.directory;
+      data.coverArtFileExtension = album.coverArtFileExtension;
+    }
+
+    data.albumName = album.name;
+    data.albumColor = album.color;
+
+    return data;
+  },
+
+  generate(data, relations, {
+    absoluteTo,
+    language,
+    urls,
+  }) {
+    const socialEmbed = {};
+
+    if (data.hasHeading) {
+      socialEmbed.heading =
+        language.$('albumPage.socialEmbed.heading', {
+          group: data.headingGroupName,
+        });
+
+      socialEmbed.headingLink =
+        absoluteTo('localized.album', data.headingGroupDirectory);
+    } else {
+      socialEmbed.heading = '';
+      socialEmbed.headingLink = null;
+    }
+
+    socialEmbed.title =
+      language.$('albumPage.socialEmbed.title', {
+        album: data.albumName,
+      });
+
+    socialEmbed.description = relations.description;
+
+    if (data.hasImage) {
+      const imagePath = urls
+        .from('shared.root')
+        .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension);
+      socialEmbed.image = '/' + imagePath;
+    }
+
+    socialEmbed.color = data.albumColor;
+
+    return socialEmbed;
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
new file mode 100644
index 0000000..5fa67b2
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
@@ -0,0 +1,50 @@
+import {accumulateSum} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: ['language'],
+
+  data(album) {
+    const data = {};
+
+    const duration = accumulateSum(album.tracks, track => track.duration);
+
+    data.hasDuration = duration > 0;
+    data.hasTracks = album.tracks.length > 0;
+    data.hasDate = !!album.date;
+    data.hasAny = (data.hasDuration || data.hasTracks || data.hasDuration);
+
+    if (!data.hasAny)
+      return data;
+
+    if (data.hasDuration)
+      data.duration = duration;
+
+    if (data.hasTracks)
+      data.tracks = album.tracks.length;
+
+    if (data.hasDate)
+      data.date = album.date;
+
+    return data;
+  },
+
+  generate(data, {
+    language,
+  }) {
+    return language.formatString(
+      'albumPage.socialEmbed.body' + [
+        data.hasDuration && '.withDuration',
+        data.hasTracks && '.withTracks',
+        data.hasDate && '.withReleaseDate',
+      ].filter(Boolean).join(''),
+
+      Object.fromEntries([
+        data.hasDuration &&
+          ['duration', language.formatDuration(data.duration)],
+        data.hasTracks &&
+          ['tracks', language.countTracks(data.tracks, {unit: true})],
+        data.hasDate &&
+          ['date', language.formatDate(data.date)],
+      ].filter(Boolean)));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js
new file mode 100644
index 0000000..c954783
--- /dev/null
+++ b/src/content/dependencies/generateAlbumStyleRules.js
@@ -0,0 +1,61 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: [
+    'to',
+  ],
+
+  data(album) {
+    const data = {};
+
+    data.hasWallpaper = !empty(album.wallpaperArtistContribs);
+    data.hasBanner = !empty(album.bannerArtistContribs);
+
+    if (data.hasWallpaper) {
+      data.hasWallpaperStyle = !!album.wallpaperStyle;
+      data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension];
+      data.wallpaperStyle = album.wallpaperStyle;
+    }
+
+    if (data.hasBanner) {
+      data.hasBannerStyle = !!album.bannerStyle;
+      data.bannerStyle = album.bannerStyle;
+    }
+
+    return data;
+  },
+
+  generate(data, {to}) {
+    const wallpaperPart =
+      (data.hasWallpaper
+        ? [
+            `body::before {`,
+            `    background-image: url("${to(...data.wallpaperPath)}");`,
+            ...(data.hasWallpaperStyle
+              ? data.wallpaperStyle
+                  .split('\n')
+                  .map(line => `    ${line}`)
+              : []),
+            `}`,
+          ]
+        : []);
+
+    const bannerPart =
+      (data.hasBannerStyle
+        ? [
+            `#banner img {`,
+            ...data.bannerStyle
+              .split('\n')
+              .map(line => `    ${line}`),
+            `}`,
+          ]
+        : []);
+
+    return [
+      ...wallpaperPart,
+      ...bannerPart,
+    ]
+      .filter(Boolean)
+      .join('\n');
+  },
+};
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
new file mode 100644
index 0000000..ce17495
--- /dev/null
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -0,0 +1,126 @@
+import {accumulateSum, empty} from '../../util/sugar.js';
+
+function displayTrackSections(album) {
+  if (empty(album.trackSections)) {
+    return false;
+  }
+
+  if (album.trackSections.length > 1) {
+    return true;
+  }
+
+  if (!album.trackSections[0].isDefaultTrackSection) {
+    return true;
+  }
+
+  return false;
+}
+
+function displayTracks(album) {
+  if (empty(album.tracks)) {
+    return false;
+  }
+
+  return true;
+}
+
+function getDisplayMode(album) {
+  if (displayTrackSections(album)) {
+    return 'trackSections';
+  } else if (displayTracks(album)) {
+    return 'tracks';
+  } else {
+    return 'none';
+  }
+}
+
+export default {
+  contentDependencies: [
+    'generateAlbumTrackListItem',
+  ],
+
+  extraDependencies: [
+    'html',
+    'language',
+  ],
+
+  relations(relation, album) {
+    const relations = {};
+
+    const displayMode = getDisplayMode(album);
+
+    if (displayMode === 'trackSections') {
+      relations.itemsByTrackSection =
+        album.trackSections.map(section =>
+          section.tracks.map(track =>
+            relation('generateAlbumTrackListItem', track, album)));
+    }
+
+    if (displayMode === 'tracks') {
+      relations.itemsByTrack =
+        album.tracks.map(track =>
+          relation('generateAlbumTrackListItem', track, album));
+    }
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    data.hasTrackNumbers = album.hasTrackNumbers;
+
+    if (displayTrackSections && !empty(album.trackSections)) {
+      data.trackSectionInfo =
+        album.trackSections.map(section => {
+          const info = {};
+
+          info.name = section.name;
+          info.duration = accumulateSum(section.tracks, track => track.duration);
+          info.durationApproximate = section.tracks.length > 1;
+
+          if (album.hasTrackNumbers) {
+            info.startIndex = section.startIndex;
+          }
+
+          return info;
+        });
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {
+    html,
+    language,
+  }) {
+    const listTag = (data.hasTrackNumbers ? 'ol' : 'ul');
+
+    if (relations.itemsByTrackSection) {
+      return html.tag('dl',
+        {class: 'album-group-list'},
+        data.trackSectionInfo.map((info, index) => [
+          html.tag('dt',
+            {class: 'content-heading', tabindex: '0'},
+            language.$('trackList.section.withDuration', {
+              section: info.name,
+              duration:
+                language.formatDuration(info.duration, {
+                  approximate: info.durationApproximate,
+                }),
+            })),
+
+          html.tag('dd',
+            html.tag(listTag,
+              data.hasTrackNumbers ? {start: info.startIndex + 1} : {},
+              relations.itemsByTrackSection[index])),
+        ]));
+    }
+
+    if (relations.itemsByTrack) {
+      return html.tag(listTag, relations.itemsByTrack);
+    }
+
+    return html.blank();
+  }
+};
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
new file mode 100644
index 0000000..fe46153
--- /dev/null
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -0,0 +1,75 @@
+import {compareArrays} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'linkContribution',
+    'linkTrack',
+  ],
+
+  extraDependencies: [
+    'getColors',
+    'html',
+    'language',
+  ],
+
+  relations(relation, track) {
+    const relations = {};
+
+    relations.contributionLinks =
+      track.artistContribs.map(({who, what}) =>
+        relation('linkContribution', who, what, {
+          showContribution: false,
+          showIcons: false,
+        }));
+
+    relations.trackLink =
+      relation('linkTrack', track);
+
+    return relations;
+  },
+
+  data(track, album) {
+    const data = {};
+
+    data.color = track.color;
+    data.duration = track.duration ?? 0;
+
+    data.showArtists =
+      !compareArrays(
+        track.artistContribs.map(c => c.who),
+        album.artistContribs.map(c => c.who),
+        {checkOrder: false});
+
+    return data;
+  },
+
+  generate(data, relations, {
+    getColors,
+    html,
+    language,
+  }) {
+    const stringOpts = {
+      duration: language.formatDuration(data.duration),
+      track: relations.trackLink,
+    };
+
+    let style;
+    if (data.color) {
+      const {primary} = getColors(data.color);
+      style = `--primary-color: ${primary}`;
+    }
+
+    return html.tag('li',
+      {style},
+      (!data.showArtists
+        ? language.$('trackList.item.withDuration', stringOpts)
+        : language.$('trackList.item.withDuration.withArtists', {
+            ...stringOpts,
+            by:
+              html.tag('span', {class: 'by'},
+                language.$('trackList.item.withArtists.by', {
+                  artists: language.formatConjunctionList(relations.contributionLinks),
+                })),
+          })));
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
new file mode 100644
index 0000000..a91eebf
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -0,0 +1,818 @@
+import {empty, filterProperties, unique} from '../../util/sugar.js';
+
+import {
+  chunkByProperties,
+  getTotalDuration,
+  sortAlbumsTracksChronologically,
+} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateArtistNavLinks',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkArtist',
+    'linkArtistGallery',
+    'linkExternal',
+    'linkGroup',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, artist) {
+    const relations = {};
+    const sections = relations.sections = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.artistNavLinks =
+      relation('generateArtistNavLinks', artist);
+
+    const processContribs = (...contribArrays) => {
+      const properties = {};
+
+      const ownContribs =
+        contribArrays
+          .map(contribs => contribs.find(({who}) => who === artist))
+          .filter(Boolean);
+
+      const contributionDescriptions =
+        ownContribs
+          .map(({what}) => what)
+          .filter(Boolean);
+
+      if (!empty(contributionDescriptions)) {
+        properties.contributionDescriptions = contributionDescriptions;
+      }
+
+      const otherArtistContribs =
+        contribArrays
+          .map(contribs => contribs.filter(({who}) => who !== artist))
+          .flat();
+
+      if (!empty(otherArtistContribs)) {
+        properties.otherArtistLinks =
+          otherArtistContribs
+            .map(({who}) => relation('linkArtist', who));
+      }
+
+      return properties;
+    };
+
+    const sortContributionEntries = (entries, sortFunction) => {
+      const things = unique(entries.map(({thing}) => thing));
+      sortFunction(things);
+
+      const outputArrays = [];
+      const thingToOutputArray = new Map();
+
+      for (const thing of things) {
+        const array = [];
+        thingToOutputArray.set(thing, array);
+        outputArrays.push(array);
+      }
+
+      for (const entry of entries) {
+        thingToOutputArray.get(entry.thing).push(entry);
+      }
+
+      entries.splice(0, entries.length, ...outputArrays.flat());
+    };
+
+    const getGroupInfo = (entries) => {
+      const allGroups = new Set();
+      const groupToDuration = new Map();
+      const groupToCount = new Map();
+
+      for (const entry of entries) {
+        for (const group of entry.album.groups) {
+          allGroups.add(group);
+          groupToCount.set(group, (groupToCount.get(group) ?? 0) + 1);
+          groupToDuration.set(group, (groupToDuration.get(group) ?? 0) + entry.duration ?? 0);
+        }
+      }
+
+      const groupInfo =
+        Array.from(allGroups)
+          .map(group => ({
+            groupLink: relation('linkGroup', group),
+            duration: groupToDuration.get(group) ?? 0,
+            count: groupToCount.get(group),
+          }));
+
+      groupInfo.sort((a, b) => b.count - a.count);
+      groupInfo.sort((a, b) => b.duration - a.duration);
+
+      return groupInfo;
+    };
+
+    if (artist.contextNotes) {
+      const contextNotes = sections.contextNotes = {};
+      contextNotes.content = relation('transformContent', artist.contextNotes);
+    }
+
+    if (!empty(artist.urls)) {
+      const visit = sections.visit = {};
+      visit.externalLinks =
+        artist.urls.map(url =>
+          relation('linkExternal', url));
+    }
+
+    const trackContributionEntries = [
+      ...artist.tracksAsArtist.map(track => ({
+        date: track.date,
+        thing: track,
+        album: track.album,
+        duration: track.duration,
+        rerelease: track.originalReleaseTrack !== null,
+        trackLink: relation('linkTrack', track),
+        ...processContribs(track.artistContribs),
+      })),
+
+      ...artist.tracksAsContributor.map(track => ({
+        date: track.date,
+        thing: track,
+        album: track.album,
+        duration: track.duration,
+        rerelease: track.originalReleaseTrack !== null,
+        trackLink: relation('linkTrack', track),
+        ...processContribs(track.contributorContribs),
+      })),
+    ];
+
+    sortContributionEntries(trackContributionEntries, sortAlbumsTracksChronologically);
+
+    const trackContributionChunks =
+      chunkByProperties(trackContributionEntries, ['album', 'date'])
+        .map(({album, date, chunk}) => ({
+          albumLink: relation('linkAlbum', album),
+          date: +date,
+          duration: getTotalDuration(chunk),
+          entries: chunk
+            .map(entry =>
+              filterProperties(entry, [
+                'contributionDescriptions',
+                'duration',
+                'otherArtistLinks',
+                'rerelease',
+                'trackLink',
+              ])),
+        }));
+
+    const trackGroupInfo = getGroupInfo(trackContributionEntries, 'duration');
+
+    if (!empty(trackContributionChunks)) {
+      const tracks = sections.tracks = {};
+      tracks.heading = relation('generateContentHeading');
+      tracks.chunks = trackContributionChunks;
+
+      if (!empty(trackGroupInfo)) {
+        tracks.groupInfo = trackGroupInfo;
+      }
+    }
+
+    // TODO: Add and integrate wallpaper and banner date fields (#90)
+    const artContributionEntries = [
+      ...artist.albumsAsCoverArtist.map(album => ({
+        kind: 'albumCover',
+        date: album.coverArtDate,
+        thing: album,
+        album: album,
+        ...processContribs(album.coverArtistContribs),
+      })),
+
+      ...artist.albumsAsWallpaperArtist.map(album => ({
+        kind: 'albumWallpaper',
+        date: album.coverArtDate,
+        thing: album,
+        album: album,
+        ...processContribs(album.wallpaperArtistContribs),
+      })),
+
+      ...artist.albumsAsBannerArtist.map(album => ({
+        kind: 'albumBanner',
+        date: album.coverArtDate,
+        thing: album,
+        album: album,
+        ...processContribs(album.bannerArtistContribs),
+      })),
+
+      ...artist.tracksAsCoverArtist.map(track => ({
+        kind: 'trackCover',
+        date: track.coverArtDate,
+        thing: track,
+        album: track.album,
+        rerelease: track.originalReleaseTrack !== null,
+        trackLink: relation('linkTrack', track),
+        ...processContribs(track.coverArtistContribs),
+      })),
+    ];
+
+    sortContributionEntries(artContributionEntries, sortAlbumsTracksChronologically);
+
+    const artContributionChunks =
+      chunkByProperties(artContributionEntries, ['album', 'date'])
+        .map(({album, date, chunk}) => ({
+          albumLink: relation('linkAlbum', album),
+          date: +date,
+          entries:
+            chunk.map(entry =>
+              filterProperties(entry, [
+                'contributionDescriptions',
+                'kind',
+                'otherArtistLinks',
+                'rerelease',
+                'trackLink',
+              ])),
+        }));
+
+    const artGroupInfo = getGroupInfo(artContributionEntries, 'count');
+
+    if (!empty(artContributionChunks)) {
+      const artworks = sections.artworks = {};
+      artworks.heading = relation('generateContentHeading');
+      artworks.chunks = artContributionChunks;
+
+      if (
+        !empty(artist.albumsAsCoverArtist) ||
+        !empty(artist.tracksAsCoverArtist)
+      ) {
+        artworks.artistGalleryLink =
+          relation('linkArtistGallery', artist);
+      }
+
+      if (!empty(artGroupInfo)) {
+        artworks.groupInfo = artGroupInfo;
+      }
+    }
+
+    // Commentary doesn't use the detailed contribution system where multiple
+    // artists are collaboratively credited for the same piece, so there isn't
+    // really anything special to do for processing or presenting it.
+
+    const commentaryEntries = [
+      ...artist.albumsAsCommentator.map(album => ({
+        kind: 'albumCommentary',
+        date: album.date,
+        thing: album,
+        album: album,
+      })),
+
+      ...artist.tracksAsCommentator.map(track => ({
+        kind: 'trackCommentary',
+        date: track.date,
+        thing: track,
+        album: track.album,
+        trackLink: relation('linkTrack', track),
+      })),
+    ];
+
+    sortContributionEntries(commentaryEntries, sortAlbumsTracksChronologically);
+
+    // We still pass through (and chunk by) date here, even though it doesn't
+    // actually get displayed on the album page. See issue #193.
+    const commentaryChunks =
+      chunkByProperties(commentaryEntries, ['album', 'date'])
+        .map(({album, date, chunk}) => ({
+          albumLink: relation('linkAlbum', album),
+          date: +date,
+          entries:
+            chunk.map(entry =>
+              filterProperties(entry, [
+                'kind',
+                'trackLink',
+              ])),
+        }));
+
+    if (!empty(commentaryChunks)) {
+      const commentary = sections.commentary = {};
+      commentary.heading = relation('generateContentHeading');
+      commentary.chunks = commentaryChunks;
+    }
+
+    return relations;
+  },
+
+  data(artist) {
+    const data = {};
+
+    data.name = artist.name;
+
+    const allTracks = unique([...artist.tracksAsArtist, ...artist.tracksAsContributor]);
+    data.totalTrackCount = allTracks.length;
+    data.totalDuration = getTotalDuration(allTracks, {originalReleasesOnly: true});
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
+    const addAccentsToEntry = ({
+      rerelease,
+      entry,
+      otherArtistLinks,
+      contributionDescriptions,
+    }) => {
+      if (rerelease) {
+        return language.$('artistPage.creditList.entry.rerelease', {entry});
+      }
+
+      const options = {entry};
+      const parts = ['artistPage.creditList.entry'];
+
+      if (otherArtistLinks) {
+        parts.push('withArtists');
+        options.artists = language.formatConjunctionList(otherArtistLinks);
+      }
+
+      if (contributionDescriptions) {
+        parts.push('withContribution');
+        options.contribution = language.formatUnitList(contributionDescriptions);
+      }
+
+      if (parts.length === 1) {
+        return entry;
+      }
+
+      return language.formatString(parts.join('.'), options);
+    };
+
+    const addAccentsToAlbumLink = ({
+      albumLink,
+      date,
+      duration,
+      entries,
+    }) => {
+      const options = {album: albumLink};
+      const parts = ['artistPage.creditList.album'];
+
+      if (date) {
+        parts.push('withDate');
+        options.date = language.formatDate(new Date(date));
+      }
+
+      if (duration) {
+        parts.push('withDuration');
+        options.duration = language.formatDuration(duration, {
+          approximate: entries.length > 1,
+        });
+      }
+
+      return language.formatString(parts.join('.'), options);
+    };
+
+    return relations.layout
+      .slots({
+        title: data.name,
+        headingMode: 'sticky',
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          sec.contextNotes && [
+            html.tag('p', language.$('releaseInfo.note')),
+            html.tag('blockquote',
+              sec.contextNotes.content),
+          ],
+
+          sec.visit &&
+            html.tag('p',
+              language.$('releaseInfo.visitOn', {
+                links: language.formatDisjunctionList(sec.visit.externalLinks),
+              })),
+
+          sec.artworks?.artistGalleryLink &&
+            html.tag('p',
+              language.$('artistPage.viewArtGallery', {
+                link: sec.artworks.artistGalleryLink.slots({
+                  content: language.$('artistPage.viewArtGallery.link'),
+                }),
+              })),
+
+          (sec.tracks || sec.artworsk || sec.flashes || sec.commentary) &&
+            html.tag('p',
+              language.$('misc.jumpTo.withLinks', {
+                links: language.formatUnitList(
+                  [
+                    sec.tracks &&
+                      html.tag('a',
+                        {href: '#tracks'},
+                        language.$('artistPage.trackList.title')),
+
+                    sec.artworks &&
+                      html.tag('a',
+                        {href: '#art'},
+                        language.$('artistPage.artList.title')),
+
+                    sec.flashes &&
+                      html.tag('a',
+                        {href: '#flashes'},
+                        language.$('artistPage.flashList.title')),
+
+                    sec.commentary &&
+                      html.tag('a',
+                        {href: '#commentary'},
+                        language.$('artistPage.commentaryList.title')),
+                  ].filter(Boolean)),
+              })),
+
+          sec.tracks && [
+            sec.tracks.heading
+              .slots({
+                tag: 'h2',
+                id: 'tracks',
+                title: language.$('artistPage.trackList.title'),
+              }),
+
+            data.totalDuration > 0 &&
+              html.tag('p',
+                language.$('artistPage.contributedDurationLine', {
+                  artist: data.name,
+                  duration:
+                    language.formatDuration(data.totalDuration, {
+                      approximate: data.totalTrackCount > 1,
+                      unit: true,
+                    }),
+                })),
+
+            sec.tracks.groupInfo &&
+              html.tag('p',
+                language.$('artistPage.musicGroupsLine', {
+                  groups:
+                    language.formatUnitList(
+                      sec.tracks.groupInfo.map(({groupLink, count, duration}) =>
+                        (duration
+                          ? language.$('artistPage.groupsLine.item.withDuration', {
+                              group: groupLink,
+                              duration: language.formatDuration(duration, {approximate: count > 1}),
+                            })
+                          : language.$('artistPage.groupsLine.item.withCount', {
+                              group: groupLink,
+                              count: language.countContributions(count),
+                            })))),
+                })),
+
+            html.tag('dl',
+              sec.tracks.chunks.map(({albumLink, date, duration, entries}) => [
+                html.tag('dt',
+                  addAccentsToAlbumLink({albumLink, date, duration, entries})),
+
+                html.tag('dd',
+                  html.tag('ul',
+                    entries
+                      .map(({trackLink, duration, ...properties}) => ({
+                        entry:
+                          (duration
+                            ? language.$('artistPage.creditList.entry.track.withDuration', {
+                                track: trackLink,
+                                duration: language.formatDuration(duration),
+                              })
+                            : language.$('artistPage.creditList.entry.track', {
+                                track: trackLink,
+                              })),
+                        ...properties,
+                      }))
+                      .map(addAccentsToEntry)
+                      .map(entry => html.tag('li', entry)))),
+              ])),
+          ],
+
+          sec.artworks && [
+            sec.artworks.heading
+              .slots({
+                tag: 'h2',
+                id: 'art',
+                title: language.$('artistPage.artList.title'),
+              }),
+
+            sec.artworks.artistGalleryLink &&
+              html.tag('p',
+                language.$('artistPage.viewArtGallery.orBrowseList', {
+                  link: sec.artworks.artistGalleryLink.slots({
+                    content: language.$('artistPage.viewArtGallery.link'),
+                  }),
+                })),
+
+            sec.artworks.groupInfo &&
+              html.tag('p',
+                language.$('artistPage.artGroupsLine', {
+                  groups:
+                    language.formatUnitList(
+                      sec.artworks.groupInfo.map(({groupLink, count}) =>
+                        language.$('artistPage.groupsLine.item.withCount', {
+                          group: groupLink,
+                          count:
+                            language.countContributions(count),
+                        }))),
+                })),
+
+            html.tag('dl',
+              sec.artworks.chunks.map(({albumLink, date, entries}) => [
+                html.tag('dt',
+                  addAccentsToAlbumLink({albumLink, date, entries})),
+
+                html.tag('dd',
+                  html.tag('ul',
+                    entries
+                      .map(({kind, trackLink, ...properties}) => ({
+                        entry:
+                          (kind === 'trackCover'
+                            ? language.$('artistPage.creditList.entry.track', {
+                                track: trackLink,
+                              })
+                            : html.tag('i',
+                                language.$('artistPage.creditList.entry.album.' + {
+                                  albumWallpaper: 'wallpaperArt',
+                                  albumBanner: 'bannerArt',
+                                  albumCover: 'coverArt',
+                                }[kind]))),
+                        ...properties,
+                      }))
+                      .map(addAccentsToEntry)
+                      .map(entry => html.tag('li', entry)))),
+              ])),
+          ],
+
+          sec.commentary && [
+            sec.commentary.heading
+              .slots({
+                tag: 'h2',
+                id: 'commentary',
+                title: language.$('artistPage.commentaryList.title'),
+              }),
+
+            html.tag('dl',
+              sec.commentary.chunks.map(({albumLink, entries}) => [
+                html.tag('dt',
+                  language.$('artistPage.creditList.album', {
+                    album: albumLink,
+                  })),
+
+                html.tag('dd',
+                  html.tag('ul',
+                    entries
+                      .map(({kind, trackLink}) =>
+                        (kind === 'trackCommentary'
+                          ? language.$('artistPage.creditList.entry.track', {
+                              track: trackLink,
+                            })
+                          : html.tag('i',
+                              language.$('artistPage.creditList.entry.album.commentary'))))
+                      .map(entry => html.tag('li', entry)))),
+              ])),
+          ],
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.artistNavLinks
+            .slots({
+              showExtraLinks: true,
+            })
+            .content,
+      });
+  },
+};
+
+/*
+
+export function write(artist, {wikiData}) {
+  const {groupData, wikiInfo} = wikiData;
+
+  let flashes, flashListChunks;
+  if (wikiInfo.enableFlashesAndGames) {
+    flashes = sortFlashesChronologically(artist.flashesAsContributor.slice());
+    flashListChunks = chunkByProperties(
+      flashes.map((flash) => ({
+        act: flash.act,
+        flash,
+        date: flash.date,
+        // Manual artists/contrib properties here, 8ecause we don't
+        // want to show the full list of other contri8utors inline.
+        // (It can often 8e very, very large!)
+        artists: [],
+        contrib: flash.contributorContribs.find(({who}) => who === artist),
+      })),
+      ['act']
+    ).map(({act, chunk}) => ({
+      act,
+      chunk,
+      dateFirst: chunk[0].date,
+      dateLast: chunk[chunk.length - 1].date,
+    }));
+  }
+
+  const unbound_serializeArtistsAndContrib =
+    (key, {serializeContribs, serializeLink}) =>
+    (thing) => {
+      const {artists, contrib} = getArtistsAndContrib(thing, key);
+      const ret = {};
+      ret.link = serializeLink(thing);
+      if (contrib.what) ret.contribution = contrib.what;
+      if (!empty(artists)) ret.otherArtists = serializeContribs(artists);
+      return ret;
+    };
+
+  const unbound_serializeTrackListChunks = (chunks, {serializeLink}) =>
+    chunks.map(({date, album, chunk, duration}) => ({
+      album: serializeLink(album),
+      date,
+      duration,
+      tracks: chunk.map(({track}) => ({
+        link: serializeLink(track),
+        duration: track.duration,
+      })),
+    }));
+
+  const jumpTo = {
+    tracks: !empty(allTracks),
+    art: !empty(artThingsAll),
+    flashes: wikiInfo.enableFlashesAndGames && !empty(flashes),
+    commentary: !empty(commentaryThings),
+  };
+
+  const showJumpTo = Object.values(jumpTo).includes(true);
+
+  const data = {
+    type: 'data',
+    path: ['artist', artist.directory],
+    data: ({serializeContribs, serializeLink}) => {
+      const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, {
+        serializeContribs,
+        serializeLink,
+      });
+
+      const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, {
+        serializeLink,
+      });
+
+      return {
+        albums: {
+          asCoverArtist: artist.albumsAsCoverArtist
+            .map(serializeArtistsAndContrib('coverArtistContribs')),
+          asWallpaperArtist: artist.albumsAsWallpaperArtist
+            .map(serializeArtistsAndContrib('wallpaperArtistContribs')),
+          asBannerArtist: artist.albumsAsBannerArtis
+            .map(serializeArtistsAndContrib('bannerArtistContribs')),
+        },
+        flashes: wikiInfo.enableFlashesAndGames
+          ? {
+              asContributor: artist.flashesAsContributor
+                .map(flash => getArtistsAndContrib(flash, 'contributorContribs'))
+                .map(({contrib, thing: flash}) => ({
+                  link: serializeLink(flash),
+                  contribution: contrib.what,
+                })),
+            }
+          : null,
+        tracks: {
+          asArtist: artist.tracksAsArtist
+            .map(serializeArtistsAndContrib('artistContribs')),
+          asContributor: artist.tracksAsContributo
+            .map(serializeArtistsAndContrib('contributorContribs')),
+          chunked: serializeTrackListChunks(trackListChunks),
+        },
+      };
+    },
+  };
+
+  const infoPage = {
+    type: 'page',
+    path: ['artist', artist.directory],
+    page: ({
+      fancifyURL,
+      generateInfoGalleryLinks,
+      getArtistAvatar,
+      getArtistString,
+      html,
+      link,
+      language,
+      transformMultiline,
+    }) => {
+      const generateTrackList = bindOpts(unbound_generateTrackList, {
+        getArtistString,
+        html,
+        language,
+        link,
+      });
+
+      return {
+        title: language.$('artistPage.title', {artist: name}),
+
+        cover: artist.hasAvatar && {
+          src: getArtistAvatar(artist),
+          alt: language.$('misc.alt.artistAvatar'),
+        },
+
+        main: {
+          headingMode: 'sticky',
+
+          content: [
+            ...html.fragment(
+              wikiInfo.enableFlashesAndGames &&
+              !empty(flashes) && [
+                html.tag('h2',
+                  {id: 'flashes', class: ['content-heading']},
+                  language.$('artistPage.flashList.title')),
+
+                html.tag('dl',
+                  flashListChunks.flatMap(({
+                    act,
+                    chunk,
+                    dateFirst,
+                    dateLast,
+                  }) => [
+                    html.tag('dt',
+                      language.$('artistPage.creditList.flashAct.withDateRange', {
+                        act: link.flash(chunk[0].flash, {
+                          text: act.name,
+                        }),
+                        dateRange: language.formatDateRange(
+                          dateFirst,
+                          dateLast
+                        ),
+                      })),
+
+                    html.tag('dd',
+                      html.tag('ul',
+                        chunk
+                          .map(({flash, ...props}) => ({
+                            ...props,
+                            entry: language.$('artistPage.creditList.entry.flash', {
+                              flash: link.flash(flash),
+                            }),
+                          }))
+                          .map(opts => generateEntryAccents({
+                            getArtistString,
+                            language,
+                            ...opts,
+                          }))
+                          .map(row => html.tag('li', row)))),
+                  ])),
+              ]),
+          ],
+        },
+      };
+    },
+  };
+
+  const artThingsGallery = sortAlbumsTracksChronologically(
+    [
+      ...(artist.albumsAsCoverArtist ?? []),
+      ...(artist.tracksAsCoverArtist ?? []),
+    ],
+    {latestFirst: true, getDate: (o) => o.coverArtDate});
+
+  const galleryPage = hasGallery && {
+    type: 'page',
+    path: ['artistGallery', artist.directory],
+    page: ({
+      generateInfoGalleryLinks,
+      getAlbumCover,
+      getGridHTML,
+      getTrackCover,
+      html,
+      link,
+      language,
+    }) => ({
+      title: language.$('artistGalleryPage.title', {artist: name}),
+
+      main: {
+        classes: ['top-index'],
+        headingMode: 'static',
+
+        content: [
+          html.tag('p',
+            {class: 'quick-info'},
+            language.$('artistGalleryPage.infoLine', {
+              coverArts: language.countCoverArts(artThingsGallery.length, {
+                unit: true,
+              }),
+            })),
+
+          html.tag('div',
+            {class: 'grid-listing'},
+            getGridHTML({
+              entries: artThingsGallery.map((item) => ({item})),
+              srcFn: (thing) =>
+                thing.album
+                  ? getTrackCover(thing)
+                  : getAlbumCover(thing),
+              linkFn: (thing, opts) =>
+                thing.album
+                  ? link.track(thing, opts)
+                  : link.album(thing, opts),
+            })),
+        ],
+      },
+    }),
+  };
+
+  return [data, infoPage, galleryPage].filter(Boolean);
+}
+
+*/
diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js
new file mode 100644
index 0000000..f283b30
--- /dev/null
+++ b/src/content/dependencies/generateArtistNavLinks.js
@@ -0,0 +1,105 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'linkArtist',
+    'linkArtistGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableListings: wikiInfo.enableListings,
+    };
+  },
+
+  relations(relation, sprawl, artist) {
+    const relations = {};
+
+    relations.artistMainLink =
+      relation('linkArtist', artist);
+
+    relations.artistInfoLink =
+      relation('linkArtist', artist);
+
+    if (
+      !empty(artist.albumsAsCoverArtist) ||
+      !empty(artist.tracksAsCoverArtist)
+    ) {
+      relations.artistGalleryLink =
+        relation('linkArtistGallery', artist);
+    }
+
+    return relations;
+  },
+
+  data(sprawl) {
+    return {
+      enableListings: sprawl.enableListings,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return html.template({
+      annotation: `generateArtistNav`,
+      slots: {
+        showExtraLinks: {type: 'boolean', default: false},
+
+        currentExtra: {
+          validate: v => v.is('gallery'),
+        },
+      },
+
+      content(slots) {
+        const infoLink =
+          relations.artistInfoLink?.slots({
+            attributes: {class: slots.currentExtra === null && 'current'},
+            content: language.$('misc.nav.info'),
+          });
+
+        const {content: extraLinks = []} =
+          slots.showExtraLinks &&
+            {content: [
+              relations.artistGalleryLink?.slots({
+                attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+                content: language.$('misc.nav.gallery'),
+              }),
+            ]};
+
+        const mostAccentLinks = [
+          ...extraLinks,
+        ].filter(Boolean);
+
+        // Don't show the info accent link all on its own.
+        const allAccentLinks =
+          (empty(mostAccentLinks)
+            ? []
+            : [infoLink, ...mostAccentLinks]);
+
+        const accent =
+          (empty(allAccentLinks)
+            ? html.blank()
+            : `(${language.formatUnitList(allAccentLinks)})`);
+
+        return [
+          {auto: 'home'},
+
+          data.enableListings &&
+            {
+              path: ['localized.listingIndex'],
+              title: language.$('listingIndex.title'),
+            },
+
+          {
+            accent,
+            html:
+              language.$('artistPage.nav.artist', {
+                artist: relations.artistMainLink,
+              }),
+          },
+        ];
+      },
+    });
+  },
+};
diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js
new file mode 100644
index 0000000..a61b5e6
--- /dev/null
+++ b/src/content/dependencies/generateChronologyLinks.js
@@ -0,0 +1,88 @@
+import {accumulateSum, empty} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  generate({html, language}) {
+    return html.template({
+      annotation: `generateChronologyLinks`,
+
+      slots: {
+        chronologyInfoSets: {
+          validate: v =>
+            v.arrayOf(
+              v.validateProperties({
+                headingString: v.isString,
+                contributions: v.arrayOf(v.validateProperties({
+                  index: v.isCountingNumber,
+                  artistLink: v.isHTML,
+                  previousLink: v.isHTML,
+                  nextLink: v.isHTML,
+                })),
+              })),
+        }
+      },
+
+      content(slots) {
+        if (empty(slots.chronologyInfoSets)) {
+          return html.blank();
+        }
+
+        const totalContributionCount =
+          accumulateSum(
+            slots.chronologyInfoSets,
+            ({contributions}) => contributions.length);
+
+        if (totalContributionCount === 0) {
+          return html.blank();
+        }
+
+        if (totalContributionCount > 8) {
+          return html.tag('div', {class: 'chronology'},
+            language.$('misc.chronology.seeArtistPages'));
+        }
+
+        return html.tags(
+          slots.chronologyInfoSets.map(({
+            headingString,
+            contributions,
+          }) =>
+            contributions.map(({
+              index,
+              artistLink,
+              previousLink,
+              nextLink,
+            }) => {
+              const heading =
+                html.tag('span', {class: 'heading'},
+                  language.$(headingString, {
+                    index: language.formatIndex(index),
+                    artist: artistLink,
+                  }));
+
+              const navigation =
+                (previousLink || nextLink) &&
+                  html.tag('span', {class: 'buttons'},
+                    language.formatUnitList([
+                      previousLink?.slots({
+                        tooltip: true,
+                        color: false,
+                        content: language.$('misc.nav.previous'),
+                      }),
+
+                      nextLink?.slots({
+                        tooltip: true,
+                        color: false,
+                        content: language.$('misc.nav.next'),
+                      }),
+                    ].filter(Boolean)));
+
+              return html.tag('div', {class: 'chronology'},
+                (navigation
+                  ? language.$('misc.chronology.withNavigation', {heading, navigation})
+                  : heading));
+            })));
+      },
+    });
+  },
+};
diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js
new file mode 100644
index 0000000..4460093
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleRules.js
@@ -0,0 +1,41 @@
+export default {
+  extraDependencies: [
+    'getColors',
+  ],
+
+  data(color) {
+    return {color};
+  },
+
+  generate(data, {
+    getColors,
+  }) {
+    if (!data.color) return '';
+
+    const {
+      primary,
+      dark,
+      dim,
+      dimGhost,
+      bg,
+      bgBlack,
+      shadow,
+    } = getColors(data.color);
+
+    const variables = [
+      `--primary-color: ${primary}`,
+      `--dark-color: ${dark}`,
+      `--dim-color: ${dim}`,
+      `--dim-ghost-color: ${dimGhost}`,
+      `--bg-color: ${bg}`,
+      `--bg-black-color: ${bgBlack}`,
+      `--shadow-color: ${shadow}`,
+    ];
+
+    return [
+      `:root {`,
+      ...variables.map((line) => `    ${line};`),
+      `}`,
+    ].join('\n');
+  },
+};
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
new file mode 100644
index 0000000..1666ef4
--- /dev/null
+++ b/src/content/dependencies/generateContentHeading.js
@@ -0,0 +1,27 @@
+export default {
+  extraDependencies: [
+    'html',
+  ],
+
+  generate({html}) {
+    return html.template({
+      annotation: 'generateContentHeading',
+
+      slots: {
+        title: {type: 'html'},
+        id: {type: 'string'},
+        tag: {type: 'string', default: 'p'},
+      },
+
+      content(slots) {
+        return html.tag(slots.tag,
+          {
+            class: 'content-heading',
+            id: slots.id,
+            tabindex: '0',
+          },
+          slots.title);
+      },
+    });
+  }
+}
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
new file mode 100644
index 0000000..a7a7f85
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -0,0 +1,83 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: ['image', 'linkArtTag'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, artTags) {
+    const relations = {};
+
+    relations.image =
+      relation('image', artTags);
+
+    if (artTags) {
+      relations.tagLinks =
+        artTags
+          .filter(tag => !tag.isContentWarning)
+          .map(tag => relation('linkArtTag', tag));
+    } else {
+      relations.tagLinks = null;
+    }
+
+    return relations;
+  },
+
+  generate(relations, {html, language}) {
+    return html.template({
+      annotation: 'generateCoverArtwork',
+
+      slots: {
+        path: {
+          validate: v => v.validateArrayItems(v.isString),
+        },
+
+        alt: {
+          type: 'string',
+        },
+
+        displayMode: {
+          validate: v => v.is('primary', 'thumbnail'),
+          default: 'primary',
+        },
+      },
+
+      content(slots) {
+        switch (slots.displayMode) {
+          case 'primary':
+            return html.tag('div', {id: 'cover-art-container'}, [
+              relations.image
+                .slots({
+                  path: slots.path,
+                  alt: slots.alt,
+                  thumb: 'medium',
+                  id: 'cover-art',
+                  reveal: true,
+                  link: true,
+                  square: true,
+                }),
+
+              !empty(relations.tagLinks) &&
+                html.tag('p',
+                  language.$('releaseInfo.artTags.inline', {
+                    tags: language.formatUnitList(relations.tagLinks),
+                  })),
+              ]);
+
+          case 'thumbnail':
+            return relations.image
+              .slots({
+                path: slots.path,
+                alt: slots.alt,
+                thumb: 'small',
+                reveal: false,
+                link: false,
+                square: true,
+              });
+
+          case 'default':
+            return html.blank();
+        }
+      },
+    });
+  },
+};
diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js
new file mode 100644
index 0000000..01b5b20
--- /dev/null
+++ b/src/content/dependencies/generateFooterLocalizationLinks.js
@@ -0,0 +1,56 @@
+export default {
+  extraDependencies: [
+    'defaultLanguage',
+    'html',
+    'language',
+    'languages',
+    'pagePath',
+    'to',
+  ],
+
+  generate({
+    defaultLanguage,
+    html,
+    language,
+    languages,
+    pagePath,
+    to,
+  }) {
+    const links = Object.entries(languages)
+      .filter(([code, language]) => code !== 'default' && !language.hidden)
+      .map(([code, language]) => language)
+      .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0))
+      .map((language) =>
+        html.tag('span',
+          html.tag('a',
+            {
+              href:
+                language === defaultLanguage
+                  ? to(
+                      'localizedDefaultLanguage.' + pagePath[0],
+                      ...pagePath.slice(1))
+                  : to(
+                      'localizedWithBaseDirectory.' + pagePath[0],
+                      language.code,
+                      ...pagePath.slice(1)),
+            },
+            language.name)));
+
+    return html.tag('div', {class: 'footer-localization-links'},
+      language.$('misc.uiLanguage', {
+        languages: links.join('\n'),
+      }));
+  },
+};
+
+/*
+function unbound_getFooterLocalizationLinks({
+  html,
+  defaultLanguage,
+  language,
+  languages,
+  pagePath,
+  to,
+}) {
+}
+*/
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
new file mode 100644
index 0000000..55f5b94
--- /dev/null
+++ b/src/content/dependencies/generatePageLayout.js
@@ -0,0 +1,506 @@
+import {empty, openAggregate} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleRules',
+    'generateFooterLocalizationLinks',
+    'generateStickyHeadingContainer',
+    'transformContent',
+  ],
+
+  extraDependencies: [
+    'cachebust',
+    'html',
+    'language',
+    'to',
+    'transformMultiline',
+    'wikiData',
+  ],
+
+  sprawl({wikiInfo}) {
+    return {
+      footerContent: wikiInfo.footerContent,
+      wikiColor: wikiInfo.color,
+      wikiName: wikiInfo.nameShort,
+    };
+  },
+
+  data({wikiName}) {
+    return {
+      wikiName,
+    };
+  },
+
+  relations(relation, sprawl) {
+    const relations = {};
+
+    relations.footerLocalizationLinks =
+      relation('generateFooterLocalizationLinks');
+
+    relations.stickyHeadingContainer =
+      relation('generateStickyHeadingContainer');
+
+    relations.defaultFooterContent =
+      relation('transformContent', sprawl.footerContent);
+
+    relations.defaultColorStyleRules =
+      relation('generateColorStyleRules', sprawl.wikiColor);
+
+    return relations;
+  },
+
+  generate(data, relations, {
+    cachebust,
+    html,
+    language,
+    to,
+  }) {
+    const sidebarSlots = side => ({
+      // Content is a flat HTML array. It'll generate one sidebar section
+      // if specified.
+      [side + 'Content']: {type: 'html'},
+
+      // Multiple is an array of {content: (HTML)} objects. Each of these
+      // will generate one sidebar section.
+      [side + 'Multiple']: {
+        validate: v =>
+          v.arrayOf(
+            v.validateProperties({
+              content: v.isHTML,
+            })),
+      },
+
+      // Sticky mode controls which sidebar section(s), if any, follow the
+      // scroll position, "sticking" to the top of the browser viewport.
+      //
+      // 'last' - last or only sidebar box is sticky
+      // 'column' - entire column, incl. multiple boxes from top, is sticky
+      // 'none' - sidebar not sticky at all, stays at top of page
+      //
+      // Note: This doesn't affect the content of any sidebar section, only
+      // the whole section's containing box (or the sidebar column as a whole).
+      [side + 'StickyMode']: {
+        validate: v => v.is('last', 'column', 'static'),
+      },
+
+      // Collapsing sidebars disappear when the viewport is sufficiently
+      // thin. (This is the default.) Override as false to make the sidebar
+      // stay visible in thinner viewports, where the page layout will be
+      // reflowed so the sidebar is as wide as the screen and appears below
+      // nav, above the main content.
+      [side + 'Collapse']: {type: 'boolean', default: true},
+
+      // Wide sidebars generally take up more horizontal space in the normal
+      // page layout, and should be used if the content of the sidebar has
+      // a greater than typical focus compared to main content.
+      [side + 'Wide']: {type: 'boolean', defualt: false},
+    });
+
+    return html.template({
+      annotation: 'generatePageLayout',
+
+      slots: {
+        title: {type: 'html'},
+        cover: {type: 'html'},
+        coverNeedsReveal: {type: 'boolean'},
+
+        socialEmbed: {type: 'html'},
+
+        colorStyleRules: {
+          validate: v => v.arrayOf(v.isString),
+          default: [],
+        },
+
+        additionalStyleRules: {
+          validate: v => v.arrayOf(v.isString),
+          default: [],
+        },
+
+        mainClasses: {
+          validate: v => v.arrayOf(v.isString),
+          default: [],
+        },
+
+        // Main
+
+        mainContent: {type: 'html'},
+
+        headingMode: {
+          validate: v => v.is('sticky', 'static'),
+          default: 'static',
+        },
+
+        // Sidebars
+
+        ...sidebarSlots('leftSidebar'),
+        ...sidebarSlots('rightSidebar'),
+
+        // Nav & Footer
+
+        navContent: {type: 'html'},
+        navBottomRowContent: {type: 'html'},
+
+        navLinkStyle: {
+          validate: v => v.is('hierarchical', 'index'),
+          default: 'index',
+        },
+
+        navLinks: {
+          validate: v =>
+            v.arrayOf(object => {
+              v.isObject(object);
+
+              const aggregate = openAggregate({message: `Errors validating navigation link`});
+
+              aggregate.call(v.validateProperties({
+                auto: () => true,
+                html: () => true,
+
+                path: () => true,
+                title: () => true,
+                accent: () => true,
+              }), object);
+
+              if (object.auto || object.html) {
+                if (object.auto && object.html) {
+                  aggregate.push(new TypeError(`Don't specify both auto and html`));
+                } else if (object.auto) {
+                  aggregate.call(v.is('home', 'current'), object.auto);
+                } else {
+                  aggregate.call(v.isHTML, object.html);
+                }
+
+                if (object.path || object.title) {
+                  aggregate.push(new TypeError(`Don't specify path or title along with auto or html`));
+                }
+              } else {
+                aggregate.call(v.validateProperties({
+                  path: v.arrayOf(v.isString),
+                  title: v.isString,
+                }), {
+                  path: object.path,
+                  title: object.title,
+                });
+              }
+
+              aggregate.close();
+
+              return true;
+            })
+        },
+
+        footerContent: {type: 'html'},
+      },
+
+      content(slots) {
+        let titleHTML = null;
+
+        if (!html.isBlank(slots.title)) {
+          switch (slots.headingMode) {
+            case 'sticky':
+              titleHTML =
+                relations.stickyHeadingContainer.slots({
+                  title: slots.title,
+                  cover: slots.cover,
+                  needsReveal: slots.coverNeedsReveal,
+                });
+              break;
+            case 'static':
+              titleHTML = html.tag('h1', slots.title);
+              break;
+          }
+        }
+
+        let footerContent = slots.footerContent;
+
+        if (html.isBlank(footerContent)) {
+          footerContent = relations.defaultFooterContent
+            .slot('mode', 'multiline');
+        }
+
+        const mainHTML =
+          html.tag('main', {
+            id: 'content',
+            class: slots.mainClasses,
+          }, [
+            titleHTML,
+
+            slots.cover,
+
+            html.tag('div',
+              {
+                [html.onlyIfContent]: true,
+                class: 'main-content-container',
+              },
+              slots.mainContent),
+          ]);
+
+        const footerHTML =
+          html.tag('footer',
+            {[html.onlyIfContent]: true, id: 'footer'},
+            [
+              html.tag('div',
+                {
+                  [html.onlyIfContent]: true,
+                  class: 'footer-content',
+                },
+                footerContent),
+
+              relations.footerLocalizationLinks,
+            ]);
+
+        const navHTML = html.tag('nav',
+          {
+            [html.onlyIfContent]: true,
+            id: 'header',
+            class: [
+              !empty(slots.navLinks) && 'nav-has-main-links',
+              !html.isBlank(slots.navContent) && 'nav-has-content',
+              !html.isBlank(slots.navBottomRowContent) && 'nav-has-bottom-row',
+            ],
+          },
+          [
+            html.tag('div',
+              {
+                [html.onlyIfContent]: true,
+                class: [
+                  'nav-main-links',
+                  'nav-links-' + slots.navLinkStyle,
+                ],
+              },
+              slots.navLinks?.map((cur, i) => {
+                let content;
+
+                if (cur.html) {
+                  content = cur.html;
+                } else {
+                  let title;
+                  let href;
+
+                  switch (cur.auto) {
+                    case 'home':
+                      title = data.wikiName;
+                      href = to('localized.home');
+                      break;
+                    case 'current':
+                      title = slots.title;
+                      href = '';
+                      break;
+                    case null:
+                    case undefined:
+                      title = cur.title;
+                      href = to(...cur.path);
+                      break;
+                  }
+
+                  content = html.tag('a',
+                    {href},
+                    title);
+                }
+
+                let className;
+
+                if (cur.auto === 'current') {
+                  className = 'current';
+                } else if (
+                  slots.navLinkStyle === 'hierarchical' &&
+                  i === slots.navLinks.length - 1
+                ) {
+                  className = 'current';
+                }
+
+                return html.tag('span',
+                  {class: className},
+                  [
+                    html.tag('span',
+                      {class: 'nav-link-content'},
+                      content),
+                    html.tag('span',
+                      {[html.onlyIfContent]: true, class: 'nav-link-accent'},
+                      cur.accent),
+                  ]);
+              })),
+
+            html.tag('div',
+              {[html.onlyIfContent]: true, class: 'nav-bottom-row'},
+              slots.navBottomRowContent),
+
+            html.tag('div',
+              {[html.onlyIfContent]: true, class: 'nav-content'},
+              slots.navContent),
+          ])
+
+        const generateSidebarHTML = (side, id) => {
+          const content = slots[side + 'Content'];
+          const multiple = slots[side + 'Multiple'];
+          const stickyMode = slots[side + 'StickyMode'];
+          const wide = slots[side + 'Wide'];
+          const collapse = slots[side + 'Collapse'];
+
+          let sidebarClasses = [];
+          let sidebarContent = html.blank();
+
+          if (!html.isBlank(content)) {
+            sidebarClasses = ['sidebar'];
+            sidebarContent = content;
+          } else if (multiple) {
+            sidebarClasses = ['sidebar-multiple'];
+            sidebarContent =
+              multiple
+                .filter(Boolean)
+                .map(({content}) =>
+                  html.tag('div',
+                    {
+                      [html.onlyIfContent]: true,
+                      class: 'sidebar',
+                    },
+                    content));
+          }
+
+          return html.tag('div',
+            {
+              [html.onlyIfContent]: true,
+              id,
+              class: [
+                'sidebar-column',
+                wide && 'wide',
+                !collapse && 'no-hide',
+                stickyMode !== 'static' && `sticky-${stickyMode}`,
+                ...sidebarClasses,
+              ],
+            },
+            sidebarContent);
+        }
+
+        const sidebarLeftHTML = generateSidebarHTML('leftSidebar', 'sidebar-left');
+        const sidebarRightHTML = generateSidebarHTML('rightSidebar', 'sidebar-right');
+        const collapseSidebars = slots.leftSidebarCollapse && slots.rightSidebarCollapse;
+
+        const layoutHTML = [
+          navHTML,
+          // banner.position === 'top' && bannerHTML,
+          // secondaryNavHTML,
+          html.tag('div',
+            {
+              class: [
+                'layout-columns',
+                !collapseSidebars && 'vertical-when-thin',
+                (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar',
+                (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars',
+                !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars',
+                sidebarLeftHTML && 'has-sidebar-left',
+                sidebarRightHTML && 'has-sidebar-right',
+              ],
+            },
+            [
+              sidebarLeftHTML,
+              mainHTML,
+              sidebarRightHTML,
+            ]),
+          // banner.position === 'bottom' && bannerHTML,
+          footerHTML,
+        ].filter(Boolean).join('\n');
+
+        const documentHTML = html.tags([
+          `<!DOCTYPE html>`,
+          html.tag('html',
+            {
+              lang: language.intlCode,
+              'data-language-code': language.code,
+
+              /*
+              'data-url-key': 'localized.' + pagePath[0],
+              ...Object.fromEntries(
+                pagePath.slice(1).map((v, i) => [['data-url-value' + i], v])),
+              */
+
+              'data-rebase-localized': to('localized.root'),
+              'data-rebase-shared': to('shared.root'),
+              'data-rebase-media': to('media.root'),
+              'data-rebase-data': to('data.root'),
+            },
+            [
+              // developersComment,
+
+              html.tag('head', [
+                /*
+                html.tag('title',
+                  showWikiNameInTitle
+                    ? language.formatString('misc.pageTitle.withWikiName', {
+                        title,
+                        wikiName: data.wikiName,
+                      })
+                    : language.formatString('misc.pageTitle', {title})),
+                */
+
+                html.tag('meta', {charset: 'utf-8'}),
+                html.tag('meta', {
+                  name: 'viewport',
+                  content: 'width=device-width, initial-scale=1',
+                }),
+
+                /*
+                ...(
+                  Object.entries(meta)
+                    .filter(([key, value]) => value)
+                    .map(([key, value]) => html.tag('meta', {[key]: value}))),
+
+                canonical &&
+                  html.tag('link', {
+                    rel: 'canonical',
+                    href: canonical,
+                  }),
+
+                ...(
+                  localizedCanonical
+                    .map(({lang, href}) => html.tag('link', {
+                      rel: 'alternate',
+                      hreflang: lang,
+                      href,
+                    }))),
+
+                */
+
+                // slots.socialEmbed,
+
+                html.tag('link', {
+                  rel: 'stylesheet',
+                  href: to('shared.staticFile', `site4.css?${cachebust}`),
+                }),
+
+                html.tag('style', [
+                  (empty(slots.colorStyleRules)
+                    ? relations.defaultColorStyleRules
+                    : slots.colorStyleRules),
+                  slots.additionalStyleRules,
+                ]),
+
+                html.tag('script', {
+                  src: to('shared.staticFile', `lazy-loading.js?${cachebust}`),
+                }),
+              ]),
+
+              html.tag('body',
+                // {style: body.style || ''},
+                [
+                  html.tag('div', {id: 'page-container'}, [
+                    // mainHTML && skippersHTML,
+                    layoutHTML,
+                  ]),
+
+                  // infoCardHTML,
+                  // imageOverlayHTML,
+
+                  html.tag('script', {
+                    type: 'module',
+                    src: to('shared.staticFile', `client.js?${cachebust}`),
+                  }),
+                ]),
+            ])
+        ]);
+
+        return documentHTML;
+      },
+    });
+  },
+};
diff --git a/src/content/dependencies/generatePreviousNextLinks.js b/src/content/dependencies/generatePreviousNextLinks.js
new file mode 100644
index 0000000..42b2c42
--- /dev/null
+++ b/src/content/dependencies/generatePreviousNextLinks.js
@@ -0,0 +1,36 @@
+export default {
+  // Returns an array with the slotted previous and next links, prepared
+  // for inclusion in a page's navigation bar. Include with other links
+  // in the nav bar and then join them all as a unit list, for example.
+
+  extraDependencies: ['html', 'language'],
+
+  generate({html, language}) {
+    return html.template({
+      annotation: `generatePreviousNextLinks`,
+
+      slots: {
+        previousLink: {type: 'html'},
+        nextLink: {type: 'html'},
+      },
+
+      content(slots) {
+        return [
+          !html.isBlank(slots.previousLink) &&
+            slots.previousLink.slots({
+              tooltip: true,
+              attributes: {id: 'previous-button'},
+              content: language.$('misc.nav.previous'),
+            }),
+
+          !html.isBlank(slots.nextLink) &&
+            slots.nextLink?.slots({
+              tooltip: true,
+              attributes: {id: 'next-button'},
+              content: language.$('misc.nav.next'),
+            }),
+        ].filter(Boolean);
+      },
+    });
+  },
+};
diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js
new file mode 100644
index 0000000..cbd477e
--- /dev/null
+++ b/src/content/dependencies/generateStaticPage.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generatePageLayout', 'transformContent'],
+
+  relations(relation, staticPage) {
+    return {
+      layout: relation('generatePageLayout'),
+      content: relation('transformContent', staticPage.content),
+    };
+  },
+
+  data(staticPage) {
+    return {
+      name: staticPage.name,
+      stylesheet: staticPage.stylesheet,
+    };
+  },
+
+  generate(data, relations) {
+    return relations.layout
+      .slots({
+        title: data.name,
+        headingMode: 'sticky',
+
+        additionalStyleRules:
+          (data.stylesheet
+            ? [data.stylesheet]
+            : []),
+
+        mainClasses: ['long-content'],
+        mainContent: relations.content,
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      });
+  },
+};
diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
new file mode 100644
index 0000000..fb6d830
--- /dev/null
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -0,0 +1,45 @@
+export default {
+  extraDependencies: ['html'],
+
+  generate({html}) {
+    return html.template({
+      annotation: `generateStickyHeadingContainer`,
+
+      slots: {
+        title: {type: 'html'},
+        cover: {type: 'html'},
+        needsReveal: {type: 'boolean', default: false},
+      },
+
+      content(slots) {
+        const hasCover = !html.isBlank(slots.cover);
+
+        return html.tag('div',
+          {
+            class: [
+              'content-sticky-heading-container',
+              hasCover && 'has-cover',
+            ],
+          },
+          [
+            html.tag('div', {class: 'content-sticky-heading-row'}, [
+              html.tag('h1', slots.title),
+
+              hasCover &&
+                html.tag('div', {class: 'content-sticky-heading-cover-container'},
+                  html.tag('div',
+                    {class: [
+                      'content-sticky-heading-cover',
+                      slots.needsReveal &&
+                        'content-sticky-heading-cover-needs-reveal',
+                    ]},
+                    slots.cover.slot('displayMode', 'thumbnail')))
+            ]),
+
+            html.tag('div', {class: 'content-sticky-subheading-row'},
+              html.tag('h2', {class: 'content-sticky-subheading'})),
+          ]);
+      },
+    });
+  },
+};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
new file mode 100644
index 0000000..ee68f53
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -0,0 +1,132 @@
+import getChronologyRelations from '../util/getChronologyRelations.js';
+import {sortAlbumsTracksChronologically} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateTrackInfoPageContent',
+    'generateAlbumNavAccent',
+    'generateAlbumSidebar',
+    'generateAlbumStyleRules',
+    'generateChronologyLinks',
+    'generateColorStyleRules',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkArtist',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['language'],
+
+  relations(relation, track) {
+    return {
+      layout: relation('generatePageLayout'),
+
+      artistChronologyContributions: getChronologyRelations(track, {
+        contributions: [...track.artistContribs, ...track.contributorContribs],
+
+        linkArtist: artist => relation('linkArtist', artist),
+        linkThing: track => relation('linkTrack', track),
+
+        getThings: artist =>
+          sortAlbumsTracksChronologically([
+            ...artist.tracksAsArtist,
+            ...artist.tracksAsContributor,
+          ]),
+      }),
+
+      coverArtistChronologyContributions: getChronologyRelations(track, {
+        contributions: track.coverArtistContribs,
+
+        linkArtist: artist => relation('linkArtist', artist),
+
+        linkThing: trackOrAlbum =>
+          (trackOrAlbum.album
+            ? relation('linkTrack', trackOrAlbum)
+            : relation('linkAlbum', trackOrAlbum)),
+
+        getThings: artist =>
+          sortAlbumsTracksChronologically([
+            ...artist.albumsAsCoverArtist,
+            ...artist.tracksAsCoverArtist,
+          ], {
+            getDate: albumOrTrack => albumOrTrack.coverArtDate,
+          }),
+      }),
+
+      albumLink: relation('linkAlbum', track.album),
+      trackLink: relation('linkTrack', track),
+      albumNavAccent: relation('generateAlbumNavAccent', track.album, track),
+      chronologyLinks: relation('generateChronologyLinks'),
+
+      content: relation('generateTrackInfoPageContent', track),
+      sidebar: relation('generateAlbumSidebar', track.album, track),
+      albumStyleRules: relation('generateAlbumStyleRules', track.album),
+      colorStyleRules: relation('generateColorStyleRules', track.color),
+    };
+  },
+
+  data(track) {
+    return {
+      name: track.name,
+
+      hasTrackNumbers: track.album.hasTrackNumbers,
+      trackNumber: track.album.tracks.indexOf(track) + 1,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.layout
+      .slots({
+        title: language.$('trackPage.title', {track: data.name}),
+        headingMode: 'sticky',
+
+        colorStyleRules: [relations.colorStyleRules],
+        additionalStyleRules: [relations.albumStyleRules],
+
+        cover: relations.content.cover,
+        coverNeedsReveal: relations.content.coverNeedsReveal,
+        mainContent: relations.content.main.content,
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.albumLink},
+          {
+            html:
+              (data.hasTrackNumbers
+                ? language.$('trackPage.nav.track.withNumber', {
+                    number: data.trackNumber,
+                    track: relations.trackLink
+                      .slot('attributes', {class: 'current'}),
+                  })
+                : language.$('trackPage.nav.track', {
+                    track: relations.trackLink
+                      .slot('attributes', {class: 'current'}),
+                  })),
+          },
+        ],
+
+        navBottomRowContent:
+          relations.albumNavAccent.slots({
+            showTrackNavigation: true,
+            showExtraLinks: false,
+          }),
+
+        navContent:
+          relations.chronologyLinks.slots({
+            chronologyInfoSets: [
+              {
+                headingString: 'misc.chronology.heading.track',
+                contributions: relations.artistChronologyContributions,
+              },
+              {
+                headingString: 'misc.chronology.heading.coverArt',
+                contributions: relations.coverArtistChronologyContributions,
+              },
+            ],
+          }),
+
+        ...relations.sidebar,
+      });
+  },
+}
diff --git a/src/content/dependencies/generateTrackInfoPageContent.js b/src/content/dependencies/generateTrackInfoPageContent.js
new file mode 100644
index 0000000..c33c2f6
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPageContent.js
@@ -0,0 +1,671 @@
+import {empty} from '../../util/sugar.js';
+import {sortFlashesChronologically} from '../../util/wiki-data.js';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalFilesShortcut',
+    'generateAlbumAdditionalFilesList',
+    'generateContentHeading',
+    'generateCoverArtwork',
+    'generateTrackList',
+    'generateTrackListDividedByGroups',
+    'linkAlbum',
+    'linkContribution',
+    'linkExternal',
+    'linkFlash',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups,
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
+    };
+  },
+
+  relations(relation, sprawl, track) {
+    const {album} = track;
+
+    const relations = {};
+    const sections = relations.sections = {};
+
+    const contributionLinksRelation = contribs =>
+      contribs
+        .slice(0, 4)
+        .map(contrib =>
+          relation('linkContribution', contrib.who, contrib.what));
+
+    const additionalFilesSection = additionalFiles => ({
+      heading: relation('generateContentHeading'),
+      list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
+    });
+
+    // Section: Release info
+
+    const releaseInfo = sections.releaseInfo = {};
+
+    releaseInfo.artistContributionLinks =
+      contributionLinksRelation(track.artistContribs);
+
+    if (track.hasUniqueCoverArt) {
+      relations.cover =
+        relation('generateCoverArtwork', track.artTags);
+      releaseInfo.coverArtistContributionLinks =
+        contributionLinksRelation(track.coverArtistContribs);
+    } else if (album.hasCoverArt) {
+      relations.cover =
+        relation('generateCoverArtwork', album.artTags);
+    } else {
+      relations.cover = null;
+    }
+
+    // Section: Listen on
+
+    const listen = sections.listen = {};
+
+    if (!empty(track.urls)) {
+      listen.externalLinks =
+        track.urls.map(url =>
+          relation('linkExternal', url));
+    }
+
+    // Section: Extra links
+
+    const extra = sections.extra = {};
+
+    if (!empty(track.additionalFiles)) {
+      extra.additionalFilesShortcut =
+        relation('generateAdditionalFilesShortcut', track.additionalFiles);
+    }
+
+    // Section: Other releases
+
+    if (!empty(track.otherReleases)) {
+      const otherReleases = sections.otherReleases = {};
+
+      otherReleases.heading =
+        relation('generateContentHeading');
+
+      otherReleases.items =
+        track.otherReleases.map(track => ({
+          trackLink: relation('linkTrack', track),
+          albumLink: relation('linkAlbum', track.album),
+        }));
+    }
+
+    // Section: Contributors
+
+    if (!empty(track.contributorContribs)) {
+      const contributors = sections.contributors = {};
+
+      contributors.heading =
+        relation('generateContentHeading');
+
+      contributors.contributionLinks =
+        contributionLinksRelation(track.contributorContribs);
+    }
+
+    // Section: Referenced tracks
+
+    if (!empty(track.referencedTracks)) {
+      const references = sections.references = {};
+
+      references.heading =
+        relation('generateContentHeading');
+
+      references.list =
+        relation('generateTrackList', track.referencedTracks);
+    }
+
+    // Section: Tracks that reference
+
+    if (!empty(track.referencedByTracks)) {
+      const referencedBy = sections.referencedBy = {};
+
+      referencedBy.heading =
+        relation('generateContentHeading');
+
+      referencedBy.list =
+        relation('generateTrackListDividedByGroups',
+          track.referencedByTracks,
+          sprawl.divideTrackListsByGroups);
+    }
+
+    // Section: Sampled tracks
+
+    if (!empty(track.sampledTracks)) {
+      const samples = sections.samples = {};
+
+      samples.heading =
+        relation('generateContentHeading');
+
+      samples.list =
+        relation('generateTrackList', track.sampledTracks);
+    }
+
+    // Section: Tracks that sample
+
+    if (!empty(track.sampledByTracks)) {
+      const sampledBy = sections.sampledBy = {};
+
+      sampledBy.heading =
+        relation('generateContentHeading');
+
+      sampledBy.list =
+        relation('generateTrackListDividedByGroups',
+          track.sampledByTracks,
+          sprawl.divideTrackListsByGroups);
+    }
+
+    // Section: Flashes that feature
+
+    if (sprawl.enableFlashesAndGames) {
+      const sortedFeatures =
+        sortFlashesChronologically(
+          [track, ...track.otherReleases].flatMap(track =>
+            track.featuredInFlashes.map(flash => ({
+              // These aren't going to be exposed directly, they're processed
+              // into the appropriate relations after this sort.
+              flash, track,
+
+              // These properties are only used for the sort.
+              act: flash.act,
+              date: flash.date,
+            }))));
+
+      if (!empty(sortedFeatures)) {
+        const flashesThatFeature = sections.flashesThatFeature = {};
+
+        flashesThatFeature.heading =
+          relation('generateContentHeading');
+
+        flashesThatFeature.entries =
+          sortedFeatures.map(({flash, track: directlyFeaturedTrack}) =>
+            (directlyFeaturedTrack === track
+              ? {
+                  flashLink: relation('linkFlash', flash),
+                }
+              : {
+                  flashLink: relation('linkFlash', flash),
+                  trackLink: relation('linkTrack', directlyFeaturedTrack),
+                }));
+      }
+    }
+
+    // Section: Lyrics
+
+    if (track.lyrics) {
+      const lyrics = sections.lyrics = {};
+
+      lyrics.heading =
+        relation('generateContentHeading');
+
+      lyrics.content =
+        relation('transformContent', track.lyrics);
+    }
+
+    // Sections: Sheet music files, MIDI/proejct files, additional files
+
+    if (!empty(track.sheetMusicFiles)) {
+      sections.sheetMusicFiles = additionalFilesSection(track.sheetMusicFiles);
+    }
+
+    if (!empty(track.midiProjectFiles)) {
+      sections.midiProjectFiles = additionalFilesSection(track.midiProjectFiles);
+    }
+
+    if (!empty(track.additionalFiles)) {
+      sections.additionalFiles = additionalFilesSection(track.additionalFiles);
+    }
+
+    // Section: Artist commentary
+
+    if (track.commentary) {
+      const artistCommentary = sections.artistCommentary = {};
+
+      artistCommentary.heading =
+        relation('generateContentHeading');
+
+      artistCommentary.content =
+        relation('transformContent', track.commentary);
+    }
+
+    return relations;
+  },
+
+  data(sprawl, track) {
+    const data = {};
+
+    const {album} = track;
+
+    data.name = track.name;
+    data.date = track.date;
+    data.duration = track.duration;
+
+    data.hasUniqueCoverArt = track.hasUniqueCoverArt;
+    data.hasAlbumCoverArt = album.hasCoverArt;
+
+    if (track.hasUniqueCoverArt) {
+      data.albumCoverArtDirectory = album.directory;
+      data.trackCoverArtDirectory = track.directory;
+      data.coverArtFileExtension = track.coverArtFileExtension;
+      data.coverNeedsReveal = track.artTags.some(t => t.isContentWarning);
+
+      if (track.coverArtDate && +track.coverArtDate !== +track.date) {
+        data.coverArtDate = track.coverArtDate;
+      }
+    } else if (track.album.hasCoverArt) {
+      data.albumCoverArtDirectory = album.directory;
+      data.coverArtFileExtension = album.coverArtFileExtension;
+      data.coverNeedsReveal = album.artTags.some(t => t.isContentWarning);
+    }
+
+    if (!empty(track.additionalFiles)) {
+      data.numAdditionalFiles = track.additionalFiles.length;
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const content = {};
+
+    const {sections: sec} = relations;
+
+    const formatContributions =
+      (stringKey, contributionLinks, {showContribution = true, showIcons = true} = {}) =>
+        contributionLinks &&
+          language.$(stringKey, {
+            artists:
+              language.formatConjunctionList(
+                contributionLinks.map(link =>
+                  link.slots({showContribution, showIcons}))),
+          });
+
+    if (data.hasUniqueCoverArt) {
+      content.cover = relations.cover
+        .slots({
+          path: [
+            'media.trackCover',
+            data.albumCoverArtDirectory,
+            data.trackCoverArtDirectory,
+            data.coverArtFileExtension,
+          ],
+        });
+      content.coverNeedsReveal = data.coverNeedsReveal;
+    } else if (data.hasAlbumCoverArt) {
+      content.cover = relations.cover
+        .slots({
+          path: [
+            'media.albumCover',
+            data.albumCoverArtDirectory,
+            data.coverArtFileExtension,
+          ],
+        });
+      content.coverNeedsReveal = data.coverNeedsReveal;
+    } else {
+      content.cover = null;
+      content.coverNeedsReveal = null;
+    }
+
+    content.main = {
+      headingMode: 'sticky',
+
+      content: html.tags([
+        html.tag('p', {
+          [html.onlyIfContent]: true,
+          [html.joinChildren]: html.tag('br'),
+        }, [
+          formatContributions('releaseInfo.by', sec.releaseInfo.artistContributionLinks),
+          formatContributions('releaseInfo.coverArtBy', sec.releaseInfo.coverArtistContributionLinks),
+
+          data.date &&
+            language.$('releaseInfo.released', {
+              date: language.formatDate(data.date),
+            }),
+
+          data.coverArtDate &&
+            language.$('releaseInfo.artReleased', {
+              date: language.formatDate(data.coverArtDate),
+            }),
+
+          data.duration &&
+            language.$('releaseInfo.duration', {
+              duration: language.formatDuration(data.duration),
+            }),
+        ]),
+
+        html.tag('p',
+          (sec.listen.externalLinks
+            ? language.$('releaseInfo.listenOn', {
+                links: language.formatDisjunctionList(sec.listen.externalLinks),
+              })
+            : language.$('releaseInfo.listenOn.noLinks', {
+                name: html.tag('i', data.name),
+              }))),
+
+        html.tag('p',
+          {
+            [html.onlyIfContent]: true,
+            [html.joinChildren]: '<br>',
+          },
+          [
+            sec.sheetMusicFiles &&
+              language.$('releaseInfo.sheetMusicFiles.shortcut', {
+                link: html.tag('a',
+                  {href: '#sheet-music-files'},
+                  language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
+              }),
+
+            sec.midiProjectFiles &&
+              language.$('releaseInfo.midiProjectFiles.shortcut', {
+                link: html.tag('a',
+                  {href: '#midi-project-files'},
+                  language.$('releaseInfo.midiProjectFiles.shortcut.link')),
+              }),
+
+            sec.additionalFiles &&
+              sec.extra.additionalFilesShortcut,
+
+            sec.artistCommentary &&
+              language.$('releaseInfo.readCommentary', {
+                link: html.tag('a',
+                  {href: '#artist-commentary'},
+                  language.$('releaseInfo.readCommentary.link')),
+              }),
+          ]),
+
+        sec.otherReleases && [
+          sec.otherReleases.heading
+            .slots({
+              id: 'also-released-as',
+              title: language.$('releaseInfo.alsoReleasedAs'),
+            }),
+
+          html.tag('ul',
+            sec.otherReleases.items.map(({trackLink, albumLink}) =>
+              html.tag('li',
+                language.$('releaseInfo.alsoReleasedAs.item', {
+                  track: trackLink,
+                  album: albumLink,
+                })))),
+        ],
+
+        sec.contributors && [
+          sec.contributors.heading
+            .slots({
+              id: 'contributors',
+              title: language.$('releaseInfo.contributors'),
+            }),
+
+          html.tag('ul', sec.contributors.contributionLinks.map(contributionLink =>
+            html.tag('li',
+              contributionLink
+                .slots({
+                  showIcons: true,
+                  showContribution: true,
+                })))),
+        ],
+
+        sec.references && [
+          sec.references.heading
+            .slots({
+              id: 'references',
+              title:
+                language.$('releaseInfo.tracksReferenced', {
+                  track: html.tag('i', data.name),
+                }),
+            }),
+
+          sec.references.list,
+        ],
+
+        sec.referencedBy && [
+          sec.referencedBy.heading
+            .slots({
+              id: 'referenced-by',
+              title:
+                language.$('releaseInfo.tracksThatReference', {
+                  track: html.tag('i', data.name),
+                }),
+            }),
+
+          sec.referencedBy.list,
+        ],
+
+        sec.samples && [
+          sec.samples.heading
+            .slots({
+              id: 'samples',
+              title:
+                language.$('releaseInfo.tracksSampled', {
+                  track: html.tag('i', data.name),
+                }),
+            }),
+
+          sec.samples.list,
+        ],
+
+        sec.sampledBy && [
+          sec.sampledBy.heading
+            .slots({
+              id: 'referenced-by',
+              title:
+                language.$('releaseInfo.tracksThatSample', {
+                  track: html.tag('i', data.name),
+                }),
+            }),
+
+          sec.sampledBy.list,
+        ],
+        sec.flashesThatFeature && [
+          sec.flashesThatFeature.heading
+            .slots({
+              id: 'featured-in',
+              title:
+                language.$('releaseInfo.flashesThatFeature', {
+                  track: html.tag('i', data.name),
+                }),
+            }),
+
+          html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) =>
+            (trackLink
+              ? html.tag('li', {class: 'rerelease'},
+                  language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
+                    flash: flashLink,
+                    track: trackLink,
+                  }))
+              : html.tag('li',
+                  language.$('releaseInfo.flashesThatFeature.item', {
+                    flash: flashLink,
+                  }))))),
+        ],
+
+        sec.lyrics && [
+          sec.lyrics.heading
+            .slots({
+              id: 'lyrics',
+              title: language.$('releaseInfo.lyrics'),
+            }),
+
+          html.tag('blockquote',
+            sec.lyrics.content
+              .slot('mode', 'lyrics')),
+        ],
+
+        sec.sheetMusicFiles && [
+          sec.sheetMusicFiles.heading
+            .slots({
+              id: 'sheet-music-files',
+              title: language.$('releaseInfo.sheetMusicFiles.heading'),
+            }),
+
+          sec.sheetMusicFiles.list,
+        ],
+
+        sec.midiProjectFiles && [
+          sec.midiProjectFiles.heading
+            .slots({
+              id: 'midi-project-files',
+              title: language.$('releaseInfo.midiProjectFiles.heading'),
+            }),
+
+          sec.midiProjectFiles.list,
+        ],
+
+        sec.additionalFiles && [
+          sec.additionalFiles.heading
+            .slots({
+              id: 'additional-files',
+              title:
+                language.$('releaseInfo.additionalFiles.heading', {
+                  additionalFiles:
+                    language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
+                }),
+            }),
+
+          sec.additionalFiles.list,
+        ],
+
+        sec.artistCommentary && [
+          sec.artistCommentary.heading
+            .slots({
+              id: 'artist-commentary',
+              title: language.$('releaseInfo.artistCommentary')
+            }),
+
+          html.tag('blockquote',
+            sec.artistCommentary.content
+              .slot('mode', 'multiline')),
+        ],
+      ]),
+    };
+
+    return content;
+  },
+};
+
+/*
+  const generateCommentary = ({language, link, transformMultiline}) =>
+    transformMultiline([
+      track.commentary,
+      ...otherReleases.map((track) =>
+        track.commentary
+          ?.split('\n')
+          .filter((line) => line.replace(/<\/b>/g, '').includes(':</i>'))
+          .flatMap(line => [
+            line,
+            language.$('releaseInfo.artistCommentary.seeOriginalRelease', {
+              original: link.track(track),
+            }),
+          ])
+          .join('\n')
+      ),
+    ].filter(Boolean).join('\n'));
+
+  const data = {
+    type: 'data',
+    path: ['track', track.directory],
+    data: ({
+      serializeContribs,
+      serializeCover,
+      serializeGroupsForTrack,
+      serializeLink,
+    }) => ({
+      name: track.name,
+      directory: track.directory,
+      dates: {
+        released: track.date,
+        originallyReleased: track.originalDate,
+        coverArtAdded: track.coverArtDate,
+      },
+      duration: track.duration,
+      color: track.color,
+      cover: serializeCover(track, getTrackCover),
+      artistsContribs: serializeContribs(track.artistContribs),
+      contributorContribs: serializeContribs(track.contributorContribs),
+      coverArtistContribs: serializeContribs(track.coverArtistContribs || []),
+      album: serializeLink(track.album),
+      groups: serializeGroupsForTrack(track),
+      references: track.references.map(serializeLink),
+      referencedBy: track.referencedBy.map(serializeLink),
+      alsoReleasedAs: otherReleases.map((track) => ({
+        track: serializeLink(track),
+        album: serializeLink(track.album),
+      })),
+    }),
+  };
+
+  const getSocialEmbedDescription = ({
+    getArtistString: _getArtistString,
+    language,
+  }) => {
+    const hasArtists = !empty(track.artistContribs);
+    const hasCoverArtists = !empty(track.coverArtistContribs);
+    const getArtistString = (contribs) =>
+      _getArtistString(contribs, {
+        // We don't want to put actual HTML tags in social embeds (sadly
+        // they don't get parsed and displayed, generally speaking), so
+        // override the link argument so that artist "links" just show
+        // their names.
+        link: {artist: (artist) => artist.name},
+      });
+    if (!hasArtists && !hasCoverArtists) return '';
+    return language.formatString(
+      'trackPage.socialEmbed.body' +
+        [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists']
+          .filter(Boolean)
+          .join(''),
+      Object.fromEntries(
+        [
+          hasArtists && ['artists', getArtistString(track.artistContribs)],
+          hasCoverArtists && [
+            'coverArtists',
+            getArtistString(track.coverArtistContribs),
+          ],
+        ].filter(Boolean)
+      )
+    );
+  };
+
+  const page = {
+    page: () => {
+      return {
+        title: language.$('trackPage.title', {track: track.name}),
+        stylesheet: getAlbumStylesheet(album, {to}),
+
+        themeColor: track.color,
+        theme:
+          getThemeString(track.color, {
+            additionalVariables: [
+              `--album-directory: ${album.directory}`,
+              `--track-directory: ${track.directory}`,
+            ]
+          }),
+
+        socialEmbed: {
+          heading: language.$('trackPage.socialEmbed.heading', {
+            album: track.album.name,
+          }),
+          headingLink: absoluteTo('localized.album', album.directory),
+          title: language.$('trackPage.socialEmbed.title', {
+            track: track.name,
+          }),
+          description: getSocialEmbedDescription({getArtistString, language}),
+          image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}),
+          color: track.color,
+        },
+
+        secondaryNav: generateAlbumSecondaryNav(album, track, {
+          getLinkThemeString,
+          html,
+          language,
+          link,
+        }),
+      };
+    },
+  };
+*/
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
new file mode 100644
index 0000000..e2e9f48
--- /dev/null
+++ b/src/content/dependencies/generateTrackList.js
@@ -0,0 +1,55 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: ['linkTrack', 'linkContribution'],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, tracks) {
+    if (empty(tracks)) {
+      return {};
+    }
+
+    return {
+      items: tracks.map(track => ({
+        trackLink:
+          relation('linkTrack', track),
+
+        contributionLinks:
+          track.artistContribs.map(contrib =>
+            relation('linkContribution', contrib.who, contrib.what)),
+      })),
+    };
+  },
+
+  generate(relations, {html, language}) {
+    return html.template({
+      annotation: `generateTrackList`,
+
+      slots: {
+        showContribution: {type: 'boolean', default: false},
+        showIcons: {type: 'boolean', default: false},
+      },
+
+      content(slots) {
+        return html.tag('ul',
+          relations.items.map(({trackLink, contributionLinks}) =>
+            html.tag('li',
+              language.$('trackList.item.withArtists', {
+                track: trackLink,
+                by:
+                  html.tag('span', {class: 'by'},
+                    language.$('trackList.item.withArtists.by', {
+                      artists:
+                        language.formatConjunctionList(
+                          contributionLinks.map(link =>
+                            link.slots({
+                              showContribution: slots.showContribution,
+                              showIcons: slots.showIcons,
+                            }))),
+                    })),
+              }))));
+      },
+    });
+  },
+};
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
new file mode 100644
index 0000000..1f1ebef
--- /dev/null
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -0,0 +1,53 @@
+import {empty} from '../../util/sugar.js';
+
+import groupTracksByGroup from '../util/groupTracksByGroup.js';
+
+export default {
+  contentDependencies: ['generateTrackList', 'linkGroup'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, tracks, groups) {
+    if (empty(tracks)) {
+      return {};
+    }
+
+    if (empty(groups)) {
+      return {
+        flatList:
+          relation('generateTrackList', tracks),
+      };
+    }
+
+    const lists = groupTracksByGroup(tracks, groups);
+
+    return {
+      groupedLists:
+        Array.from(lists.entries()).map(([groupOrOther, tracks]) => ({
+          ...(groupOrOther === 'other'
+                ? {other: true}
+                : {groupLink: relation('linkGroup', groupOrOther)}),
+
+          list:
+            relation('generateTrackList', tracks),
+        })),
+    };
+  },
+
+  generate(relations, {html, language}) {
+    if (relations.flatList) {
+      return relations.flatList;
+    }
+
+    return html.tag('dl',
+      relations.groupedLists.map(({other, groupLink, list}) => [
+        html.tag('dt',
+          (other
+            ? language.$('trackList.group.fromOther')
+            : language.$('trackList.group', {
+                group: groupLink
+              }))),
+
+        html.tag('dd', list),
+      ]));
+  },
+};
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
new file mode 100644
index 0000000..f9cb00b
--- /dev/null
+++ b/src/content/dependencies/image.js
@@ -0,0 +1,207 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: [
+    'getSizeOfImageFile',
+    'html',
+    'language',
+    'thumb',
+    'to',
+  ],
+
+  data(artTags) {
+    const data = {};
+
+    if (artTags) {
+      data.contentWarnings =
+        artTags
+          .filter(tag => tag.isContentWarning)
+          .map(tag => tag.name);
+    } else {
+      data.contentWarnings = null;
+    }
+
+    return data;
+  },
+
+  generate(data, {
+    getSizeOfImageFile,
+    html,
+    language,
+    thumb,
+    to,
+  }) {
+    return html.template({
+      annotation: 'image',
+
+      slots: {
+        src: {
+          type: 'string',
+        },
+
+        path: {
+          validate: v => v.validateArrayItems(v.isString),
+        },
+
+        thumb: {type: 'string'},
+
+        reveal: {type: 'boolean', default: true},
+        link: {type: 'boolean', default: false},
+        lazy: {type: 'boolean', default: false},
+        square: {type: 'boolean', default: false},
+
+        id: {type: 'string'},
+        alt: {type: 'string'},
+        width: {type: 'number'},
+        height: {type: 'number'},
+
+        missingSourceContent: {type: 'html'},
+      },
+
+      content(slots) {
+        let originalSrc;
+
+        if (slots.src) {
+          originalSrc = slots.src;
+        } else if (!empty(slots.path)) {
+          originalSrc = to(...slots.path);
+        } else {
+          originalSrc = '';
+        }
+
+        const thumbSrc =
+          originalSrc &&
+            (slots.thumb
+              ? thumb[slots.thumb](originalSrc)
+              : originalSrc);
+
+        const willLink = typeof slots.link === 'string' || slots.link;
+
+        const willReveal =
+          slots.reveal &&
+          originalSrc &&
+          !empty(data.contentWarnings);
+
+        const willSquare = slots.square;
+
+        const idOnImg = willLink ? null : slots.id;
+        const idOnLink = willLink ? slots.id : null;
+
+        if (!originalSrc) {
+          return prepare(
+            html.tag('div', {class: 'image-text-area'},
+              slots.missingSourceContent));
+        }
+
+        let fileSize = null;
+        if (willLink) {
+          const mediaRoot = to('media.root');
+          if (originalSrc.startsWith(mediaRoot)) {
+            fileSize =
+              getSizeOfImageFile(
+                originalSrc
+                  .slice(mediaRoot.length)
+                  .replace(/^\//, ''));
+          }
+        }
+
+        let reveal = null;
+        if (willReveal) {
+          reveal = [
+            language.$('misc.contentWarnings', {
+              warnings: language.formatUnitList(data.contentWarnings),
+            }),
+            html.tag('br'),
+            html.tag('span', {class: 'reveal-interaction'},
+              language.$('misc.contentWarnings.reveal')),
+          ];
+        }
+
+        const imgAttributes = {
+          id: idOnImg,
+          alt: slots.alt,
+          width: slots.width,
+          height: slots.height,
+          'data-original-size': fileSize,
+        };
+
+        const nonlazyHTML =
+          originalSrc &&
+            prepare(
+              html.tag('img', {
+                ...imgAttributes,
+                src: thumbSrc,
+              }));
+
+        if (slots.lazy) {
+          return html.tags([
+            html.tag('noscript', nonlazyHTML),
+            prepare(
+              html.tag('img',
+                {
+                  ...imgAttributes,
+                  class: 'lazy',
+                  'data-original': thumbSrc,
+                }),
+              true),
+          ]);
+        }
+
+        return nonlazyHTML;
+
+        function prepare(content, hide = false) {
+          let wrapped = content;
+
+          wrapped =
+            html.tag('div', {class: 'image-container'},
+              html.tag('div', {class: 'image-inner-area'},
+                wrapped));
+
+          if (willReveal) {
+            wrapped =
+              html.tag('div', {class: 'reveal'}, [
+                wrapped,
+                html.tag('span', {class: 'reveal-text-container'},
+                  html.tag('span', {class: 'reveal-text'},
+                    reveal)),
+              ]);
+          }
+
+          if (willSquare) {
+            wrapped =
+              html.tag('div',
+                {
+                  class: [
+                    'square',
+                    hide && !willLink && 'js-hide'
+                  ],
+                },
+
+                html.tag('div', {class: 'square-content'},
+                  wrapped));
+          }
+
+          if (willLink) {
+            wrapped = html.tag('a',
+              {
+                id: idOnLink,
+                class: [
+                  'box',
+                  'image-link',
+                  hide && 'js-hide',
+                ],
+
+                href:
+                  (typeof slots.link === 'string'
+                    ? slots.link
+                    : originalSrc),
+              },
+              wrapped);
+          }
+
+          return wrapped;
+        }
+      },
+    });
+  }
+};
diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js
new file mode 100644
index 0000000..1210d78
--- /dev/null
+++ b/src/content/dependencies/index.js
@@ -0,0 +1,235 @@
+import chokidar from 'chokidar';
+import EventEmitter from 'events';
+import * as path from 'path';
+import {ESLint} from 'eslint';
+import {fileURLToPath} from 'url';
+
+import contentFunction from '../../content-function.js';
+import {color, logWarn} from '../../util/cli.js';
+import {annotateFunction} from '../../util/sugar.js';
+
+function cachebust(filePath) {
+  if (filePath in cachebust.cache) {
+    cachebust.cache[filePath] += 1;
+    return `${filePath}?cachebust${cachebust.cache[filePath]}`;
+  } else {
+    cachebust.cache[filePath] = 0;
+    return filePath;
+  }
+}
+
+cachebust.cache = Object.create(null);
+
+export function watchContentDependencies({
+  mock = null,
+  logging = true,
+} = {}) {
+  const events = new EventEmitter();
+  const contentDependencies = {};
+
+  let emittedReady = false;
+  let initialScanComplete = false;
+  let allDependenciesFulfilled = false;
+
+  Object.assign(events, {
+    contentDependencies,
+    close,
+  });
+
+  const eslint = new ESLint();
+
+  // Watch adjacent files
+  const metaPath = fileURLToPath(import.meta.url);
+  const metaDirname = path.dirname(metaPath);
+  const watcher = chokidar.watch(metaDirname);
+
+  watcher.on('all', (event, filePath) => {
+    if (!['add', 'change'].includes(event)) return;
+    if (filePath === metaPath) return;
+    handlePathUpdated(filePath);
+  });
+
+  watcher.on('unlink', (filePath) => {
+    if (filePath === metaPath) {
+      console.error(`Yeowzers content dependencies just got nuked.`);
+      return;
+    }
+    handlePathRemoved(filePath);
+  });
+
+  watcher.on('ready', () => {
+    initialScanComplete = true;
+    checkReadyConditions();
+  });
+
+  if (mock) {
+    const errors = [];
+    for (const [functionName, spec] of Object.entries(mock)) {
+      try {
+        const fn = processFunctionSpec(functionName, spec);
+        contentDependencies[functionName] = fn;
+      } catch (error) {
+        error.message = `(${functionName}) ${error.message}`;
+        errors.push(error);
+      }
+    }
+    if (errors.length) {
+      throw new AggregateError(errors, `Errors processing mocked content functions`);
+    }
+    checkReadyConditions();
+  }
+
+  return events;
+
+  async function close() {
+    return watcher.close();
+  }
+
+  function checkReadyConditions() {
+    if (emittedReady) {
+      return;
+    }
+
+    if (!initialScanComplete) {
+      return;
+    }
+
+    checkAllDependenciesFulfilled();
+
+    if (!allDependenciesFulfilled) {
+      return;
+    }
+
+    events.emit('ready');
+    emittedReady = true;
+  }
+
+  function checkAllDependenciesFulfilled() {
+    allDependenciesFulfilled = !Object.values(contentDependencies).includes(null);
+  }
+
+  function getFunctionName(filePath) {
+    const shortPath = path.basename(filePath);
+    const functionName = shortPath.slice(0, -path.extname(shortPath).length);
+    return functionName;
+  }
+
+  function isMocked(functionName) {
+    return !!mock && Object.keys(mock).includes(functionName);
+  }
+
+  async function handlePathRemoved(filePath) {
+    const functionName = getFunctionName(filePath);
+    if (isMocked(functionName)) return;
+
+    delete contentDependencies[functionName];
+  }
+
+  async function handlePathUpdated(filePath) {
+    const functionName = getFunctionName(filePath);
+    if (isMocked(functionName)) return;
+
+    let error = null;
+
+    main: {
+      const eslintResults = await eslint.lintFiles([filePath]);
+      const eslintFormatter = await eslint.loadFormatter('stylish');
+      const eslintResultText = eslintFormatter.format(eslintResults);
+      if (eslintResultText.trim().length) {
+        console.log(eslintResultText);
+      }
+
+      let spec;
+      try {
+        spec = (await import(cachebust(filePath))).default;
+      } catch (caughtError) {
+        error = caughtError;
+        error.message = `Error importing: ${error.message}`;
+        break main;
+      }
+
+      let fn;
+      try {
+        fn = processFunctionSpec(functionName, spec);
+      } catch (caughtError) {
+        error = caughtError;
+        break main;
+      }
+
+      if (logging && initialScanComplete) {
+        const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'});
+        console.log(color.green(`[${timestamp}] Updated ${functionName}`));
+      }
+
+      contentDependencies[functionName] = fn;
+
+      events.emit('update', functionName);
+      checkReadyConditions();
+    }
+
+    if (!error) {
+      return true;
+    }
+
+    if (!(functionName in contentDependencies)) {
+      contentDependencies[functionName] = null;
+    }
+
+    events.emit('error', functionName, error);
+
+    if (logging) {
+      if (contentDependencies[functionName]) {
+        logWarn`Failed to import ${functionName} - using existing version`;
+      } else {
+        logWarn`Failed to import ${functionName} - no prior version loaded`;
+      }
+
+      if (typeof error === 'string') {
+        console.error(color.yellow(error));
+      } else {
+        console.error(error);
+      }
+    }
+
+    return false;
+  }
+
+  function processFunctionSpec(functionName, spec) {
+    if (typeof spec.data === 'function') {
+      annotateFunction(spec.data, {name: functionName, description: 'data'});
+    }
+
+    if (typeof spec.generate === 'function') {
+      annotateFunction(spec.generate, {name: functionName});
+    }
+
+    let fn;
+    try {
+      fn = contentFunction(spec);
+    } catch (error) {
+      error.message = `Error loading spec: ${error.message}`;
+      throw error;
+    }
+
+    return fn;
+  }
+}
+
+export function quickLoadContentDependencies(opts) {
+  return new Promise((resolve, reject) => {
+    const watcher = watchContentDependencies(opts);
+
+    watcher.on('error', (name, error) => {
+      watcher.close().then(() => {
+        error.message = `Error loading dependency ${name}: ${error}`;
+        reject(error);
+      });
+    });
+
+    watcher.on('ready', () => {
+      watcher.close().then(() => {
+        resolve(watcher.contentDependencies);
+      });
+    });
+  });
+}
diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js
new file mode 100644
index 0000000..36b0d13
--- /dev/null
+++ b/src/content/dependencies/linkAlbum.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.album', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js
new file mode 100644
index 0000000..27c0ba9
--- /dev/null
+++ b/src/content/dependencies/linkAlbumAdditionalFile.js
@@ -0,0 +1,26 @@
+export default {
+  contentDependencies: [
+    'linkTemplate',
+  ],
+
+  relations(relation) {
+    return {
+      linkTemplate: relation('linkTemplate'),
+    };
+  },
+
+  data(album, file) {
+    return {
+      albumDirectory: album.directory,
+      file,
+    };
+  },
+
+  generate(data, relations) {
+    return relations.linkTemplate
+      .slots({
+        path: ['media.albumAdditionalFile', data.albumDirectory, data.file],
+        content: data.file,
+      });
+  },
+};
diff --git a/src/content/dependencies/linkAlbumCommentary.js b/src/content/dependencies/linkAlbumCommentary.js
new file mode 100644
index 0000000..ab519fd
--- /dev/null
+++ b/src/content/dependencies/linkAlbumCommentary.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumCommentary', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumGallery.js b/src/content/dependencies/linkAlbumGallery.js
new file mode 100644
index 0000000..e3f30a2
--- /dev/null
+++ b/src/content/dependencies/linkAlbumGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumGallery', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtTag.js b/src/content/dependencies/linkArtTag.js
new file mode 100644
index 0000000..7ddb778
--- /dev/null
+++ b/src/content/dependencies/linkArtTag.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artTag) =>
+    ({link: relation('linkThing', 'localized.tag', artTag)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtist.js b/src/content/dependencies/linkArtist.js
new file mode 100644
index 0000000..718ee6f
--- /dev/null
+++ b/src/content/dependencies/linkArtist.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artist) =>
+    ({link: relation('linkThing', 'localized.artist', artist)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtistGallery.js b/src/content/dependencies/linkArtistGallery.js
new file mode 100644
index 0000000..66dc172
--- /dev/null
+++ b/src/content/dependencies/linkArtistGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artist) =>
+    ({link: relation('linkThing', 'localized.artistGallery', artist)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
new file mode 100644
index 0000000..1d0e2d6
--- /dev/null
+++ b/src/content/dependencies/linkContribution.js
@@ -0,0 +1,74 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  contentDependencies: [
+    'linkArtist',
+    'linkExternalAsIcon',
+  ],
+
+  extraDependencies: [
+    'html',
+    'language',
+  ],
+
+  relations(relation, artist) {
+    const relations = {};
+
+    relations.artistLink = relation('linkArtist', artist);
+
+    relations.artistIcons =
+      (artist.urls ?? []).map(url =>
+        relation('linkExternalAsIcon', url));
+
+    return relations;
+  },
+
+  data(artist, contribution) {
+    return {contribution};
+  },
+
+  generate(data, relations, {
+    html,
+    language,
+  }) {
+    return html.template({
+      annotation: 'linkContribution',
+
+      slots: {
+        showContribution: {type: 'boolean', default: false},
+        showIcons: {type: 'boolean', default: false},
+      },
+
+      content(slots) {
+        const hasContributionPart = !!(slots.showContribution && data.contribution);
+        const hasExternalPart = !!(slots.showIcons && !empty(relations.artistIcons));
+
+        const externalLinks = hasExternalPart &&
+          html.tag('span',
+            {[html.noEdgeWhitespace]: true, class: 'icons'},
+            language.formatUnitList(relations.artistIcons));
+
+        return (
+          (hasContributionPart
+            ? (hasExternalPart
+                ? language.$('misc.artistLink.withContribution.withExternalLinks', {
+                    artist: relations.artistLink,
+                    contrib: data.contribution,
+                    links: externalLinks,
+                  })
+                : language.$('misc.artistLink.withContribution', {
+                    artist: relations.artistLink,
+                    contrib: data.contribution,
+                  }))
+            : (hasExternalPart
+                ? language.$('misc.artistLink.withExternalLinks', {
+                    artist: relations.artistLink,
+                    links: externalLinks,
+                  })
+                : language.$('misc.artistLink', {
+                    artist: relations.artistLink,
+                  }))));
+      },
+    });
+  },
+};
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
new file mode 100644
index 0000000..08191a2
--- /dev/null
+++ b/src/content/dependencies/linkExternal.js
@@ -0,0 +1,93 @@
+// TODO: Define these as extra dependencies and pass them somewhere
+const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
+const MASTODON_DOMAINS = ['types.pl'];
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data(url, {
+    type = 'generic',
+  } = {}) {
+    const types = ['generic', 'album'];
+    if (!types.includes(type)) {
+      throw new TypeError(`Expected type to be one of ${types}`);
+    }
+
+    return {
+      url,
+      type,
+    };
+  },
+
+  generate(data, {html, language}) {
+    let isLocal;
+    let domain;
+    try {
+      domain = new URL(data.url).hostname;
+    } catch (error) {
+      // No support for relative local URLs yet, sorry! (I.e, local URLs must
+      // be absolute relative to the domain name in order to work.)
+      isLocal = true;
+    }
+
+    const a = html.tag('a',
+      {
+        href: data.url,
+        class: 'nowrap',
+      },
+
+      // truly unhinged indentation here
+      isLocal
+        ? language.$('misc.external.local')
+
+    : domain.includes('bandcamp.com')
+        ? language.$('misc.external.bandcamp')
+
+    : BANDCAMP_DOMAINS.includes(domain)
+        ? language.$('misc.external.bandcamp.domain', {domain})
+
+    : MASTODON_DOMAINS.includes(domain)
+        ? language.$('misc.external.mastodon.domain', {domain})
+
+    : domain.includes('youtu')
+        ? data.type === 'album'
+          ? data.url.includes('list=')
+            ? language.$('misc.external.youtube.playlist')
+            : language.$('misc.external.youtube.fullAlbum')
+          : language.$('misc.external.youtube')
+
+    : domain.includes('soundcloud')
+        ? language.$('misc.external.soundcloud')
+
+    : domain.includes('tumblr.com')
+        ? language.$('misc.external.tumblr')
+
+    : domain.includes('twitter.com')
+        ? language.$('misc.external.twitter')
+
+    : domain.includes('deviantart.com')
+        ? language.$('misc.external.deviantart')
+
+    : domain.includes('wikipedia.org')
+        ? language.$('misc.external.wikipedia')
+
+    : domain.includes('poetryfoundation.org')
+        ? language.$('misc.external.poetryFoundation')
+
+    : domain.includes('instagram.com')
+        ? language.$('misc.external.instagram')
+
+    : domain.includes('patreon.com')
+        ? language.$('misc.external.patreon')
+
+    : domain.includes('spotify.com')
+        ? language.$('misc.external.spotify')
+
+    : domain.includes('newgrounds.com')
+        ? language.$('misc.external.newgrounds')
+
+        : domain);
+
+    return a;
+  }
+};
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
new file mode 100644
index 0000000..6496d02
--- /dev/null
+++ b/src/content/dependencies/linkExternalAsIcon.js
@@ -0,0 +1,46 @@
+// TODO: Define these as extra dependencies and pass them somewhere
+const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
+const MASTODON_DOMAINS = ['types.pl'];
+
+export default {
+  extraDependencies: ['html', 'language', 'to'],
+
+  data(url) {
+    return {url};
+  },
+
+  generate(data, {html, language, to}) {
+    const domain = new URL(data.url).hostname;
+    const [id, msg] = (
+      domain.includes('bandcamp.com')
+        ? ['bandcamp', language.$('misc.external.bandcamp')]
+      : BANDCAMP_DOMAINS.includes(domain)
+        ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})]
+      : MASTODON_DOMAINS.includes(domain)
+        ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})]
+      : domain.includes('youtu')
+        ? ['youtube', language.$('misc.external.youtube')]
+      : domain.includes('soundcloud')
+        ? ['soundcloud', language.$('misc.external.soundcloud')]
+      : domain.includes('tumblr.com')
+        ? ['tumblr', language.$('misc.external.tumblr')]
+      : domain.includes('twitter.com')
+        ? ['twitter', language.$('misc.external.twitter')]
+      : domain.includes('deviantart.com')
+        ? ['deviantart', language.$('misc.external.deviantart')]
+      : domain.includes('instagram.com')
+        ? ['instagram', language.$('misc.external.bandcamp')]
+      : domain.includes('newgrounds.com')
+        ? ['newgrounds', language.$('misc.external.newgrounds')]
+        : ['globe', language.$('misc.external.domain', {domain})]);
+
+    return html.tag('a',
+      {href: data.url, class: 'icon'},
+      html.tag('svg', [
+        html.tag('title', msg),
+        html.tag('use', {
+          href: to('shared.staticFile', `icons.svg#icon-${id}`),
+        }),
+      ]));
+  },
+};
diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js
new file mode 100644
index 0000000..65158ff
--- /dev/null
+++ b/src/content/dependencies/linkExternalFlash.js
@@ -0,0 +1,41 @@
+// Note: This function is seriously hard-coded for HSMusic, with custom
+// presentation of links to Homestuck flashes hosted various places.
+
+export default {
+  contentDependencies: ['linkExternal'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, url) {
+    return {
+      link: relation('linkExternal', url),
+    };
+  },
+
+  data(url, flash) {
+    return {
+      url,
+      page: flash.page,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    const {link} = relations;
+    const {url, page} = data;
+
+    return html.tag('span',
+      {class: 'nowrap'},
+
+      url.includes('homestuck.com')
+        ? isNaN(Number(page))
+          ? language.$('misc.external.flash.homestuck.secret', {link})
+          : language.$('misc.external.flash.homestuck.page', {link, page})
+
+    : url.includes('bgreco.net')
+        ? language.$('misc.external.flash.bgreco', {link})
+
+    : url.includes('youtu')
+        ? language.$('misc.external.flash.youtube', {link})
+
+        : link);
+  },
+};
diff --git a/src/content/dependencies/linkFlash.js b/src/content/dependencies/linkFlash.js
new file mode 100644
index 0000000..93dd5a2
--- /dev/null
+++ b/src/content/dependencies/linkFlash.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, flash) =>
+    ({link: relation('linkThing', 'localized.flash', flash)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkGroup.js b/src/content/dependencies/linkGroup.js
new file mode 100644
index 0000000..ebab1b5
--- /dev/null
+++ b/src/content/dependencies/linkGroup.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, group) =>
+    ({link: relation('linkThing', 'localized.groupInfo', group)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkGroupGallery.js b/src/content/dependencies/linkGroupGallery.js
new file mode 100644
index 0000000..86c4a0f
--- /dev/null
+++ b/src/content/dependencies/linkGroupGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, group) =>
+    ({link: relation('linkThing', 'localized.groupGallery', group)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkListing.js b/src/content/dependencies/linkListing.js
new file mode 100644
index 0000000..f27d93a
--- /dev/null
+++ b/src/content/dependencies/linkListing.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, listing) =>
+    ({link: relation('linkThing', 'localized.listing', listing)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkNewsEntry.js b/src/content/dependencies/linkNewsEntry.js
new file mode 100644
index 0000000..1fb32dd
--- /dev/null
+++ b/src/content/dependencies/linkNewsEntry.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, newsEntry) =>
+    ({link: relation('linkThing', 'localized.newsEntry', newsEntry)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js
new file mode 100644
index 0000000..032af6c
--- /dev/null
+++ b/src/content/dependencies/linkStaticPage.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, staticPage) =>
+    ({link: relation('linkThing', 'localized.staticPage', staticPage)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
new file mode 100644
index 0000000..9109ab5
--- /dev/null
+++ b/src/content/dependencies/linkTemplate.js
@@ -0,0 +1,73 @@
+import {empty} from '../../util/sugar.js';
+
+export default {
+  extraDependencies: [
+    'appendIndexHTML',
+    'getColors',
+    'html',
+    'to',
+  ],
+
+  generate({
+    appendIndexHTML,
+    getColors,
+    html,
+    to,
+  }) {
+    return html.template({
+      annotation: 'linkTemplate',
+
+      slots: {
+        href: {type: 'string'},
+        path: {validate: v => v.validateArrayItems(v.isString)},
+        hash: {type: 'string'},
+
+        tooltip: {validate: v => v.isString},
+        attributes: {validate: v => v.isAttributes},
+        color: {validate: v => v.isColor},
+        content: {type: 'html'},
+      },
+
+      content(slots) {
+        let href = slots.href;
+        let style;
+        let title;
+
+        if (!href && !empty(slots.path)) {
+          href = to(...slots.path);
+        }
+
+        if (appendIndexHTML) {
+          if (
+            /^(?!https?:\/\/).+\/$/.test(href) &&
+            href.endsWith('/')
+          ) {
+            href += 'index.html';
+          }
+        }
+
+        if (slots.hash) {
+          href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash;
+        }
+
+        if (slots.color) {
+          const {primary, dim} = getColors(slots.color);
+          style = `--primary-color: ${primary}; --dim-color: ${dim}`;
+        }
+
+        if (slots.tooltip) {
+          title = slots.tooltip;
+        }
+
+        return html.tag('a',
+          {
+            ...slots.attributes ?? {},
+            href,
+            style,
+            title,
+          },
+          slots.content);
+      },
+    });
+  },
+}
diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js
new file mode 100644
index 0000000..1e648ee
--- /dev/null
+++ b/src/content/dependencies/linkThing.js
@@ -0,0 +1,91 @@
+export default {
+  contentDependencies: [
+    'linkTemplate',
+  ],
+
+  extraDependencies: [
+    'html',
+  ],
+
+  relations(relation) {
+    return {
+      linkTemplate: relation('linkTemplate'),
+    };
+  },
+
+  data(pathKey, thing) {
+    return {
+      pathKey,
+
+      color: thing.color,
+      directory: thing.directory,
+
+      name: thing.name,
+      nameShort: thing.nameShort,
+    };
+  },
+
+  generate(data, relations, {html}) {
+    const path = [data.pathKey, data.directory];
+
+    return html.template({
+      annotation: 'linkThing',
+
+      slots: {
+        content: relations.linkTemplate.getSlotDescription('content'),
+        preferShortName: {type: 'boolean', default: false},
+
+        tooltip: {
+          validate: v => v.oneOf(v.isBoolean, v.isString),
+          default: false,
+        },
+
+        color: {
+          validate: v => v.oneOf(v.isBoolean, v.isColor),
+          default: true,
+        },
+
+        attributes: relations.linkTemplate.getSlotDescription('attributes'),
+        hash: relations.linkTemplate.getSlotDescription('hash'),
+      },
+
+      content(slots) {
+        let content = slots.content;
+
+        const name =
+          (slots.preferShortName
+            ? data.nameShort ?? data.name
+            : data.name);
+
+        if (html.isBlank(content)) {
+          content = name;
+        }
+
+        let color = null;
+        if (slots.color === true) {
+          color = data.color ?? null;
+        } else if (typeof slots.color === 'string') {
+          color = slots.color;
+        }
+
+        let tooltip = null;
+        if (slots.tooltip === true) {
+          tooltip = name;
+        } else if (typeof slots.tooltip === 'string') {
+          tooltip = slots.tooltip;
+        }
+
+        return relations.linkTemplate
+          .slots({
+            path,
+            content,
+            color,
+            tooltip,
+
+            attributes: slots.attributes,
+            hash: slots.hash,
+          });
+      },
+    });
+  },
+}
diff --git a/src/content/dependencies/linkTrack.js b/src/content/dependencies/linkTrack.js
new file mode 100644
index 0000000..d5d9672
--- /dev/null
+++ b/src/content/dependencies/linkTrack.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, track) =>
+    ({link: relation('linkThing', 'localized.track', track)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
new file mode 100644
index 0000000..bf4233f
--- /dev/null
+++ b/src/content/dependencies/transformContent.js
@@ -0,0 +1,325 @@
+import {marked} from 'marked';
+
+import {bindFind} from '../../util/find.js';
+import {parseInput} from '../../util/replacer.js';
+import {replacerSpec} from '../../util/transform-content.js';
+
+const linkThingRelationMap = {
+  album: 'linkAlbum',
+  albumCommentary: 'linkAlbumCommentary',
+  albumGallery: 'linkAlbumGallery',
+  artist: 'linkArtist',
+  artistGallery: 'linkArtistGallery',
+  flash: 'linkFlash',
+  groupInfo: 'linkGroup',
+  groupGallery: 'linkGroupGallery',
+  listing: 'linkListing',
+  newsEntry: 'linkNewsEntry',
+  staticPage: 'linkStaticPage',
+  tag: 'linkArtTag',
+  track: 'linkTrack',
+};
+
+const linkValueRelationMap = {
+  // media: 'linkPathFromMedia',
+  // root: 'linkPathFromRoot',
+  // site: 'linkPathFromSite',
+};
+
+const linkIndexRelationMap = {
+  // commentaryIndex: 'linkCommentaryIndex',
+  // flashIndex: 'linkFlashIndex',
+  // home: 'linkHome',
+  // listingIndex: 'linkListingIndex',
+  // newsIndex: 'linkNewsIndex',
+};
+
+function getPlaceholder(node, content) {
+  return {type: 'text', data: content.slice(node.i, node.iEnd)};
+}
+
+export default {
+  contentDependencies: [
+    ...Object.values(linkThingRelationMap),
+    ...Object.values(linkValueRelationMap),
+    ...Object.values(linkIndexRelationMap),
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl(wikiData, content) {
+    const find = bindFind(wikiData);
+
+    const parsedNodes = parseInput(content);
+
+    return {
+      nodes: parsedNodes
+        .map(node => {
+          if (node.type !== 'tag') {
+            return node;
+          }
+
+          const placeholder = getPlaceholder(node, content);
+
+          const replacerKeyImplied = !node.data.replacerKey;
+          const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data;
+
+          // TODO: We don't support recursive nodes like before, at the moment. Sorry!
+          // const replacerValue = transformNodes(node.data.replacerValue, opts);
+          const replacerValue = node.data.replacerValue[0].data;
+
+          const spec = replacerSpec[replacerKey];
+
+          if (!spec) {
+            return placeholder;
+          }
+
+          if (spec.link) {
+            let data = {key: spec.link};
+
+            determineData: {
+              // No value at all: this is an index link.
+              if (!replacerValue) {
+                break determineData;
+              }
+
+              // Nothing to find: the link operates on a path or string, not a data object.
+              if (!spec.find) {
+                data.value = replacerValue;
+                break determineData;
+              }
+
+              const thing =
+                find[spec.find](
+                  (replacerKeyImplied
+                    ? replacerValue
+                    : replacerKey + `:` + replacerValue),
+                  wikiData);
+
+              // Nothing was found: this is unexpected, so return placeholder.
+              if (!thing) {
+                return placeholder;
+              }
+
+              // Something was found: the link operates on that thing.
+              data.thing = thing;
+            }
+
+            const {transformName} = spec;
+
+            // TODO: Again, no recursive nodes. Sorry!
+            // const enteredLabel = node.data.label && transformNode(node.data.label, opts);
+            const enteredLabel = node.data.label?.data;
+            const enteredHash = node.data.hash?.data;
+
+            data.label =
+              enteredLabel ??
+                (transformName && data.thing.name
+                  ? transformName(data.thing.name, node, content)
+                  : null);
+
+            data.hash = enteredHash ?? null;
+
+            return {i: node.i, iEnd: node.iEnd, type: 'link', data};
+          }
+
+          // This will be another {type: 'tag'} node which gets processed in
+          // generate.
+          return node;
+        }),
+    };
+  },
+
+  data(sprawl, content) {
+    return {
+      content,
+
+      nodes:
+        sprawl.nodes
+          .map(node => {
+            // Replace link nodes with a stub. It'll be replaced (by position)
+            // with an item from relations.
+            if (node.type === 'link') {
+              return {type: 'link'};
+            }
+
+            // Other nodes will get processed in generate.
+            return node;
+          }),
+    };
+  },
+
+  relations(relation, sprawl, content) {
+    const {nodes} = sprawl;
+
+    const relationOrPlaceholder =
+      (node, name, arg) =>
+        (name
+          ? {
+              link: relation(name, arg),
+              label: node.data.label,
+              hash: node.data.hash,
+            }
+          : getPlaceholder(node, content));
+
+    return {
+      links:
+        nodes
+          .filter(({type}) => type === 'link')
+          .map(node => {
+            const {key, thing, value} = node.data;
+
+            if (thing) {
+              return relationOrPlaceholder(node, linkThingRelationMap[key], thing);
+            } else if (value) {
+              return relationOrPlaceholder(node, linkValueRelationMap[key], value);
+            } else {
+              return relationOrPlaceholder(node, linkIndexRelationMap[key]);
+            }
+          }),
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    let linkIndex = 0;
+
+    // This array contains only straight text and link nodes, which are directly
+    // representable in html (so no further processing is needed on the level of
+    // individual nodes).
+    const contentFromNodes =
+      data.nodes.map(node => {
+        if (node.type === 'text') {
+          return {type: 'text', data: node.data};
+        }
+
+        if (node.type === 'link') {
+          const linkNode = relations.links[linkIndex++];
+          if (linkNode.type === 'text') {
+            return {type: 'text', data: linkNode.data};
+          }
+
+          const {link, label, hash} = linkNode;
+
+          return {
+            type: 'link',
+            data: link.slots({content: label, hash}),
+          };
+        }
+
+        if (node.type === 'tag') {
+          const {replacerKey, replacerValue} = node.data;
+
+          const spec = replacerSpec[replacerKey];
+
+          if (!spec) {
+            return getPlaceholder(node, data.content);
+          }
+
+          const {value: valueFn, html: htmlFn} = spec;
+
+          const value =
+            (valueFn
+              ? valueFn(replacerValue)
+              : replacerValue);
+
+          const contents =
+            (htmlFn
+              ? htmlFn(value, {html, language})
+              : value);
+
+          return {type: 'text', data: contents};
+        }
+
+        return getPlaceholder(node, data.content);
+      });
+
+    return html.template({
+      annotation: `transformContent`,
+
+      slots: {
+        mode: {
+          validate: v => v.is('inline', 'multiline', 'lyrics'),
+          default: 'multiline',
+        },
+      },
+
+      content(slots) {
+        // In inline mode, no further processing is needed!
+
+        if (slots.mode === 'inline') {
+          return html.tags(contentFromNodes.map(node => node.data));
+        }
+
+        // Multiline mode has a secondary processing stage where it's passed...
+        // through marked! Rolling your own Markdown only gets you so far :D
+
+        const markedOptions = {
+          headerIds: false,
+          mangle: false,
+        };
+
+        // This is separated into its own function just since we're gonna reuse
+        // it in a minute if everything goes to heck in lyrics mode.
+        const transformMultiline = () => {
+          const markedInput =
+            contentFromNodes
+              .map(node => {
+                if (node.type === 'text') {
+                  return node.data;
+                } else {
+                  return node.data.toString();
+                }
+              })
+              .join('')
+
+              // Compress multiple line breaks into single line breaks.
+              .replace(/\n{2,}/g, '\n')
+              // Expand line breaks which don't follow a list.
+              .replace(/(?<!^ *-.*)\n+/gm, '\n\n')
+              // Expand line breaks which are at the end of a list.
+              .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n');
+
+          return marked.parse(markedInput, markedOptions);
+        }
+
+        if (slots.mode === 'multiline') {
+          // Unfortunately, we kind of have to be super evil here and stringify
+          // the links, or else parse marked's output into html tags, which is
+          // very out of scope at the moment.
+          return transformMultiline();
+        }
+
+        // Lyrics mode goes through marked too, but line breaks are processed
+        // differently. Instead of having each line get its own paragraph,
+        // "adjacent" lines are joined together (with blank lines separating
+        // each verse/paragraph).
+
+        if (slots.mode === 'lyrics') {
+          // If it looks like old data, using <br> instead of bunched together
+          // lines... then oh god... just use transformMultiline. Perishes.
+          if (
+            contentFromNodes.some(node =>
+              node.type === 'text' &&
+              node.data.includes('<br'))
+          ) {
+            return transformMultiline();
+          }
+
+          // Lyrics mode is also evil for the same stringifying reasons as
+          // multiline.
+          return marked.parse(
+            contentFromNodes
+              .map(node => {
+                if (node.type === 'text') {
+                  return node.data.replace(/\b\n\b/g, '<br>\n');
+                } else {
+                  return node.data.toString();
+                }
+              })
+              .join(''),
+            markedOptions);
+        }
+      },
+    });
+  },
+}
diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js
new file mode 100644
index 0000000..11281e7
--- /dev/null
+++ b/src/content/util/getChronologyRelations.js
@@ -0,0 +1,42 @@
+export default function getChronologyRelations(thing, {
+  contributions,
+  linkArtist,
+  linkThing,
+  getThings,
+}) {
+  // One call to getChronologyRelations is considered "lumping" together all
+  // contributions as carrying equivalent meaning (for example, "artist"
+  // contributions and "contributor" contributions are bunched together in
+  // one call to getChronologyRelations, while "cover artist" contributions
+  // are a separate call). getChronologyRelations prevents duplicates that
+  // carry the same meaning by only using the first instance of each artist
+  // in the contributions array passed to it. It's expected that the string
+  // identifying which kind of contribution ("track" or "cover art") is
+  // shared and applied to all contributions, as providing them together
+  // in one call to getChronologyRelations implies they carry the same
+  // meaning.
+
+  const artistsSoFar = new Set();
+
+  contributions = contributions.filter(({who}) => {
+    if (artistsSoFar.has(who)) {
+      return false;
+    } else {
+      artistsSoFar.add(who);
+      return true;
+    }
+  });
+
+  return contributions.map(({who}) => {
+    const things = Array.from(new Set(getThings(who)));
+    const index = things.indexOf(thing);
+    const previous = things[index - 1];
+    const next = things[index + 1];
+    return {
+      index: index + 1,
+      artistLink: linkArtist(who),
+      previousLink: previous ? linkThing(previous) : null,
+      nextLink: next ? linkThing(next) : null,
+    };
+  });
+}
diff --git a/src/content/util/groupTracksByGroup.js b/src/content/util/groupTracksByGroup.js
new file mode 100644
index 0000000..bae2c8c
--- /dev/null
+++ b/src/content/util/groupTracksByGroup.js
@@ -0,0 +1,23 @@
+import {empty} from '../../util/sugar.js';
+
+export default function groupTracksByGroup(tracks, groups) {
+  const lists = new Map(groups.map(group => [group, []]));
+  lists.set('other', []);
+
+  for (const track of tracks) {
+    const group = groups.find(group => group.albums.includes(track.album));
+    if (group) {
+      lists.get(group).push(track);
+    } else {
+      other.get('other').push(track);
+    }
+  }
+
+  for (const [key, tracks] of lists.entries()) {
+    if (empty(tracks)) {
+      lists.delete(key);
+    }
+  }
+
+  return lists;
+}
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 2a188f2..d371f51 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -1,5 +1,6 @@
 import Thing from './thing.js';
 
+import {empty} from '../../util/sugar.js';
 import find from '../../util/find.js';
 
 export class Album extends Thing {
@@ -34,12 +35,12 @@ export class Album extends Thing {
       update: {validate: isDate},
 
       expose: {
-        dependencies: ['date', 'hasCoverArt'],
+        dependencies: ['date', 'coverArtistContribsByRef'],
         transform: (coverArtDate, {
+          coverArtistContribsByRef,
           date,
-          hasCoverArt,
         }) =>
-          (hasCoverArt
+          (!empty(coverArtistContribsByRef)
             ? coverArtDate ?? date ?? null
             : null),
       },
@@ -103,7 +104,6 @@ export class Album extends Thing {
       update: {validate: isDimensions},
     },
 
-    hasCoverArt: Thing.common.flag(true),
     hasTrackArt: Thing.common.flag(true),
     hasTrackNumbers: Thing.common.flag(true),
     isListedOnHomepage: Thing.common.flag(true),
@@ -123,18 +123,16 @@ export class Album extends Thing {
 
     artistContribs: Thing.common.dynamicContribs('artistContribsByRef'),
     coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'),
-    trackCoverArtistContribs: Thing.common.dynamicContribs(
-      'trackCoverArtistContribsByRef'
-    ),
-    wallpaperArtistContribs: Thing.common.dynamicContribs(
-      'wallpaperArtistContribsByRef'
-    ),
-    bannerArtistContribs: Thing.common.dynamicContribs(
-      'bannerArtistContribsByRef'
-    ),
+    trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'),
+    wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'),
+    bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'),
 
     commentatorArtists: Thing.common.commentatorArtists(),
 
+    hasCoverArt: Thing.common.contribsPresent('coverArtistContribsByRef'),
+    hasWallpaperArt: Thing.common.contribsPresent('wallpaperArtistContribsByRef'),
+    hasBannerArt: Thing.common.contribsPresent('bannerArtistContribsByRef'),
+
     tracks: {
       flags: {expose: true},
 
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 303f33f..f144b21 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -27,9 +27,8 @@ export class Artist extends Thing {
 
     aliasNames: {
       flags: {update: true, expose: true},
-      update: {
-        validate: validateArrayItems(isName),
-      },
+      update: {validate: validateArrayItems(isName)},
+      expose: {transform: (names) => names ?? []},
     },
 
     isAlias: Thing.common.flag(),
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index c18e811..a79dd77 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -68,10 +68,10 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
     Group,
 
     validators: {
+      is,
       isCountingNumber,
       isString,
       validateArrayItems,
-      validateFromConstants,
     },
   } = opts) => ({
     ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
@@ -95,7 +95,7 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
       flags: {update: true, expose: true},
 
       update: {
-        validate: validateFromConstants('grid', 'carousel'),
+        validate: is('grid', 'carousel'),
       },
 
       expose: {
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 3086ad2..98ab9bc 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -252,19 +252,19 @@ export class Language extends Thing {
   // Conjunction list: A, B, and C
   formatConjunctionList(array) {
     this.assertIntlAvailable('intl_listConjunction');
-    return this.intl_listConjunction.format(array);
+    return this.intl_listConjunction.format(array.map(arr => arr.toString()));
   }
 
   // Disjunction lists: A, B, or C
   formatDisjunctionList(array) {
     this.assertIntlAvailable('intl_listDisjunction');
-    return this.intl_listDisjunction.format(array);
+    return this.intl_listDisjunction.format(array.map(arr => arr.toString()));
   }
 
   // Unit lists: A, B, C
   formatUnitList(array) {
     this.assertIntlAvailable('intl_listUnit');
-    return this.intl_listUnit.format(array);
+    return this.intl_listUnit.format(array.map(arr => arr.toString()));
   }
 
   // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index 9c59436..5004f4e 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -23,6 +23,7 @@ import {
 
 import {inspect} from 'util';
 import {color} from '../../util/cli.js';
+import {empty} from '../../util/sugar.js';
 import {getKebabCase} from '../../util/wiki-data.js';
 
 import find from '../../util/find.js';
@@ -63,6 +64,7 @@ export default class Thing extends CacheableObject {
     urls: () => ({
       flags: {update: true, expose: true},
       update: {validate: validateArrayItems(isURL)},
+      expose: {transform: (value) => value ?? []},
     }),
 
     // A file extension! Or the default, if provided when calling this.
@@ -312,6 +314,20 @@ export default class Thing extends CacheableObject {
       },
     }),
 
+    // Nice 'n simple shorthand for an exposed-only flag which is true when any
+    // contributions are present in the specified property.
+    contribsPresent: (contribsByRefProperty) => ({
+      flags: {expose: true},
+      expose: {
+        dependencies: [contribsByRefProperty],
+        compute({
+          [contribsByRefProperty]: contribsByRef,
+        }) {
+          return !empty(contribsByRef);
+        },
+      }
+    }),
+
     // Neat little shortcut for "reversing" the reference lists stored on other
     // things - for example, tracks specify a "referenced tracks" property, and
     // you would use this to compute a corresponding "referenced *by* tracks"
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
index b116120..1409210 100644
--- a/src/data/things/validators.js
+++ b/src/data/things/validators.js
@@ -112,7 +112,12 @@ export function isInstance(value, constructor) {
 }
 
 export function isDate(value) {
-  return isInstance(value, Date);
+  isInstance(value, Date);
+
+  if (isNaN(value))
+    throw new TypeError(`Expected valid date`);
+
+  return true;
 }
 
 export function isObject(value) {
@@ -133,6 +138,34 @@ export function isArray(value) {
   return true;
 }
 
+// This one's shaped a bit different from other "is" functions.
+// More like validate functions, it returns a function.
+export function is(...values) {
+  if (Array.isArray(values)) {
+    values = new Set(values);
+  }
+
+  if (values.size === 1) {
+    const expected = Array.from(values)[0];
+
+    return (value) => {
+      if (value !== expected) {
+        throw new TypeError(`Expected ${expected}, got ${value}`);
+      }
+
+      return true;
+    };
+  }
+
+  return (value) => {
+    if (!values.has(value)) {
+      throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`);
+    }
+
+    return true;
+  };
+}
+
 function validateArrayItemsHelper(itemValidator) {
   return (item, index) => {
     try {
@@ -162,18 +195,12 @@ export function validateArrayItems(itemValidator) {
   };
 }
 
-export function validateInstanceOf(constructor) {
-  return (object) => isInstance(object, constructor);
+export function arrayOf(itemValidator) {
+  return validateArrayItems(itemValidator);
 }
 
-export function validateFromConstants(...values) {
-  return (value) => {
-    if (!values.includes(value)) {
-      throw new TypeError(`Expected one of ${values.join(', ')}`);
-    }
-
-    return true;
-  };
+export function validateInstanceOf(constructor) {
+  return (object) => isInstance(object, constructor);
 }
 
 // Wiki data (primitives & non-primitives)
diff --git a/src/data/yaml.js b/src/data/yaml.js
index de0b506..73450f1 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -192,7 +192,6 @@ export const processAlbumDocument = makeProcessDocument(T.Album, {
     color: 'Color',
     urls: 'URLs',
 
-    hasCoverArt: 'Has Cover Art',
     hasTrackArt: 'Has Track Art',
     hasTrackNumbers: 'Has Track Numbers',
     isListedOnHomepage: 'Listed on Homepage',
diff --git a/src/misc-templates.js b/src/misc-templates.js
index 8f3f016..dfff4d8 100644
--- a/src/misc-templates.js
+++ b/src/misc-templates.js
@@ -16,547 +16,8 @@ import {
   getTotalDuration,
   sortAlbumsTracksChronologically,
   sortChronologically,
-  sortFlashesChronologically,
 } from './util/wiki-data.js';
 
-const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
-
-const MASTODON_DOMAINS = ['types.pl'];
-
-// "Additional Files" listing
-
-function unbound_generateAdditionalFilesShortcut(additionalFiles, {
-  html,
-  language,
-}) {
-  if (empty(additionalFiles)) return '';
-
-  return language.$('releaseInfo.additionalFiles.shortcut', {
-    anchorLink:
-      html.tag('a',
-        {href: '#additional-files'},
-        language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
-    titles: language.formatUnitList(
-      additionalFiles.map(g => g.title)),
-  });
-}
-
-function unbound_generateAdditionalFilesList(additionalFiles, {
-  html,
-  language,
-
-  getFileSize,
-  linkFile,
-}) {
-  if (empty(additionalFiles)) return [];
-
-  return html.tag('dl',
-    additionalFiles.flatMap(({title, description, files}) => [
-      html.tag('dt',
-        (description
-          ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
-              title,
-              description,
-            })
-          : language.$('releaseInfo.additionalFiles.entry', {title}))),
-
-      html.tag('dd',
-        html.tag('ul',
-          files.map((file) => {
-            const size = (getFileSize && getFileSize(file));
-            return html.tag('li',
-              (size
-                ? language.$('releaseInfo.additionalFiles.file.withSize', {
-                    file: linkFile(file),
-                    size: language.formatFileSize(size),
-                  })
-                : language.$('releaseInfo.additionalFiles.file', {
-                    file: linkFile(file),
-                  })))
-          }))),
-    ]));
-}
-
-// Artist strings
-
-function unbound_getArtistString(artists, {
-  html,
-  language,
-  link,
-
-  iconifyURL,
-
-  showIcons = false,
-  showContrib = false,
-}) {
-  return language.formatConjunctionList(
-    artists.map(({who, what}) => {
-      const {urls} = who;
-
-      const hasContribPart = !!(showContrib && what);
-      const hasExternalPart = !!(showIcons && !empty(urls));
-
-      const artistLink = link.artist(who);
-
-      const externalLinks = hasExternalPart &&
-        html.tag('span',
-          {
-            [html.noEdgeWhitespace]: true,
-            class: 'icons'
-          },
-          language.formatUnitList(
-            urls.slice(0, 4).map(url => iconifyURL(url, {language}))));
-
-      return html.tag('span', {class: 'nowrap'},
-        (hasContribPart
-          ? (hasExternalPart
-              ? language.$('misc.artistLink.withContribution.withExternalLinks', {
-                  artist: artistLink,
-                  contrib: what,
-                  links: externalLinks,
-                })
-              : language.$('misc.artistLink.withContribution', {
-                  artist: artistLink,
-                  contrib: what,
-                }))
-          : (hasExternalPart
-              ? language.$('misc.artistLink.withExternalLinks', {
-                  artist: artistLink,
-                  links: externalLinks,
-                })
-              : language.$('misc.artistLink', {
-                  artist: artistLink,
-                }))));
-    }));
-}
-
-// Chronology links
-
-function unbound_generateChronologyLinks(currentThing, {
-  html,
-  language,
-  link,
-
-  generateNavigationLinks,
-
-  dateKey = 'date',
-  contribKey,
-  getThings,
-  headingString,
-}) {
-  const contributions = currentThing[contribKey];
-
-  if (empty(contributions)) {
-    return [];
-  }
-
-  if (contributions.length > 8) {
-    return html.tag('div', {class: 'chronology'},
-      language.$('misc.chronology.seeArtistPages'));
-  }
-
-  return contributions
-    .map(({who: artist}) => {
-      const thingsUnsorted = unique(getThings(artist))
-        .filter((t) => t[dateKey]);
-
-      // Kinda a hack, but we automatically detect which is (probably) the
-      // right function to use here.
-      const args = [thingsUnsorted, {getDate: (t) => t[dateKey]}];
-      const things = (
-        thingsUnsorted.every(t => t instanceof T.Album || t instanceof T.Track)
-          ? sortAlbumsTracksChronologically(...args)
-      : thingsUnsorted.every(t => t instanceof T.Flash)
-          ? sortFlashesChronologically(...args)
-          : sortChronologically(...args));
-
-      if (things.length === 0) return '';
-
-      const index = things.indexOf(currentThing);
-
-      if (index === -1) return '';
-
-      const heading = (
-        html.tag('span', {class: 'heading'},
-          language.$(headingString, {
-            index: language.formatIndex(index + 1, {language}),
-            artist: link.artist(artist),
-          })));
-
-      const navigation = things.length > 1 &&
-        html.tag('span',
-          {
-            [html.onlyIfContent]: true,
-            class: 'buttons',
-          },
-          generateNavigationLinks(currentThing, {
-            data: things,
-            isMain: false,
-          }));
-
-      return (
-        html.tag('div', {class: 'chronology'},
-          (navigation
-            ? language.$('misc.chronology.withNavigation', {
-                heading,
-                navigation,
-              })
-            : heading)));
-    });
-}
-
-// Content warning tags
-
-function unbound_getRevealStringFromContentWarningMessage(warnings, {
-  html,
-  language,
-}) {
-  return (
-    language.$('misc.contentWarnings', {warnings}) +
-    html.tag('br') +
-    html.tag('span', {class: 'reveal-interaction'},
-      language.$('misc.contentWarnings.reveal'))
-  );
-}
-
-function unbound_getRevealStringFromArtTags(tags, {
-  getRevealStringFromContentWarningMessage,
-  language,
-}) {
-  return (
-    tags?.some(tag => tag.isContentWarning) &&
-      getRevealStringFromContentWarningMessage(
-        language.formatUnitList(
-          tags
-            .filter(tag => tag.isContentWarning)
-            .map(tag => tag.name)))
-  );
-}
-
-// Cover art links
-
-function unbound_generateCoverLink({
-  html,
-  img,
-  language,
-  link,
-
-  getRevealStringFromArtTags,
-
-  alt,
-  path,
-  src,
-  tags = [],
-  to,
-  wikiData,
-}) {
-  const {wikiInfo} = wikiData;
-
-  if (!src && path) {
-    src = to(...path);
-  }
-
-  if (!src) {
-    throw new Error(`Expected src or path`);
-  }
-
-  const linkedTags = tags.filter(tag => !tag.isContentWarning);
-
-  return html.tag('div', {id: 'cover-art-container'}, [
-    img({
-      src,
-      alt,
-      thumb: 'medium',
-      id: 'cover-art',
-      link: true,
-      square: true,
-      reveal: getRevealStringFromArtTags(tags),
-    }),
-
-    wikiInfo.enableArtTagUI &&
-    linkedTags.length &&
-      html.tag('p', {class: 'tags'},
-        language.$('releaseInfo.artTags.inline', {
-          tags: language.formatUnitList(
-            linkedTags.map(tag => link.tag(tag))),
-        })),
-  ]);
-}
-
-// CSS & color shenanigans
-
-function unbound_getThemeString(color, {
-  getColors,
-
-  additionalVariables = [],
-} = {}) {
-  if (!color) return '';
-
-  const {
-    primary,
-    dark,
-    dim,
-    dimGhost,
-    bg,
-    bgBlack,
-    shadow,
-  } = getColors(color);
-
-  const variables = [
-    `--primary-color: ${primary}`,
-    `--dark-color: ${dark}`,
-    `--dim-color: ${dim}`,
-    `--dim-ghost-color: ${dimGhost}`,
-    `--bg-color: ${bg}`,
-    `--bg-black-color: ${bgBlack}`,
-    `--shadow-color: ${shadow}`,
-    ...additionalVariables,
-  ].filter(Boolean);
-
-  if (!variables.length) return '';
-
-  return [
-    `:root {`,
-    ...variables.map((line) => `    ${line};`),
-    `}`
-  ].join('\n');
-}
-
-function unbound_getAlbumStylesheet(album, {
-  to,
-}) {
-  const hasWallpaper = album.wallpaperArtistContribs.length >= 1;
-  const hasWallpaperStyle = !!album.wallpaperStyle;
-  const hasBannerStyle = !!album.bannerStyle;
-
-  const wallpaperSource =
-    (hasWallpaper &&
-      to(
-        'media.albumWallpaper',
-        album.directory,
-        album.wallpaperFileExtension));
-
-  const wallpaperPart =
-    (hasWallpaper
-      ? [
-          `body::before {`,
-          `    background-image: url("${wallpaperSource}");`,
-          ...(hasWallpaperStyle
-            ? album.wallpaperStyle
-                .split('\n')
-                .map(line => `    ${line}`)
-            : []),
-          `}`,
-        ]
-      : []);
-
-  const bannerPart =
-    (hasBannerStyle
-      ? [
-          `#banner img {`,
-          ...album.bannerStyle
-            .split('\n')
-            .map(line => `    ${line}`),
-          `}`,
-        ]
-      : []);
-
-  return [
-    ...wallpaperPart,
-    ...bannerPart,
-  ]
-    .filter(Boolean)
-    .join('\n');
-}
-
-// Divided track lists
-
-function unbound_generateTrackListDividedByGroups(tracks, {
-  html,
-  language,
-
-  getTrackItem,
-  wikiData,
-}) {
-  const {divideTrackListsByGroups: groups} = wikiData.wikiInfo;
-
-  if (empty(groups)) {
-    return html.tag('ul',
-      tracks.map(t => getTrackItem(t)));
-  }
-
-  const lists = Object.fromEntries(
-    groups.map((group) => [
-      group.directory,
-      {group, tracks: []}
-    ]));
-
-  const other = [];
-
-  for (const track of tracks) {
-    const {album} = track;
-    const group = groups.find((g) => g.albums.includes(album));
-    if (group) {
-      lists[group.directory].tracks.push(track);
-    } else {
-      other.push(track);
-    }
-  }
-
-  const dt = name =>
-    html.tag('dt',
-      language.$('trackList.group', {
-        group: name,
-      }));
-
-  const ddul = tracks =>
-    html.tag('dd',
-      html.tag('ul',
-        tracks.map(t => getTrackItem(t))));
-
-  return html.tag('dl', [
-    ...Object.values(lists)
-      .filter(({tracks}) => tracks.length)
-      .flatMap(({group, tracks}) => [
-        dt(group.name),
-        ddul(tracks),
-      ]),
-
-    ...html.fragment(
-      other.length && [
-        dt(language.$('trackList.group.other')),
-        ddul(other),
-      ]),
-  ]);
-}
-
-// Fancy lookin' links
-
-function unbound_fancifyURL(url, {
-  html,
-  language,
-
-  album = false,
-} = {}) {
-  let local = Symbol();
-  let domain;
-  try {
-    domain = new URL(url).hostname;
-  } catch (error) {
-    // No support for relative local URLs yet, sorry! (I.e, local URLs must
-    // be absolute relative to the domain name in order to work.)
-    domain = local;
-  }
-
-  return html.tag('a',
-    {
-      href: url,
-      class: 'nowrap',
-    },
-
-    // truly unhinged indentation here
-    domain === local
-      ? language.$('misc.external.local')
-  : domain.includes('bandcamp.com')
-    ? language.$('misc.external.bandcamp')
-  : BANDCAMP_DOMAINS.includes(domain)
-    ? language.$('misc.external.bandcamp.domain', {domain})
-  : MASTODON_DOMAINS.includes(domain)
-    ? language.$('misc.external.mastodon.domain', {domain})
-  : domain.includes('youtu')
-    ? album
-      ? url.includes('list=')
-        ? language.$('misc.external.youtube.playlist')
-        : language.$('misc.external.youtube.fullAlbum')
-      : language.$('misc.external.youtube')
-  : domain.includes('soundcloud')
-    ? language.$('misc.external.soundcloud')
-  : domain.includes('tumblr.com')
-    ? language.$('misc.external.tumblr')
-  : domain.includes('twitter.com')
-    ? language.$('misc.external.twitter')
-  : domain.includes('deviantart.com')
-    ? language.$('misc.external.deviantart')
-  : domain.includes('wikipedia.org')
-    ? language.$('misc.external.wikipedia')
-  : domain.includes('poetryfoundation.org')
-    ? language.$('misc.external.poetryFoundation')
-  : domain.includes('instagram.com')
-    ? language.$('misc.external.instagram')
-  : domain.includes('patreon.com')
-    ? language.$('misc.external.patreon')
-  : domain.includes('spotify.com')
-    ? language.$('misc.external.spotify')
-  : domain.includes('newgrounds.com')
-    ? language.$('misc.external.newgrounds')
-    : domain);
-}
-
-function unbound_fancifyFlashURL(url, flash, {
-  html,
-  language,
-
-  fancifyURL,
-}) {
-  const link = fancifyURL(url);
-  return html.tag('span',
-    {class: 'nowrap'},
-    url.includes('homestuck.com')
-      ? isNaN(Number(flash.page))
-        ? language.$('misc.external.flash.homestuck.secret', {link})
-        : language.$('misc.external.flash.homestuck.page', {
-            link,
-            page: flash.page,
-          })
-  : url.includes('bgreco.net')
-    ? language.$('misc.external.flash.bgreco', {link})
-  : url.includes('youtu')
-    ? language.$('misc.external.flash.youtube', {link})
-    : link);
-}
-
-function unbound_iconifyURL(url, {
-  html,
-  language,
-  to,
-}) {
-  const domain = new URL(url).hostname;
-  const [id, msg] = (
-    domain.includes('bandcamp.com')
-      ? ['bandcamp', language.$('misc.external.bandcamp')]
-    : BANDCAMP_DOMAINS.includes(domain)
-      ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})]
-    : MASTODON_DOMAINS.includes(domain)
-      ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})]
-    : domain.includes('youtu')
-      ? ['youtube', language.$('misc.external.youtube')]
-    : domain.includes('soundcloud')
-      ? ['soundcloud', language.$('misc.external.soundcloud')]
-    : domain.includes('tumblr.com')
-      ? ['tumblr', language.$('misc.external.tumblr')]
-    : domain.includes('twitter.com')
-      ? ['twitter', language.$('misc.external.twitter')]
-    : domain.includes('deviantart.com')
-      ? ['deviantart', language.$('misc.external.deviantart')]
-    : domain.includes('instagram.com')
-      ? ['instagram', language.$('misc.external.bandcamp')]
-    : domain.includes('newgrounds.com')
-      ? ['newgrounds', language.$('misc.external.newgrounds')]
-      : ['globe', language.$('misc.external.domain', {domain})]);
-
-  return html.tag('a',
-    {
-      href: url,
-      class: 'icon',
-    },
-    html.tag('svg', [
-      html.tag('title', msg),
-      html.tag('use', {
-        href: to('shared.staticFile', `icons.svg#icon-${id}`),
-      }),
-    ]));
-}
-
 // Grids
 
 function unbound_getGridHTML({
@@ -636,123 +97,6 @@ function unbound_getFlashGridHTML({
   });
 }
 
-// Images
-
-function unbound_img({
-  getSizeOfImageFile,
-  html,
-  to,
-
-  src,
-  alt,
-  noSrcText = '',
-  thumb: thumbKey,
-  reveal,
-  id,
-  class: className,
-  width,
-  height,
-  link = false,
-  lazy = false,
-  square = false,
-}) {
-  const willSquare = square;
-  const willLink = typeof link === 'string' || link;
-
-  const originalSrc = src;
-  const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src);
-
-  const href =
-    (willLink
-      ? (typeof link === 'string'
-          ? link
-          : originalSrc)
-      : null);
-
-  let fileSize = null;
-  const mediaRoot = to('media.root');
-  if (href?.startsWith(mediaRoot)) {
-    fileSize = getSizeOfImageFile(href.slice(mediaRoot.length).replace(/^\//, ''));
-  }
-
-  const imgAttributes = {
-    id: link ? '' : id,
-    class: className,
-    alt,
-    width,
-    height,
-    'data-original-size': fileSize,
-  };
-
-  const noSrcHTML =
-    !src &&
-      wrap(
-        html.tag('div',
-          {class: 'image-text-area'},
-          noSrcText));
-
-  const nonlazyHTML =
-    src &&
-      wrap(
-        html.tag('img', {
-          ...imgAttributes,
-          src: thumbSrc,
-        }));
-
-  const lazyHTML =
-    src &&
-    lazy &&
-      wrap(
-        html.tag('img',
-          {
-            ...imgAttributes,
-            class: [className, 'lazy'],
-            'data-original': thumbSrc,
-          }),
-        true);
-
-  if (!src) {
-    return noSrcHTML;
-  } else if (lazy) {
-    return html.tag('noscript', nonlazyHTML) + '\n' + lazyHTML;
-  } else {
-    return nonlazyHTML;
-  }
-
-  function wrap(input, hide = false) {
-    let wrapped = input;
-
-    wrapped = html.tag('div', {class: 'image-container'}, wrapped);
-
-    if (reveal) {
-      wrapped = html.tag('div', {class: 'reveal'}, [
-        wrapped,
-        html.tag('span', {class: 'reveal-text-container'},
-          html.tag('span', {class: 'reveal-text'}, reveal)),
-      ]);
-    }
-
-    if (willSquare) {
-      wrapped = html.tag('div', {class: 'square-content'}, wrapped);
-      wrapped = html.tag('div',
-        {class: ['square', hide && !willLink && 'js-hide']},
-        wrapped);
-    }
-
-    if (willLink) {
-      wrapped = html.tag('a',
-        {
-          id,
-          class: ['box', hide && 'js-hide', 'image-link'],
-          href,
-        },
-        wrapped);
-    }
-
-    return wrapped;
-  }
-}
-
 // Carousel reels
 
 // Layout constants:
@@ -851,228 +195,11 @@ function unbound_getCarouselHTML({
               }))))));
 }
 
-// Nav-bar links
-
-function unbound_generateInfoGalleryLinks(currentThing, isGallery, {
-  link,
-  language,
-
-  linkKeyGallery,
-  linkKeyInfo,
-}) {
-  return [
-    link[linkKeyInfo](currentThing, {
-      class: isGallery ? '' : 'current',
-      text: language.$('misc.nav.info'),
-    }),
-    link[linkKeyGallery](currentThing, {
-      class: isGallery ? 'current' : '',
-      text: language.$('misc.nav.gallery'),
-    }),
-  ].join(', ');
-}
-
-// Generate "previous" and "next" links relative to a given current thing and a
-// data set (array of things) which includes it, optionally including additional
-// provided links like "random". This is for use in navigation bars and other
-// inline areas.
-//
-// By default, generated links include ID attributes which enable client-side
-// keyboard shortcuts. Provide isMain: false to disable this (if the generated
-// links aren't the for the page's primary navigation).
-function unbound_generateNavigationLinks(current, {
-  language,
-  link,
-
-  additionalLinks = [],
-  data,
-  isMain = true,
-  linkKey = 'anything',
-  returnAsArray = false,
-}) {
-  let previousLink, nextLink;
-
-  if (current) {
-    const linkFn = link[linkKey].bind(link);
-
-    const index = data.indexOf(current);
-    const previousThing = data[index - 1];
-    const nextThing = data[index + 1];
-
-    previousLink = previousThing &&
-      linkFn(previousThing, {
-        attributes: {
-          id: isMain && 'previous-button',
-          title: previousThing.name,
-        },
-        text: language.$('misc.nav.previous'),
-        color: false,
-      });
-
-    nextLink = nextThing &&
-      linkFn(nextThing, {
-        attributes: {
-          id: isMain && 'next-button',
-          title: nextThing.name,
-        },
-        text: language.$('misc.nav.next'),
-        color: false,
-      });
-  }
-
-  const links = [
-    previousLink,
-    nextLink,
-    ...additionalLinks,
-  ].filter(Boolean);
-
-  if (returnAsArray) {
-    return links;
-  } else if (empty(links)) {
-    return '';
-  } else {
-    return language.formatUnitList(links);
-  }
-}
-
-// Sticky heading, ooooo
-
-function unbound_generateContentHeading({
-  html,
-
-  id,
-  title,
-}) {
-  return html.tag('p',
-    {
-      class: 'content-heading',
-      id,
-      tabindex: '0',
-    },
-    title);
-}
-
-function unbound_generateStickyHeadingContainer({
-  html,
-  img,
-
-  class: classes,
-  coverSrc,
-  coverAlt,
-  coverArtTags,
-  title,
-}) {
-  return html.tag('div',
-    {class: [
-      'content-sticky-heading-container',
-      coverSrc && 'has-cover',
-    ].concat(classes)},
-    [
-      html.tag('div', {class: 'content-sticky-heading-row'}, [
-        html.tag('h1', title),
-
-        // Cover art in the sticky heading never uses the 'reveal' setting
-        // because it's too small to effectively display content warnings.
-        // Instead, if art has content warnings, it's hidden from the sticky
-        // heading by default, and will be enabled once the main cover art
-        // is revealed.
-        coverSrc &&
-          html.tag('div', {class: 'content-sticky-heading-cover-container'},
-            html.tag('div',
-              {
-                class: [
-                  'content-sticky-heading-cover',
-                  coverArtTags.some(tag => tag.isContentWarning) &&
-                    'content-sticky-heading-cover-needs-reveal',
-                ],
-              },
-              img({
-                src: coverSrc,
-                alt: coverAlt,
-                thumb: 'small',
-                link: false,
-                square: true,
-              }))),
-      ]),
-
-      html.tag('div', {class: 'content-sticky-subheading-row'},
-        html.tag('h2', {class: 'content-sticky-subheading'})),
-    ]);
-}
-
-// Footer stuff
-
-function unbound_getFooterLocalizationLinks({
-  html,
-  defaultLanguage,
-  language,
-  languages,
-  pagePath,
-  to,
-}) {
-  const links = Object.entries(languages)
-    .filter(([code, language]) => code !== 'default' && !language.hidden)
-    .map(([code, language]) => language)
-    .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0))
-    .map((language) =>
-      html.tag('span',
-        html.tag('a',
-          {
-            href:
-              language === defaultLanguage
-                ? to(
-                    'localizedDefaultLanguage.' + pagePath[0],
-                    ...pagePath.slice(1))
-                : to(
-                    'localizedWithBaseDirectory.' + pagePath[0],
-                    language.code,
-                    ...pagePath.slice(1)),
-          },
-          language.name)));
-
-  return html.tag('div', {class: 'footer-localization-links'},
-    language.$('misc.uiLanguage', {
-      languages: links.join('\n'),
-    }));
-}
-
 // Exports
 
 export {
-  unbound_generateAdditionalFilesList as generateAdditionalFilesList,
-  unbound_generateAdditionalFilesShortcut as generateAdditionalFilesShortcut,
-
-  unbound_getArtistString as getArtistString,
-
-  unbound_generateChronologyLinks as generateChronologyLinks,
-
-  unbound_getRevealStringFromContentWarningMessage as getRevealStringFromContentWarningMessage,
-  unbound_getRevealStringFromArtTags as getRevealStringFromArtTags,
-
-  unbound_generateCoverLink as generateCoverLink,
-
-  unbound_getThemeString as getThemeString,
-  unbound_getAlbumStylesheet as getAlbumStylesheet,
-
-  unbound_generateTrackListDividedByGroups as generateTrackListDividedByGroups,
-
-  unbound_fancifyURL as fancifyURL,
-  unbound_fancifyFlashURL as fancifyFlashURL,
-  unbound_iconifyURL as iconifyURL,
-
   unbound_getGridHTML as getGridHTML,
   unbound_getAlbumGridHTML as getAlbumGridHTML,
   unbound_getFlashGridHTML as getFlashGridHTML,
-
   unbound_getCarouselHTML as getCarouselHTML,
-
-  unbound_img as img,
-
-  unbound_generateInfoGalleryLinks as generateInfoGalleryLinks,
-  unbound_generateNavigationLinks as generateNavigationLinks,
-
-  unbound_generateContentHeading as generateContentHeading,
-  unbound_generateStickyHeadingContainer as generateStickyHeadingContainer,
-
-  unbound_getFooterLocalizationLinks as getFooterLocalizationLinks,
 }
diff --git a/src/page/album.js b/src/page/album.js
index 9ee57c0..111cab8 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -1,66 +1,62 @@
 // Album page specification.
 
-import {
-  bindOpts,
-  compareArrays,
-  empty,
-} from '../util/sugar.js';
-
-import {
-  getAlbumCover,
-  getAlbumListTag,
-  getTotalDuration,
-} from '../util/wiki-data.js';
-
 export const description = `per-album info & track artwork gallery pages`;
 
 export function targets({wikiData}) {
   return wikiData.albumData;
 }
 
-export function write(album, {wikiData}) {
-  const unbound_trackToListItem = (track, {
-    getArtistString,
-    getLinkThemeString,
-    html,
-    language,
-    link,
-  }) => {
-    const itemOpts = {
-      duration: language.formatDuration(track.duration ?? 0),
-      track: link.track(track),
-    };
+export function pathsForTarget(album) {
+  const hasGalleryPage = album.tracks.some(t => t.hasUniqueCoverArt);
+  const hasCommentaryPage = !!album.commentary || album.tracks.some(t => t.commentary);
 
-    return html.tag('li',
-      {style: getLinkThemeString(track.color)},
-      compareArrays(
-        track.artistContribs.map((c) => c.who),
-        album.artistContribs.map((c) => c.who),
-        {checkOrder: false}
-      )
-        ? language.$('trackList.item.withDuration', itemOpts)
-        : language.$('trackList.item.withDuration.withArtists', {
-            ...itemOpts,
-            by: html.tag('span',
-              {class: 'by'},
-              language.$('trackList.item.withArtists.by', {
-                artists: getArtistString(track.artistContribs),
-              })),
-          }));
-  };
+  return [
+    {
+      type: 'page',
+      path: ['album', album.directory],
 
-  const hasAdditionalFiles = !empty(album.additionalFiles);
-  const numAdditionalFiles = album.additionalFiles.flatMap((g) => g.files).length;
+      contentFunction: {
+        name: 'generateAlbumInfoPage',
+        args: [album],
+      },
+    },
 
-  const albumDuration = getTotalDuration(album.tracks);
+    /*
+    hasGalleryPage && {
+      type: 'page',
+      path: ['albumGallery', album.directory],
 
-  const displayTrackSections =
-    album.trackSections &&
-      (album.trackSections.length > 1 ||
-        !album.trackSections[0]?.isDefaultTrackSection);
+      contentFunction: {
+        name: 'generateAlbumGalleryPage',
+        args: [album],
+      },
+    },
+
+    hasCommentaryPage && {
+      type: 'page',
+      path: ['albumCommentary', album.directory],
+
+      contentFunction: {
+        name: 'generateAlbumCommentaryPage',
+        args: [album],
+      },
+    },
 
-  const listTag = getAlbumListTag(album);
+    {
+      type: 'data',
+      path: ['album', album.directory],
 
+      contentFunction: {
+        name: 'generateAlbumDataFile',
+        args: [album],
+      },
+    },
+    */
+  ];
+}
+
+/*
+export function write(album, {wikiData}) {
   const getSocialEmbedDescription = ({
     getArtistString: _getArtistString,
     language,
@@ -123,297 +119,6 @@ export function write(album, {wikiData}) {
     }),
   };
 
-  const infoPage = {
-    type: 'page',
-    path: ['album', album.directory],
-    page: ({
-      absoluteTo,
-      fancifyURL,
-      generateAdditionalFilesShortcut,
-      generateAdditionalFilesList,
-      generateChronologyLinks,
-      generateContentHeading,
-      generateNavigationLinks,
-      getAlbumCover,
-      getAlbumStylesheet,
-      getArtistString,
-      getLinkThemeString,
-      getSizeOfAdditionalFile,
-      getThemeString,
-      html,
-      link,
-      language,
-      transformMultiline,
-      urls,
-    }) => {
-      const trackToListItem = bindOpts(unbound_trackToListItem, {
-        getArtistString,
-        getLinkThemeString,
-        html,
-        language,
-        link,
-      });
-
-      return {
-        title: language.$('albumPage.title', {album: album.name}),
-        stylesheet: getAlbumStylesheet(album),
-
-        themeColor: album.color,
-        theme:
-          getThemeString(album.color, {
-            additionalVariables: [
-              `--album-directory: ${album.directory}`,
-            ],
-          }),
-
-        socialEmbed: {
-          heading:
-            (empty(album.groups)
-              ? ''
-              : language.$('albumPage.socialEmbed.heading', {
-                  group: album.groups[0].name,
-                })),
-          headingLink:
-            (empty(album.groups)
-              ? null
-              : absoluteTo('localized.album', album.groups[0].directory)),
-          title: language.$('albumPage.socialEmbed.title', {
-            album: album.name,
-          }),
-          description: getSocialEmbedDescription({getArtistString, language}),
-          image: '/' + getAlbumCover(album, {to: urls.from('shared.root').to}),
-          color: album.color,
-        },
-
-        banner: !empty(album.bannerArtistContribs) && {
-          dimensions: album.bannerDimensions,
-          path: [
-            'media.albumBanner',
-            album.directory,
-            album.bannerFileExtension,
-          ],
-          alt: language.$('misc.alt.albumBanner'),
-          position: 'top',
-        },
-
-        cover: {
-          src: getAlbumCover(album),
-          alt: language.$('misc.alt.albumCover'),
-          artTags: album.artTags,
-        },
-
-        main: {
-          headingMode: 'sticky',
-
-          content: [
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                !empty(album.artistContribs) &&
-                  language.$('releaseInfo.by', {
-                    artists: getArtistString(album.artistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(album.coverArtistContribs) &&
-                  language.$('releaseInfo.coverArtBy', {
-                    artists: getArtistString(album.coverArtistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(album.wallpaperArtistContribs) &&
-                  language.$('releaseInfo.wallpaperArtBy', {
-                    artists: getArtistString(album.wallpaperArtistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(album.bannerArtistContribs) &&
-                  language.$('releaseInfo.bannerArtBy', {
-                    artists: getArtistString(album.bannerArtistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                album.date &&
-                  language.$('releaseInfo.released', {
-                    date: language.formatDate(album.date),
-                  }),
-
-                album.hasCoverArt &&
-                album.coverArtDate &&
-                +album.coverArtDate !== +album.date &&
-                  language.$('releaseInfo.artReleased', {
-                    date: language.formatDate(album.coverArtDate),
-                  }),
-
-                albumDuration > 0 &&
-                  language.$('releaseInfo.duration', {
-                    duration: language.formatDuration(albumDuration, {
-                      approximate: album.tracks.length > 1,
-                    }),
-                  }),
-              ]),
-
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                hasAdditionalFiles &&
-                  generateAdditionalFilesShortcut(album.additionalFiles),
-
-                checkGalleryPage(album) &&
-                  language.$('releaseInfo.viewGallery', {
-                    link: link.albumGallery(album, {
-                      text: language.$('releaseInfo.viewGallery.link'),
-                    }),
-                  }),
-
-                checkCommentaryPage(album) &&
-                  language.$('releaseInfo.viewCommentary', {
-                    link: link.albumCommentary(album, {
-                      text: language.$('releaseInfo.viewCommentary.link'),
-                    }),
-                  }),
-              ]),
-
-            !empty(album.urls) &&
-              html.tag('p',
-                language.$('releaseInfo.listenOn', {
-                  links: language.formatDisjunctionList(
-                    album.urls.map(url => fancifyURL(url, {album: true}))
-                  ),
-                })),
-
-            displayTrackSections &&
-            !empty(album.trackSections) &&
-              html.tag('dl',
-                {class: 'album-group-list'},
-                album.trackSections.flatMap(({
-                  name,
-                  startIndex,
-                  tracks,
-                }) => [
-                  html.tag('dt',
-                    {class: ['content-heading']},
-                    language.$('trackList.section.withDuration', {
-                      duration: language.formatDuration(getTotalDuration(tracks), {
-                        approximate: tracks.length > 1,
-                      }),
-                      section: name,
-                    })),
-                  html.tag('dd',
-                    html.tag(listTag,
-                      listTag === 'ol' ? {start: startIndex + 1} : {},
-                      tracks.map(trackToListItem))),
-                ])),
-
-            !displayTrackSections &&
-            !empty(album.tracks) &&
-              html.tag(listTag,
-                album.tracks.map(trackToListItem)),
-
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                album.dateAddedToWiki &&
-                  language.$('releaseInfo.addedToWiki', {
-                    date: language.formatDate(
-                      album.dateAddedToWiki
-                    ),
-                  })
-              ]),
-
-            ...html.fragment(
-              hasAdditionalFiles && [
-                generateContentHeading({
-                  id: 'additional-files',
-                  title: language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles: language.countAdditionalFiles(numAdditionalFiles, {
-                      unit: true,
-                    }),
-                  }),
-                }),
-
-                generateAlbumAdditionalFilesList(album, album.additionalFiles, {
-                  generateAdditionalFilesList,
-                  getSizeOfAdditionalFile,
-                  link,
-                  urls,
-                }),
-              ]),
-
-            ...html.fragment(
-              album.commentary && [
-                generateContentHeading({
-                  id: 'artist-commentary',
-                  title: language.$('releaseInfo.artistCommentary'),
-                }),
-
-                html.tag('blockquote', transformMultiline(album.commentary)),
-              ]),
-          ],
-        },
-
-        sidebarLeft: generateAlbumSidebar(album, null, {
-          fancifyURL,
-          getLinkThemeString,
-          html,
-          link,
-          language,
-          transformMultiline,
-          wikiData,
-        }),
-
-        nav: {
-          linkContainerClasses: ['nav-links-hierarchy'],
-          links: [
-            {toHome: true},
-            {
-              html: language.$('albumPage.nav.album', {
-                album: link.album(album, {class: 'current'}),
-              }),
-            },
-            {
-              divider: false,
-              html: generateAlbumNavLinks(album, null, {
-                generateNavigationLinks,
-                html,
-                language,
-                link,
-              }),
-            }
-          ],
-          content: generateAlbumChronologyLinks(album, null, {
-            generateChronologyLinks,
-            html,
-          }),
-        },
-
-        secondaryNav: generateAlbumSecondaryNav(album, null, {
-          getLinkThemeString,
-          html,
-          language,
-          link,
-        }),
-      };
-    },
-  };
-
   // TODO: only gen if there are any tracks with art
   const galleryPage = {
     type: 'page',
@@ -494,153 +199,6 @@ export function write(album, {wikiData}) {
       }),
     }),
   };
-
-  return [
-    infoPage,
-    galleryPage,
-    data,
-  ];
-}
-
-// Utility functions
-
-export function generateAlbumSidebar(album, currentTrack, {
-  fancifyURL,
-  getLinkThemeString,
-  html,
-  language,
-  link,
-  transformMultiline,
-}) {
-  const isAlbumPage = !currentTrack;
-  const isTrackPage = !!currentTrack;
-
-  const listTag = getAlbumListTag(album);
-
-  const {trackSections} = album;
-
-  const trackToListItem = (track) =>
-    html.tag('li',
-      {class: track === currentTrack && 'current'},
-      language.$('albumSidebar.trackList.item', {
-        track: link.track(track),
-      }));
-
-  const nameOrDefault = (isDefaultTrackSection, name) =>
-    isDefaultTrackSection
-      ? language.$('albumSidebar.trackList.fallbackSectionName')
-      : name;
-
-  const trackListPart = [
-    html.tag('h1', link.album(album)),
-    ...trackSections.map(({name, color, startIndex, tracks, isDefaultTrackSection}) => {
-      const groupName =
-        html.tag('span',
-          {class: 'group-name'},
-          nameOrDefault(
-            isDefaultTrackSection,
-            name
-          ));
-      return html.tag('details',
-        {
-          // Leave side8ar track groups collapsed on al8um homepage,
-          // since there's already a view of all the groups expanded
-          // in the main content area.
-          open: isTrackPage && tracks.includes(currentTrack),
-          class: tracks.includes(currentTrack) && 'current',
-        },
-        [
-          html.tag(
-            'summary',
-            {style: getLinkThemeString(color)},
-            html.tag('span', [
-              listTag === 'ol' &&
-                language.$('albumSidebar.trackList.group.withRange', {
-                  group: groupName,
-                  range: `${startIndex + 1}&ndash;${
-                    startIndex + tracks.length
-                  }`,
-                }),
-              listTag === 'ul' &&
-                language.$('albumSidebar.trackList.group', {
-                  group: groupName,
-                }),
-            ])),
-          html.tag(listTag,
-            listTag === 'ol' ? {start: startIndex + 1} : {},
-            tracks.map(trackToListItem)),
-        ]);
-    }),
-  ];
-
-  const {groups} = album;
-
-  const groupParts = groups
-    .map((group) => {
-      const albums = group.albums.filter((album) => album.date);
-      const index = albums.indexOf(album);
-      const next = index >= 0 && albums[index + 1];
-      const previous = index > 0 && albums[index - 1];
-      return {group, next, previous};
-    })
-    // This is a map and not a flatMap because the distinction between which
-    // group sets of elements belong to matters. That means this variable is an
-    // array of arrays, and we'll need to treat it as such later!
-    .map(({group, next, previous}) => [
-      html.tag('h1', language.$('albumSidebar.groupBox.title', {
-        group: link.groupInfo(group),
-      })),
-
-      isAlbumPage &&
-        transformMultiline(group.descriptionShort),
-
-      !empty(group.urls) &&
-        html.tag('p', language.$('releaseInfo.visitOn', {
-          links: language.formatDisjunctionList(
-            group.urls.map((url) => fancifyURL(url))
-          ),
-        })),
-
-      ...html.fragment(
-        isAlbumPage && [
-          next &&
-            html.tag('p',
-              {class: 'group-chronology-link'},
-              language.$('albumSidebar.groupBox.next', {
-                album: link.album(next),
-              })),
-
-          previous &&
-            html.tag('p',
-              {class: 'group-chronology-link'},
-              language.$('albumSidebar.groupBox.previous', {
-                album: link.album(previous),
-              })),
-        ]),
-    ]);
-
-  if (empty(groupParts)) {
-    return {
-      stickyMode: 'column',
-      content: trackListPart,
-    };
-  } else if (isTrackPage) {
-    const combinedGroupPart = {
-      classes: ['no-sticky-header'],
-      content: groupParts
-        .map(groupPart => groupPart.filter(Boolean).join('\n'))
-        .join('\n<hr>\n'),
-    };
-    return {
-      stickyMode: 'column',
-      multiple: [trackListPart, combinedGroupPart],
-    };
-  } else {
-    return {
-      stickyMode: 'last',
-      multiple: [...groupParts, trackListPart],
-    };
-  }
 }
 
 export function generateAlbumSecondaryNav(album, currentTrack, {
@@ -696,174 +254,4 @@ export function generateAlbumSecondaryNav(album, currentTrack, {
     content: groupParts,
   };
 }
-
-function checkGalleryPage(album) {
-  return album.tracks.some(t => t.hasUniqueCoverArt);
-}
-
-function checkCommentaryPage(album) {
-  return !!album.commentary || album.tracks.some(t => t.commentary);
-}
-
-export function generateAlbumNavLinks(album, currentTrack, {
-  generateNavigationLinks,
-  html,
-  language,
-  link,
-
-  currentExtra = null,
-  showTrackNavigation = true,
-  showExtraLinks = null,
-}) {
-  const isTrackPage = !!currentTrack;
-
-  showExtraLinks ??= currentTrack ? false : true;
-
-  const extraLinks = showExtraLinks ? [
-    checkGalleryPage(album) &&
-      link.albumGallery(album, {
-        class: [currentExtra === 'gallery' && 'current'],
-        text: language.$('albumPage.nav.gallery'),
-      }),
-
-    checkCommentaryPage(album) &&
-      link.albumCommentary(album, {
-        class: [currentExtra === 'commentary' && 'current'],
-        text: language.$('albumPage.nav.commentary'),
-      }),
-  ].filter(Boolean) : [];
-
-  const previousNextLinks =
-    showTrackNavigation &&
-    album.tracks.length > 1 &&
-      generateNavigationLinks(currentTrack, {
-        data: album.tracks,
-        linkKey: 'track',
-        returnAsArray: true,
-      })
-
-  const randomLink =
-    showTrackNavigation &&
-    album.tracks.length > 1 &&
-      html.tag('a',
-        {
-          href: '#',
-          'data-random': 'track-in-album',
-          id: 'random-button'
-        },
-        (isTrackPage
-          ? language.$('trackPage.nav.random')
-          : language.$('albumPage.nav.randomTrack')));
-
-  const allLinks = [
-    ...previousNextLinks || [],
-    ...extraLinks || [],
-    randomLink,
-  ].filter(Boolean);
-
-  if (empty(allLinks)) {
-    return '';
-  }
-
-  return `(${language.formatUnitList(allLinks)})`;
-}
-
-export function generateAlbumExtrasPageNav(album, currentExtra, {
-  html,
-  language,
-  link,
-}) {
-  return {
-    linkContainerClasses: ['nav-links-hierarchy'],
-    links: [
-      {toHome: true},
-      {
-        html: language.$('albumPage.nav.album', {
-          album: link.album(album, {class: 'current'}),
-        }),
-      },
-      {
-        divider: false,
-        html: generateAlbumNavLinks(album, null, {
-          currentExtra,
-          showTrackNavigation: false,
-          showExtraLinks: true,
-
-          html,
-          language,
-          link,
-        }),
-      }
-    ],
-  };
-}
-
-export function generateAlbumChronologyLinks(album, currentTrack, {
-  generateChronologyLinks,
-  html,
-}) {
-  return html.tag(
-    'div',
-    {
-      [html.onlyIfContent]: true,
-      class: 'nav-chronology-links',
-    },
-    [
-      ...html.fragment(
-        currentTrack && [
-          ...html.fragment(
-            generateChronologyLinks(currentTrack, {
-              contribKey: 'artistContribs',
-              getThings: (artist) => [
-                ...artist.tracksAsArtist,
-                ...artist.tracksAsContributor,
-              ],
-              headingString: 'misc.chronology.heading.track',
-            })),
-
-          ...html.fragment(
-            generateChronologyLinks(currentTrack, {
-              contribKey: 'contributorContribs',
-              getThings: (artist) => [
-                ...artist.tracksAsArtist,
-                ...artist.tracksAsContributor,
-              ],
-              headingString: 'misc.chronology.heading.track',
-            })),
-        ]),
-
-      ...html.fragment(
-        generateChronologyLinks(currentTrack || album, {
-          contribKey: 'coverArtistContribs',
-          dateKey: 'coverArtDate',
-          getThings: (artist) => [
-            ...artist.albumsAsCoverArtist,
-            ...artist.tracksAsCoverArtist,
-          ],
-          headingString: 'misc.chronology.heading.coverArt',
-        })),
-    ]);
-}
-
-export function generateAlbumAdditionalFilesList(album, additionalFiles, {
-  fileSize = true,
-
-  generateAdditionalFilesList,
-  getSizeOfAdditionalFile,
-  link,
-  urls,
-}) {
-  return generateAdditionalFilesList(additionalFiles, {
-    getFileSize:
-      (fileSize
-        ? (file) =>
-            // TODO: Kinda near the metal here...
-            getSizeOfAdditionalFile(
-              urls
-                .from('media.root')
-                .to('media.albumAdditionalFile', album.directory, file))
-        : () => null),
-    linkFile: (file) =>
-      link.albumAdditionalFile({album, file}),
-  });
-}
+*/
diff --git a/src/page/artist.js b/src/page/artist.js
index 4ef44d3..ad36516 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -2,682 +2,22 @@
 //
 // NB: See artist-alias.js for artist alias redirect pages.
 
-import {
-  bindOpts,
-  empty,
-  unique,
-} from '../util/sugar.js';
-
-import {
-  chunkByProperties,
-  getTotalDuration,
-  sortAlbumsTracksChronologically,
-  sortFlashesChronologically,
-} from '../util/wiki-data.js';
-
 export const description = `per-artist info & artwork gallery pages`;
 
 export function targets({wikiData}) {
   return wikiData.artistData;
 }
 
-export function write(artist, {wikiData}) {
-  const {groupData, wikiInfo} = wikiData;
-
-  const {name, urls, contextNotes} = artist;
-
-  const artThingsAll = sortAlbumsTracksChronologically(
-    unique([
-      ...(artist.albumsAsCoverArtist ?? []),
-      ...(artist.albumsAsWallpaperArtist ?? []),
-      ...(artist.albumsAsBannerArtist ?? []),
-      ...(artist.tracksAsCoverArtist ?? []),
-    ]),
-    {getDate: (o) => o.coverArtDate});
-
-  const artThingsGallery = sortAlbumsTracksChronologically(
-    [
-      ...(artist.albumsAsCoverArtist ?? []),
-      ...(artist.tracksAsCoverArtist ?? []),
-    ],
-    {latestFirst: true, getDate: (o) => o.coverArtDate});
-
-  const commentaryThings = sortAlbumsTracksChronologically([
-    ...(artist.albumsAsCommentator ?? []),
-    ...(artist.tracksAsCommentator ?? []),
-  ]);
-
-  const hasGallery = !empty(artThingsGallery);
-
-  const getArtistsAndContrib = (thing, key) => ({
-    artists: thing[key]?.filter(({who}) => who !== artist),
-    contrib: thing[key]?.find(({who}) => who === artist),
-    thing,
-    key,
-  });
-
-  const artListChunks = chunkByProperties(
-    artThingsAll.flatMap((thing) =>
-      ['coverArtistContribs', 'wallpaperArtistContribs', 'bannerArtistContribs']
-        .map((key) => getArtistsAndContrib(thing, key))
-        .filter(({contrib}) => contrib)
-        .map((props) => ({
-          album: thing.album || thing,
-          track: thing.album ? thing : null,
-          date: thing.date,
-          ...props,
-        }))),
-    ['date', 'album']);
-
-  const commentaryListChunks = chunkByProperties(
-    commentaryThings.map((thing) => ({
-      album: thing.album || thing,
-      track: thing.album ? thing : null,
-    })),
-    ['album']);
-
-  const allTracks = sortAlbumsTracksChronologically(
-    unique([
-      ...(artist.tracksAsArtist ?? []),
-      ...(artist.tracksAsContributor ?? []),
-    ]));
-
-  const chunkTracks = (tracks) =>
-    chunkByProperties(
-      tracks.map((track) => ({
-        track,
-        date: +track.date,
-        album: track.album,
-        duration: track.duration,
-        originalReleaseTrack: track.originalReleaseTrack,
-        artists: track.artistContribs.some(({who}) => who === artist)
-          ? track.artistContribs.filter(({who}) => who !== artist)
-          : track.contributorContribs.filter(({who}) => who !== artist),
-        contrib: {
-          who: artist,
-          whatArray: [
-            track.artistContribs.find(({who}) => who === artist)?.what,
-            track.contributorContribs.find(({who}) => who === artist)?.what,
-          ].filter(Boolean),
-        },
-      })),
-      ['date', 'album'])
-    .map(({date, album, chunk}) => ({
-      date,
-      album,
-      chunk,
-      duration: getTotalDuration(chunk, {originalReleasesOnly: true}),
-    }));
-
-  const trackListChunks = chunkTracks(allTracks);
-  const totalDuration = getTotalDuration(allTracks.filter(t => !t.originalReleaseTrack));
-
-  const countGroups = (things) => {
-    const usedGroups = things.flatMap(
-      (thing) => thing.groups || thing.album?.groups || []);
-    return groupData
-      .map((group) => ({
-        group,
-        contributions: usedGroups.filter(g => g === group).length,
-      }))
-      .filter(({contributions}) => contributions > 0)
-      .sort((a, b) => b.contributions - a.contributions);
-  };
-
-  const musicGroups = countGroups(allTracks);
-  const artGroups = countGroups(artThingsAll);
-
-  let flashes, flashListChunks;
-  if (wikiInfo.enableFlashesAndGames) {
-    flashes = sortFlashesChronologically(artist.flashesAsContributor.slice());
-    flashListChunks = chunkByProperties(
-      flashes.map((flash) => ({
-        act: flash.act,
-        flash,
-        date: flash.date,
-        // Manual artists/contrib properties here, 8ecause we don't
-        // want to show the full list of other contri8utors inline.
-        // (It can often 8e very, very large!)
-        artists: [],
-        contrib: flash.contributorContribs.find(({who}) => who === artist),
-      })),
-      ['act']
-    ).map(({act, chunk}) => ({
-      act,
-      chunk,
-      dateFirst: chunk[0].date,
-      dateLast: chunk[chunk.length - 1].date,
-    }));
-  }
-
-  const generateEntryAccents = ({
-    getArtistString,
-    language,
-    original,
-    entry,
-    artists,
-    contrib,
-  }) =>
-    original
-      ? language.$('artistPage.creditList.entry.rerelease', {entry})
-      : !empty(artists)
-      ? contrib.what || contrib.whatArray?.length
-        ? language.$('artistPage.creditList.entry.withArtists.withContribution', {
-            entry,
-            artists: getArtistString(artists),
-            contribution: contrib.whatArray
-              ? language.formatUnitList(contrib.whatArray)
-              : contrib.what,
-          })
-        : language.$('artistPage.creditList.entry.withArtists', {
-            entry,
-            artists: getArtistString(artists),
-          })
-      : contrib.what || contrib.whatArray?.length
-      ? language.$('artistPage.creditList.entry.withContribution', {
-          entry,
-          contribution: contrib.whatArray
-            ? language.formatUnitList(contrib.whatArray)
-            : contrib.what,
-        })
-      : entry;
-
-  const unbound_generateTrackList = (chunks, {
-    getArtistString,
-    html,
-    language,
-    link,
-  }) =>
-    html.tag('dl',
-      chunks.flatMap(({date, album, chunk, duration}) => [
-        html.tag('dt',
-          date && duration ?
-            language.$('artistPage.creditList.album.withDate.withDuration', {
-              album: link.album(album),
-              date: language.formatDate(date),
-              duration: language.formatDuration(duration, {
-                approximate: true,
-              }),
-            }) :
-
-          date ?
-            language.$('artistPage.creditList.album.withDate', {
-              album: link.album(album),
-              date: language.formatDate(date),
-            }) :
-
-          duration ?
-            language.$('artistPage.creditList.album.withDuration', {
-              album: link.album(album),
-              duration: language.formatDuration(duration, {
-                approximate: true,
-              }),
-            }) :
-
-          language.$('artistPage.creditList.album', {
-            album: link.album(album),
-          })),
-
-        html.tag('dd',
-          html.tag('ul',
-            chunk
-              .map(({track, ...props}) => ({
-                original: track.originalReleaseTrack,
-                entry: language.$('artistPage.creditList.entry.track.withDuration', {
-                  track: link.track(track),
-                  duration: language.formatDuration(track.duration ?? 0),
-                }),
-                ...props,
-              }))
-              .map(({original, ...opts}) =>
-                html.tag('li',
-                  {class: original && 'rerelease'},
-                  generateEntryAccents({
-                    getArtistString,
-                    language,
-                    original,
-                    ...opts,
-                  })
-                )
-              ))),
-      ]));
-
-  const unbound_serializeArtistsAndContrib =
-    (key, {serializeContribs, serializeLink}) =>
-    (thing) => {
-      const {artists, contrib} = getArtistsAndContrib(thing, key);
-      const ret = {};
-      ret.link = serializeLink(thing);
-      if (contrib.what) ret.contribution = contrib.what;
-      if (!empty(artists)) ret.otherArtists = serializeContribs(artists);
-      return ret;
-    };
-
-  const unbound_serializeTrackListChunks = (chunks, {serializeLink}) =>
-    chunks.map(({date, album, chunk, duration}) => ({
-      album: serializeLink(album),
-      date,
-      duration,
-      tracks: chunk.map(({track}) => ({
-        link: serializeLink(track),
-        duration: track.duration,
-      })),
-    }));
-
-  const jumpTo = {
-    tracks: !empty(allTracks),
-    art: !empty(artThingsAll),
-    flashes: wikiInfo.enableFlashesAndGames && !empty(flashes),
-    commentary: !empty(commentaryThings),
-  };
-
-  const showJumpTo = Object.values(jumpTo).includes(true);
-
-  const data = {
-    type: 'data',
-    path: ['artist', artist.directory],
-    data: ({serializeContribs, serializeLink}) => {
-      const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, {
-        serializeContribs,
-        serializeLink,
-      });
-
-      const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, {
-        serializeLink,
-      });
-
-      return {
-        albums: {
-          asCoverArtist: artist.albumsAsCoverArtist
-            .map(serializeArtistsAndContrib('coverArtistContribs')),
-          asWallpaperArtist: artist.albumsAsWallpaperArtist
-            .map(serializeArtistsAndContrib('wallpaperArtistContribs')),
-          asBannerArtist: artist.albumsAsBannerArtis
-            .map(serializeArtistsAndContrib('bannerArtistContribs')),
-        },
-        flashes: wikiInfo.enableFlashesAndGames
-          ? {
-              asContributor: artist.flashesAsContributor
-                .map(flash => getArtistsAndContrib(flash, 'contributorContribs'))
-                .map(({contrib, thing: flash}) => ({
-                  link: serializeLink(flash),
-                  contribution: contrib.what,
-                })),
-            }
-          : null,
-        tracks: {
-          asArtist: artist.tracksAsArtist
-            .map(serializeArtistsAndContrib('artistContribs')),
-          asContributor: artist.tracksAsContributo
-            .map(serializeArtistsAndContrib('contributorContribs')),
-          chunked: serializeTrackListChunks(trackListChunks),
-        },
-      };
-    },
-  };
-
-  const infoPage = {
-    type: 'page',
-    path: ['artist', artist.directory],
-    page: ({
-      fancifyURL,
-      generateInfoGalleryLinks,
-      getArtistAvatar,
-      getArtistString,
-      html,
-      link,
-      language,
-      transformMultiline,
-    }) => {
-      const generateTrackList = bindOpts(unbound_generateTrackList, {
-        getArtistString,
-        html,
-        language,
-        link,
-      });
-
-      return {
-        title: language.$('artistPage.title', {artist: name}),
-
-        cover: artist.hasAvatar && {
-          src: getArtistAvatar(artist),
-          alt: language.$('misc.alt.artistAvatar'),
-        },
-
-        main: {
-          headingMode: 'sticky',
-
-          content: [
-            ...html.fragment(
-              contextNotes && [
-                html.tag('p',
-                  language.$('releaseInfo.note')),
-
-                html.tag('blockquote',
-                  transformMultiline(contextNotes)),
-
-                html.tag('hr'),
-              ]),
-
-            !empty(urls) &&
-              html.tag('p',
-                language.$('releaseInfo.visitOn', {
-                  links: language.formatDisjunctionList(
-                    urls.map((url) => fancifyURL(url, {language}))
-                  ),
-                })),
-
-            hasGallery &&
-              html.tag('p',
-                language.$('artistPage.viewArtGallery', {
-                  link: link.artistGallery(artist, {
-                    text: language.$('artistPage.viewArtGallery.link'),
-                  }),
-                })),
-
-            showJumpTo &&
-              html.tag('p',
-                language.$('misc.jumpTo.withLinks', {
-                  links: language.formatUnitList(
-                    [
-                      jumpTo.tracks &&
-                        html.tag('a',
-                          {href: '#tracks'},
-                          language.$('artistPage.trackList.title')),
-
-                      jumpTo.art &&
-                        html.tag('a',
-                          {href: '#art'},
-                          language.$('artistPage.artList.title')),
-
-                      jumpTo.flashes &&
-                        html.tag('a',
-                          {href: '#flashes'},
-                          language.$('artistPage.flashList.title')),
-
-                      jumpTo.commentary &&
-                        html.tag('a',
-                          {href: '#commentary'},
-                          language.$('artistPage.commentaryList.title')),
-                    ].filter(Boolean)),
-                })),
-
-            ...html.fragment(
-              !empty(allTracks) && [
-                html.tag('h2',
-                  {id: 'tracks', class: ['content-heading']},
-                  language.$('artistPage.trackList.title')),
-
-                totalDuration > 0 &&
-                  html.tag('p',
-                    language.$('artistPage.contributedDurationLine', {
-                      artist: artist.name,
-                      duration: language.formatDuration(
-                        totalDuration,
-                        {
-                          approximate: true,
-                          unit: true,
-                        }
-                      ),
-                    })),
-
-                !empty(musicGroups) &&
-                  html.tag('p',
-                    language.$('artistPage.musicGroupsLine', {
-                      groups: language.formatUnitList(
-                        musicGroups.map(({group, contributions}) =>
-                          language.$('artistPage.groupsLine.item', {
-                            group: link.groupInfo(group),
-                            contributions:
-                              language.countContributions(
-                                contributions
-                              ),
-                          })
-                        )
-                      ),
-                    })),
-
-                generateTrackList(trackListChunks),
-              ]),
-
-            ...html.fragment(
-              !empty(artThingsAll) && [
-                html.tag('h2',
-                  {id: 'art', class: ['content-heading']},
-                  language.$('artistPage.artList.title')),
-
-                hasGallery &&
-                  html.tag('p',
-                    language.$('artistPage.viewArtGallery.orBrowseList', {
-                      link: link.artistGallery(artist, {
-                        text: language.$('artistPage.viewArtGallery.link'),
-                      })
-                    })),
-
-                !empty(artGroups) &&
-                  html.tag('p',
-                    language.$('artistPage.artGroupsLine', {
-                    groups: language.formatUnitList(
-                      artGroups.map(({group, contributions}) =>
-                        language.$('artistPage.groupsLine.item', {
-                          group: link.groupInfo(group),
-                          contributions:
-                            language.countContributions(
-                              contributions
-                            ),
-                        })
-                      )
-                    ),
-                  })),
-
-                html.tag('dl',
-                  artListChunks.flatMap(({date, album, chunk}) => [
-                    html.tag('dt', language.$('artistPage.creditList.album.withDate', {
-                      album: link.album(album),
-                      date: language.formatDate(date),
-                    })),
-
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({track, key, ...props}) => ({
-                            ...props,
-                            entry:
-                              track
-                                ? language.$('artistPage.creditList.entry.track', {
-                                    track: link.track(track),
-                                  })
-                                : html.tag('i',
-                                    language.$('artistPage.creditList.entry.album.' + {
-                                      wallpaperArtistContribs:
-                                        'wallpaperArt',
-                                      bannerArtistContribs:
-                                        'bannerArt',
-                                      coverArtistContribs:
-                                        'coverArt',
-                                    }[key])),
-                          }))
-                          .map((opts) => generateEntryAccents({
-                            getArtistString,
-                            language,
-                            ...opts,
-                          }))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-
-            ...html.fragment(
-              wikiInfo.enableFlashesAndGames &&
-              !empty(flashes) && [
-                html.tag('h2',
-                  {id: 'flashes', class: ['content-heading']},
-                  language.$('artistPage.flashList.title')),
-
-                html.tag('dl',
-                  flashListChunks.flatMap(({
-                    act,
-                    chunk,
-                    dateFirst,
-                    dateLast,
-                  }) => [
-                    html.tag('dt',
-                      language.$('artistPage.creditList.flashAct.withDateRange', {
-                        act: link.flash(chunk[0].flash, {
-                          text: act.name,
-                        }),
-                        dateRange: language.formatDateRange(
-                          dateFirst,
-                          dateLast
-                        ),
-                      })),
-
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({flash, ...props}) => ({
-                            ...props,
-                            entry: language.$('artistPage.creditList.entry.flash', {
-                              flash: link.flash(flash),
-                            }),
-                          }))
-                          .map(opts => generateEntryAccents({
-                            getArtistString,
-                            language,
-                            ...opts,
-                          }))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-
-            ...html.fragment(
-              !empty(commentaryThings) && [
-                html.tag('h2',
-                  {id: 'commentary', class: ['content-heading']},
-                  language.$('artistPage.commentaryList.title')),
-
-                html.tag('dl',
-                  commentaryListChunks.flatMap(({album, chunk}) => [
-                    html.tag('dt',
-                      language.$('artistPage.creditList.album', {
-                        album: link.album(album),
-                      })),
-
-                    html.tag('dd',
-                      html.tag('ul',
-                        chunk
-                          .map(({track}) => track
-                            ? language.$('artistPage.creditList.entry.track', {
-                                track: link.track(track),
-                              })
-                            : html.tag('i',
-                                language.$('artistPage.creditList.entry.album.commentary')))
-                          .map(row => html.tag('li', row)))),
-                  ])),
-              ]),
-          ],
-        },
-
-        nav: generateNavForArtist(artist, false, hasGallery, {
-          generateInfoGalleryLinks,
-          link,
-          language,
-          wikiData,
-        }),
-      };
-    },
-  };
-
-  const galleryPage = hasGallery && {
-    type: 'page',
-    path: ['artistGallery', artist.directory],
-    page: ({
-      generateInfoGalleryLinks,
-      getAlbumCover,
-      getGridHTML,
-      getTrackCover,
-      html,
-      link,
-      language,
-    }) => ({
-      title: language.$('artistGalleryPage.title', {artist: name}),
-
-      main: {
-        classes: ['top-index'],
-        headingMode: 'static',
-
-        content: [
-          html.tag('p',
-            {class: 'quick-info'},
-            language.$('artistGalleryPage.infoLine', {
-              coverArts: language.countCoverArts(artThingsGallery.length, {
-                unit: true,
-              }),
-            })),
-
-          html.tag('div',
-            {class: 'grid-listing'},
-            getGridHTML({
-              entries: artThingsGallery.map((item) => ({item})),
-              srcFn: (thing) =>
-                thing.album
-                  ? getTrackCover(thing)
-                  : getAlbumCover(thing),
-              linkFn: (thing, opts) =>
-                thing.album
-                  ? link.track(thing, opts)
-                  : link.album(thing, opts),
-            })),
-        ],
-      },
-
-      nav: generateNavForArtist(artist, true, hasGallery, {
-        generateInfoGalleryLinks,
-        link,
-        language,
-        wikiData,
-      }),
-    }),
-  };
-
-  return [data, infoPage, galleryPage].filter(Boolean);
-}
-
-// Utility functions
-
-function generateNavForArtist(artist, isGallery, hasGallery, {
-  generateInfoGalleryLinks,
-  language,
-  link,
-  wikiData,
-}) {
-  const {wikiInfo} = wikiData;
+export function pathsForTarget(artist) {
+  return [
+    {
+      type: 'page',
+      path: ['artist', artist.directory],
 
-  const infoGalleryLinks =
-    hasGallery &&
-    generateInfoGalleryLinks(artist, isGallery, {
-      link,
-      language,
-      linkKeyGallery: 'artistGallery',
-      linkKeyInfo: 'artist',
-    });
-
-  return {
-    linkContainerClasses: ['nav-links-hierarchy'],
-    links: [
-      {toHome: true},
-      wikiInfo.enableListings && {
-        path: ['localized.listingIndex'],
-        title: language.$('listingIndex.title'),
-      },
-      {
-        html: language.$('artistPage.nav.artist', {
-          artist: link.artist(artist, {class: 'current'}),
-        }),
+      contentFunction: {
+        name: 'generateArtistInfoPage',
+        args: [artist],
       },
-      hasGallery && {
-        divider: false,
-        html: `(${infoGalleryLinks})`,
-      },
-    ],
-  };
+    },
+  ];
 }
diff --git a/src/page/index.js b/src/page/index.js
index f580cbe..77ebfb6 100644
--- a/src/page/index.js
+++ b/src/page/index.js
@@ -2,52 +2,20 @@
 // other modules here! It's not the page spec for the homepage - see
 // homepage.js for that.
 //
-// Each module published in this list should follow a particular format,
-// including any of the following exports:
+// (TODO: The docs here from initial draft were totally outdated.
+//        We don't have docs for the new setup yet.
+//        Write those!!)
 //
-// condition({wikiData})
-//     Returns a boolean indicating whether to process targets/writes (true) or
-//     skip this page spec altogether (false). This is usually used for
-//     selectively toggling pages according to site feature flags, though it may
-//     also be used to e.g. skip out if no targets would be found (preventing
-//     writeTargetless from generating an empty index page).
-//
-// targets({wikiData})
-//     Gets the objects which this page's write() function should be called on.
-//     Usually this will simply mean returning the appropriate thingData array,
-//     but it may also apply filter/map/etc if useful.
-//
-// write(thing, {wikiData})
-//     Provides descriptors for any page and data writes associated with the
-//     given thing (which will be a value from the targets() array). This
-//     includes page (HTML) writes, data (JSON) writes, etc. Notably, this
-//     function does not perform any file operations itself; it only describes
-//     the operations which will be processed elsewhere, once for each
-//     translation language.  The write function also immediately transforms
-//     any data which will be reused across writes of the same page, so that
-//     this data is effectively cached (rather than recalculated for each
-//     language/write).
-//
-// writeTargetless({wikiData})
-//     Provides descriptors for page/data/etc writes which will be used
-//     without concern for targets. This is usually used for writing index pages
-//     which should be generated just once (rather than corresponding to
-//     targets).
-//
-// As these modules are effectively the HTML templates for all site layout,
-// common patterns may also be exported alongside the special exports above.
-// These functions should be referenced only from adjacent modules, as they
-// pertain only to site page generation.
 
 export * as album from './album.js';
-export * as albumCommentary from './album-commentary.js';
+// export * as albumCommentary from './album-commentary.js';
 export * as artist from './artist.js';
-export * as artistAlias from './artist-alias.js';
-export * as flash from './flash.js';
-export * as group from './group.js';
-export * as homepage from './homepage.js';
-export * as listing from './listing.js';
-export * as news from './news.js';
+// export * as artistAlias from './artist-alias.js';
+// export * as flash from './flash.js';
+// export * as group from './group.js';
+// export * as homepage from './homepage.js';
+// export * as listing from './listing.js';
+// export * as news from './news.js';
 export * as static from './static.js';
-export * as tag from './tag.js';
+// export * as tag from './tag.js';
 export * as track from './track.js';
diff --git a/src/page/static.js b/src/page/static.js
index 8572db4..82330de 100644
--- a/src/page/static.js
+++ b/src/page/static.js
@@ -8,26 +8,16 @@ export function targets({wikiData}) {
   return wikiData.staticPageData;
 }
 
-export function write(staticPage) {
-  const page = {
-    type: 'page',
-    path: ['staticPage', staticPage.directory],
-    page: ({
-      transformMultiline,
-    }) => ({
-      title: staticPage.name,
-      stylesheet: staticPage.stylesheet,
+export function pathsForTarget(staticPage) {
+  return [
+    {
+      type: 'page',
+      path: ['staticPage', staticPage.directory],
 
-      main: {
-        classes: ['long-content'],
-        headingMode: 'sticky',
-
-        content: transformMultiline(staticPage.content),
+      contentFunction: {
+        name: 'generateStaticPage',
+        args: [staticPage],
       },
-
-      nav: {simple: true},
-    }),
-  };
-
-  return [page];
+    },
+  ];
 }
diff --git a/src/page/track.js b/src/page/track.js
index b6b03f3..e75b695 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -1,553 +1,21 @@
 // Track page specification.
 
-import {
-  generateAlbumChronologyLinks,
-  generateAlbumNavLinks,
-  generateAlbumSecondaryNav,
-  generateAlbumSidebar,
-  generateAlbumAdditionalFilesList as unbound_generateAlbumAdditionalFilesList,
-} from './album.js';
-
-import {
-  bindOpts,
-  empty,
-} from '../util/sugar.js';
-
-import {
-  getTrackCover,
-  getAlbumListTag,
-  sortFlashesChronologically,
-} from '../util/wiki-data.js';
-
 export const description = `per-track info pages`;
 
 export function targets({wikiData}) {
   return wikiData.trackData;
 }
 
-export function write(track, {wikiData}) {
-  const {wikiInfo} = wikiData;
-
-  const {
-    album,
-    contributorContribs,
-    referencedByTracks,
-    referencedTracks,
-    sampledByTracks,
-    sampledTracks,
-    otherReleases,
-  } = track;
-
-  const listTag = getAlbumListTag(album);
-
-  let flashesThatFeature;
-  if (wikiInfo.enableFlashesAndGames) {
-    flashesThatFeature = sortFlashesChronologically(
-      [track, ...otherReleases].flatMap((track) =>
-        track.featuredInFlashes.map((flash) => ({
-          flash,
-          as: track,
-          directory: flash.directory,
-          name: flash.name,
-          date: flash.date,
-        }))
-      )
-    );
-  }
-
-  const unbound_getTrackItem = (track, {
-    getArtistString,
-    html,
-    language,
-    link,
-  }) =>
-    html.tag('li',
-      language.$('trackList.item.withArtists', {
-        track: link.track(track),
-        by: html.tag('span',
-          {class: 'by'},
-          language.$('trackList.item.withArtists.by', {
-            artists: getArtistString(track.artistContribs),
-          })),
-      }));
-
-  const hasCommentary =
-    track.commentary || otherReleases.some((t) => t.commentary);
+export function pathsForTarget(track) {
+  return [
+    {
+      type: 'page',
+      path: ['track', track.directory],
 
-  const hasAdditionalFiles = !empty(track.additionalFiles);
-  const hasSheetMusicFiles = !empty(track.sheetMusicFiles);
-  const hasMidiProjectFiles = !empty(track.midiProjectFiles);
-  const numAdditionalFiles = album.additionalFiles.flatMap((g) => g.files).length;
-
-  const generateCommentary = ({language, link, transformMultiline}) =>
-    transformMultiline([
-      track.commentary,
-      ...otherReleases.map((track) =>
-        track.commentary
-          ?.split('\n')
-          .filter((line) => line.replace(/<\/b>/g, '').includes(':</i>'))
-          .flatMap(line => [
-            line,
-            language.$('releaseInfo.artistCommentary.seeOriginalRelease', {
-              original: link.track(track),
-            }),
-          ])
-          .join('\n')
-      ),
-    ].filter(Boolean).join('\n'));
-
-  const data = {
-    type: 'data',
-    path: ['track', track.directory],
-    data: ({
-      serializeContribs,
-      serializeCover,
-      serializeGroupsForTrack,
-      serializeLink,
-    }) => ({
-      name: track.name,
-      directory: track.directory,
-      dates: {
-        released: track.date,
-        originallyReleased: track.originalDate,
-        coverArtAdded: track.coverArtDate,
+      contentFunction: {
+        name: 'generateTrackInfoPage',
+        args: [track],
       },
-      duration: track.duration,
-      color: track.color,
-      cover: serializeCover(track, getTrackCover),
-      artistsContribs: serializeContribs(track.artistContribs),
-      contributorContribs: serializeContribs(track.contributorContribs),
-      coverArtistContribs: serializeContribs(track.coverArtistContribs || []),
-      album: serializeLink(track.album),
-      groups: serializeGroupsForTrack(track),
-      references: track.references.map(serializeLink),
-      referencedBy: track.referencedBy.map(serializeLink),
-      alsoReleasedAs: otherReleases.map((track) => ({
-        track: serializeLink(track),
-        album: serializeLink(track.album),
-      })),
-    }),
-  };
-
-  const getSocialEmbedDescription = ({
-    getArtistString: _getArtistString,
-    language,
-  }) => {
-    const hasArtists = !empty(track.artistContribs);
-    const hasCoverArtists = !empty(track.coverArtistContribs);
-    const getArtistString = (contribs) =>
-      _getArtistString(contribs, {
-        // We don't want to put actual HTML tags in social embeds (sadly
-        // they don't get parsed and displayed, generally speaking), so
-        // override the link argument so that artist "links" just show
-        // their names.
-        link: {artist: (artist) => artist.name},
-      });
-    if (!hasArtists && !hasCoverArtists) return '';
-    return language.formatString(
-      'trackPage.socialEmbed.body' +
-        [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists']
-          .filter(Boolean)
-          .join(''),
-      Object.fromEntries(
-        [
-          hasArtists && ['artists', getArtistString(track.artistContribs)],
-          hasCoverArtists && [
-            'coverArtists',
-            getArtistString(track.coverArtistContribs),
-          ],
-        ].filter(Boolean)
-      )
-    );
-  };
-
-  const page = {
-    type: 'page',
-    path: ['track', track.directory],
-    page: ({
-      absoluteTo,
-      fancifyURL,
-      generateAdditionalFilesList,
-      generateAdditionalFilesShortcut,
-      generateChronologyLinks,
-      generateContentHeading,
-      generateNavigationLinks,
-      generateTrackListDividedByGroups,
-      getAlbumStylesheet,
-      getArtistString,
-      getLinkThemeString,
-      getSizeOfAdditionalFile,
-      getThemeString,
-      getTrackCover,
-      html,
-      link,
-      language,
-      transformLyrics,
-      transformMultiline,
-      to,
-      urls,
-    }) => {
-      const getTrackItem = bindOpts(unbound_getTrackItem, {
-        getArtistString,
-        html,
-        language,
-        link,
-      });
-
-      const generateAlbumAdditionalFilesList = bindOpts(unbound_generateAlbumAdditionalFilesList, {
-        [bindOpts.bindIndex]: 2,
-        generateAdditionalFilesList,
-        getSizeOfAdditionalFile,
-        link,
-        urls,
-      });
-
-      return {
-        title: language.$('trackPage.title', {track: track.name}),
-        stylesheet: getAlbumStylesheet(album, {to}),
-
-        themeColor: track.color,
-        theme:
-          getThemeString(track.color, {
-            additionalVariables: [
-              `--album-directory: ${album.directory}`,
-              `--track-directory: ${track.directory}`,
-            ]
-          }),
-
-        socialEmbed: {
-          heading: language.$('trackPage.socialEmbed.heading', {
-            album: track.album.name,
-          }),
-          headingLink: absoluteTo('localized.album', album.directory),
-          title: language.$('trackPage.socialEmbed.title', {
-            track: track.name,
-          }),
-          description: getSocialEmbedDescription({getArtistString, language}),
-          image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}),
-          color: track.color,
-        },
-
-        // disabled for now! shifting banner position per height of page is disorienting
-        /*
-        banner: !empty(album.bannerArtistContribs) && {
-          classes: ['dim'],
-          dimensions: album.bannerDimensions,
-          path: ['media.albumBanner', album.directory, album.bannerFileExtension],
-          alt: language.$('misc.alt.albumBanner'),
-          position: 'bottom'
-        },
-        */
-
-        cover: {
-          src: getTrackCover(track),
-          alt: language.$('misc.alt.trackCover'),
-          artTags: track.artTags,
-        },
-
-        main: {
-          headingMode: 'sticky',
-
-          content: [
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                !empty(track.artistContribs) &&
-                  language.$('releaseInfo.by', {
-                    artists: getArtistString(track.artistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                !empty(track.coverArtistContribs) &&
-                  language.$('releaseInfo.coverArtBy', {
-                    artists: getArtistString(track.coverArtistContribs, {
-                      showContrib: true,
-                      showIcons: true,
-                    }),
-                  }),
-
-                track.date &&
-                  language.$('releaseInfo.released', {
-                    date: language.formatDate(track.date),
-                  }),
-
-                track.hasCoverArt &&
-                track.coverArtDate &&
-                +track.coverArtDate !== +track.date &&
-                  language.$('releaseInfo.artReleased', {
-                    date: language.formatDate(track.coverArtDate),
-                  }),
-
-                track.duration &&
-                  language.$('releaseInfo.duration', {
-                    duration: language.formatDuration(
-                      track.duration
-                    ),
-                  }),
-              ]),
-
-            html.tag('p',
-              {
-                [html.onlyIfContent]: true,
-                [html.joinChildren]: '<br>',
-              },
-              [
-                hasSheetMusicFiles &&
-                  language.$('releaseInfo.sheetMusicFiles.shortcut', {
-                    link: html.tag('a',
-                      {href: '#sheet-music-files'},
-                      language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
-                  }),
-
-                hasMidiProjectFiles &&
-                  language.$('releaseInfo.midiProjectFiles.shortcut', {
-                    link: html.tag('a',
-                      {href: '#midi-project-files'},
-                      language.$('releaseInfo.midiProjectFiles.shortcut.link')),
-                  }),
-
-                hasAdditionalFiles &&
-                  generateAdditionalFilesShortcut(track.additionalFiles),
-              ]),
-
-            html.tag('p',
-              (empty(track.urls)
-                ? language.$('releaseInfo.listenOn.noLinks')
-                : language.$('releaseInfo.listenOn', {
-                    links: language.formatDisjunctionList(
-                      track.urls.map(url => fancifyURL(url, {language}))),
-                  }))),
-
-            ...html.fragment(
-              !empty(otherReleases) && [
-                generateContentHeading({
-                  id: 'also-released-as',
-                  title: language.$('releaseInfo.alsoReleasedAs'),
-                }),
-
-                html.tag('ul', otherReleases.map(track =>
-                  html.tag('li', language.$('releaseInfo.alsoReleasedAs.item', {
-                    track: link.track(track),
-                    album: link.album(track.album),
-                  })))),
-              ]),
-
-            ...html.fragment(
-              !empty(contributorContribs) && [
-                generateContentHeading({
-                  id: 'contributors',
-                  title: language.$('releaseInfo.contributors'),
-                }),
-
-                html.tag('ul', contributorContribs.map(contrib =>
-                  html.tag('li', getArtistString([contrib], {
-                    showContrib: true,
-                    showIcons: true,
-                  })))),
-              ]),
-
-            ...html.fragment(
-              !empty(referencedTracks) && [
-                generateContentHeading({
-                  id: 'references',
-                  title:
-                    language.$('releaseInfo.tracksReferenced', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                html.tag('ul', referencedTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              !empty(referencedByTracks) && [
-                generateContentHeading({
-                  id: 'referenced-by',
-                  title:
-                    language.$('releaseInfo.tracksThatReference', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                generateTrackListDividedByGroups(referencedByTracks, {
-                  getTrackItem,
-                  wikiData,
-                }),
-              ]),
-
-            ...html.fragment(
-              !empty(sampledTracks) && [
-                generateContentHeading({
-                  id: 'samples',
-                  title:
-                    language.$('releaseInfo.tracksSampled', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                html.tag('ul', sampledTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              !empty(sampledByTracks) && [
-                generateContentHeading({
-                  id: 'sampled-by',
-                  title:
-                    language.$('releaseInfo.tracksThatSample', {
-                      track: html.tag('i', track.name),
-                    })
-                }),
-
-                html.tag('ul', sampledByTracks.map(getTrackItem)),
-              ]),
-
-            ...html.fragment(
-              wikiInfo.enableFlashesAndGames &&
-              !empty(flashesThatFeature) && [
-                generateContentHeading({
-                  id: 'featured-in',
-                  title:
-                    language.$('releaseInfo.flashesThatFeature', {
-                      track: html.tag('i', track.name),
-                    }),
-                }),
-
-                html.tag('ul', flashesThatFeature.map(({flash, as}) =>
-                  html.tag('li',
-                    {class: as !== track && 'rerelease'},
-                    (as === track
-                      ? language.$('releaseInfo.flashesThatFeature.item', {
-                        flash: link.flash(flash),
-                      })
-                      : language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
-                        flash: link.flash(flash),
-                        track: link.track(as),
-                      }))))),
-              ]),
-
-            ...html.fragment(
-              track.lyrics && [
-                generateContentHeading({
-                  id: 'lyrics',
-                  title: language.$('releaseInfo.lyrics'),
-                }),
-
-                html.tag('blockquote', transformLyrics(track.lyrics)),
-              ]),
-
-            ...html.fragment(
-              hasSheetMusicFiles && [
-                generateContentHeading({
-                  id: 'sheet-music-files',
-                  title: language.$('releaseInfo.sheetMusicFiles.heading'),
-                }),
-
-                generateAlbumAdditionalFilesList(album, track.sheetMusicFiles, {
-                  fileSize: false,
-                }),
-              ]),
-
-            ...html.fragment(
-              hasMidiProjectFiles && [
-                generateContentHeading({
-                  id: 'midi-project-files',
-                  title: language.$('releaseInfo.midiProjectFiles.heading'),
-                }),
-
-                generateAlbumAdditionalFilesList(album, track.midiProjectFiles),
-              ]),
-
-            ...html.fragment(
-              hasAdditionalFiles && [
-                generateContentHeading({
-                  id: 'additional-files',
-                  title: language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles: language.countAdditionalFiles(numAdditionalFiles, {
-                      unit: true,
-                    }),
-                  })
-                }),
-
-                generateAlbumAdditionalFilesList(album, track.additionalFiles),
-              ]),
-
-            ...html.fragment(
-              hasCommentary && [
-                generateContentHeading({
-                  id: 'artist-commentary',
-                  title: language.$('releaseInfo.artistCommentary'),
-                }),
-
-                html.tag('blockquote', generateCommentary({
-                  link,
-                  language,
-                  transformMultiline,
-                })),
-              ]),
-          ],
-        },
-
-        sidebarLeft: generateAlbumSidebar(album, track, {
-          fancifyURL,
-          getLinkThemeString,
-          html,
-          language,
-          link,
-          transformMultiline,
-          wikiData,
-        }),
-
-        nav: {
-          linkContainerClasses: ['nav-links-hierarchy'],
-          links: [
-            {toHome: true},
-            {
-              path: ['localized.album', album.directory],
-              title: album.name,
-            },
-            listTag === 'ol' &&
-              {
-                html: language.$('trackPage.nav.track.withNumber', {
-                  number: album.tracks.indexOf(track) + 1,
-                  track: link.track(track, {class: 'current', to}),
-                }),
-              },
-            listTag === 'ul' &&
-              {
-                html: language.$('trackPage.nav.track', {
-                  track: link.track(track, {class: 'current', to}),
-                }),
-              },
-          ].filter(Boolean),
-
-          content: generateAlbumChronologyLinks(album, track, {
-            generateChronologyLinks,
-            html,
-          }),
-
-          bottomRowContent:
-            album.tracks.length > 1 &&
-              generateAlbumNavLinks(album, track, {
-                generateNavigationLinks,
-                html,
-                language,
-              }),
-        },
-
-        secondaryNav: generateAlbumSecondaryNav(album, track, {
-          getLinkThemeString,
-          html,
-          language,
-          link,
-        }),
-      };
     },
-  };
-
-  return [data, page];
+  ];
 }
diff --git a/src/static/client.js b/src/static/client.js
index efae850..35ef82e 100644
--- a/src/static/client.js
+++ b/src/static/client.js
@@ -216,6 +216,7 @@ fetch(rebase('data.json', 'rebaseShared'))
 
 // Data & info card ---------------------------------------
 
+/*
 const NORMAL_HOVER_INFO_DELAY = 750;
 const FAST_HOVER_INFO_DELAY = 250;
 const END_FAST_HOVER_DELAY = 500;
@@ -444,6 +445,7 @@ function addInfoCardLinkHandlers(type) {
 if (localStorage.tryInfoCards) {
   addInfoCardLinkHandlers('track');
 }
+*/
 
 // Custom hash links --------------------------------------
 
diff --git a/src/static/site3.css b/src/static/site4.css
index 3ebe782..28a4924 100644
--- a/src/static/site3.css
+++ b/src/static/site4.css
@@ -433,11 +433,15 @@ a:hover {
   text-decoration: underline;
 }
 
-.nav-main-links > span {
+a.current {
+  font-weight: 800;
+}
+
+.nav-main-links > span > span {
   white-space: nowrap;
 }
 
-.nav-main-links > span > a.current {
+.nav-main-links > span.current > span.nav-link-content > a {
   font-weight: 800;
 }
 
@@ -447,7 +451,7 @@ a:hover {
   font-weight: 800;
 }
 
-.nav-links-hierarchy > span:not(:first-child):not(.no-divider)::before {
+.nav-links-hierarchical > span:not(:first-child):not(.no-divider)::before {
   content: "\0020/\0020";
 }
 
@@ -1358,6 +1362,24 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   contain: paint;
 }
 
+/* Sticky sidebar */
+
+.sidebar-column.sidebar.sticky-column,
+.sidebar-column.sidebar.sticky-last,
+.sidebar-multiple.sticky-last > .sidebar:last-child,
+.sidebar-multiple.sticky-column {
+  position: sticky;
+  top: 10px;
+}
+
+.sidebar-multiple.sticky-last {
+  align-self: stretch;
+}
+
+.sidebar-multiple.sticky-column {
+  align-self: flex-start;
+}
+
 /* Image overlay */
 
 #image-overlay-container {
diff --git a/src/strings-default.json b/src/strings-default.json
index a075f44..345f20f 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -96,14 +96,19 @@
   "releaseInfo.viewCommentary.link": "commentary page",
   "releaseInfo.viewGallery": "View {LINK}!",
   "releaseInfo.viewGallery.link": "gallery page",
+  "releaseInfo.viewGalleryOrCommentary": "View {GALLERY} or {COMMENTARY}!",
+  "releaseInfo.viewGalleryOrCommentary.gallery": "gallery page",
+  "releaseInfo.viewGalleryOrCommentary.commentary": "commentary page",
   "releaseInfo.viewOriginalFile": "View {LINK}.",
   "releaseInfo.viewOriginalFile.withSize": "View {LINK} ({SIZE}).",
   "releaseInfo.viewOriginalFile.link": "original file",
   "releaseInfo.viewOriginalFile.sizeWarning": "(Heads up! If you're on a mobile plan, this is a large download.)",
   "releaseInfo.listenOn": "Listen on {LINKS}.",
-  "releaseInfo.listenOn.noLinks": "This track has no URLs at which it can be listened.",
+  "releaseInfo.listenOn.noLinks": "This wiki doesn't have any listening links for {NAME}.",
   "releaseInfo.visitOn": "Visit on {LINKS}.",
   "releaseInfo.playOn": "Play on {LINKS}.",
+  "releaseInfo.readCommentary": "Read {LINK}.",
+  "releaseInfo.readCommentary.link": "artist commentary",
   "releaseInfo.alsoReleasedAs": "Also released as:",
   "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})",
   "releaseInfo.contributors": "Contributors:",
@@ -120,8 +125,8 @@
   "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!",
   "releaseInfo.artTags": "Tags:",
   "releaseInfo.artTags.inline": "Tags: {TAGS}",
-  "releaseInfo.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}",
-  "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:",
+  "releaseInfo.additionalFiles.shortcut": "View {ANCHOR_LINK}: {TITLES}",
+  "releaseInfo.additionalFiles.shortcut.anchorLink": "additional files",
   "releaseInfo.additionalFiles.heading": "View or download {ADDITIONAL_FILES}:",
   "releaseInfo.additionalFiles.entry": "{TITLE}",
   "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}",
@@ -133,10 +138,10 @@
   "releaseInfo.midiProjectFiles.shortcut": "Download {LINK}.",
   "releaseInfo.midiProjectFiles.shortcut.link": "MIDI/project files",
   "releaseInfo.midiProjectFiles.heading": "Download MIDI/project files:",
-  "releaseInfo.note": "Note:",
+  "releaseInfo.note": "Context notes:",
   "trackList.section.withDuration": "{SECTION} ({DURATION}):",
-  "trackList.group": "{GROUP}:",
-  "trackList.group.other": "Other",
+  "trackList.group": "From {GROUP}:",
+  "trackList.group.fromOther": "From somewhere else:",
   "trackList.item.withDuration": "({DURATION}) {TRACK}",
   "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}",
   "trackList.item.withArtists": "{TRACK} {BY}",
@@ -270,7 +275,8 @@
   "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.",
   "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}",
   "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}",
-  "artistPage.groupsLine.item": "{GROUP} ({CONTRIBUTIONS})",
+  "artistPage.groupsLine.item.withCount": "{GROUP} ({COUNT})",
+  "artistPage.groupsLine.item.withDuration": "{GROUP} ({DURATION})",
   "artistPage.trackList.title": "Tracks",
   "artistPage.artList.title": "Artworks",
   "artistPage.flashList.title": "Flashes & Games",
diff --git a/src/upd8.js b/src/upd8.js
index 9f54b3b..366dc21 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -772,6 +772,7 @@ async function main() {
     developersComment,
     getSizeOfAdditionalFile,
     getSizeOfImageFile,
+    niceShowAggregate,
   });
 }
 
diff --git a/src/util/html.js b/src/util/html.js
index 1c55fb8..b5930d0 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -1,5 +1,8 @@
 // Some really simple functions for formatting HTML content.
 
+import * as commonValidators from '../data/things/validators.js';
+import {empty} from './sugar.js';
+
 // COMPREHENSIVE!
 // https://html.spec.whatwg.org/multipage/syntax.html#void-elements
 export const selfClosingTags = [
@@ -38,128 +41,731 @@ export const joinChildren = Symbol();
 // or when there are multiple children.
 export const noEdgeWhitespace = Symbol();
 
-export function tag(tagName, ...args) {
-  const selfClosing = selfClosingTags.includes(tagName);
+// Note: This is only guaranteed to return true for blanks (as returned by
+// html.blank()) and false for Tags and Templates (regardless of contents or
+// other properties). Don't depend on this to match any other values.
+export function isBlank(value) {
+  if (isTag(value)) {
+    return false;
+  }
+
+  if (isTemplate(value)) {
+    return false;
+  }
+
+  if (!Array.isArray(value)) {
+    return false;
+  }
+
+  return value.length === 0;
+}
+
+export function isTag(value) {
+  return value instanceof Tag;
+}
+
+export function isTemplate(value) {
+  return value instanceof Template;
+}
+
+export function isHTML(value) {
+  if (typeof value === 'string') {
+    return true;
+  }
+
+  if (value === null || value === undefined || value === false) {
+    return true;
+  }
+
+  if (isBlank(value) || isTag(value) || isTemplate(value)) {
+    return true;
+  }
+
+  if (Array.isArray(value)) {
+    if (value.every(isHTML)) {
+      return true;
+    }
+  }
+
+  return false;
+}
+
+export function isAttributes(value) {
+  if (typeof value !== 'object' || Array.isArray(value)) {
+    return false;
+  }
+
+  if (value === null) {
+    return false;
+  }
+
+  if (isTag(value) || isTemplate(value)) {
+    return false;
+  }
+
+  // TODO: Validate attribute values (just the general shape)
 
-  let openTag;
+  return true;
+}
+
+export const validators = {
+  // TODO: Move above implementations here and detail errors
+
+  isBlank(value) {
+    if (!isBlank(value)) {
+      throw new TypeError(`Expected html.blank()`);
+    }
+
+    return true;
+  },
+
+  isTag(value) {
+    if (!isTag(value)) {
+      throw new TypeError(`Expected HTML tag`);
+    }
+
+    return true;
+  },
+
+  isTemplate(value) {
+    if (!isTemplate(value)) {
+      throw new TypeError(`Expected HTML template`);
+    }
+
+    return true;
+  },
+
+  isHTML(value) {
+    if (!isHTML(value)) {
+      throw new TypeError(`Expected HTML content`);
+    }
+
+    return true;
+  },
+
+  isAttributes(value) {
+    if (!isAttributes(value)) {
+      throw new TypeError(`Expected HTML attributes`);
+    }
+
+    return true;
+  },
+};
+
+export function blank() {
+  return [];
+}
+
+export function tag(tagName, ...args) {
   let content;
-  let attrs;
+  let attributes;
 
-  if (typeof args[0] === 'object' && !Array.isArray(args[0])) {
-    attrs = args[0];
+  if (
+    typeof args[0] === 'object' &&
+    !(Array.isArray(args[0]) ||
+      args[0] instanceof Tag ||
+      args[0] instanceof Template)
+  ) {
+    attributes = args[0];
     content = args[1];
   } else {
     content = args[0];
   }
 
-  if (selfClosing && content) {
-    throw new Error(`Tag <${tagName}> is self-closing but got content!`);
+  return new Tag(tagName, attributes, content);
+}
+
+export function tags(content) {
+  return new Tag(null, null, content);
+}
+
+export class Tag {
+  #tagName = '';
+  #content = null;
+  #attributes = null;
+
+  constructor(tagName, attributes, content) {
+    this.tagName = tagName;
+    this.attributes = attributes;
+    this.content = content;
+  }
+
+  clone() {
+    return new Tag(this.tagName, this.attributes, this.content);
   }
 
-  if (Array.isArray(content)) {
-    if (content.some(item => Array.isArray(item))) {
-      throw new Error(`Found array instead of string (tag) or null/falsey, did you forget to \`...\` spread an array or fragment?`);
+  set tagName(value) {
+    if (value === undefined || value === null) {
+      this.tagName = '';
+      return;
+    }
+
+    if (typeof value !== 'string') {
+      throw new Error(`Expected tagName to be a string`);
+    }
+
+    if (selfClosingTags.includes(value) && this.content.length) {
+      throw new Error(`Tag <${value}> is self-closing but this tag has content`);
+    }
+
+    this.#tagName = value;
+  }
+
+  get tagName() {
+    return this.#tagName;
+  }
+
+  set attributes(attributes) {
+    if (attributes instanceof Attributes) {
+      this.#attributes = attributes;
+    } else {
+      this.#attributes = new Attributes(attributes);
+    }
+  }
+
+  get attributes() {
+    if (this.#attributes === null) {
+      this.attributes = {};
+    }
+
+    return this.#attributes;
+  }
+
+  set content(value) {
+    if (
+      this.selfClosing &&
+      !(value === null ||
+        value === undefined ||
+        !Boolean(value) ||
+        Array.isArray(value) && value.filter(Boolean).length === 0)
+    ) {
+      throw new Error(`Tag <${this.tagName}> is self-closing but got content`);
+    }
+
+    let contentArray;
+
+    if (Array.isArray(value)) {
+      contentArray = value;
+    } else {
+      contentArray = [value];
+    }
+
+    this.#content = contentArray
+      .flat(Infinity)
+      .filter(Boolean);
+
+    this.#content.toString = () => this.#stringifyContent();
+  }
+
+  get content() {
+    if (this.#content === null) {
+      this.#content = [];
+    }
+
+    return this.#content;
+  }
+
+  get selfClosing() {
+    if (this.tagName) {
+      return selfClosingTags.includes(this.tagName);
+    } else {
+      return false;
+    }
+  }
+
+  #setAttributeFlag(attribute, value) {
+    if (value) {
+      this.attributes.set(attribute, true);
+    } else {
+      this.attributes.remove(attribute);
+    }
+  }
+
+  #getAttributeFlag(attribute) {
+    return !!this.attributes.get(attribute);
+  }
+
+  #setAttributeString(attribute, value) {
+    // Note: This function accepts and records the empty string ('')
+    // distinctly from null/undefined.
+
+    if (value === undefined || value === null) {
+      this.attributes.remove(attribute);
+      return undefined;
+    } else {
+      this.attributes.set(attribute, String(value));
+    }
+  }
+
+  #getAttributeString(attribute) {
+    const value = this.attributes.get(attribute);
+
+    if (value === undefined || value === null) {
+      return undefined;
+    } else {
+      return String(value);
+    }
+  }
+
+  set onlyIfContent(value) {
+    this.#setAttributeFlag(onlyIfContent, value);
+  }
+
+  get onlyIfContent() {
+    return this.#getAttributeFlag(onlyIfContent);
+  }
+
+  set joinChildren(value) {
+    this.#setAttributeString(joinChildren, value);
+  }
+
+  get joinChildren() {
+    return this.#getAttributeString(joinChildren);
+  }
+
+  set noEdgeWhitespace(value) {
+    this.#setAttributeFlag(noEdgeWhitespace, value);
+  }
+
+  get noEdgeWhitespace() {
+    return this.#getAttributeFlag(noEdgeWhitespace);
+  }
+
+  toString() {
+    const attributesString = this.attributes.toString();
+    const contentString = this.content.toString();
+
+    if (this.onlyIfContent && !contentString) {
+      return '';
     }
 
-    const joiner = attrs?.[joinChildren];
-    content = content.filter(Boolean).join(
-      (joiner === ''
+    if (!this.tagName) {
+      return contentString;
+    }
+
+    const openTag = (attributesString
+      ? `<${this.tagName} ${attributesString}>`
+      : `<${this.tagName}>`);
+
+    if (this.selfClosing) {
+      return openTag;
+    }
+
+    const closeTag = `</${this.tagName}>`;
+
+    if (!this.content.length) {
+      return openTag + closeTag;
+    }
+
+    if (!contentString.includes('\n')) {
+      return openTag + contentString + closeTag;
+    }
+
+    const parts = [
+      openTag,
+      contentString
+        .split('\n')
+        .map((line, i) =>
+          (i === 0 && this.noEdgeWhitespace
+            ? line
+            : '    ' + line))
+        .join('\n'),
+      closeTag,
+    ];
+
+    return parts.join(
+      (this.noEdgeWhitespace
         ? ''
-        : (joiner
-            ? `\n${joiner}\n`
-            : '\n')));
+        : '\n'));
   }
 
-  if (attrs?.[onlyIfContent] && !content) {
-    return '';
+  #stringifyContent() {
+    if (this.selfClosing) {
+      return '';
+    }
+
+    const joiner =
+      (this.joinChildren === undefined
+        ? '\n'
+        : (this.joinChildren === ''
+            ? ''
+            : `\n${this.joinChildren}\n`));
+
+    return this.content
+      .map(item => item.toString())
+      .filter(Boolean)
+      .join(joiner);
   }
+}
+
+export class Attributes {
+  #attributes = Object.create(null);
 
-  if (attrs) {
-    const attrString = attributes(attrs);
-    if (attrString) {
-      openTag = `${tagName} ${attrString}`;
+  constructor(attributes) {
+    this.attributes = attributes;
+  }
+
+  set attributes(value) {
+    if (value === undefined || value === null) {
+      this.#attributes = {};
+      return;
+    }
+
+    if (typeof value !== 'object') {
+      throw new Error(`Expected attributes to be an object`);
     }
+
+    this.#attributes = Object.create(null);
+    Object.assign(this.#attributes, value);
   }
 
-  if (!openTag) {
-    openTag = tagName;
+  get attributes() {
+    return this.#attributes;
   }
 
-  if (content) {
-    if (content.includes('\n')) {
-      return [
-        `<${openTag}>`,
-        content
-          .split('\n')
-          .map((line, i) =>
-            (i === 0 && attrs?.[noEdgeWhitespace]
-              ? line
-              : '    ' + line))
-          .join('\n'),
-        `</${tagName}>`,
-      ].join(
-        (attrs?.[noEdgeWhitespace]
-          ? ''
-          : '\n'));
+  set(attribute, value) {
+    if (value === null || value === undefined) {
+      this.remove(attribute);
     } else {
-      return `<${openTag}>${content}</${tagName}>`;
+      this.#attributes[attribute] = value;
     }
-  } else if (selfClosing) {
-    return `<${openTag}>`;
-  } else {
-    return `<${openTag}></${tagName}>`;
+    return value;
+  }
+
+  get(attribute) {
+    return this.#attributes[attribute];
+  }
+
+  remove(attribute) {
+    return delete this.#attributes[attribute];
+  }
+
+  toString() {
+    return Object.entries(this.attributes)
+      .map(([key, val]) => {
+        if (typeof val === 'undefined' || val === null)
+          return [key, val, false];
+        else if (typeof val === 'string')
+          return [key, val, true];
+        else if (typeof val === 'boolean')
+          return [key, val, val];
+        else if (typeof val === 'number')
+          return [key, val.toString(), true];
+        else if (Array.isArray(val))
+          return [key, val.filter(Boolean).join(' '), val.length > 0];
+        else
+          throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
+      })
+      .filter(([_key, _val, keep]) => keep)
+      .map(([key, val]) => {
+        switch (key) {
+          case 'href':
+            return [key, encodeURI(val)];
+          default:
+            return [key, val];
+        }
+      })
+      .map(([key, val]) =>
+        typeof val === 'boolean'
+          ? `${key}`
+          : `${key}="${this.#escapeAttributeValue(val)}"`
+      )
+      .join(' ');
+  }
+
+  #escapeAttributeValue(value) {
+    return value
+      .replaceAll('"', '&quot;')
+      .replaceAll("'", '&apos;');
   }
 }
 
-export function escapeAttributeValue(value) {
-  return value.replaceAll('"', '&quot;').replaceAll("'", '&apos;');
+export function template(description) {
+  return new Template(description);
 }
 
-export function attributes(attribs) {
-  return Object.entries(attribs)
-    .map(([key, val]) => {
-      if (typeof val === 'undefined' || val === null)
-        return [key, val, false];
-      else if (typeof val === 'string')
-        return [key, val, true];
-      else if (typeof val === 'boolean')
-        return [key, val, val];
-      else if (typeof val === 'number')
-        return [key, val.toString(), true];
-      else if (Array.isArray(val))
-        return [key, val.filter(Boolean).join(' '), val.length > 0];
-      else
-        throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
-    })
-    .filter(([_key, _val, keep]) => keep)
-    .map(([key, val]) => {
-      switch (key) {
-        case 'href':
-          return [key, encodeURI(val)];
-        default:
-          return [key, val];
+export class Template {
+  #description = {};
+  #slotValues = {};
+
+  constructor(description) {
+    Template.validateDescription(description);
+    this.#description = description;
+  }
+
+  clone() {
+    const clone = new Template(this.#description);
+    clone.setSlots(this.#slotValues);
+    return clone;
+  }
+
+  static validateDescription(description) {
+    if (typeof description !== 'object') {
+      throw new TypeError(`Expected object, got ${typeof description}`);
+    }
+
+    if (description === null) {
+      throw new TypeError(`Expected object, got null`);
+    }
+
+    const topErrors = [];
+
+    if (!('content' in description)) {
+      topErrors.push(new TypeError(`Expected description.content`));
+    } else if (typeof description.content !== 'function') {
+      topErrors.push(new TypeError(`Expected description.content to be function`));
+    }
+
+    if ('annotation' in description) {
+      if (typeof description.annotation !== 'string') {
+        topErrors.push(new TypeError(`Expected annotation to be string`));
       }
-    })
-    .map(([key, val]) =>
-      typeof val === 'boolean'
-        ? `${key}`
-        : `${key}="${escapeAttributeValue(val)}"`
-    )
-    .join(' ');
-}
+    }
+
+    if ('slots' in description) validateSlots: {
+      if (typeof description.slots !== 'object') {
+        topErrors.push(new TypeError(`Expected description.slots to be object`));
+        break validateSlots;
+      }
+
+      const slotErrors = [];
 
-// Ensures the passed value is an array of elements, for usage in [...spread]
-// syntax. This may be used when it's not guaranteed whether the return value of
-// an external function is one child or an array, or in combination with
-// conditionals, e.g. fragment(cond && [x, y, z]).
-export function fragment(childOrChildren) {
-  if (!childOrChildren) {
-    return [];
+      for (const [slotName, slotDescription] of Object.entries(description.slots)) {
+        if (typeof slotDescription !== 'object' || slotDescription === null) {
+          slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`));
+          continue;
+        }
+
+        if ('default' in slotDescription) validateDefault: {
+          if (
+            slotDescription.default === undefined ||
+            slotDescription.default === null
+          ) {
+            slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`));
+            break validateDefault;
+          }
+
+          try {
+            Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription);
+          } catch (error) {
+            error.message = `(${slotName}) Error validating slot default value: ${error.message}`;
+            slotErrors.push(error);
+          }
+        }
+
+        if ('validate' in slotDescription && 'type' in slotDescription) {
+          slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`));
+        } else if (!('validate' in slotDescription || 'type' in slotDescription)) {
+          slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`));
+        } else if ('validate' in slotDescription) {
+          if (typeof slotDescription.validate !== 'function') {
+            slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`));
+          }
+        } else if ('type' in slotDescription) {
+          const acceptableSlotTypes = [
+            'string',
+            'number',
+            'bigint',
+            'boolean',
+            'symbol',
+            'html',
+          ];
+
+          if (slotDescription.type === 'function') {
+            slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`));
+          } else if (slotDescription.type === 'object') {
+            slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`));
+          } else if (!acceptableSlotTypes.includes(slotDescription.type)) {
+            slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`));
+          }
+        }
+      }
+
+      if (!empty(slotErrors)) {
+        topErrors.push(new AggregateError(slotErrors, `Errors in slot descriptions`));
+      }
+    }
+
+    if (!empty(topErrors)) {
+      throw new AggregateError(topErrors,
+        (typeof description.annotation === 'string'
+          ? `Errors validating template "${description.annotation}" description`
+          : `Errors validating template description`));
+    }
+
+    return true;
   }
 
-  if (Array.isArray(childOrChildren)) {
-    return childOrChildren;
+  slot(slotName, value) {
+    this.setSlot(slotName, value);
+    return this;
   }
 
-  return [childOrChildren];
+  slots(slotNamesToValues) {
+    this.setSlots(slotNamesToValues);
+    return this;
+  }
+
+  setSlot(slotName, value) {
+    const description = this.#getSlotDescriptionOrError(slotName);
+
+    try {
+      Template.validateSlotValueAgainstDescription(value, description);
+    } catch (error) {
+      error.message =
+        (this.description.annotation
+          ? `Error validating template "${this.description.annotation}" slot "${slotName}" value: ${error.message}`
+          : `Error validating template slot "${slotName}" value: ${error.message}`);
+      throw error;
+    }
+
+    this.#slotValues[slotName] = value;
+  }
+
+  setSlots(slotNamesToValues) {
+    if (
+      typeof slotNamesToValues !== 'object' ||
+      Array.isArray(slotNamesToValues) ||
+      slotNamesToValues === null
+    ) {
+      throw new TypeError(`Expected object mapping of slot names to values`);
+    }
+
+    const slotErrors = [];
+
+    for (const [slotName, value] of Object.entries(slotNamesToValues)) {
+      const description = this.#getSlotDescriptionNoError(slotName);
+      if (!description) {
+        slotErrors.push(new TypeError(`(${slotName}) Template doesn't have a "${slotName}" slot`));
+        continue;
+      }
+
+      try {
+        Template.validateSlotValueAgainstDescription(value, description);
+      } catch (error) {
+        error.message = `(${slotName}) ${error.message}`;
+        slotErrors.push(error);
+      }
+    }
+
+    if (!empty(slotErrors)) {
+      throw new AggregateError(slotErrors,
+        (this.description.annotation
+          ? `Error validating template "${this.description.annotation}" slots`
+          : `Error validating template slots`));
+    }
+
+    Object.assign(this.#slotValues, slotNamesToValues);
+  }
+
+  static validateSlotValueAgainstDescription(value, description) {
+    if (value === undefined) {
+      throw new TypeError(`Specify value as null or don't specify at all`);
+    }
+
+    // Null is always an acceptable slot value.
+    if (value !== null) {
+      if ('validate' in description) {
+        description.validate({
+          ...commonValidators,
+          ...validators,
+        })(value);
+      }
+
+      if ('type' in description) {
+        const {type} = description;
+        if (type === 'html') {
+          if (!isHTML(value)) {
+            throw new TypeError(`Slot expects html (tag, template or blank), got ${typeof value}`);
+          }
+        } else {
+          if (typeof value !== type) {
+            throw new TypeError(`Slot expects ${type}, got ${typeof value}`);
+          }
+        }
+      }
+    }
+
+    return true;
+  }
+
+  getSlotValue(slotName) {
+    const description = this.#getSlotDescriptionOrError(slotName);
+    const providedValue = this.#slotValues[slotName] ?? null;
+
+    if (description.type === 'html') {
+      if (!providedValue) {
+        return blank();
+      }
+
+      if (providedValue instanceof Tag || providedValue instanceof Template) {
+        return providedValue.clone();
+      }
+
+      return providedValue;
+    }
+
+    if (providedValue !== null) {
+      return providedValue;
+    }
+
+    if ('default' in description) {
+      return description.default;
+    }
+
+    return null;
+  }
+
+  getSlotDescription(slotName) {
+    return this.#getSlotDescriptionOrError(slotName);
+  }
+
+  #getSlotDescriptionNoError(slotName) {
+    if (this.#description.slots) {
+      if (Object.hasOwn(this.#description.slots, slotName)) {
+        return this.#description.slots[slotName];
+      }
+    }
+
+    return null;
+  }
+
+  #getSlotDescriptionOrError(slotName) {
+    const description = this.#getSlotDescriptionNoError(slotName);
+
+    if (!description) {
+      throw new TypeError(
+        (this.description.annotation
+          ? `Template "${this.description.annotation}" doesn't have a "${slotName}" slot`
+          : `Template doesn't have a "${slotName}" slot`));
+    }
+
+    return description;
+  }
+
+  set content(_value) {
+    throw new Error(`Template content can't be changed after constructed`);
+  }
+
+  get content() {
+    const slots = {};
+
+    for (const slotName of Object.keys(this.description.slots ?? {})) {
+      slots[slotName] = this.getSlotValue(slotName);
+    }
+
+    return this.description.content(slots);
+  }
+
+  set description(_value) {
+    throw new Error(`Template description can't be changed after constructed`);
+  }
+
+  get description() {
+    return this.#description;
+  }
+
+  toString() {
+    return this.content.toString();
+  }
 }
diff --git a/src/util/link.js b/src/util/link.js
index 6210634..a9f79c8 100644
--- a/src/util/link.js
+++ b/src/util/link.js
@@ -24,23 +24,29 @@ export function unbound_getLinkThemeString(color, {
 
 const appendIndexHTMLRegex = /^(?!https?:\/\/).+\/$/;
 
-const linkHelper =
-  (hrefFn, {
-    color = true,
-    attr = null,
-  } = {}) =>
-  (thing, {
+function linkHelper({
+  path: pathOption,
+
+  expectThing = true,
+  color: colorOption = true,
+
+  attr: attrOption = null,
+  data: dataOption = null,
+  text: textOption = null,
+}) {
+  const generateLink = (data, {
     getLinkThemeString,
     to,
 
     text = '',
     attributes = null,
     class: className = '',
-    color: color2 = true,
+    color = true,
     hash = '',
     preferShortName = false,
   }) => {
-    let href = hrefFn(thing, {to});
+    const path = (expectThing ? pathOption(data) : pathOption());
+    let href = to(...path);
 
     if (link.globalOptions.appendIndexHTML) {
       if (appendIndexHTMLRegex.test(href)) {
@@ -52,41 +58,100 @@ const linkHelper =
       href += (hash.startsWith('#') ? '' : '#') + hash;
     }
 
-    return html.tag(
-      'a',
+    return html.tag('a',
       {
-        ...(attr ? attr(thing) : {}),
+        ...(attrOption ? attrOption(data) : {}),
         ...(attributes ? attributes : {}),
         href,
         style:
-          typeof color2 === 'string'
-            ? getLinkThemeString(color2)
-            : color2 && color
-            ? getLinkThemeString(thing.color)
+          typeof color === 'string'
+            ? getLinkThemeString(color)
+            : color && colorOption
+            ? getLinkThemeString(data.color)
             : '',
         class: className,
       },
+
       (text ||
-        (preferShortName
-          ? thing.nameShort ?? thing.name
-          : thing.name))
-    );
+        (textOption
+          ? textOption(data)
+          : (preferShortName
+              ? data.nameShort ?? data.name
+              : data.name))));
+  };
+
+  generateLink.data = thing => {
+    if (!expectThing) {
+      throw new Error(`This kind of link doesn't need any data serialized`);
+    }
+
+    const data = (dataOption ? dataOption(thing) : {});
+
+    if (colorOption) {
+      data.color = thing.color;
+    }
+
+    if (!textOption) {
+      data.name = thing.name;
+      data.nameShort = thing.nameShort ?? thing.name;
+    }
+
+    return data;
   };
 
-const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) =>
-  linkHelper((thing, {to}) => to('localized.' + key, thing.directory), {
-    attr: (thing) => ({
-      ...(attr ? attr(thing) : {}),
-      ...(expose ? {[expose]: thing.directory} : {}),
+  return generateLink;
+}
+
+function linkDirectory(key, {
+  exposeDirectory = null,
+  prependLocalized = true,
+
+  data = null,
+  attr = null,
+  ...conf
+} = {}) {
+  return linkHelper({
+    data: thing => ({
+      ...(data ? data(thing) : {}),
+      directory: thing.directory,
     }),
+
+    path: data =>
+      (prependLocalized
+        ? ['localized.' + key, data.directory]
+        : [key, data.directory]),
+
+    attr: (data) => ({
+      ...(attr ? attr(data) : {}),
+      ...(exposeDirectory ? {[exposeDirectory]: data.directory} : {}),
+    }),
+
     ...conf,
   });
+}
 
-const linkPathname = (key, conf) =>
-  linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf);
+function linkIndex(key, conf) {
+  return linkHelper({
+    path: () => [key],
 
-const linkIndex = (key, conf) =>
-  linkHelper((_, {to}) => to('localized.' + key), conf);
+    expectThing: false,
+    ...conf,
+  });
+}
+
+function linkAdditionalFile(key, conf) {
+  return linkHelper({
+    data: ({file, album}) => ({
+      directory: album.directory,
+      file,
+    }),
+
+    path: data => ['media.albumAdditionalFile', data.directory, data.file],
+
+    color: false,
+    ...conf,
+  });
+}
 
 // Mapping of Thing constructor classes to the key for a link.x() function.
 // These represent a sensible "default" link, i.e. to the primary page for
@@ -114,6 +179,7 @@ const link = {
   },
 
   album: linkDirectory('album'),
+  albumAdditionalFile: linkAdditionalFile('albumAdditionalFile'),
   albumGallery: linkDirectory('albumGallery'),
   albumCommentary: linkDirectory('albumCommentary'),
   artist: linkDirectory('artist', {color: false}),
@@ -130,32 +196,26 @@ const link = {
   newsEntry: linkDirectory('newsEntry', {color: false}),
   staticPage: linkDirectory('staticPage', {color: false}),
   tag: linkDirectory('tag'),
-  track: linkDirectory('track', {expose: 'data-track'}),
-
-  // TODO: This is a bit hacky. Files are just strings (not objects), so we
-  // have to manually provide the album alongside the file. They also don't
-  // follow the usual {name: whatever} type shape, so we have to provide that
-  // ourselves.
-  _albumAdditionalFileHelper: linkHelper(
-    (fakeFileObject, {to}) =>
-      to(
-        'media.albumAdditionalFile',
-        fakeFileObject.album.directory,
-        fakeFileObject.name),
-    {color: false}),
-
-  albumAdditionalFile: ({file, album}, {to, ...opts}) =>
-    link._albumAdditionalFileHelper(
-      {
-        name: file,
-        album,
-      },
-      {to, ...opts}),
-
-  media: linkPathname('media.path', {color: false}),
-  root: linkPathname('shared.path', {color: false}),
-  data: linkPathname('data.path', {color: false}),
-  site: linkPathname('localized.path', {color: false}),
+  track: linkDirectory('track', {exposeDirectory: 'data-track'}),
+
+  media: linkDirectory('media.path', {
+    prependLocalized: false,
+    color: false,
+  }),
+
+  root: linkDirectory('shared.path', {
+    prependLocalized: false,
+    color: false,
+  }),
+  data: linkDirectory('data.path', {
+    prependLocalized: false,
+    color: false,
+  }),
+
+  site: linkDirectory('localized.path', {
+    prependLocalized: false,
+    color: false,
+  }),
 
   // This is NOT an arrow functions because it should be callable for other
   // "this" objects - i.e, if we bind arguments in other functions on the same
diff --git a/src/util/replacer.js b/src/util/replacer.js
index ea957ed..50a9000 100644
--- a/src/util/replacer.js
+++ b/src/util/replacer.js
@@ -221,11 +221,10 @@ function parseNodes(input, i, stopAt, textOnly) {
       let hash;
 
       if (stop_literal === tagHash) {
-        N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+        N = parseOneTextNode(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
 
         if (!stopped) throw endOfInput(i, `reading hash`);
-
-        if (!N) throw makeError(i, `Expected content (hash).`);
+        if (!N) throw makeError(i, `Expected text (hash).`);
 
         hash = N;
         i = stop_iParse;
@@ -294,6 +293,10 @@ function parseNodes(input, i, stopAt, textOnly) {
 }
 
 export function parseInput(input) {
+  if (typeof input !== 'string') {
+    throw new TypeError(`Expected input to be string, got ${input}`);
+  }
+
   try {
     return parseNodes(input, 0);
   } catch (errorNode) {
@@ -378,7 +381,7 @@ function evaluateTag(node, opts) {
     (transformName && transformName(value.name, node, input)) ||
     null;
 
-  const hash = node.data.hash && transformNodes(node.data.hash, opts);
+  const hash = node.data.hash && transformNode(node.data.hash, opts);
 
   const args =
     node.data.args &&
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 0813c1d..6ab70bc 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -82,6 +82,14 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
 export const withEntries = (obj, fn) =>
   Object.fromEntries(fn(Object.entries(obj)));
 
+export function filterProperties(obj, properties) {
+  const set = new Set(properties);
+  return Object.fromEntries(
+    Object
+      .entries(obj)
+      .filter(([key]) => set.has(key)));
+}
+
 export function queue(array, max = 50) {
   if (max === 0) {
     return array.map((fn) => fn());
@@ -146,10 +154,20 @@ export function bindOpts(fn, bind) {
     ]);
   };
 
-  Object.defineProperty(bound, 'name', {
-    value: fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`,
+  annotateFunction(bound, {
+    name: fn,
+    trait: 'options-bound',
   });
 
+  for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(fn))) {
+    if (key === 'length') continue;
+    if (key === 'name') continue;
+    if (key === 'arguments') continue;
+    if (key === 'caller') continue;
+    if (key === 'prototype') continue;
+    Object.defineProperty(bound, key, descriptor);
+  }
+
   return bound;
 }
 
@@ -216,6 +234,10 @@ export function openAggregate({
       );
     };
 
+  aggregate.push = (error) => {
+    errors.push(error);
+  };
+
   aggregate.call = (fn, ...args) => {
     return aggregate.wrap(fn)(...args);
   };
@@ -421,6 +443,7 @@ export function _withAggregate(mode, aggregateOpts, fn) {
 export function showAggregate(topError, {
   pathToFileURL = f => f,
   showTraces = true,
+  print = true,
 } = {}) {
   const recursive = (error, {level}) => {
     let header = showTraces
@@ -465,7 +488,13 @@ export function showAggregate(topError, {
     }
   };
 
-  console.error(recursive(topError, {level: 0}));
+  const message = recursive(topError, {level: 0});
+
+  if (print) {
+    console.error(message);
+  } else {
+    return message;
+  }
 }
 
 export function decorateErrorWithIndex(fn) {
@@ -478,3 +507,74 @@ export function decorateErrorWithIndex(fn) {
     }
   };
 }
+
+// Delicious function annotations, such as:
+//
+//   (*bound) soWeAreBackInTheMine
+//   (data *unfulfilled) generateShrekTwo
+//
+export function annotateFunction(fn, {
+  name: nameOrFunction = null,
+  description: newDescription,
+  trait: newTrait,
+}) {
+  let name;
+
+  if (typeof nameOrFunction === 'function') {
+    name = nameOrFunction.name;
+  } else if (typeof nameOrFunction === 'string') {
+    name = nameOrFunction;
+  }
+
+  name ??= fn.name ?? 'anonymous';
+
+  const match = name.match(/^ *(?<prefix>.*?) *\((?<description>.*)( #(?<trait>.*))?\) *(?<suffix>.*) *$/);
+
+  let prefix, suffix, description, trait;
+  if (match) {
+    ({prefix, suffix, description, trait} = match.groups);
+  }
+
+  prefix ??= '';
+  suffix ??= name;
+  description ??= '';
+  trait ??= '';
+
+  if (newDescription) {
+    if (description) {
+      description += '; ' + newDescription;
+    } else {
+      description = newDescription;
+    }
+  }
+
+  if (newTrait) {
+    if (trait) {
+      trait += ' #' + newTrait;
+    } else {
+      trait = '#' + newTrait;
+    }
+  }
+
+  let parenthesesPart;
+
+  if (description && trait) {
+    parenthesesPart = `${description} ${trait}`;
+  } else if (description || trait) {
+    parenthesesPart = description || trait;
+  } else {
+    parenthesesPart = '';
+  }
+
+  let finalName;
+
+  if (prefix && parenthesesPart) {
+    finalName = `${prefix} (${parenthesesPart}) ${suffix}`;
+  } else if (parenthesesPart) {
+    finalName = `(${parenthesesPart}) ${suffix}`;
+  } else {
+    finalName = suffix;
+  }
+
+  Object.defineProperty(fn, 'name', {value: finalName});
+}
diff --git a/src/util/transform-content.js b/src/util/transform-content.js
index d1d0f51..454cb37 100644
--- a/src/util/transform-content.js
+++ b/src/util/transform-content.js
@@ -3,7 +3,6 @@
 // interfaces for converting various content found in wiki data to HTML for
 // display on the site.
 
-import * as html from './html.js';
 export {transformInline} from './replacer.js';
 
 export const replacerSpec = {
@@ -34,7 +33,7 @@ export const replacerSpec = {
   date: {
     find: null,
     value: (ref) => new Date(ref),
-    html: (date, {language}) =>
+    html: (date, {html, language}) =>
       html.tag('time',
         {datetime: date.toString()},
         language.formatDate(date)),
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index ffaaa7a..d605335 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -5,59 +5,28 @@
 import chroma from 'chroma-js';
 
 import {
-  fancifyFlashURL,
-  fancifyURL,
-  getAlbumGridHTML,
-  getAlbumStylesheet,
-  getArtistString,
-  getCarouselHTML,
-  getFlashGridHTML,
-  getGridHTML,
-  getRevealStringFromArtTags,
-  getRevealStringFromContentWarningMessage,
-  getThemeString,
-  generateAdditionalFilesList,
-  generateAdditionalFilesShortcut,
-  generateChronologyLinks,
-  generateContentHeading,
-  generateCoverLink,
-  generateInfoGalleryLinks,
-  generateTrackListDividedByGroups,
-  generateNavigationLinks,
-  generateStickyHeadingContainer,
-  iconifyURL,
-  img,
-} from '../misc-templates.js';
-
-import {
   replacerSpec,
   transformInline,
-  transformLyrics,
-  transformMultiline,
+  // transformLyrics,
+  // transformMultiline,
 } from '../util/transform-content.js';
 
 import * as html from '../util/html.js';
 
-import {bindOpts, withEntries} from '../util/sugar.js';
+import {bindOpts} from '../util/sugar.js';
 import {getColors} from '../util/colors.js';
 import {bindFind} from '../util/find.js';
-
-import link, {getLinkThemeString} from '../util/link.js';
-
-import {
-  getAlbumCover,
-  getArtistAvatar,
-  getFlashCover,
-  getTrackCover,
-} from '../util/wiki-data.js';
+import {thumb} from '../util/urls.js';
 
 export function bindUtilities({
   absoluteTo,
+  cachebust,
   defaultLanguage,
   getSizeOfAdditionalFile,
   getSizeOfImageFile,
   language,
   languages,
+  pagePath,
   to,
   urls,
   wikiData,
@@ -69,42 +38,22 @@ export function bindUtilities({
 
   Object.assign(bound, {
     absoluteTo,
+    cachebust,
     defaultLanguage,
     getSizeOfAdditionalFile,
     getSizeOfImageFile,
     html,
     language,
     languages,
+    pagePath,
+    thumb,
     to,
     urls,
     wikiData,
-  })
-
-  bound.img = bindOpts(img, {
-    [bindOpts.bindIndex]: 0,
-    getSizeOfImageFile,
-    html,
-    to,
-  });
-
-  bound.getColors = bindOpts(getColors, {
-    chroma,
-  });
-
-  bound.getLinkThemeString = bindOpts(getLinkThemeString, {
-    getColors: bound.getColors,
+    wikiInfo: wikiData.wikiInfo,
   });
 
-  bound.getThemeString = bindOpts(getThemeString, {
-    getColors: bound.getColors,
-  });
-
-  bound.link = withEntries(link, (entries) =>
-    entries
-      .map(([key, fn]) => [key, bindOpts(fn, {
-        getLinkThemeString: bound.getLinkThemeString,
-        to,
-      })]));
+  bound.getColors = bindOpts(getColors, {chroma});
 
   bound.find = bindFind(wikiData, {mode: 'warn'});
 
@@ -117,6 +66,7 @@ export function bindUtilities({
     wikiData,
   });
 
+  /*
   bound.transformMultiline = bindOpts(transformMultiline, {
     img: bound.img,
     to,
@@ -127,81 +77,14 @@ export function bindUtilities({
     transformInline: bound.transformInline,
     transformMultiline: bound.transformMultiline,
   });
+  */
 
-  bound.iconifyURL = bindOpts(iconifyURL, {
-    html,
-    language,
-    to,
-  });
-
-  bound.fancifyURL = bindOpts(fancifyURL, {
-    html,
-    language,
-  });
-
-  bound.fancifyFlashURL = bindOpts(fancifyFlashURL, {
-    [bindOpts.bindIndex]: 2,
-    html,
-    language,
-
-    fancifyURL: bound.fancifyURL,
-  });
-
-  bound.getRevealStringFromContentWarningMessage = bindOpts(getRevealStringFromContentWarningMessage, {
-    html,
-    language,
-  });
-
-  bound.getRevealStringFromArtTags = bindOpts(getRevealStringFromArtTags, {
-    language,
-
-    getRevealStringFromContentWarningMessage: bound.getRevealStringFromContentWarningMessage,
-  });
-
-  bound.getArtistString = bindOpts(getArtistString, {
-    html,
-    link: bound.link,
-    language,
-
-    iconifyURL: bound.iconifyURL,
-  });
-
-  bound.getAlbumCover = bindOpts(getAlbumCover, {
-    to,
-  });
-
-  bound.getTrackCover = bindOpts(getTrackCover, {
-    to,
-  });
-
-  bound.getFlashCover = bindOpts(getFlashCover, {
-    to,
-  });
-
-  bound.getArtistAvatar = bindOpts(getArtistAvatar, {
-    to,
-  });
-
-  bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, {
-    html,
-    language,
-  });
-
-  bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, {
-    html,
-    language,
-  });
-
+  /*
   bound.generateNavigationLinks = bindOpts(generateNavigationLinks, {
     link: bound.link,
     language,
   });
 
-  bound.generateContentHeading = bindOpts(generateContentHeading, {
-    [bindOpts.bindIndex]: 0,
-    html,
-  });
-
   bound.generateStickyHeadingContainer = bindOpts(generateStickyHeadingContainer, {
     [bindOpts.bindIndex]: 0,
     html,
@@ -217,30 +100,12 @@ export function bindUtilities({
     generateNavigationLinks: bound.generateNavigationLinks,
   });
 
-  bound.generateCoverLink = bindOpts(generateCoverLink, {
-    [bindOpts.bindIndex]: 0,
-    html,
-    img: bound.img,
-    link: bound.link,
-    language,
-    to,
-    wikiData,
-
-    getRevealStringFromArtTags: bound.getRevealStringFromArtTags,
-  });
-
   bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, {
     [bindOpts.bindIndex]: 2,
     link: bound.link,
     language,
   });
 
-  bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, {
-    html,
-    language,
-    wikiData,
-  });
-
   bound.getGridHTML = bindOpts(getGridHTML, {
     [bindOpts.bindIndex]: 0,
     img: bound.img,
@@ -271,11 +136,8 @@ export function bindUtilities({
     [bindOpts.bindIndex]: 0,
     img: bound.img,
     html,
-  })
-
-  bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, {
-    to,
   });
+  */
 
   return bound;
 }
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index 6dfa7d7..10b40cf 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -11,7 +11,7 @@ import {serializeThings} from '../../data/serialize.js';
 import * as pageSpecs from '../../page/index.js';
 
 import {logInfo, logWarn, progressCallAll} from '../../util/cli.js';
-
+import {empty} from '../../util/sugar.js';
 import {
   getPagePathname,
   getPagePathnameAcrossLanguages,
@@ -20,11 +20,21 @@ import {
 } from '../../util/urls.js';
 
 import {
-  generateDocumentHTML,
   generateGlobalWikiDataJSON,
   generateRedirectHTML,
 } from '../page-template.js';
 
+import {
+  watchContentDependencies,
+} from '../../content/dependencies/index.js';
+
+import {
+  fillRelationsLayoutFromSlotResults,
+  flattenRelationsTree,
+  getRelationsTree,
+  getNeededContentDependencyNames,
+} from '../../content-function.js';
+
 const defaultHost = '0.0.0.0';
 const defaultPort = 8002;
 
@@ -64,20 +74,35 @@ export async function go({
   developersComment,
   getSizeOfAdditionalFile,
   getSizeOfImageFile,
+  niceShowAggregate,
 }) {
+  const showError = (error) => {
+    if (error instanceof AggregateError && niceShowAggregate) {
+      niceShowAggregate(error);
+    } else {
+      console.error(error);
+    }
+  };
+
   const host = cliOptions['host'] ?? defaultHost;
   const port = parseInt(cliOptions['port'] ?? defaultPort);
 
+  const contentDependenciesWatcher = await watchContentDependencies();
+  const {contentDependencies: allContentDependencies} = contentDependenciesWatcher;
+
+  contentDependenciesWatcher.on('error', () => {});
+  await new Promise(resolve => contentDependenciesWatcher.once('ready', resolve));
+
   let targetSpecPairs = getPageSpecsWithTargets({wikiData});
   const pages = progressCallAll(`Computing page data & paths for ${targetSpecPairs.length} targets.`,
-    targetSpecPairs.map(({
+    targetSpecPairs.flatMap(({
       pageSpec,
       target,
       targetless,
     }) => () =>
       targetless
-        ? pageSpec.writeTargetless({wikiData})
-        : pageSpec.write(target, {wikiData}))).flat();
+        ? [pageSpec.writeTargetless({wikiData})]
+        : pageSpec.pathsForTarget(target))).flat();
 
   logInfo`Will be serving a total of ${pages.length} pages.`;
 
@@ -143,7 +168,7 @@ export async function go({
         response.writeHead(500, contentTypeJSON);
         response.end({error: `Internal error serializing wiki JSON`});
         console.error(`${requestHead} [500] /data.json`);
-        console.error(error);
+        showError(error);
       }
       return;
     }
@@ -186,7 +211,7 @@ export async function go({
           response.writeHead(500, contentTypePlain);
           response.end(`Internal error accessing ${localFileArea} file for: ${safePath}`);
           console.error(`${requestHead} [500] ${pathname}`);
-          console.error(error);
+          showError(error);
         }
         return;
       }
@@ -239,7 +264,7 @@ export async function go({
         response.writeHead(500, contentTypePlain);
         response.end(`Failed during file-to-response pipeline`);
         console.error(`${requestHead} [500] ${pathname}`);
-        console.error(error);
+        showError(error);
       }
       return;
     }
@@ -305,8 +330,6 @@ export async function go({
         return;
       }
 
-      response.writeHead(200, contentTypeHTML);
-
       const localizedPathnames = getPagePathnameAcrossLanguages({
         defaultLanguage,
         languages,
@@ -314,37 +337,139 @@ export async function go({
         urls,
       });
 
+      const {name, args} = page.contentFunction;
+
       const bound = bindUtilities({
         absoluteTo,
+        cachebust,
         defaultLanguage,
         getSizeOfAdditionalFile,
         getSizeOfImageFile,
         language,
         languages,
+        pagePath: servePath,
         to,
         urls,
         wikiData,
       });
 
-      const pageInfo = page.page(bound);
-
-      const pageHTML = generateDocumentHTML(pageInfo, {
+      const allExtraDependencies = {
         ...bound,
-        cachebust,
-        developersComment,
-        localizedPathnames,
-        oEmbedJSONHref: null, // No oEmbed support for live dev server
-        pagePath: servePath,
-        pathname,
-      });
+
+        appendIndexHTML: false,
+        transformMultiline: text => `<p>${text}</p>`,
+      };
+
+      // NOTE: ALL THIS STUFF IS PASTED, REVIEW AND INTEGRATE SOON(TM)
+
+      const treeInfo = getRelationsTree(allContentDependencies, name, wikiData, ...args);
+      const flatTreeInfo = flattenRelationsTree(treeInfo);
+      const {root, relationIdentifier, flatRelationSlots} = flatTreeInfo;
+
+      const neededContentDependencyNames =
+        getNeededContentDependencyNames(allContentDependencies, name);
+
+      // Content functions aren't recursive, so by following the set above
+      // sequentually, we will always provide fulfilled content functions as the
+      // dependencies for later content functions.
+      const fulfilledContentDependencies = {};
+      for (const name of neededContentDependencyNames) {
+        const unfulfilledContentFunction = allContentDependencies[name];
+        if (!unfulfilledContentFunction) continue;
+
+        const {contentDependencies, extraDependencies} = unfulfilledContentFunction;
+
+        if (empty(contentDependencies) && empty(extraDependencies)) {
+          fulfilledContentDependencies[name] = unfulfilledContentFunction;
+          continue;
+        }
+
+        const fulfillments = {};
+
+        for (const dependencyName of contentDependencies ?? []) {
+          if (dependencyName in fulfilledContentDependencies) {
+            fulfillments[dependencyName] =
+              fulfilledContentDependencies[dependencyName];
+          }
+        }
+
+        for (const dependencyName of extraDependencies ?? []) {
+          if (dependencyName in allExtraDependencies) {
+            fulfillments[dependencyName] =
+              allExtraDependencies[dependencyName];
+          }
+        }
+
+        fulfilledContentDependencies[name] =
+          unfulfilledContentFunction.fulfill(fulfillments);
+      }
+
+      // There might still be unfulfilled content functions if dependencies weren't
+      // provided as part of allContentDependencies or allExtraDependencies.
+      // Catch and report these early, together in an aggregate error.
+      const unfulfilledErrors = [];
+      const unfulfilledNames = [];
+      for (const name of neededContentDependencyNames) {
+        const contentFunction = fulfilledContentDependencies[name];
+        if (!contentFunction) continue;
+        if (!contentFunction.fulfilled) {
+          try {
+            contentFunction();
+          } catch (error) {
+            error.message = `(${name}) ${error.message}`;
+            unfulfilledErrors.push(error);
+            unfulfilledNames.push(name);
+          }
+        }
+      }
+
+      if (!empty(unfulfilledErrors)) {
+        throw new AggregateError(unfulfilledErrors, `Content functions unfulfilled (${unfulfilledNames.join(', ')})`);
+      }
+
+      const slotResults = {};
+
+      function runContentFunction({name, args, relations: flatRelations}) {
+        const contentFunction = fulfilledContentDependencies[name];
+
+        if (!contentFunction) {
+          throw new Error(`Content function ${name} unfulfilled or not listed`);
+        }
+
+        const sprawl =
+          contentFunction.sprawl?.(allExtraDependencies.wikiData, ...args);
+
+        const relations =
+          fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, flatRelations);
+
+        const data =
+          (sprawl
+            ? contentFunction.data?.(sprawl, ...args)
+            : contentFunction.data?.(...args));
+
+        const generateArgs = [data, relations].filter(Boolean);
+
+        return contentFunction(...generateArgs);
+      }
+
+      for (const slot of Object.getOwnPropertySymbols(flatRelationSlots)) {
+        slotResults[slot] = runContentFunction(flatRelationSlots[slot]);
+      }
+
+      const topLevelResult = runContentFunction(root);
+
+      // END PASTE
+
+      const pageHTML = topLevelResult.toString();
 
       console.log(`${requestHead} [200] ${pathname}`);
+      response.writeHead(200, contentTypeHTML);
       response.end(pageHTML);
     } catch (error) {
       response.writeHead(500, contentTypePlain);
       response.end(`Error generating page, view server log for details\n`);
       console.error(`${requestHead} [500] ${pathname}`);
-      console.error(error);
+      showError(error);
     }
   });
 
@@ -360,7 +485,7 @@ export async function go({
       }, 10_000);
     } else {
       console.error(`Server error detected (code: ${error.code})`);
-      console.error(error);
+      showError(error);
     }
   });
 
diff --git a/src/write/page-template.js b/src/write/page-template.js
index 8a3b44e..d3d7b09 100644
--- a/src/write/page-template.js
+++ b/src/write/page-template.js
@@ -3,11 +3,6 @@ import chroma from 'chroma-js';
 import * as html from '../util/html.js';
 import {getColors} from '../util/colors.js';
 
-import {
-  getFooterLocalizationLinks,
-  getRevealStringFromContentWarningMessage,
-} from '../misc-templates.js';
-
 export function generateDevelopersCommentHTML({
   buildTime,
   commit,
@@ -153,40 +148,7 @@ export function generateDocumentHTML(pageInfo, {
   const collapseSidebars =
     sidebarLeft.collapse !== false && sidebarRight.collapse !== false;
 
-  const mainHTML =
-    html.tag('main', {
-      id: 'content',
-      class: main.classes,
-    }, [
-      ...html.fragment(
-          !title ?
-            null
-        : main.headingMode === 'sticky' ?
-            generateStickyHeadingContainer({
-              coverSrc: cover.src,
-              coverAlt: cover.alt,
-              coverArtTags: cover.artTags,
-              title,
-            })
-        : main.headingMode === 'static' ?
-            html.tag('h1', title)
-        : null),
-
-      ...html.fragment(
-        cover.src &&
-          generateCoverLink({
-            src: cover.src,
-            alt: cover.alt,
-            tags: cover.artTags,
-          })),
 
-      html.tag('div',
-        {
-          [html.onlyIfContent]: true,
-          class: 'main-content-container',
-        },
-        main.content),
-    ]);
 
   const footerHTML =
     html.tag('footer',
@@ -378,31 +340,6 @@ export function generateDocumentHTML(pageInfo, {
         height: banner.dimensions[1] || 200,
       }));
 
-  const layoutHTML = [
-    navHTML,
-    banner.position === 'top' && bannerHTML,
-    secondaryNavHTML,
-    html.tag('div',
-      {
-        class: [
-          'layout-columns',
-          !collapseSidebars && 'vertical-when-thin',
-          (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar',
-          (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars',
-          !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars',
-          sidebarLeftHTML && 'has-sidebar-left',
-          sidebarRightHTML && 'has-sidebar-right',
-        ],
-      },
-      [
-        sidebarLeftHTML,
-        mainHTML,
-        sidebarRightHTML,
-      ]),
-    banner.position === 'bottom' && bannerHTML,
-    footerHTML,
-  ].filter(Boolean).join('\n');
-
   const processSkippers = skipperList =>
     skipperList
       .filter(Boolean)
@@ -612,92 +549,7 @@ export function generateDocumentHTML(pageInfo, {
       }),
   ].filter(Boolean).join('\n');
 
-  return `<!DOCTYPE html>\n` + html.tag('html',
-    {
-      lang: language.intlCode,
-      'data-language-code': language.code,
-      'data-url-key': 'localized.' + pagePath[0],
-      ...Object.fromEntries(
-        pagePath.slice(1).map((v, i) => [['data-url-value' + i], v])),
-      'data-rebase-localized': to('localized.root'),
-      'data-rebase-shared': to('shared.root'),
-      'data-rebase-media': to('media.root'),
-      'data-rebase-data': to('data.root'),
-    },
-    [
-      developersComment,
-
-      html.tag('head', [
-        html.tag('title',
-          showWikiNameInTitle
-            ? language.formatString('misc.pageTitle.withWikiName', {
-                title,
-                wikiName: wikiInfo.nameShort,
-              })
-            : language.formatString('misc.pageTitle', {title})),
-
-        html.tag('meta', {charset: 'utf-8'}),
-        html.tag('meta', {
-          name: 'viewport',
-          content: 'width=device-width, initial-scale=1',
-        }),
-
-        ...(
-          Object.entries(meta)
-            .filter(([key, value]) => value)
-            .map(([key, value]) => html.tag('meta', {[key]: value}))),
-
-        canonical &&
-          html.tag('link', {
-            rel: 'canonical',
-            href: canonical,
-          }),
-
-        ...(
-          localizedCanonical
-            .map(({lang, href}) => html.tag('link', {
-              rel: 'alternate',
-              hreflang: lang,
-              href,
-            }))),
-
-        socialEmbedHTML,
-
-        html.tag('link', {
-          rel: 'stylesheet',
-          href: to('shared.staticFile', `site3.css?${cachebust}`),
-        }),
-
-        html.tag('style',
-          {[html.onlyIfContent]: true},
-          [
-            theme,
-            stylesheet,
-          ]),
-
-        html.tag('script', {
-          src: to('shared.staticFile', `lazy-loading.js?${cachebust}`),
-        }),
-      ]),
-
-      html.tag('body',
-        {style: body.style || ''},
-        [
-          html.tag('div', {id: 'page-container'}, [
-            mainHTML &&
-            skippersHTML,
-            layoutHTML,
-          ]),
-
-          infoCardHTML,
-          imageOverlayHTML,
-
-          html.tag('script', {
-            type: 'module',
-            src: to('shared.staticFile', `client.js?${cachebust}`),
-          }),
-        ]),
-    ]);
+  return `<!DOCTYPE html>\n`
 }
 
 export function generateOEmbedJSON(pageInfo, {language, wikiData}) {
diff --git a/tap-snapshots/test/snapshot/generateAdditionalFilesList.js.test.cjs b/tap-snapshots/test/snapshot/generateAdditionalFilesList.js.test.cjs
new file mode 100644
index 0000000..5ca6348
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateAdditionalFilesList.js.test.cjs
@@ -0,0 +1,29 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateAdditionalFilesList.js TAP generateAdditionalFilesList (snapshot) > basic behavior 1`] = `
+<dl>
+    <dt>SBURB Wallpaper</dt>
+    <dd>
+        <ul>
+            <li>link to 1280x1024 (2.5 kB)</li>
+            <li>link to 1440x900</li>
+        </ul>
+    </dd>
+    <dt>Alternate Covers: This is just an example description.</dt>
+    <dd>
+        <ul>
+            <li>link to alt1 (1.2 MB)</li>
+            <li>link to alt3 (1.2 MB)</li>
+        </ul>
+    </dd>
+</dl>
+`
+
+exports[`test/snapshot/generateAdditionalFilesList.js TAP generateAdditionalFilesList (snapshot) > no additional files 1`] = `
+
+`
diff --git a/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs b/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs
new file mode 100644
index 0000000..c94371c
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs
@@ -0,0 +1,14 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateAdditionalFilesShortcut.js TAP generateAdditionalFilesShortcut (snapshot) > basic behavior 1`] = `
+View <a href="#additional-files">additional files</a>: SBURB Wallpaper, Alternate Covers
+`
+
+exports[`test/snapshot/generateAdditionalFilesShortcut.js TAP generateAdditionalFilesShortcut (snapshot) > no additional files 1`] = `
+
+`
diff --git a/tap-snapshots/test/snapshot/generateAlbumTrackListItem.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumTrackListItem.js.test.cjs
new file mode 100644
index 0000000..c561d34
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateAlbumTrackListItem.js.test.cjs
@@ -0,0 +1,21 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateAlbumTrackListItem.js TAP generateAlbumTrackListItem (snapshot) > basic behavior 1`] = `
+<li style="--primary-color: #33cc77">(0:54) <a href="track/final-spice/" style="--primary-color: #33cc77; --dim-color: #437854">Final Spice</a> <span class="by">by <a href="artist/toby-fox/">Toby Fox</a> and <a href="artist/james-roach/">James Roach</a></span></li>
+`
+
+exports[`test/snapshot/generateAlbumTrackListItem.js TAP generateAlbumTrackListItem (snapshot) > hide artists if inherited from album 1`] = `
+<li>(_:__) <a href="track/track1/">Same artists, same order</a></li>
+<li>(_:__) <a href="track/track2/">Same artists, different order</a></li>
+<li>(_:__) <a href="track/track3/">Extra artist</a> <span class="by">by <a href="artist/toby-fox/">Toby Fox</a>, <a href="artist/james-roach/">James Roach</a>, and <a href="artist/clark-powell/">Clark Powell</a></span></li>
+<li>(_:__) <a href="track/track4/">Missing artist</a> <span class="by">by <a href="artist/toby-fox/">Toby Fox</a></span></li>
+`
+
+exports[`test/snapshot/generateAlbumTrackListItem.js TAP generateAlbumTrackListItem (snapshot) > zero duration, zero artists 1`] = `
+<li>(_:__) <a href="track/you-have-got-to-be-about-the-most-superficial-commentator-on-con-langues-since-the-idiotic-b-gilson/">You have got to be about the most superficial commentator on con-langues since the idiotic B. Gilson.</a></li>
+`
diff --git a/tap-snapshots/test/snapshot/generateContributionLinks.js.test.cjs b/tap-snapshots/test/snapshot/generateContributionLinks.js.test.cjs
new file mode 100644
index 0000000..0abd2d3
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateContributionLinks.js.test.cjs
@@ -0,0 +1,52 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateContributionLinks.js TAP generateContributionLinks (snapshot) > no accents 1`] = `
+<a href="artist/clark-powell/">Clark Powell</a>, <a href="artist/the-big-baddies/">Grounder & Scratch</a>, and <a href="artist/toby-fox/">Toby Fox</a>
+`
+
+exports[`test/snapshot/generateContributionLinks.js TAP generateContributionLinks (snapshot) > only showContribution 1`] = `
+<a href="artist/clark-powell/">Clark Powell</a>, <a href="artist/the-big-baddies/">Grounder & Scratch</a> (Snooping), and <a href="artist/toby-fox/">Toby Fox</a> (Arrangement)
+`
+
+exports[`test/snapshot/generateContributionLinks.js TAP generateContributionLinks (snapshot) > only showIcons 1`] = `
+<a href="artist/clark-powell/">Clark Powell</a> (<span class="icons"><a href="https://soundcloud.com/plazmataz" class="icon">
+        <svg>
+            <title>SoundCloud</title>
+            <use href="static/icons.svg#icon-soundcloud"></use>
+        </svg>
+    </a></span>), <a href="artist/the-big-baddies/">Grounder & Scratch</a>, and <a href="artist/toby-fox/">Toby Fox</a> (<span class="icons"><a href="https://tobyfox.bandcamp.com/" class="icon">
+        <svg>
+            <title>Bandcamp</title>
+            <use href="static/icons.svg#icon-bandcamp"></use>
+        </svg>
+    </a>, <a href="https://toby.fox/" class="icon">
+        <svg>
+            <title>External (toby.fox)</title>
+            <use href="static/icons.svg#icon-globe"></use>
+        </svg>
+    </a></span>)
+`
+
+exports[`test/snapshot/generateContributionLinks.js TAP generateContributionLinks (snapshot) > showContribution & showIcons 1`] = `
+<a href="artist/clark-powell/">Clark Powell</a> (<span class="icons"><a href="https://soundcloud.com/plazmataz" class="icon">
+        <svg>
+            <title>SoundCloud</title>
+            <use href="static/icons.svg#icon-soundcloud"></use>
+        </svg>
+    </a></span>), <a href="artist/the-big-baddies/">Grounder & Scratch</a> (Snooping), and <a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons"><a href="https://tobyfox.bandcamp.com/" class="icon">
+        <svg>
+            <title>Bandcamp</title>
+            <use href="static/icons.svg#icon-bandcamp"></use>
+        </svg>
+    </a>, <a href="https://toby.fox/" class="icon">
+        <svg>
+            <title>External (toby.fox)</title>
+            <use href="static/icons.svg#icon-globe"></use>
+        </svg>
+    </a></span>)
+`
diff --git a/tap-snapshots/test/snapshot/image.js.test.cjs b/tap-snapshots/test/snapshot/image.js.test.cjs
new file mode 100644
index 0000000..f151293
--- /dev/null
+++ b/tap-snapshots/test/snapshot/image.js.test.cjs
@@ -0,0 +1,60 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/image.js TAP image (snapshot) > content warnings via tags 1`] = `
+<div class="reveal">
+    <div class="image-container"><div class="image-inner-area"><img src="media/album-art/beyond-canon/cover.png"></div></div>
+    <span class="reveal-text-container">
+        <span class="reveal-text">
+            cw: too cool for school
+            <br>
+            <span class="reveal-interaction">click to show</span>
+        </span>
+    </span>
+</div>
+`
+
+exports[`test/snapshot/image.js TAP image (snapshot) > id with link 1`] = `
+<a id="banana" class="box image-link" href="foobar"><div class="image-container"><div class="image-inner-area"><img src="foobar"></div></div></a>
+`
+
+exports[`test/snapshot/image.js TAP image (snapshot) > id with square 1`] = `
+<div class="square"><div class="square-content"><div class="image-container"><div class="image-inner-area"><img id="banana" src="foobar"></div></div></div></div>
+`
+
+exports[`test/snapshot/image.js TAP image (snapshot) > id without link 1`] = `
+<div class="image-container"><div class="image-inner-area"><img id="banana" src="foobar"></div></div>
+`
+
+exports[`test/snapshot/image.js TAP image (snapshot) > lazy with square 1`] = `
+<noscript><div class="square"><div class="square-content"><div class="image-container"><div class="image-inner-area"><img src="foobar"></div></div></div></div></noscript>
+<div class="square js-hide"><div class="square-content"><div class="image-container"><div class="image-inner-area"><img class="lazy" data-original="foobar"></div></div></div></div>
+`
+
+exports[`test/snapshot/image.js TAP image (snapshot) > link with file size 1`] = `
+<a class="box image-link" href="media/album-art/pingas/cover.png"><div class="image-container"><div class="image-inner-area"><img data-original-size="1000000" src="media/album-art/pingas/cover.png"></div></div></a>
+`
+
+exports[`test/snapshot/image.js TAP image (snapshot) > source missing 1`] = `
+<div class="image-container"><div class="image-inner-area"><div class="image-text-area">Example of missing source message.</div></div></div>
+`
+
+exports[`test/snapshot/image.js TAP image (snapshot) > source via path 1`] = `
+<div class="image-container"><div class="image-inner-area"><img src="media/album-art/beyond-canon/cover.png"></div></div>
+`
+
+exports[`test/snapshot/image.js TAP image (snapshot) > source via src 1`] = `
+<div class="image-container"><div class="image-inner-area"><img src="https://example.com/bananas.gif"></div></div>
+`
+
+exports[`test/snapshot/image.js TAP image (snapshot) > square 1`] = `
+<div class="square"><div class="square-content"><div class="image-container"><div class="image-inner-area"><img src="foobar"></div></div></div></div>
+`
+
+exports[`test/snapshot/image.js TAP image (snapshot) > width & height 1`] = `
+<div class="image-container"><div class="image-inner-area"><img width="600" height="400" src="foobar"></div></div>
+`
diff --git a/tap-snapshots/test/snapshot/linkArtist.js.test.cjs b/tap-snapshots/test/snapshot/linkArtist.js.test.cjs
new file mode 100644
index 0000000..77516f3
--- /dev/null
+++ b/tap-snapshots/test/snapshot/linkArtist.js.test.cjs
@@ -0,0 +1,14 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/linkArtist.js TAP linkArtist (snapshot) > basic behavior 1`] = `
+<a href="artist/toby-fox/">Toby Fox</a>
+`
+
+exports[`test/snapshot/linkArtist.js TAP linkArtist (snapshot) > prefer short name 1`] = `
+<a href="artist/55gore/">55gore</a>
+`
diff --git a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs
new file mode 100644
index 0000000..e437681
--- /dev/null
+++ b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs
@@ -0,0 +1,43 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/linkExternal.js TAP linkExternal (snapshot) > basic domain matches 1`] = `
+<a href="https://homestuck.bandcamp.com/" class="nowrap">Bandcamp</a>
+<a href="https://soundcloud.com/plazmataz" class="nowrap">SoundCloud</a>
+<a href="https://aeritus.tumblr.com/" class="nowrap">Tumblr</a>
+<a href="https://twitter.com/awkwarddoesart" class="nowrap">Twitter</a>
+<a href="https://www.deviantart.com/chesswanderlust-sama" class="nowrap">DeviantArt</a>
+<a href="https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)" class="nowrap">Wikipedia</a>
+<a href="https://www.poetryfoundation.org/poets/christina-rossetti" class="nowrap">Poetry Foundation</a>
+<a href="https://www.instagram.com/levc_egm/" class="nowrap">Instagram</a>
+<a href="https://www.patreon.com/CecilyRenns" class="nowrap">Patreon</a>
+<a href="https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3" class="nowrap">Spotify</a>
+<a href="https://buzinkai.newgrounds.com/" class="nowrap">Newgrounds</a>
+`
+
+exports[`test/snapshot/linkExternal.js TAP linkExternal (snapshot) > custom domains for common platforms 1`] = `
+<a href="https://music.solatrus.com/" class="nowrap">music.solatrus.com</a>
+<a href="https://types.pl/" class="nowrap">Mastodon (types.pl)</a>
+`
+
+exports[`test/snapshot/linkExternal.js TAP linkExternal (snapshot) > custom matches - type: album 1`] = `
+<a href="https://youtu.be/abc" class="nowrap">YouTube (full album)</a>
+<a href="https://youtube.com/watch?v=abc" class="nowrap">YouTube (full album)</a>
+<a href="https://youtube.com/Playlist?list=kweh" class="nowrap">YouTube (playlist)</a>
+<a href="https://youtu.be/abc" class="nowrap">YouTube</a>
+<a href="https://youtu.be/abc?list=kweh" class="nowrap">YouTube</a>
+<a href="https://youtube.com/watch?v=abc" class="nowrap">YouTube</a>
+<a href="https://youtube.com/watch?v=abc&list=kweh" class="nowrap">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js TAP linkExternal (snapshot) > missing domain (arbitrary local path) 1`] = `
+<a href="/foo/bar/baz.mp3" class="nowrap">Wiki Archive (local upload)</a>
+`
+
+exports[`test/snapshot/linkExternal.js TAP linkExternal (snapshot) > unknown domain (arbitrary world wide web path) 1`] = `
+<a href="https://snoo.ping.as/usual/i/see/" class="nowrap">snoo.ping.as</a>
+`
diff --git a/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs b/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs
new file mode 100644
index 0000000..d7f6c1c
--- /dev/null
+++ b/tap-snapshots/test/snapshot/linkExternalFlash.js.test.cjs
@@ -0,0 +1,18 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/linkExternalFlash.js TAP linkExternalFlash (snapshot) > basic behavior 1`] = `
+<span class="nowrap"><a href="https://homestuck.com/story/4109/" class="nowrap">homestuck.com</a> (page 4109)</span>
+<span class="nowrap"><a href="https://youtu.be/FDt-SLyEcjI" class="nowrap">YouTube</a> (on any device)</span>
+<span class="nowrap"><a href="https://www.bgreco.net/hsflash/006009.html" class="nowrap">www.bgreco.net</a> (HQ Audio)</span>
+<span class="nowrap"><a href="https://www.newgrounds.com/portal/view/582345" class="nowrap">Newgrounds</a></span>
+`
+
+exports[`test/snapshot/linkExternalFlash.js TAP linkExternalFlash (snapshot) > secret page 1`] = `
+<span class="nowrap"><a href="https://homestuck.com/story/pony/" class="nowrap">homestuck.com</a> (secret page)</span>
+<span class="nowrap"><a href="https://youtu.be/USB1pj6hAjU" class="nowrap">YouTube</a> (on any device)</span>
+`
diff --git a/tap-snapshots/test/snapshot/linkTemplate.js.test.cjs b/tap-snapshots/test/snapshot/linkTemplate.js.test.cjs
new file mode 100644
index 0000000..3c4acb5
--- /dev/null
+++ b/tap-snapshots/test/snapshot/linkTemplate.js.test.cjs
@@ -0,0 +1,14 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/linkTemplate.js TAP linkTemplate (snapshot) > fill many slots 1`] = `
+<a class="dog" id="cat1" href="https://hsmusic.wiki/media/cool%20file.pdf#fooey" style="--primary-color: #123456ff; --dim-color: #12345677">My Cool Link</a>
+`
+
+exports[`test/snapshot/linkTemplate.js TAP linkTemplate (snapshot) > fill path slot & provide appendIndexHTML 1`] = `
+<a href="/c*lzone/myCoolPath/ham/pineapple/tomato/index.html"></a>
+`
diff --git a/test/lib/content-function.js b/test/lib/content-function.js
new file mode 100644
index 0000000..ac527ab
--- /dev/null
+++ b/test/lib/content-function.js
@@ -0,0 +1,148 @@
+import chroma from 'chroma-js';
+import * as path from 'path';
+import {fileURLToPath} from 'url';
+
+import mock from './generic-mock.js';
+import {quickEvaluate} from '../../src/content-function.js';
+import {quickLoadContentDependencies} from '../../src/content/dependencies/index.js';
+
+import urlSpec from '../../src/url-spec.js';
+import * as html from '../../src/util/html.js';
+import {empty, showAggregate} from '../../src/util/sugar.js';
+import {getColors} from '../../src/util/colors.js';
+import {generateURLs, thumb} from '../../src/util/urls.js';
+import {processLanguageFile} from '../../src/data/language.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+export function testContentFunctions(t, message, fn) {
+  const urls = generateURLs(urlSpec);
+
+  t.test(message, async t => {
+    let loadedContentDependencies;
+
+    const language = await processLanguageFile('./src/strings-default.json');
+    const mocks = [];
+
+    const evaluate = ({
+      from = 'localized.home',
+      contentDependencies = {},
+      extraDependencies = {},
+      ...opts
+    }) => {
+      if (!loadedContentDependencies) {
+        throw new Error(`Await .load() before performing tests`);
+      }
+
+      const {to} = urls.from(from);
+
+      return cleanCatchAggregate(() => {
+        return quickEvaluate({
+          ...opts,
+          contentDependencies: {
+            ...contentDependencies,
+            ...loadedContentDependencies,
+          },
+          extraDependencies: {
+            html,
+            language,
+            thumb,
+            to,
+            urls,
+            appendIndexHTML: false,
+            getColors: c => getColors(c, {chroma}),
+            ...extraDependencies,
+          },
+        });
+      });
+    };
+
+    evaluate.load = async (opts) => {
+      if (loadedContentDependencies) {
+        throw new Error(`Already loaded!`);
+      }
+
+      loadedContentDependencies = await asyncCleanCatchAggregate(() =>
+        quickLoadContentDependencies({
+          logging: false,
+          ...opts,
+        }));
+    };
+
+    evaluate.snapshot = (...args) => {
+      if (!loadedContentDependencies) {
+        throw new Error(`Await .load() before performing tests`);
+      }
+
+      const [description, opts] =
+        (typeof args[0] === 'string'
+          ? args
+          : ['output', ...args]);
+
+      let result = evaluate(opts);
+
+      if (opts.multiple) {
+        result = result.map(item => item.toString()).join('\n');
+      } else {
+        result = result.toString();
+      }
+
+      t.matchSnapshot(result, description);
+    };
+
+    evaluate.mock = (...opts) => {
+      const {value, close} = mock(...opts);
+      mocks.push({close});
+      return value;
+    };
+
+    await fn(t, evaluate);
+
+    if (!empty(mocks)) {
+      cleanCatchAggregate(() => {
+        const errors = [];
+        for (const {close} of mocks) {
+          try {
+            close();
+          } catch (error) {
+            errors.push(error);
+          }
+        }
+        if (!empty(errors)) {
+          throw new AggregateError(errors, `Errors closing mocks`);
+        }
+      });
+    }
+  });
+}
+
+function printAggregate(error) {
+  if (error instanceof AggregateError) {
+    const message = showAggregate(error, {
+      showTraces: true,
+      print: false,
+      pathToFileURL: f => path.relative(path.join(__dirname, '../..'), fileURLToPath(f)),
+    });
+    for (const line of message.split('\n')) {
+      console.error(line);
+    }
+  }
+}
+
+function cleanCatchAggregate(fn) {
+  try {
+    return fn();
+  } catch (error) {
+    printAggregate(error);
+    throw error;
+  }
+}
+
+async function asyncCleanCatchAggregate(fn) {
+  try {
+    return await fn();
+  } catch (error) {
+    printAggregate(error);
+    throw error;
+  }
+}
diff --git a/test/lib/generic-mock.js b/test/lib/generic-mock.js
new file mode 100644
index 0000000..119f873
--- /dev/null
+++ b/test/lib/generic-mock.js
@@ -0,0 +1,314 @@
+import {same} from 'tcompare';
+
+import {empty} from '../../src/util/sugar.js';
+
+export default function mock(callback) {
+  const mocks = [];
+
+  const track = callback => (...args) => {
+    const {value, close} = callback(...args);
+    mocks.push({close});
+    return value;
+  };
+
+  const mock = {
+    function: track(mockFunction),
+  };
+
+  return {
+    value: callback(mock),
+    close: () => {
+      const errors = [];
+      for (const mock of mocks) {
+        try {
+          mock.close();
+        } catch (error) {
+          errors.push(error);
+        }
+      }
+      if (!empty(errors)) {
+        throw new AggregateError(errors, `Errors closing sub-mocks`);
+      }
+    },
+  };
+}
+
+export function mockFunction(...args) {
+  let name = '(anonymous)';
+  let behavior = null;
+
+  if (args.length === 2) {
+    if (
+      typeof args[0] === 'string' &&
+      typeof args[1] === 'function'
+    ) {
+      name = args[0];
+      behavior = args[1];
+    } else {
+      throw new TypeError(`Expected name to be a string`);
+    }
+  } else if (args.length === 1) {
+    if (typeof args[0] === 'string') {
+      name = args[0];
+    } else if (typeof args[0] === 'function') {
+      behavior = args[0];
+    } else if (args[0] !== null) {
+      throw new TypeError(`Expected string (name), function (behavior), both, or null / no arguments`);
+    }
+  } else if (args.length > 2) {
+    throw new TypeError(`Expected string (name), function (behavior), both, or null / no arguments`);
+  }
+
+  let currentCallDescription = newCallDescription();
+  const allCallDescriptions = [currentCallDescription];
+
+  const topLevelErrors = [];
+  let runningCallCount = 0;
+  let limitCallCount = false;
+  let markedAsOnce = false;
+
+  const fn = (...args) => {
+    const description = processCall(...args);
+    return description.behavior(...args);
+  };
+
+  fn.behavior = value => {
+    if (!(value === null || (
+      typeof value === 'function'
+    ))) {
+      throw new TypeError(`Expected function or null`);
+    }
+
+    currentCallDescription.behavior = behavior;
+    currentCallDescription.described = true;
+
+    return fn;
+  }
+
+  fn.argumentCount = value => {
+    if (!(value === null || (
+      typeof value === 'number' &&
+      value === parseInt(value) &&
+      value >= 0
+    ))) {
+      throw new TypeError(`Expected whole number or null`);
+    }
+
+    if (currentCallDescription.argsPattern) {
+      throw new TypeError(`Unexpected .argumentCount() when .args() has been called`);
+    }
+
+    currentCallDescription.argsPattern = {length: value};
+    currentCallDescription.described = true;
+
+    return fn;
+  };
+
+  fn.args = (...args) => {
+    const value = args[0];
+
+    if (args.length > 1 || !(value === null || Array.isArray(value))) {
+      throw new TypeError(`Expected one array or null`);
+    }
+
+    currentCallDescription.argsPattern = Object.fromEntries(
+      value
+        .map((v, i) => v === undefined ? false : [i, v])
+        .filter(Boolean)
+        .concat([['length', value.length]]));
+
+    currentCallDescription.described = true;
+
+    return fn;
+  };
+
+  fn.neverCalled = (...args) => {
+    if (!empty(args)) {
+      throw new TypeError(`Didn't expect any arguments`);
+    }
+
+    if (allCallDescriptions[0].described) {
+      throw new TypeError(`Unexpected .neverCalled() when any descriptions provided`);
+    }
+
+    limitCallCount = true;
+    allCallDescriptions.splice(0, allCallDescriptions.length);
+
+    currentCallDescription = new Proxy({}, {
+      set() {
+        throw new Error(`Unexpected description when .neverCalled() has been called`);
+      },
+    });
+
+    return fn;
+  };
+
+  fn.once = (...args) => {
+    if (!empty(args)) {
+      throw new TypeError(`Didn't expect any arguments`);
+    }
+
+    if (allCallDescriptions.length > 1) {
+      throw new TypeError(`Unexpected .once() when providing multiple descriptions`);
+    }
+
+    currentCallDescription.described = true;
+    limitCallCount = true;
+    markedAsOnce = true;
+
+    return fn;
+  };
+
+  fn.next = (...args) => {
+    if (!empty(args)) {
+      throw new TypeError(`Didn't expect any arguments`);
+    }
+
+    if (markedAsOnce) {
+      throw new TypeError(`Unexpected .next() when .once() has been called`);
+    }
+
+    currentCallDescription = newCallDescription();
+    allCallDescriptions.push(currentCallDescription);
+
+    limitCallCount = true;
+
+    return fn;
+  };
+
+  fn.repeat = times => {
+    // Note: This function should be called AFTER filling out the
+    // call description which is being repeated.
+
+    if (!(
+      typeof times === 'number' &&
+      times === parseInt(times) &&
+      times >= 2
+    )) {
+      throw new TypeError(`Expected whole number of at least 2`);
+    }
+
+    if (markedAsOnce) {
+      throw new TypeError(`Unexpected .repeat() when .once() has been called`);
+    }
+
+    // The current call description is already in the full list,
+    // so skip the first push.
+    for (let i = 2; i <= times; i++) {
+      allCallDescriptions.push(currentCallDescription);
+    }
+
+    // Prep a new description like when calling .next().
+    currentCallDescription = newCallDescription();
+    allCallDescriptions.push(currentCallDescription);
+
+    limitCallCount = true;
+
+    return fn;
+  };
+
+  return {
+    value: fn,
+    close: () => {
+      const totalCallCount = runningCallCount;
+      const expectedCallCount = countDescribedCalls();
+
+      if (limitCallCount && totalCallCount !== expectedCallCount) {
+        if (expectedCallCount > 1) {
+          topLevelErrors.push(new Error(`Expected ${expectedCallCount} calls, got ${totalCallCount}`));
+        } else if (expectedCallCount === 1) {
+          topLevelErrors.push(new Error(`Expected 1 call, got ${totalCallCount}`));
+        } else {
+          topLevelErrors.push(new Error(`Expected no calls, got ${totalCallCount}`));
+        }
+      }
+
+      if (topLevelErrors.length) {
+        throw new AggregateError(topLevelErrors, `Errors in mock ${name}`);
+      }
+    },
+  };
+
+  function newCallDescription() {
+    return {
+      described: false,
+      behavior: behavior ?? null,
+      argumentCount: null,
+      argsPattern: null,
+    };
+  }
+
+  function processCall(...args) {
+    const callErrors = [];
+
+    runningCallCount++;
+
+    // No further processing, this indicates the function shouldn't have been
+    // called at all and there aren't any descriptions to match this call with.
+    if (empty(allCallDescriptions)) {
+      return newCallDescription();
+    }
+
+    const currentCallNumber = runningCallCount;
+    const currentDescription = selectCallDescription(currentCallNumber);
+
+    const {
+      argumentCount,
+      argsPattern,
+    } = currentDescription;
+
+    if (argumentCount !== null && args.length !== argumentCount) {
+      callErrors.push(
+        new Error(`Argument count mismatch: expected ${argumentCount}, got ${args.length}`));
+    }
+
+    if (argsPattern !== null) {
+      const keysToCheck = Object.keys(argsPattern);
+      const argsAsObject = Object.fromEntries(
+        args
+          .map((v, i) => [i.toString(), v])
+          .filter(([i]) => keysToCheck.includes(i))
+          .concat([['length', args.length]]));
+
+      const {match, diff} = same(argsAsObject, argsPattern);
+      if (!match) {
+        callErrors.push(new Error(`Argument pattern mismatch:\n` + diff));
+      }
+    }
+
+    if (!empty(callErrors)) {
+      const aggregate = new AggregateError(callErrors, `Errors in call #${currentCallNumber}`);
+      topLevelErrors.push(aggregate);
+    }
+
+    return currentDescription;
+  }
+
+  function selectCallDescription(currentCallNumber) {
+    if (currentCallNumber > countDescribedCalls()) {
+      const lastDescription = lastCallDescription();
+      if (lastDescription.described) {
+        return newCallDescription();
+      } else {
+        return lastDescription;
+      }
+    } else {
+      return allCallDescriptions[currentCallNumber - 1];
+    }
+  }
+
+  function countDescribedCalls() {
+    if (empty(allCallDescriptions)) {
+      return 0;
+    }
+
+    return (
+      (lastCallDescription().described
+        ? allCallDescriptions.length
+        : allCallDescriptions.length - 1));
+  }
+
+  function lastCallDescription() {
+    return allCallDescriptions[allCallDescriptions.length - 1];
+  }
+}
diff --git a/test/lib/strict-match-error.js b/test/lib/strict-match-error.js
new file mode 100644
index 0000000..e3b36e9
--- /dev/null
+++ b/test/lib/strict-match-error.js
@@ -0,0 +1,50 @@
+export function strictlyThrows(t, fn, pattern) {
+  const error = catchErrorOrNull(fn);
+
+  t.currentAssert = strictlyThrows;
+
+  if (error === null) {
+    t.fail(`expected to throw`);
+    return;
+  }
+
+  const nameAndMessage = `${pattern.constructor.name} ${pattern.message}`;
+  t.match(
+    prepareErrorForMatch(error),
+    prepareErrorForMatch(pattern),
+    (pattern instanceof AggregateError
+      ? `expected to throw: ${nameAndMessage} (${pattern.errors.length} error(s))`
+      : `expected to throw: ${nameAndMessage}`));
+}
+
+function prepareErrorForMatch(error) {
+  if (error instanceof RegExp) {
+    return {
+      message: error,
+    };
+  }
+
+  if (!(error instanceof Error)) {
+    return error;
+  }
+
+  const matchable = {
+    name: error.constructor.name,
+    message: error.message,
+  };
+
+  if (error instanceof AggregateError) {
+    matchable.errors = error.errors.map(prepareErrorForMatch);
+  }
+
+  return matchable;
+}
+
+function catchErrorOrNull(fn) {
+  try {
+    fn();
+    return null;
+  } catch (error) {
+    return error;
+  }
+}
diff --git a/test/snapshot/generateAdditionalFilesList.js b/test/snapshot/generateAdditionalFilesList.js
new file mode 100644
index 0000000..0c27ad1
--- /dev/null
+++ b/test/snapshot/generateAdditionalFilesList.js
@@ -0,0 +1,63 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'generateAdditionalFilesList (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('no additional files', {
+    name: 'generateAdditionalFilesList',
+    args: [[]],
+  });
+
+  evaluate.snapshot('basic behavior', {
+    name: 'generateAdditionalFilesList',
+    args: [
+      [
+        {
+          title: 'SBURB Wallpaper',
+          files: [
+            'sburbwp_1280x1024.jpg',
+            'sburbwp_1440x900.jpg',
+            'sburbwp_1920x1080.jpg',
+          ],
+        },
+        {
+          title: 'Fake Section',
+          description: 'Ooo, what happens if there are NO file links provided?',
+          files: [
+            'oops.mp3',
+            'Internet Explorer.gif',
+            'daisy.mp3',
+          ],
+        },
+        {
+          title: 'Alternate Covers',
+          description: 'This is just an example description.',
+          files: [
+            'Homestuck_Vol4_alt1.jpg',
+            'Homestuck_Vol4_alt2.jpg',
+            'Homestuck_Vol4_alt3.jpg',
+          ],
+        },
+      ],
+    ],
+    postprocess: template => template
+      .slot('fileLinks', {
+        'sburbwp_1280x1024.jpg': 'link to 1280x1024',
+        'sburbwp_1440x900.jpg': 'link to 1440x900',
+        'sburbwp_1920x1080.jpg': null,
+        'Homestuck_Vol4_alt1.jpg': 'link to alt1',
+        'Homestuck_Vol4_alt2.jpg': null,
+        'Homestuck_Vol4_alt3.jpg': 'link to alt3',
+      })
+      .slot('fileSizes', {
+        'sburbwp_1280x1024.jpg': 2500,
+        'sburbwp_1440x900.jpg': null,
+        'sburbwp_1920x1080.jpg': null,
+        'Internet Explorer.gif': 1,
+        'Homestuck_Vol4_alt1.jpg': 1234567,
+        'Homestuck_Vol4_alt2.jpg': 1234567,
+        'Homestuck_Vol4_alt3.jpg': 1234567,
+      }),
+  });
+});
diff --git a/test/snapshot/generateAdditionalFilesShortcut.js b/test/snapshot/generateAdditionalFilesShortcut.js
new file mode 100644
index 0000000..0ca777b
--- /dev/null
+++ b/test/snapshot/generateAdditionalFilesShortcut.js
@@ -0,0 +1,36 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'generateAdditionalFilesShortcut (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('no additional files', {
+    name: 'generateAdditionalFilesShortcut',
+    args: [[]],
+  });
+
+  evaluate.snapshot('basic behavior', {
+    name: 'generateAdditionalFilesShortcut',
+    args: [
+      [
+        {
+          title: 'SBURB Wallpaper',
+          files: [
+            'sburbwp_1280x1024.jpg',
+            'sburbwp_1440x900.jpg',
+            'sburbwp_1920x1080.jpg',
+          ],
+        },
+        {
+          title: 'Alternate Covers',
+          description: 'This is just an example description.',
+          files: [
+            'Homestuck_Vol4_alt1.jpg',
+            'Homestuck_Vol4_alt2.jpg',
+            'Homestuck_Vol4_alt3.jpg',
+          ],
+        },
+      ],
+    ],
+  });
+});
diff --git a/test/snapshot/generateAlbumTrackListItem.js b/test/snapshot/generateAlbumTrackListItem.js
new file mode 100644
index 0000000..e87c6de
--- /dev/null
+++ b/test/snapshot/generateAlbumTrackListItem.js
@@ -0,0 +1,66 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'generateAlbumTrackListItem (snapshot)', async (t, evaluate) => {
+  const artist1 = {directory: 'toby-fox', name: 'Toby Fox', urls: ['https://toby.fox/']};
+  const artist2 = {directory: 'james-roach', name: 'James Roach'};
+  const artist3 = {directory: 'clark-powell', name: 'Clark Powell'};
+  const artist4 = {directory: ''}
+  const albumContribs = [{who: artist1}, {who: artist2}];
+
+  await evaluate.load();
+
+  evaluate.snapshot('basic behavior', {
+    name: 'generateAlbumTrackListItem',
+    args: [
+      {
+        // Just pretend Hiveswap Act 1 OST doesn't have its own Artists field OK?
+        // We test that kind of case later!
+        name: 'Final Spice',
+        directory: 'final-spice',
+        duration: 54,
+        color: '#33cc77',
+        artistContribs: [
+          {who: artist1, what: 'composition & arrangement'},
+          {who: artist2, what: 'arrangement'},
+        ],
+      },
+      {artistContribs: []},
+    ],
+  });
+
+  evaluate.snapshot('zero duration, zero artists', {
+    name: 'generateAlbumTrackListItem',
+    args: [
+      {
+        name: 'You have got to be about the most superficial commentator on con-langues since the idiotic B. Gilson.',
+        directory: 'you-have-got-to-be-about-the-most-superficial-commentator-on-con-langues-since-the-idiotic-b-gilson',
+        duration: 0,
+        artistContribs: [],
+      },
+      {artistContribs: []},
+    ],
+  });
+
+  evaluate.snapshot('hide artists if inherited from album', {
+    name: 'generateAlbumTrackListItem',
+    multiple: [
+      {args: [
+        {directory: 'track1', name: 'Same artists, same order', artistContribs: [{who: artist1}, {who: artist2}]},
+        {artistContribs: albumContribs},
+      ]},
+      {args: [
+        {directory: 'track2', name: 'Same artists, different order', artistContribs: [{who: artist2}, {who: artist1}]},
+        {artistContribs: albumContribs},
+      ]},
+      {args: [
+        {directory: 'track3', name: 'Extra artist', artistContribs: [{who: artist1}, {who: artist2}, {who: artist3}]},
+        {artistContribs: albumContribs},
+      ]},
+      {args: [
+        {directory: 'track4', name: 'Missing artist', artistContribs: [{who: artist1}]},
+        {artistContribs: albumContribs},
+      ]},
+    ],
+  });
+});
diff --git a/test/snapshot/generateContributionLinks.js b/test/snapshot/generateContributionLinks.js
new file mode 100644
index 0000000..3283d3b
--- /dev/null
+++ b/test/snapshot/generateContributionLinks.js
@@ -0,0 +1,55 @@
+// todo: this dependency was replaced with linkContribution, restructure test
+// remove generateContributionLinks.js.test.cjs snapshot file too!
+
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+t.skip('generateContributionLinks (snapshot)');
+
+void (() => testContentFunctions(t, 'generateContributionLinks (snapshot)', async (t, evaluate) => {
+  const artist1 = {
+    name: 'Clark Powell',
+    directory: 'clark-powell',
+    urls: ['https://soundcloud.com/plazmataz'],
+  };
+
+  const artist2 = {
+    name: 'Grounder & Scratch',
+    directory: 'the-big-baddies',
+    urls: [],
+  };
+
+  const artist3 = {
+    name: 'Toby Fox',
+    directory: 'toby-fox',
+    urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'],
+  };
+
+  const contributions = [
+    {who: artist1, what: null},
+    {who: artist2, what: 'Snooping'},
+    {who: artist3, what: 'Arrangement'},
+  ];
+
+  await evaluate.load();
+
+  evaluate.snapshot('showContribution & showIcons', {
+    name: 'generateContributionLinks',
+    args: [contributions, {showContribution: true, showIcons: true}],
+  });
+
+  evaluate.snapshot('only showContribution', {
+    name: 'generateContributionLinks',
+    args: [contributions, {showContribution: true, showIcons: false}],
+  });
+
+  evaluate.snapshot('only showIcons', {
+    name: 'generateContributionLinks',
+    args: [contributions, {showContribution: false, showIcons: true}],
+  });
+
+  evaluate.snapshot('no accents', {
+    name: 'generateContributionLinks',
+    args: [contributions, {showContribution: false, showIcons: false}],
+  });
+}));
diff --git a/test/snapshot/image.js b/test/snapshot/image.js
new file mode 100644
index 0000000..eeffb84
--- /dev/null
+++ b/test/snapshot/image.js
@@ -0,0 +1,92 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  const quickSnapshot = (message, opts) =>
+    evaluate.snapshot(message, {
+      name: 'image',
+      extraDependencies: {
+        getSizeOfImageFile: () => 0,
+      },
+      ...opts,
+    });
+
+  quickSnapshot('source via path', {
+    postprocess: template => template
+      .slot('path', ['media.albumCover', 'beyond-canon', 'png']),
+  });
+
+  quickSnapshot('source via src', {
+    postprocess: template => template
+      .slot('src', 'https://example.com/bananas.gif'),
+  });
+
+  quickSnapshot('source missing', {
+    postprocess: template => template
+      .slot('missingSourceContent', 'Example of missing source message.'),
+  });
+
+  quickSnapshot('id without link', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('id', 'banana'),
+  });
+
+  quickSnapshot('id with link', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('link', true)
+      .slot('id', 'banana'),
+  });
+
+  quickSnapshot('id with square', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('square', true)
+      .slot('id', 'banana'),
+  })
+
+  quickSnapshot('width & height', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('width', 600)
+      .slot('height', 400),
+  });
+
+  quickSnapshot('square', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('square', true),
+  });
+
+  quickSnapshot('lazy with square', {
+    postprocess: template => template
+      .slot('src', 'foobar')
+      .slot('lazy', true)
+      .slot('square', true),
+  });
+
+  quickSnapshot('link with file size', {
+    extraDependencies: {
+      getSizeOfImageFile: () => 10 ** 6,
+    },
+
+    postprocess: template => template
+      .slot('path', ['media.albumCover', 'pingas', 'png'])
+      .slot('link', true),
+  });
+
+  quickSnapshot('content warnings via tags', {
+    args: [
+      [
+        {name: 'Dirk Strider', directory: 'dirk'},
+        {name: 'too cool for school', isContentWarning: true},
+      ],
+    ],
+
+    postprocess: template => template
+      .slot('path', ['media.albumCover', 'beyond-canon', 'png']),
+  })
+});
diff --git a/test/snapshot/linkArtist.js b/test/snapshot/linkArtist.js
new file mode 100644
index 0000000..0451f1c
--- /dev/null
+++ b/test/snapshot/linkArtist.js
@@ -0,0 +1,30 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'linkArtist (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('basic behavior', {
+    name: 'linkArtist',
+    args: [
+      {
+        name: `Toby Fox`,
+        directory: `toby-fox`,
+      }
+    ],
+  });
+
+  evaluate.snapshot('prefer short name', {
+    name: 'linkArtist',
+    args: [
+      {
+        name: 'ICCTTCMDMIROTMCWMWFTPFTDDOTARHPOESWGBTWEATFCWSEBTSSFOFG',
+        nameShort: '55gore',
+        directory: '55gore',
+      },
+    ],
+
+    postprocess: v => v
+      .slot('preferShortName', true),
+  });
+});
diff --git a/test/snapshot/linkExternal.js b/test/snapshot/linkExternal.js
new file mode 100644
index 0000000..9661aea
--- /dev/null
+++ b/test/snapshot/linkExternal.js
@@ -0,0 +1,57 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'linkExternal (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('missing domain (arbitrary local path)', {
+    name: 'linkExternal',
+    args: ['/foo/bar/baz.mp3']
+  });
+
+  evaluate.snapshot('unknown domain (arbitrary world wide web path)', {
+    name: 'linkExternal',
+    args: ['https://snoo.ping.as/usual/i/see/'],
+  });
+
+  evaluate.snapshot('basic domain matches', {
+    name: 'linkExternal',
+    multiple: [
+      {args: ['https://homestuck.bandcamp.com/']},
+      {args: ['https://soundcloud.com/plazmataz']},
+      {args: ['https://aeritus.tumblr.com/']},
+      {args: ['https://twitter.com/awkwarddoesart']},
+      {args: ['https://www.deviantart.com/chesswanderlust-sama']},
+      {args: ['https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)']},
+      {args: ['https://www.poetryfoundation.org/poets/christina-rossetti']},
+      {args: ['https://www.instagram.com/levc_egm/']},
+      {args: ['https://www.patreon.com/CecilyRenns']},
+      {args: ['https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3']},
+      {args: ['https://buzinkai.newgrounds.com/']},
+    ],
+  });
+
+  evaluate.snapshot('custom matches - type: album', {
+    name: 'linkExternal',
+    multiple: [
+      {args: ['https://youtu.be/abc', {type: 'album'}]},
+      {args: ['https://youtube.com/watch?v=abc', {type: 'album'}]},
+      {args: ['https://youtube.com/Playlist?list=kweh', {type: 'album'}]},
+
+      // Reuse default when no type specified
+      {args: ['https://youtu.be/abc']},
+      {args: ['https://youtu.be/abc?list=kweh']},
+      {args: ['https://youtube.com/watch?v=abc']},
+      {args: ['https://youtube.com/watch?v=abc&list=kweh']},
+    ],
+  });
+
+  evaluate.snapshot('custom domains for common platforms', {
+    name: 'linkExternal',
+    multiple: [
+      // Just one domain of each platform is OK here
+      {args: ['https://music.solatrus.com/']},
+      {args: ['https://types.pl/']},
+    ],
+  });
+});
diff --git a/test/snapshot/linkExternalFlash.js b/test/snapshot/linkExternalFlash.js
new file mode 100644
index 0000000..7bb86c6
--- /dev/null
+++ b/test/snapshot/linkExternalFlash.js
@@ -0,0 +1,24 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'linkExternalFlash (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('basic behavior', {
+    name: 'linkExternalFlash',
+    multiple: [
+      {args: ['https://homestuck.com/story/4109/', {page: '4109'}]},
+      {args: ['https://youtu.be/FDt-SLyEcjI', {page: '4109'}]},
+      {args: ['https://www.bgreco.net/hsflash/006009.html', {page: '4109'}]},
+      {args: ['https://www.newgrounds.com/portal/view/582345', {page: '4109'}]},
+    ],
+  });
+
+  evaluate.snapshot('secret page', {
+    name: 'linkExternalFlash',
+    multiple: [
+      {args: ['https://homestuck.com/story/pony/', {page: 'pony'}]},
+      {args: ['https://youtu.be/USB1pj6hAjU', {page: 'pony'}]},
+    ],
+  });
+});
diff --git a/test/snapshot/linkTemplate.js b/test/snapshot/linkTemplate.js
new file mode 100644
index 0000000..e9a1ccd
--- /dev/null
+++ b/test/snapshot/linkTemplate.js
@@ -0,0 +1,33 @@
+import t from 'tap';
+import {testContentFunctions} from '../lib/content-function.js';
+
+testContentFunctions(t, 'linkTemplate (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('fill many slots', {
+    name: 'linkTemplate',
+
+    extraDependencies: {
+      getColors: c => ({primary: c + 'ff', dim: c + '77'}),
+    },
+
+    postprocess: v => v
+      .slot('color', '#123456')
+      .slot('href', 'https://hsmusic.wiki/media/cool file.pdf')
+      .slot('hash', 'fooey')
+      .slot('attributes', {class: 'dog', id: 'cat1'})
+      .slot('content', 'My Cool Link'),
+  });
+
+  evaluate.snapshot('fill path slot & provide appendIndexHTML', {
+    name: 'linkTemplate',
+
+    extraDependencies: {
+      to: (...path) => '/c*lzone/' + path.join('/') + '/',
+      appendIndexHTML: true,
+    },
+
+    postprocess: v => v
+      .slot('path', ['myCoolPath', 'ham', 'pineapple', 'tomato']),
+  });
+});
diff --git a/test/unit/content/dependencies/generateContributionLinks.js b/test/unit/content/dependencies/generateContributionLinks.js
new file mode 100644
index 0000000..328adc0
--- /dev/null
+++ b/test/unit/content/dependencies/generateContributionLinks.js
@@ -0,0 +1,109 @@
+// todo: this dependency was replaced with linkContribution, restructure test
+
+import t from 'tap';
+import {testContentFunctions} from '../../../lib/content-function.js';
+
+t.skip('generateContributionLinks (unit)', async t => {
+  const artist1 = {
+    name: 'Clark Powell',
+    urls: ['https://soundcloud.com/plazmataz'],
+  };
+
+  const artist2 = {
+    name: 'Grounder & Scratch',
+    urls: [],
+  };
+
+  const artist3 = {
+    name: 'Toby Fox',
+    urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'],
+  };
+
+  const contributions = [
+    {who: artist1, what: null},
+    {who: artist2, what: 'Snooping'},
+    {who: artist3, what: 'Arrangement'},
+  ];
+
+  await testContentFunctions(t, 'generateContributionLinks (unit 1)', async (t, evaluate) => {
+    const config = {
+      showContribution: true,
+      showIcons: true,
+    };
+
+    await evaluate.load({
+      mock: evaluate.mock(mock => ({
+        linkArtist: {
+          relations: mock.function('linkArtist.relations', () => ({}))
+            .args([undefined, artist1]).next()
+            .args([undefined, artist2]).next()
+            .args([undefined, artist3]),
+
+          data: mock.function('linkArtist.data', () => ({}))
+            .args([artist1]).next()
+            .args([artist2]).next()
+            .args([artist3]),
+
+          // This can be tweaked to return a specific (mocked) template
+          // for each artist if we need to test for slots in the future.
+          generate: mock.function('linkArtist.generate', () => 'artist link')
+            .repeat(3),
+        },
+
+        linkExternalAsIcon: {
+          data: mock.function('linkExternalAsIcon.data', () => ({}))
+            .args([artist1.urls[0]]).next()
+            .args([artist3.urls[0]]).next()
+            .args([artist3.urls[1]]),
+
+          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+            .repeat(3),
+        }
+      })),
+    });
+
+    evaluate({
+      name: 'generateContributionLinks',
+      args: [contributions, config],
+    });
+  });
+
+  await testContentFunctions(t, 'generateContributionLinks (unit 2)', async (t, evaluate) => {
+    const config = {
+      showContribution: false,
+      showIcons: false,
+    };
+
+    await evaluate.load({
+      mock: evaluate.mock(mock => ({
+        linkArtist: {
+          relations: mock.function('linkArtist.relations', () => ({}))
+            .args([undefined, artist1]).next()
+            .args([undefined, artist2]).next()
+            .args([undefined, artist3]),
+
+          data: mock.function('linkArtist.data', () => ({}))
+            .args([artist1]).next()
+            .args([artist2]).next()
+            .args([artist3]),
+
+          generate: mock.function(() => 'artist link')
+            .repeat(3),
+        },
+
+        linkExternalAsIcon: {
+          data: mock.function('linkExternalAsIcon.data', () => ({}))
+            .neverCalled(),
+
+          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+            .neverCalled(),
+        },
+      })),
+    });
+
+    evaluate({
+      name: 'generateContributionLinks',
+      args: [contributions, config],
+    });
+  });
+});
diff --git a/test/unit/content/dependencies/linkArtist.js b/test/unit/content/dependencies/linkArtist.js
new file mode 100644
index 0000000..9fceb97
--- /dev/null
+++ b/test/unit/content/dependencies/linkArtist.js
@@ -0,0 +1,31 @@
+import t from 'tap';
+import {testContentFunctions} from '../../../lib/content-function.js';
+
+testContentFunctions(t, 'linkArtist (unit)', async (t, evaluate) => {
+  const artistObject = {};
+  const linkTemplate = {};
+
+  await evaluate.load({
+    mock: evaluate.mock(mock => ({
+      linkThing: {
+        relations: mock.function('linkThing.relations', () => ({}))
+          .args([undefined, 'localized.artist', artistObject])
+          .once(),
+
+        data: mock.function('linkThing.data', () => ({}))
+          .args(['localized.artist', artistObject])
+          .once(),
+
+        generate: mock.function('linkThing.data', () => linkTemplate)
+          .once(),
+      }
+    })),
+  });
+
+  const result = evaluate({
+    name: 'linkArtist',
+    args: [artistObject],
+  });
+
+  t.equal(result, linkTemplate);
+});
diff --git a/test/cacheable-object.js b/test/unit/data/things/cacheable-object.js
index 664a648..d7a88ce 100644
--- a/test/cacheable-object.js
+++ b/test/unit/data/things/cacheable-object.js
@@ -1,8 +1,6 @@
-import test from 'tape';
+import t from 'tap';
 
-import CacheableObject from '../src/data/cacheable-object.js';
-
-// Utility
+import CacheableObject from '../../../../src/data/things/cacheable-object.js';
 
 function newCacheableObject(PD) {
   return new (class extends CacheableObject {
@@ -10,9 +8,7 @@ function newCacheableObject(PD) {
   });
 }
 
-// Tests
-
-test(`CacheableObject simple separate update & expose`, t => {
+t.test(`CacheableObject simple separate update & expose`, t => {
   const obj = newCacheableObject({
     number: {
       flags: {
@@ -37,7 +33,7 @@ test(`CacheableObject simple separate update & expose`, t => {
   t.equal(obj.timesTwo, 10);
 });
 
-test(`CacheableObject basic cache behavior`, t => {
+t.test(`CacheableObject basic cache behavior`, t => {
   let computeCount = 0;
 
   const obj = newCacheableObject({
@@ -64,31 +60,31 @@ test(`CacheableObject basic cache behavior`, t => {
 
   t.plan(8);
 
-  t.is(computeCount, 0);
+  t.equal(computeCount, 0);
 
   obj.string = 'hello world';
-  t.is(computeCount, 0);
+  t.equal(computeCount, 0);
 
   obj.karkat;
-  t.is(computeCount, 1);
+  t.equal(computeCount, 1);
 
   obj.karkat;
-  t.is(computeCount, 1);
+  t.equal(computeCount, 1);
 
   obj.string = 'testing once again';
-  t.is(computeCount, 1);
+  t.equal(computeCount, 1);
 
   obj.karkat;
-  t.is(computeCount, 2);
+  t.equal(computeCount, 2);
 
   obj.string = 'testing once again';
-  t.is(computeCount, 2);
+  t.equal(computeCount, 2);
 
   obj.karkat;
-  t.is(computeCount, 2);
+  t.equal(computeCount, 2);
 });
 
-test(`CacheableObject combined update & expose (no transform)`, t => {
+t.test(`CacheableObject combined update & expose (no transform)`, t => {
   const obj = newCacheableObject({
     directory: {
       flags: {
@@ -100,14 +96,14 @@ test(`CacheableObject combined update & expose (no transform)`, t => {
 
   t.plan(2);
 
-  t.directory = 'the-world-revolving';
-  t.is(t.directory, 'the-world-revolving');
+  obj.directory = 'the-world-revolving';
+  t.equal(obj.directory, 'the-world-revolving');
 
-  t.directory = 'chaos-king';
-  t.is(t.directory, 'chaos-king');
+  obj.directory = 'chaos-king';
+  t.equal(obj.directory, 'chaos-king');
 });
 
-test(`CacheableObject combined update & expose (basic transform)`, t => {
+t.test(`CacheableObject combined update & expose (basic transform)`, t => {
   const obj = newCacheableObject({
     getsRepeated: {
       flags: {
@@ -124,10 +120,10 @@ test(`CacheableObject combined update & expose (basic transform)`, t => {
   t.plan(1);
 
   obj.getsRepeated = 'dog';
-  t.is(obj.getsRepeated, 'dogdog');
+  t.equal(obj.getsRepeated, 'dogdog');
 });
 
-test(`CacheableObject combined update & expose (transform with dependency)`, t => {
+t.test(`CacheableObject combined update & expose (transform with dependency)`, t => {
   const obj = newCacheableObject({
     customRepeat: {
       flags: {
@@ -152,16 +148,16 @@ test(`CacheableObject combined update & expose (transform with dependency)`, t =
 
   obj.customRepeat = 'dog';
   obj.times = 1;
-  t.is(obj.customRepeat, 'dog');
+  t.equal(obj.customRepeat, 'dog');
 
   obj.times = 5;
-  t.is(obj.customRepeat, 'dogdogdogdogdog');
+  t.equal(obj.customRepeat, 'dogdogdogdogdog');
 
   obj.customRepeat = 'cat';
-  t.is(obj.customRepeat, 'catcatcatcatcat');
+  t.equal(obj.customRepeat, 'catcatcatcatcat');
 });
 
-test(`CacheableObject validate on update`, t => {
+t.test(`CacheableObject validate on update`, t => {
   const mockError = new TypeError(`Expected a string, not ${typeof value}`);
 
   const obj = newCacheableObject({
@@ -197,7 +193,7 @@ test(`CacheableObject validate on update`, t => {
   t.plan(6);
 
   obj.directory = 'megalovania';
-  t.is(obj.directory, 'megalovania');
+  t.equal(obj.directory, 'megalovania');
 
   try {
     obj.directory = 25;
@@ -205,13 +201,13 @@ test(`CacheableObject validate on update`, t => {
     thrownError = err;
   }
 
-  t.is(thrownError, mockError);
-  t.is(obj.directory, 'megalovania');
+  t.equal(thrownError, mockError);
+  t.equal(obj.directory, 'megalovania');
 
   const date = new Date(`25 December 2009`);
 
   obj.date = date;
-  t.is(obj.date, date);
+  t.equal(obj.date, date);
 
   try {
     obj.date = `TWELFTH PERIGEE'S EVE`;
@@ -219,11 +215,11 @@ test(`CacheableObject validate on update`, t => {
     thrownError = err;
   }
 
-  t.is(thrownError?.constructor, TypeError);
-  t.is(obj.date, date);
+  t.equal(thrownError?.constructor, TypeError);
+  t.equal(obj.date, date);
 });
 
-test(`CacheableObject default update property value`, t => {
+t.test(`CacheableObject default update property value`, t => {
   const obj = newCacheableObject({
     fruit: {
       flags: {
@@ -238,10 +234,10 @@ test(`CacheableObject default update property value`, t => {
   });
 
   t.plan(1);
-  t.is(obj.fruit, 'potassium');
+  t.equal(obj.fruit, 'potassium');
 });
 
-test(`CacheableObject default property throws if invalid`, t => {
+t.test(`CacheableObject default property throws if invalid`, t => {
   const mockError = new TypeError(`Expected a string, not ${typeof value}`);
 
   t.plan(1);
@@ -270,5 +266,5 @@ test(`CacheableObject default property throws if invalid`, t => {
     thrownError = err;
   }
 
-  t.is(thrownError, mockError);
+  t.equal(thrownError, mockError);
 });
diff --git a/test/things.js b/test/unit/data/things/track.js
index 0d74b60..0dad0e6 100644
--- a/test/things.js
+++ b/test/unit/data/things/track.js
@@ -1,11 +1,13 @@
-import test from 'tape';
+import t from 'tap';
 
-import {
+import thingConstructors from '../../../../src/data/things/index.js';
+
+const {
   Album,
   Thing,
   Track,
   TrackGroup,
-} from '../src/data/things.js';
+} = thingConstructors;
 
 function stubAlbum(tracks) {
   const album = new Album();
@@ -18,7 +20,7 @@ function stubAlbum(tracks) {
   return album;
 }
 
-test(`Track.coverArtDate`, t => {
+t.test(`Track.coverArtDate`, t => {
   t.plan(5);
 
   // Priority order is as follows, with the last (trackCoverArtDate) being
@@ -37,7 +39,7 @@ test(`Track.coverArtDate`, t => {
 
   // 1. coverArtDate defaults to null
 
-  t.is(track.coverArtDate, null);
+  t.equal(track.coverArtDate, null);
 
   // 2. coverArtDate inherits album release date
 
@@ -47,7 +49,7 @@ test(`Track.coverArtDate`, t => {
   track.albumData = [];
   track.albumData = [album];
 
-  t.is(track.coverArtDate, albumDate);
+  t.equal(track.coverArtDate, albumDate);
 
   // 3. coverArtDate inherits album trackArtDate
 
@@ -57,17 +59,17 @@ test(`Track.coverArtDate`, t => {
   track.albumData = [];
   track.albumData = [album];
 
-  t.is(track.coverArtDate, albumTrackArtDate);
+  t.equal(track.coverArtDate, albumTrackArtDate);
 
   // 4. coverArtDate is overridden dateFirstReleased
 
   track.dateFirstReleased = trackDateFirstReleased;
 
-  t.is(track.coverArtDate, trackDateFirstReleased);
+  t.equal(track.coverArtDate, trackDateFirstReleased);
 
   // 5. coverArtDate is overridden coverArtDate
 
   track.coverArtDate = trackCoverArtDate;
 
-  t.is(track.coverArtDate, trackCoverArtDate);
+  t.equal(track.coverArtDate, trackCoverArtDate);
 });
diff --git a/test/data-validators.js b/test/unit/data/things/validators.js
index f13f3f0..53cba06 100644
--- a/test/data-validators.js
+++ b/test/unit/data/things/validators.js
@@ -1,10 +1,11 @@
-import _test from 'tape';
-import { showAggregate } from '../src/util/sugar.js';
+import t from 'tap';
+import { showAggregate } from '../../../../src/util/sugar.js';
 
 import {
   // Basic types
   isBoolean,
   isCountingNumber,
+  isDate,
   isNumber,
   isString,
   isStringNonEmpty,
@@ -15,19 +16,25 @@ import {
   validateArrayItems,
 
   // Wiki data
+  isColor,
+  isCommentary,
+  isContribution,
+  isContributionList,
   isDimensions,
   isDirectory,
   isDuration,
   isFileExtension,
+  isName,
+  isURL,
   validateReference,
   validateReferenceList,
 
   // Compositional utilities
   oneOf,
-} from '../src/data/validators.js';
+} from '../../../../src/data/things/validators.js';
 
-function test(msg, fn) {
-  _test(msg, t => {
+function test(t, msg, fn) {
+  t.test(msg, t => {
     try {
       fn(t);
     } catch (error) {
@@ -39,11 +46,9 @@ function test(msg, fn) {
   });
 }
 
-test.skip = _test.skip;
-
 // Basic types
 
-test('isBoolean', t => {
+test(t, 'isBoolean', t => {
   t.plan(4);
   t.ok(isBoolean(true));
   t.ok(isBoolean(false));
@@ -51,7 +56,7 @@ test('isBoolean', t => {
   t.throws(() => isBoolean('yes'), TypeError);
 });
 
-test('isNumber', t => {
+test(t, 'isNumber', t => {
   t.plan(6);
   t.ok(isNumber(123));
   t.ok(isNumber(0.05));
@@ -61,7 +66,7 @@ test('isNumber', t => {
   t.throws(() => isNumber(true), TypeError);
 });
 
-test('isCountingNumber', t => {
+test(t, 'isCountingNumber', t => {
   t.plan(6);
   t.ok(isCountingNumber(3));
   t.ok(isCountingNumber(1));
@@ -71,14 +76,14 @@ test('isCountingNumber', t => {
   t.throws(() => isCountingNumber('612'), TypeError);
 });
 
-test('isString', t => {
+test(t, 'isString', t => {
   t.plan(3);
   t.ok(isString('hello!'));
   t.ok(isString(''));
   t.throws(() => isString(100), TypeError);
 });
 
-test('isStringNonEmpty', t => {
+test(t, 'isStringNonEmpty', t => {
   t.plan(4);
   t.ok(isStringNonEmpty('hello!'));
   t.throws(() => isStringNonEmpty(''), TypeError);
@@ -88,25 +93,28 @@ test('isStringNonEmpty', t => {
 
 // Complex types
 
-test('isArray', t => {
+test(t, '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(t, 'isDate', t => {
+  t.plan(3);
+  t.ok(isDate(new Date('2023-03-27 09:24:15')));
+  t.throws(() => isDate(new Date(Infinity)), TypeError);
+  t.throws(() => isDimensions('2023-03-27 09:24:15'), TypeError);
 });
 
-test('isObject', t => {
+test(t, 'isObject', t => {
   t.plan(3);
   t.ok(isObject({}));
   t.ok(isObject([]));
   t.throws(() => isObject(null), TypeError);
 });
 
-test('validateArrayItems', t => {
+test(t, 'validateArrayItems', t => {
   t.plan(6);
 
   t.ok(validateArrayItems(isNumber)([3, 4, 5]));
@@ -119,31 +127,56 @@ test('validateArrayItems', t => {
     caughtError = err;
   }
 
-  t.isNot(caughtError, null);
-  t.true(caughtError instanceof AggregateError);
-  t.is(caughtError.errors.length, 1);
-  t.true(caughtError.errors[0] instanceof TypeError);
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 1);
+  t.ok(caughtError.errors[0] instanceof TypeError);
 });
 
 // Wiki data
 
-test.skip('isColor', t => {
-  // TODO
+t.test('isColor', t => {
+  t.plan(9);
+  t.ok(isColor('#123'));
+  t.ok(isColor('#1234'));
+  t.ok(isColor('#112233'));
+  t.ok(isColor('#11223344'));
+  t.ok(isColor('#abcdef00'));
+  t.ok(isColor('#ABCDEF'));
+  t.throws(() => isColor('#ggg'), TypeError);
+  t.throws(() => isColor('red'), TypeError);
+  t.throws(() => isColor('hsl(150deg 30% 60%)'), TypeError);
 });
 
-test.skip('isCommentary', t => {
-  // TODO
+t.test('isCommentary', t => {
+  t.plan(6);
+  t.ok(isCommentary(`<i>Toby Fox:</i>\ndogsong.mp3`));
+  t.ok(isCommentary(`Technically, this works:</i>`));
+  t.ok(isCommentary(`<i><b>Whodunnit:</b></i>`));
+  t.throws(() => isCommentary(123), TypeError);
+  t.throws(() => isCommentary(``), TypeError);
+  t.throws(() => isCommentary(`<i><u>Toby Fox:</u></i>`));
 });
 
-test.skip('isContribution', t => {
-  // TODO
+t.test('isContribution', t => {
+  t.plan(4);
+  t.ok(isContribution({who: 'artist:toby-fox', what: 'Music'}));
+  t.ok(isContribution({who: 'Toby Fox'}));
+  t.throws(() => isContribution(({who: 'group:umspaf', what: 'Organizing'})),
+    {errors: /who/});
+  t.throws(() => isContribution(({who: 'artist:toby-fox', what: 123})),
+    {errors: /what/});
 });
 
-test.skip('isContributionList', t => {
-  // TODO
+t.test('isContributionList', t => {
+  t.plan(4);
+  t.ok(isContributionList([{who: 'Beavis'}, {who: 'Butthead', what: 'Wrangling'}]));
+  t.ok(isContributionList([]));
+  t.throws(() => isContributionList(2));
+  t.throws(() => isContributionList(['Charlie', 'Woodstock']));
 });
 
-test('isDimensions', t => {
+test(t, 'isDimensions', t => {
   t.plan(6);
   t.ok(isDimensions([1, 1]));
   t.ok(isDimensions([50, 50]));
@@ -153,7 +186,7 @@ test('isDimensions', t => {
   t.throws(() => isDimensions('800x200'), TypeError);
 });
 
-test('isDirectory', t => {
+test(t, 'isDirectory', t => {
   t.plan(6);
   t.ok(isDirectory('savior-of-the-waking-world'));
   t.ok(isDirectory('MeGaLoVania'));
@@ -163,7 +196,7 @@ test('isDirectory', t => {
   t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), TypeError);
 });
 
-test('isDuration', t => {
+test(t, 'isDuration', t => {
   t.plan(5);
   t.ok(isDuration(60));
   t.ok(isDuration(0.02));
@@ -172,7 +205,7 @@ test('isDuration', t => {
   t.throws(() => isDuration('10:25'), TypeError);
 });
 
-test('isFileExtension', t => {
+test(t, 'isFileExtension', t => {
   t.plan(6);
   t.ok(isFileExtension('png'));
   t.ok(isFileExtension('jpg'));
@@ -182,15 +215,23 @@ test('isFileExtension', t => {
   t.throws(() => isFileExtension('just an image bro!!!!'), TypeError);
 });
 
-test.skip('isName', t => {
-  // TODO
+t.test('isName', t => {
+  t.plan(4);
+  t.ok(isName('Dogz 2.0'));
+  t.ok(isName('album:this-track-is-only-named-thusly-to-give-niklink-a-headache'));
+  t.ok(isName(''));
+  t.throws(() => isName(612));
 });
 
-test.skip('isURL', t => {
-  // TODO
+t.test('isURL', t => {
+  t.plan(4);
+  t.ok(isURL(`https://hsmusic.wiki/foo/bar/hi?baz=25#hash`));
+  t.throws(() => isURL(`/the/dog/zone/`));
+  t.throws(() => isURL(25));
+  t.throws(() => isURL(new URL(`https://hsmusic.wiki/perfectly/reasonable/`)));
 });
 
-test('validateReference', t => {
+test(t, 'validateReference', t => {
   t.plan(16);
 
   const typeless = validateReference();
@@ -217,7 +258,7 @@ test('validateReference', t => {
   t.throws(() => typeless('album:undertale-soundtrack'));
 });
 
-test('validateReferenceList', t => {
+test(t, 'validateReferenceList', t => {
   const track = validateReferenceList('track');
   const artist = validateReferenceList('artist');
 
@@ -235,14 +276,14 @@ test('validateReferenceList', t => {
     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);
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 2);
+  t.ok(caughtError.errors[0] instanceof TypeError);
+  t.ok(caughtError.errors[1] instanceof TypeError);
 });
 
-test('oneOf', t => {
+test(t, 'oneOf', t => {
   t.plan(11);
 
   const isStringOrNumber = oneOf(isString, isNumber);
@@ -267,11 +308,11 @@ test('oneOf', t => {
     caughtError = err;
   }
 
-  t.isNot(caughtError, null);
-  t.true(caughtError instanceof AggregateError);
-  t.is(caughtError.errors.length, 2);
-  t.true(caughtError.errors[0] instanceof TypeError);
-  t.is(caughtError.errors[0].check, isString);
-  t.is(caughtError.errors[1], mockError);
-  t.is(caughtError.errors[1].check, neverSucceeds);
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 2);
+  t.ok(caughtError.errors[0] instanceof TypeError);
+  t.equal(caughtError.errors[0].check, isString);
+  t.equal(caughtError.errors[1], mockError);
+  t.equal(caughtError.errors[1].check, neverSucceeds);
 });
diff --git a/test/unit/util/html.js b/test/unit/util/html.js
new file mode 100644
index 0000000..82f96b4
--- /dev/null
+++ b/test/unit/util/html.js
@@ -0,0 +1,906 @@
+import t from 'tap';
+
+import * as html from '../../../src/util/html.js';
+const {Tag, Attributes, Template} = html;
+
+import {strictlyThrows} from '../../lib/strict-match-error.js';
+
+t.test(`html.tag`, t => {
+  t.plan(14);
+
+  const tag1 =
+    html.tag('div',
+      {[html.onlyIfContent]: true, foo: 'bar'},
+      'child');
+
+  // 1-5: basic behavior when passing attributes
+  t.ok(tag1 instanceof Tag);
+  t.ok(tag1.onlyIfContent);
+  t.equal(tag1.attributes.get('foo'), 'bar');
+  t.equal(tag1.content.length, 1);
+  t.equal(tag1.content[0], 'child');
+
+  const tag2 = html.tag('div', ['two', 'children']);
+
+  // 6-8: basic behavior when not passing attributes
+  t.equal(tag2.content.length, 2);
+  t.equal(tag2.content[0], 'two');
+  t.equal(tag2.content[1], 'children');
+
+  const genericTag = html.tag('div');
+  const genericTemplate = html.template({
+    content: () => html.blank(),
+  });
+
+  // 9-10: tag treated as content, not attributes
+  const tag3 = html.tag('div', genericTag);
+  t.equal(tag3.content.length, 1);
+  t.equal(tag3.content[0], genericTag);
+
+  // 11-12: template treated as content, not attributes
+  const tag4 = html.tag('div', genericTemplate);
+  t.equal(tag4.content.length, 1);
+  t.equal(tag4.content[0], genericTemplate);
+
+  // 13-14: deep flattening support
+  const tag6 =
+    html.tag('div', [
+      true &&
+        [[[[[[
+          true &&
+            [[[[[`That's deep.`]]]]],
+        ]]]]]],
+    ]);
+  t.equal(tag6.content.length, 1);
+  t.equal(tag6.content[0], `That's deep.`);
+});
+
+t.test(`Tag (basic interface)`, t => {
+  t.plan(11);
+
+  const tag1 = new Tag();
+
+  // 1-5: essential properties & no arguments provided
+  t.equal(tag1.tagName, '');
+  t.ok(Array.isArray(tag1.content));
+  t.equal(tag1.content.length, 0);
+  t.ok(tag1.attributes instanceof Attributes);
+  t.equal(tag1.attributes.toString(), '');
+
+  const tag2 = new Tag('div', {id: 'banana'}, ['one', 'two', tag1]);
+
+  // 6-11: properties on basic usage
+  t.equal(tag2.tagName, 'div');
+  t.equal(tag2.content.length, 3);
+  t.equal(tag2.content[0], 'one');
+  t.equal(tag2.content[1], 'two');
+  t.equal(tag2.content[2], tag1);
+  t.equal(tag2.attributes.get('id'), 'banana');
+});
+
+t.test(`Tag (self-closing)`, t => {
+  t.plan(10);
+
+  const tag1 = new Tag('br');
+  const tag2 = new Tag('div');
+  const tag3 = new Tag('div');
+  tag3.tagName = 'br';
+
+  // 1-3: selfClosing depends on tagName
+  t.ok(tag1.selfClosing);
+  t.notOk(tag2.selfClosing);
+  t.ok(tag3.selfClosing);
+
+  // 4: constructing self-closing tag with content throws
+  t.throws(() => new Tag('br', null, 'bananas'), /self-closing/);
+
+  // 5: setting content on self-closing tag throws
+  t.throws(() => { tag1.content = ['suspicious']; }, /self-closing/);
+
+  // 6-9: setting empty content on self-closing tag doesn't throw
+  t.doesNotThrow(() => { tag1.content = null; });
+  t.doesNotThrow(() => { tag1.content = undefined; });
+  t.doesNotThrow(() => { tag1.content = ''; });
+  t.doesNotThrow(() => { tag1.content = [null, '', false]; });
+
+  const tag4 = new Tag('div', null, 'bananas');
+
+  // 10: changing tagName to self-closing when tag has content throws
+  t.throws(() => { tag4.tagName = 'br'; }, /self-closing/);
+});
+
+t.test(`Tag (properties from attributes - from constructor)`, t => {
+  t.plan(6);
+
+  const tag = new Tag('div', {
+    [html.onlyIfContent]: true,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '<br>',
+  });
+
+  // 1-3: basic exposed properties from attributes in constructor
+  t.ok(tag.onlyIfContent);
+  t.ok(tag.noEdgeWhitespace);
+  t.equal(tag.joinChildren, '<br>');
+
+  // 4-6: property values stored on attributes with public symbols
+  t.equal(tag.attributes.get(html.onlyIfContent), true);
+  t.equal(tag.attributes.get(html.noEdgeWhitespace), true);
+  t.equal(tag.attributes.get(html.joinChildren), '<br>');
+});
+
+t.test(`Tag (properties from attributes - mutating)`, t => {
+  t.plan(12);
+
+  // 1-3: exposed properties reflect reasonable attribute values
+
+  const tag1 = new Tag('div', {
+    [html.onlyIfContent]: true,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '<br>',
+  });
+
+  tag1.attributes.set(html.onlyIfContent, false);
+  tag1.attributes.remove(html.noEdgeWhitespace);
+  tag1.attributes.set(html.joinChildren, '🍇');
+
+  t.equal(tag1.onlyIfContent, false);
+  t.equal(tag1.noEdgeWhitespace, false);
+  t.equal(tag1.joinChildren, '🍇');
+
+  // 4-6: exposed properties reflect unreasonable attribute values
+
+  const tag2 = new Tag('div', {
+    [html.onlyIfContent]: true,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '<br>',
+  });
+
+  tag2.attributes.set(html.onlyIfContent, '');
+  tag2.attributes.set(html.noEdgeWhitespace, 12345);
+  tag2.attributes.set(html.joinChildren, 0.0001);
+
+  t.equal(tag2.onlyIfContent, false);
+  t.equal(tag2.noEdgeWhitespace, true);
+  t.equal(tag2.joinChildren, '0.0001');
+
+  // 7-9: attribute values reflect reasonable mutated properties
+
+  const tag3 = new Tag('div', null, {
+    [html.onlyIfContent]: false,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '🍜',
+  })
+
+  tag3.onlyIfContent = true;
+  tag3.noEdgeWhitespace = false;
+  tag3.joinChildren = '🦑';
+
+  t.equal(tag3.attributes.get(html.onlyIfContent), true);
+  t.equal(tag3.attributes.get(html.noEdgeWhitespace), undefined);
+  t.equal(tag3.joinChildren, '🦑');
+
+  // 10-12: attribute values reflect unreasonable mutated properties
+
+  const tag4 = new Tag('div', null, {
+    [html.onlyIfContent]: false,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '🍜',
+  });
+
+  tag4.onlyIfContent = 'armadillo';
+  tag4.noEdgeWhitespace = 0;
+  tag4.joinChildren = Infinity;
+
+  t.equal(tag4.attributes.get(html.onlyIfContent), true);
+  t.equal(tag4.attributes.get(html.noEdgeWhitespace), undefined);
+  t.equal(tag4.attributes.get(html.joinChildren), 'Infinity');
+});
+
+t.test(`Tag.toString`, t => {
+  t.plan(9);
+
+  // 1: basic behavior
+
+  const tag1 =
+    html.tag('div', 'Content');
+
+  t.equal(tag1.toString(),
+    `<div>Content</div>`);
+
+  // 2: stringifies nested element
+
+  const tag2 =
+    html.tag('div', html.tag('p', 'Content'));
+
+  t.equal(tag2.toString(),
+    `<div><p>Content</p></div>`);
+
+  // 3: stringifies attributes
+
+  const tag3 =
+    html.tag('div',
+      {
+        id: 'banana',
+        class: ['foo', 'bar'],
+        contenteditable: true,
+        biggerthanabreadbox: false,
+        saying: `"To light a candle is to cast a shadow..."`,
+        tabindex: 413,
+      },
+      'Content');
+
+  t.equal(tag3.toString(),
+    `<div id="banana" class="foo bar" contenteditable ` +
+    `saying="&quot;To light a candle is to cast a shadow...&quot;" ` +
+    `tabindex="413">Content</div>`);
+
+  // 4: attributes match input order
+
+  const tag4 =
+    html.tag('div',
+      {class: ['foo', 'bar'], id: 'banana'},
+      'Content');
+
+  t.equal(tag4.toString(),
+    `<div class="foo bar" id="banana">Content</div>`);
+
+  // 5: multiline contented indented
+
+  const tag5 =
+    html.tag('div', 'foo\nbar');
+
+  t.equal(tag5.toString(),
+    `<div>\n` +
+    `    foo\n` +
+    `    bar\n` +
+    `</div>`);
+
+  // 6: nested multiline content double-indented
+
+  const tag6 =
+    html.tag('div', [
+      html.tag('p',
+        'foo\nbar'),
+      html.tag('span', `I'm on one line!`),
+    ]);
+
+  t.equal(tag6.toString(),
+    `<div>\n` +
+    `    <p>\n` +
+    `        foo\n` +
+    `        bar\n` +
+    `    </p>\n` +
+    `    <span>I'm on one line!</span>\n` +
+    `</div>`);
+
+  // 7: self-closing (with attributes)
+
+  const tag7 =
+    html.tag('article', [
+      html.tag('h1', `Title`),
+      html.tag('hr', {style: `color: magenta`}),
+      html.tag('p', `Shenanigans!`),
+    ]);
+
+  t.equal(tag7.toString(),
+    `<article>\n` +
+    `    <h1>Title</h1>\n` +
+    `    <hr style="color: magenta">\n` +
+    `    <p>Shenanigans!</p>\n` +
+    `</article>`);
+
+  // 8-9: empty tagName passes content through directly
+
+  const tag8 =
+    html.tag(null, [
+      html.tag('h1', `Foo`),
+      html.tag(`h2`, `Bar`),
+    ]);
+
+  t.equal(tag8.toString(),
+    `<h1>Foo</h1>\n` +
+    `<h2>Bar</h2>`);
+
+  const tag9 =
+    html.tag(null, {
+      [html.joinChildren]: html.tag('br'),
+    }, [
+      `Say it with me...`,
+      `Supercalifragilisticexpialidocious!`
+    ]);
+
+  t.equal(tag9.toString(),
+    `Say it with me...\n` +
+    `<br>\n` +
+    `Supercalifragilisticexpialidocious!`);
+});
+
+t.test(`Tag.toString (onlyIfContent)`, t => {
+  t.plan(4);
+
+  // 1-2: basic behavior
+
+  const tag1 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      `Hello!`);
+
+  t.equal(tag1.toString(),
+    `<div>Hello!</div>`);
+
+  const tag2 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      '');
+
+  t.equal(tag2.toString(),
+    '');
+
+  // 3-4: nested onlyIfContent with "more" content
+
+  const tag3 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      [
+        '',
+        0,
+        html.tag('h1',
+          {[html.onlyIfContent]: true},
+          html.tag('strong',
+            {[html.onlyIfContent]: true})),
+        null,
+        false,
+      ]);
+
+  t.equal(tag3.toString(),
+    '');
+
+  const tag4 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      [
+        '',
+        0,
+        html.tag('h1',
+          {[html.onlyIfContent]: true},
+          html.tag('strong')),
+        null,
+        false,
+      ]);
+
+  t.equal(tag4.toString(),
+    `<div><h1><strong></strong></h1></div>`);
+});
+
+t.test(`Tag.toString (joinChildren, noEdgeWhitespace)`, t => {
+  t.plan(6);
+
+  // 1: joinChildren: default (\n), noEdgeWhitespace: true
+
+  const tag1 =
+    html.tag('div',
+      {[html.noEdgeWhitespace]: true},
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag1.toString(),
+    `<div>Foo\n` +
+    `    Bar\n` +
+    `    Baz</div>`);
+
+  // 2: joinChildren: one-line string, noEdgeWhitespace: default (false)
+
+  const tag2 =
+    html.tag('div',
+      {
+        [html.joinChildren]:
+          html.tag('br', {location: '🍍'}),
+      },
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag2.toString(),
+    `<div>\n` +
+    `    Foo\n` +
+    `    <br location="🍍">\n` +
+    `    Bar\n` +
+    `    <br location="🍍">\n` +
+    `    Baz\n` +
+    `</div>`);
+
+  // 3-4: joinChildren: blank string, noEdgeWhitespace: default (false)
+
+  const tag3 =
+    html.tag('div',
+      {[html.joinChildren]: ''},
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag3.toString(),
+    `<div>FooBarBaz</div>`);
+
+  const tag4 =
+    html.tag('div',
+      {[html.joinChildren]: ''},
+      [
+        `Ain't I\na cute one?`,
+        `~`
+      ]);
+
+  t.equal(tag4.toString(),
+    `<div>\n` +
+    `    Ain't I\n` +
+    `    a cute one?~\n` +
+    `</div>`);
+
+  // 5: joinChildren: one-line string, noEdgeWhitespace: true
+
+  const tag5 =
+    html.tag('div',
+      {
+        [html.joinChildren]: html.tag('br'),
+        [html.noEdgeWhitespace]: true,
+      },
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag5.toString(),
+    `<div>Foo\n` +
+    `    <br>\n` +
+    `    Bar\n` +
+    `    <br>\n` +
+    `    Baz</div>`);
+
+  // 6: joinChildren: empty string, noEdgeWhitespace: true
+
+  const tag6 =
+    html.tag('span',
+      {
+        [html.joinChildren]: '',
+        [html.noEdgeWhitespace]: true,
+      },
+      [
+        html.tag('i', `Oh yes~ `),
+        `You're a cute one`,
+        html.tag('sup', `💕`),
+      ]);
+
+  t.equal(tag6.toString(),
+    `<span><i>Oh yes~ </i>You're a cute one<sup>💕</sup></span>`);
+
+});
+
+t.test(`Tag.toString (custom attributes)`, t => {
+  t.plan(1);
+
+  t.test(`Tag.toString (custom attribute: href)`, t => {
+    t.plan(2);
+
+    const tag1 = html.tag('a', {href: `https://hsmusic.wiki/`});
+    t.equal(tag1.toString(), `<a href="https://hsmusic.wiki/"></a>`);
+
+    const tag2 = html.tag('a', {href: `https://hsmusic.wiki/media/Album Booklet.pdf`});
+    t.equal(tag2.toString(), `<a href="https://hsmusic.wiki/media/Album%20Booklet.pdf"></a>`);
+  });
+});
+
+t.test(`html.template`, t => {
+  t.plan(11);
+
+  let contentCalls;
+
+  // 1-4: basic behavior - no slots
+
+  contentCalls = 0;
+
+  const template1 = html.template({
+    content() {
+      contentCalls++;
+      return html.tag('hr');
+    },
+  });
+
+  t.equal(contentCalls, 0);
+  t.equal(template1.toString(), `<hr>`);
+  t.equal(contentCalls, 1);
+  template1.toString();
+  t.equal(contentCalls, 2);
+
+  // 5-10: basic behavior - slots
+
+  contentCalls = 0;
+
+  const template2 = html.template({
+    slots: {
+      foo: {
+        type: 'string',
+        default: 'Default Message',
+      },
+    },
+
+    content(slots) {
+      contentCalls++;
+      return html.tag('sub', slots.foo.toLowerCase());
+    },
+  });
+
+  t.equal(contentCalls, 0);
+  t.equal(template2.toString(), `<sub>default message</sub>`);
+  t.equal(contentCalls, 1);
+  template2.setSlot('foo', `R-r-really, me?`);
+  t.equal(contentCalls, 1);
+  t.equal(template2.toString(), `<sub>r-r-really, me?</sub>`);
+  t.equal(contentCalls, 2);
+
+  // 11: slot uses default only for null, not falsey
+
+  const template3 = html.template({
+    slots: {
+      slot1: {type: 'number', default: 123},
+      slot2: {type: 'number', default: 456},
+      slot3: {type: 'boolean', default: true},
+      slot4: {type: 'string', default: 'banana'},
+    },
+
+    content(slots) {
+      return html.tag('span', [
+        slots.slot1,
+        slots.slot2,
+        slots.slot3,
+        `(length: ${slots.slot4.length})`,
+      ].join(' '));
+    },
+  });
+
+  template3.setSlots({
+    slot1: null,
+    slot2: 0,
+    slot3: false,
+    slot4: '',
+  });
+
+  t.equal(template3.toString(), `<span>123 0 false (length: 0)</span>`);
+});
+
+t.test(`Template - description errors`, t => {
+  t.plan(14);
+
+  // 1-3: top-level description is object
+
+  strictlyThrows(t,
+    () => Template.validateDescription('snooping as usual'),
+    new TypeError(`Expected object, got string`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription(),
+    new TypeError(`Expected object, got undefined`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription(null),
+    new TypeError(`Expected object, got null`));
+
+  // 4-5: description.content is function
+
+  strictlyThrows(t,
+    () => Template.validateDescription({}),
+    new AggregateError([
+      new TypeError(`Expected description.content`),
+    ], `Errors validating template description`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      content: 'pingas',
+    }),
+    new AggregateError([
+      new TypeError(`Expected description.content to be function`),
+    ], `Errors validating template description`));
+
+  // 6: aggregate error includes template annotation
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      annotation: `my cool template`,
+      content: 'pingas',
+    }),
+    new AggregateError([
+      new TypeError(`Expected description.content to be function`),
+    ], `Errors validating template "my cool template" description`));
+
+  // 7: description.slots is object
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: 'pingas',
+      content: () => {},
+    }),
+    new AggregateError([
+      new TypeError(`Expected description.slots to be object`),
+    ], `Errors validating template description`));
+
+  // 8: slot description is object
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: 'pingas',
+      },
+
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Expected slot description to be object`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`))
+
+  // 9-10: slot description has validate or default, not both
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {},
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Expected either slot validate or type`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {
+          validate: 'pingas',
+          type: 'pingas',
+        },
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Don't specify both slot validate and type`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 11: slot validate is function
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {
+          validate: 'pingas',
+        },
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Expected slot validate to be function`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 12: slot type is name of built-in type
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {
+          type: 'pingas',
+        },
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        /\(mySlot\) Expected slot type to be one of/,
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 13: slot type has specific errors for function & object
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        slot1: {type: 'function'},
+        slot2: {type: 'object'},
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(slot1) Functions shouldn't be provided to slots`),
+        new TypeError(`(slot2) Provide validate function instead of type: object`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 14: all intended types are supported
+
+  t.doesNotThrow(
+    () => Template.validateDescription({
+      slots: {
+        slot1: {type: 'string'},
+        slot2: {type: 'number'},
+        slot3: {type: 'bigint'},
+        slot4: {type: 'boolean'},
+        slot5: {type: 'symbol'},
+        slot6: {type: 'html'},
+      },
+      content: () => {},
+    }));
+});
+
+t.test(`Template - slot value errors`, t => {
+  t.plan(8);
+
+  const template1 = html.template({
+    slots: {
+      basicString: {type: 'string'},
+      basicNumber: {type: 'number'},
+      basicBigint: {type: 'bigint'},
+      basicBoolean: {type: 'boolean'},
+      basicSymbol: {type: 'symbol'},
+      basicHTML: {type: 'html'},
+    },
+
+    content: slots =>
+      html.tag('p', [
+        `string: ${slots.basicString}`,
+        `number: ${slots.basicNumber}`,
+        `bigint: ${slots.basicBigint}`,
+        `boolean: ${slots.basicBoolean}`,
+        `symbol: ${slots.basicSymbol?.toString()   ?? 'no symbol'}`,
+
+        `html:`,
+        slots.basicHTML,
+      ]),
+  });
+
+  // 1-2: basic values match type, no error & reflected in content
+
+  t.doesNotThrow(
+    () => template1.setSlots({
+      basicString: 'pingas',
+      basicNumber: 123,
+      basicBigint: 1234567891234567n,
+      basicBoolean: true,
+      basicSymbol: Symbol(`sup`),
+      basicHTML: html.tag('span', `SnooPING AS usual, I see!`),
+    }));
+
+  t.equal(
+    template1.toString(),
+    html.tag('p', [
+      `string: pingas`,
+      `number: 123`,
+      `bigint: 1234567891234567`,
+      `boolean: true`,
+      `symbol: Symbol(sup)`,
+      `html:`,
+      html.tag('span', `SnooPING AS usual, I see!`),
+    ]).toString());
+
+  // 3-4: null matches any type, no error & reflected in content
+
+  t.doesNotThrow(
+    () => template1.setSlots({
+      basicString: null,
+      basicNumber: null,
+      basicBigint: null,
+      basicBoolean: null,
+      basicSymbol: null,
+      basicHTML: null,
+    }));
+
+  t.equal(
+    template1.toString(),
+    html.tag('p', [
+      `string: null`,
+      `number: null`,
+      `bigint: null`,
+      `boolean: null`,
+      `symbol: no symbol`,
+      `html:`,
+    ]).toString());
+
+  // 5-6: type mismatch throws error, invalidates entire setSlots call
+
+  template1.setSlots({
+    basicString: 'pingas',
+    basicNumber: 123,
+  });
+
+  strictlyThrows(t,
+    () => template1.setSlots({
+      basicBoolean: false,
+      basicSymbol: `I'm not a symbol!`,
+    }),
+    new AggregateError([
+      new TypeError(`(basicSymbol) Slot expects symbol, got string`),
+    ], `Error validating template slots`))
+
+  t.equal(
+    template1.toString(),
+    html.tag('p', [
+      `string: pingas`,
+      `number: 123`,
+      `bigint: null`,
+      `boolean: null`,
+      `symbol: no symbol`,
+      `html:`,
+    ]).toString());
+
+  const template2 = html.template({
+    slots: {
+      arrayOfStrings: {
+        validate: v => v.arrayOf(v.isString),
+        default: `Array Of Strings Fallback`.split(' '),
+      },
+
+      arrayOfHTML: {
+        validate: v => v.arrayOf(v.isHTML),
+        default: [],
+      },
+    },
+
+    content: slots =>
+      html.tag('p', [
+        html.tag('strong', slots.arrayOfStrings),
+        `arrayOfHTML length: ${slots.arrayOfHTML.length}`,
+      ]),
+  });
+
+  // 7: isHTML behaves as it should, validate fails with validate throw
+
+  strictlyThrows(t,
+    () => template2.setSlots({
+      arrayOfStrings: ['you got it', 'pingas', 0xdeadbeef],
+      arrayOfHTML: [
+        html.tag('span'),
+        html.template({content: () => 'dog'}),
+        html.blank(),
+        false && 'dogs',
+        null,
+        undefined,
+        html.tags([
+          html.tag('span', 'usual'),
+          html.tag('span', 'i'),
+        ]),
+      ],
+    }),
+    new AggregateError([
+      {
+        name: 'AggregateError',
+        message: /^\(arrayOfStrings\)/,
+        errors: {length: 1},
+      },
+    ], `Error validating template slots`));
+
+  // 8: default slot values respected
+
+  t.equal(
+    template2.toString(),
+    html.tag('p', [
+      html.tag('strong', [
+        `Array`,
+        `Of`,
+        `Strings`,
+        `Fallback`,
+      ]),
+      `arrayOfHTML length: 0`,
+    ]).toString());
+});