« 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--LICENSE.txt2
-rw-r--r--README.md129
-rw-r--r--package-lock.json2895
-rw-r--r--package.json14
-rw-r--r--src/content/dependencies/generateAdditionalFilesShortcut.js27
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js72
-rw-r--r--src/content/dependencies/generateAlbumCommentarySidebar.js47
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js6
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js29
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js77
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNav.js11
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js27
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js6
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js4
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js2
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js4
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js8
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js12
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js5
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js6
-rw-r--r--src/content/dependencies/generateChronologyLinks.js40
-rw-r--r--src/content/dependencies/generateChronologyLinksScopeSwitcher.js67
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js2
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js20
-rw-r--r--src/content/dependencies/generateCommentarySection.js17
-rw-r--r--src/content/dependencies/generateContentHeading.js23
-rw-r--r--src/content/dependencies/generateCoverArtwork.js19
-rw-r--r--src/content/dependencies/generateFlashCoverArtwork.js24
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js8
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js42
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js25
-rw-r--r--src/content/dependencies/generateGroupSecondaryNav.js18
-rw-r--r--src/content/dependencies/generateGroupSidebar.js25
-rw-r--r--src/content/dependencies/generateListingPage.js72
-rw-r--r--src/content/dependencies/generateListingSidebar.js16
-rw-r--r--src/content/dependencies/generatePageLayout.js136
-rw-r--r--src/content/dependencies/generatePageSidebar.js79
-rw-r--r--src/content/dependencies/generatePageSidebarBox.js8
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js57
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js15
-rw-r--r--src/content/dependencies/generateTrackChronologyLinks.js166
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js152
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js155
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js28
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js8
-rw-r--r--src/content/dependencies/generateWikiHomeNewsBox.js2
-rw-r--r--src/content/dependencies/generateWikiHomePage.js3
-rw-r--r--src/content/dependencies/image.js21
-rw-r--r--src/content/dependencies/linkContribution.js14
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js2
-rw-r--r--src/content/dependencies/listArtistsByDuration.js12
-rw-r--r--src/content/dependencies/listArtistsByGroup.js152
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js3
-rw-r--r--src/content/dependencies/listTracksByDate.js3
-rw-r--r--src/content/dependencies/transformContent.js11
-rw-r--r--src/content/util/getChronologyRelations.js26
-rw-r--r--src/content/util/groupTracksByGroup.js23
-rw-r--r--src/data/cacheable-object.js50
-rw-r--r--src/data/checks.js42
-rw-r--r--src/data/composite.js216
-rw-r--r--src/data/composite/things/album/index.js2
-rw-r--r--src/data/composite/things/album/withTrackSections.js116
-rw-r--r--src/data/composite/things/album/withTracks.js46
-rw-r--r--src/data/composite/things/track-section/index.js1
-rw-r--r--src/data/composite/things/track-section/withAlbum.js22
-rw-r--r--src/data/composite/things/track/index.js1
-rw-r--r--src/data/composite/things/track/inheritFromOriginalRelease.js51
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js31
-rw-r--r--src/data/composite/things/track/withContainingTrackSection.js1
-rw-r--r--src/data/composite/things/track/withPropertyFromOriginalRelease.js86
-rw-r--r--src/data/composite/wiki-data/index.js2
-rw-r--r--src/data/composite/wiki-data/withDirectory.js55
-rw-r--r--src/data/composite/wiki-data/withDirectoryFromName.js42
-rw-r--r--src/data/composite/wiki-data/withResolvedContribs.js22
-rw-r--r--src/data/composite/wiki-data/withReverseContributionList.js14
-rw-r--r--src/data/composite/wiki-data/withReverseReferenceList.js10
-rw-r--r--src/data/composite/wiki-data/withUniqueReferencingThing.js5
-rw-r--r--src/data/composite/wiki-properties/contributionList.js10
-rw-r--r--src/data/composite/wiki-properties/directory.js48
-rw-r--r--src/data/composite/wiki-properties/referenceList.js20
-rw-r--r--src/data/serialize.js5
-rw-r--r--src/data/thing.js43
-rw-r--r--src/data/things/album.js290
-rw-r--r--src/data/things/flash.js10
-rw-r--r--src/data/things/index.js7
-rw-r--r--src/data/things/language.js206
-rw-r--r--src/data/things/track.js9
-rw-r--r--src/data/things/wiki-info.js21
-rw-r--r--src/data/validators.js96
-rw-r--r--src/data/yaml.js878
-rw-r--r--src/gen-thumbs.js135
-rw-r--r--src/import-heck.js9
-rw-r--r--src/listing-spec.js2
-rw-r--r--src/search.js119
-rw-r--r--src/static/css/site-basic.css (renamed from src/static/site-basic.css)0
-rw-r--r--src/static/css/site.css (renamed from src/static/site6.css)420
-rw-r--r--src/static/js/client.js (renamed from src/static/client3.js)1584
-rw-r--r--src/static/js/lazy-loading.js (renamed from src/static/lazy-loading.js)0
-rw-r--r--src/static/js/module-import-shims.js27
-rw-r--r--src/static/js/search-worker.js620
-rw-r--r--src/static/js/xhr-util.js64
-rw-r--r--src/static/misc/icons.svg (renamed from src/static/icons.svg)0
-rw-r--r--src/static/misc/warning.svg (renamed from src/static/warning.svg)0
-rw-r--r--src/static/shared-util/README.md11
-rw-r--r--src/strings-default.yaml90
-rwxr-xr-xsrc/upd8.js1021
-rw-r--r--src/url-spec.js62
-rw-r--r--src/util/aggregate.js110
-rw-r--r--src/util/cli.js73
-rw-r--r--src/util/colors.js2
-rw-r--r--src/util/external-links.js12
-rw-r--r--src/util/html.js98
-rw-r--r--src/util/search-spec.js256
-rw-r--r--src/util/serialize.js6
-rw-r--r--src/util/sort.js3
-rw-r--r--src/util/sugar.js58
-rw-r--r--src/web-routes.js120
-rw-r--r--src/write/bind-utilities.js2
-rw-r--r--src/write/build-modes/live-dev-server.js102
-rw-r--r--src/write/build-modes/repl.js8
-rw-r--r--src/write/build-modes/static-build.js87
-rw-r--r--tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs14
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs4
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs8
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs12
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs6
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs28
-rw-r--r--tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs4
-rw-r--r--tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs2
-rw-r--r--tap-snapshots/test/snapshot/image.js.test.cjs14
-rw-r--r--tap-snapshots/test/snapshot/linkContribution.js.test.cjs96
-rw-r--r--test/lib/content-function.js18
-rw-r--r--test/lib/wiki-data.js17
-rw-r--r--test/snapshot/generateAdditionalFilesShortcut.js36
-rw-r--r--test/snapshot/generateAlbumCoverArtwork.js1
-rw-r--r--test/snapshot/generateAlbumReleaseInfo.js14
-rw-r--r--test/snapshot/generateAlbumSecondaryNav.js2
-rw-r--r--test/snapshot/generateAlbumTrackList.js7
-rw-r--r--test/snapshot/generateTrackCoverArtwork.js2
-rw-r--r--test/snapshot/generateTrackReleaseInfo.js4
-rw-r--r--test/snapshot/image.js13
-rw-r--r--test/snapshot/linkContribution.js20
-rw-r--r--test/unit/content/dependencies/linkContribution.js54
-rw-r--r--test/unit/data/cacheable-object.js2
-rw-r--r--test/unit/data/composite/things/track/withAlbum.js40
-rw-r--r--test/unit/data/things/album.js205
-rw-r--r--test/unit/data/things/art-tag.js19
-rw-r--r--test/unit/data/things/track.js165
-rw-r--r--test/unit/data/things/validators.js16
151 files changed, 9658 insertions, 3609 deletions
diff --git a/LICENSE.txt b/LICENSE.txt
index 58729853..0eb3a676 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,4 +1,4 @@
-Copyright 2019-2023 Quasar Nebula et al <qznebula@protonmail.com>
+Copyright 2019-2024 Quasar Nebula et al <qznebula@protonmail.com>
 
 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
 
diff --git a/README.md b/README.md
index 06480dee..7f354714 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,11 @@
 # HSMusic
 
-HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagining of [earlier][fandom] [projects][nsnd] archiving and celebrating the expansive history of Homestuck official and fan music. Roughly periodic releases of the website are released at [hsmusic.wiki][hsmusic]; all development occurs in this public Git repository, which can be accessed at [github.com][github].
+HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagining of [earlier][fandom] [projects][nsnd] archiving and celebrating the expansive history of Homestuck official and fan music. Roughly periodic releases of the website are released at [hsmusic.wiki][hsmusic]; all development occurs in a few different public Git repositories:
+
+- hsmusic-wiki ([GitHub][github-code], [Notabug][notabug-code], [cgit][cgit-code]): all the code used to run hsmusic on your own computer, and the canonical reference for all of the wiki's software behavior
+- hsmusic-data ([GitHub][github-data], [Notabug][notabug-data], [cgit][cgit-data]): all the data representing the contents of the wiki; collaborative additions and improvements to wiki content all end up here
+- hsmusic-media ([GitHub][github-media]): media files referenced by content in the data repository; includes all album assets, commentary images, additional files, etc
+- hsmusic-lang ([GitHub][github-lang]): localization files for presenting the wiki's user interface in different languages
 
 ## Quick Start
 
@@ -9,7 +14,7 @@ Install dependencies:
 - [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 20.x LTS should also work
 - [ImageMagick](https://imagemagick.org/) - check your package manager if it's available (e.g. apt or homebrew) or follow [installation info right from the official website](https://imagemagick.org/script/download.php)
 
-Make a new empty folder for storing all your HSMusic repositories, then clone 'em with git:
+Make a new empty folder for storing all your wiki repositories, then clone 'em with git:
 
 ```
 $ cd /path/to/my/projects/
@@ -40,7 +45,7 @@ $ npm link
 # working out the details yourself), you can just move on.
 ```
 
-Go back to the main directory (containing all the repos) and make an empty folder for the first and subsequent builds:
+Go back to the main directory (containing all the repos) and make a couple of empty folders that will be useful during builds:
 
 ```
 $ cd ..
@@ -53,131 +58,85 @@ code/  data/  media/
 # Just do cd /path/to/my/projects/hsmusic (with whatever path you created
 # the main directory in) to get back.
 
+$ mkdir cache
 $ mkdir out
 ```
 
-Then build the site:
+**Anytime a command shows `hsmusic` in the following examples,** if your `npm link` command didn't work or you get a "command not found" error, you can just replace `hsmusic` with `node code`.
 
-```
-# If you used npm link:
-$ hsmusic --data-path data --media-path media --out-path out
+The wiki uses thumbnails, but these aren't included in the media repository you downloaded. The wiki will automatically generate new thumbnails as you add them to the media repository (as part of each build), but the first time, you should just generate the thumbnails.
 
-# If you didn't:
-$ node code/src/upd8.js --data-path data --media-path media --out-path out
+```
+$ hsmusic --data-path data --media-path media --cache-path cache --thumbs-only
 ```
 
-You should get a bunch of info eventually showing the site building! It may take a while (especially since HSMusic has a lot of data nowadays).
+Provided you've got ImageMagick installed, this should go more or less error-free, although it may take a while (for the Homestuck Music Wiki, typically 40-80 minutes). It may fail to generate a few thumbnails, and will show an error message, if so. Just run the command again, and they should work the second time around.
 
-If all goes according to plan and there aren't any errors, all the site HTML should have been written to the `out` directory. Use a simple HTTP server to view it in your browser:
+Then build the site. There are two methods for this. **If you're publishing to the web** (or just want a complete, static build of the site for your own purposes), use `--static-build`, as below:
 
 ```
-$ cd site
-
-# choose your favorite HTTP server
-$ npx http-server -p 8002
-$ python3 -m http.server 8002
-$ python2 -m SimpleHTTPServer 8002
+$ hsmusic --static-build --data-path data --media-path media --cache-path cache --out-path out
 ```
 
-If you don't have access to an HTTP server or lack device permissions to run one, you can also just view the generated HTML files in your browser and *most* features should still work. (Try `--append-index-html` in the `hsmusic`/`upd8.js` command to make generated links more direct.) This isn't an officially supported way to develop, so there might be bugs, but most of the site should still work.
-
-**If you encounter any errors along the way, or would like help getting the wiki working,** please feel welcomed to reach out through the [HSMusic Community Discord Server][discord]. We're a fairly active group there and are always happy to help! **This also applies if you don't have much experience with Git, GitHub, Node, or any of the necessary tooling, and want help getting used to them.**
+The site's contents will generate in the specified `out` folder. For the Homestuck Music Wiki, this generally takes around 40-60 minutes. You can upload these to a web server if you'd like to publish the site online. Or run your HTTP server of choice (`npx http-server -p 8002`, `python3 -m http.server 8002`) to view the build locally.
 
-## Project Structure
+**If you're testing out your changes** (for example, before filing a pull request), use `--live-dev-server`, as below:
 
-### General build process
+```
+$ hsmusic --live-dev-server --data-path data --media-path media --cache-path cache
+```
 
-When you run HSMusic to build the wiki, several processes happen in succession. Any errors along the way will be reported - we hope with human-readable feedback, but [pop by the Discord][discord] if you have any questions or need help understanding errors or parts of the code.
+Once initial loading is complete (usually 8-16 seconds), the site will generate pages *as you open them in your web browser.* Open http://localhost:8002/ when hsmusic instructs you to. You have to restart the server to refresh its data and see any data changes you've saved; hold control and press C (^C) to cancel the build, then run the command again to restart the server.
 
-1. Update thumbnails in the media repo so that any new images automatically get thumbnails.
-2. Locate and read data files, processing them into relatively usable JS object-style formats.
-3. Validate the data and show any errors caught during processing.
-4. Create symlinks for static files and generate the basic directory structure for the site.
-5. Generate and write HTML files (and any supporting files) containing all content.
+### Help! It's not working
 
-### Multiple repositories
+**If you encounter any errors along the way, or would like help getting the wiki working,** please feel welcomed to reach out through the [HSMusic Community Discord Server][discord]. We're a fairly active group there and are always happy to help! **This also applies if you don't have much experience with Git, GitHub, Node, or any of the necessary tooling, and want help getting used to them.**
 
-HSMusic works using a number of repositories in tandem:
+### Building without writing `--data-path` (etc) every time
 
-- [`hsmusic-wiki`][github-code] (colloquially "code"): The code repository, including all behavior required to process data and content from the other repositories and turn it into an actual website. This is probably the repo you're viewing right now.
-  - Code is written entirely in modern JavaScript, with the actual website a static combination of HTML and CSS (with inexhaustive JavaScript for certain features).
-  - More details about the code repository below.
-- [`hsmusic-data`][github-data]: The data repository, comprising all the data which makes a given wiki what it *is*. The repository linked here is for the [Homestuck Music Wiki][hsmusic] itself, but it may be swapped out for other data repos to build other completely different wikis.
-  - This repo covers albums, tracks, artists, groups, and a variety of other things which make up the content of a music wiki.
-  - The data repo also contains all the metadata which makes one wiki unique from another: layout info, static pages (like "About & Credits"), whether or not certain site features are enabled (like "Flashes & Games" or UI for browsing groups), and so on.
-  - All data is written and accessed in the YAML format, and every file follows a specific structure described within this (code) repository. See below and the `src/data` directory for details.
-- [`hsmusic-media`][github-media]: The media repository, holding all album, track, and layout media used across the site in one place. Media and organization directly corresponds to entries in the data repository; generally the data and media repositories go together and are swapped out for another together.
-- [`hsmusic-lang`][github-lang]: The language repository, holding up-to-date strings and other localization info for HSMusic.
-  - Strings and language info are stored in top-level YAML files within this repository. They're based off the `src/strings-default.yaml` file within the code repo (and don't need to provide translations for all strings to be used for site building).
+(These specific instructions apply only for bash and zsh. If you're using another shell, e.g. on Windows, you can probably adapt the principles, but we don't have a ready-to-go script, yet. Sorry!)
 
-The code repository as well as the data and media repositories are require for site building, with the language repo optionally provided to add localization support to the wiki build.
+It can be mildly inconvenient to write (or remember to write, or copy-paste) the `--data-path data` option, and similar options, every time. hsmusic will also detect and use environment variables for these; if you specify them this way, you don't need to provide the corresponding command line options.
 
-The path to each repo may be specified respectively by the `--data-path`, `--media-path`, and `--lang-path` arguments (when building the site or using e.g. data-related CLI tools). If you find it inconvenient to type or keep track of these values, you may alternatively set environment variables `HSMUSIC_DATA`, `HSMUSIC_MEDIA`, and `HSMUSIC_LANG` to provide the same values. One convenient layout for locally organizing the HSMusic repositories is shown below:
+Suppose you've locally organized your wiki repositories as below:
 
     path/to/my/projects/
       hsmusic/
+        cache/  <empty directory, or cached generated files>
         code/   <clone of hsmusic-wiki>
         data/   <clone of hsmusic-data>
         media/  <clone of hsmusic-media>
-        out/    <empty directory> (will be overwritten)
-        env.sh
+        out/    <empty directory, or a static build>
 
-The `env.sh` script shown above is a straightforward utility for loading those variables into the envronment, so you don't need to type path arguments every time:
+Create an `env.sh` file inside the top-level `hsmusic` folder, containing `data`, `media`, etc. If your shell is **bash,** enter these contents:
 
     #!/bin/bash
     base="$(realpath "$(dirname ${BASH_SOURCE[0]})")"
+    export HSMUSIC_CACHE="$base/cache/"
     export HSMUSIC_DATA="$base/data/"
     export HSMUSIC_MEDIA="$base/media/"
-    # export HSMUSIC_LANG="$base/lang/" # uncomment if present
     export HSMUSIC_OUT="$base/out/"
 
-Then use `source env.sh` when starting work from the CLI to get access to all the convenient environment variables. (This setup is written for Bash of course, but you can use the same idea to export env variables with your own shell's syntax.)
-
-### Code repository source structure
+If your shell is **zsh,** enter these contents:
 
-The source code for HSMusic is divided across a number of source files, loosely grouped together in a number of directories:
-
-- `src/`
-  - `data/`
-    - `things/`: Descriptors for individual types of data objects used across the wiki, notably including:
-      - `cacheable-object.js`: Backbone of how data objects (colloquially "things") store, share, and compute their properties
-      - `thing.js`: Common superclass for most data objects, with a bunch of utilities and common behavior
-      - `validators.js`: Convenient error-throwing utilities which help ensure properties set on data objects follow the right format
-    - `yaml.js`: Mappings from YAML documents (the format used in `hsmusic-data`) to things (actual data objects), and a full suite of utilities used to actually load that data from scratch
-  - `content/`: Functions which generate HTML content; these go from bite-sized, commonly reused utilities (like `linkTemplate`) all the way up to entire page definitions (like `generateArtistInfoPage`)
-  - `page/`: Definitions for page paths, mapping data objects to paths served over an HTTP server (or written to an output folder) and to the functions which actually generate those pages' content
-  - `write/`: Common utilities and output methods for controlling what hsmusic does to turn data and media into something you actually visit; these each define a variety of command-line arguments and are basically the interchangeable  "second half" of upd8.js
-    - `live-dev-server.js`: Gets the site available for viewing as quickly as possible, generating and serving pages as they are requested from a local HTTP server; reacts live to code changes in the `content` directory
-    - `static-build.js`: Builds the entire site at once, writing all the output to one self-contained folder which can be uploaded to a static file server
-    - `repl.js`: Provides a convenient REPL to run filters and transformations on data objects right from the Node.js command line
-  - `static/`: Purely client-side files are kept here, e.g. site CSS, icon SVGs, and client-side JS
-  - `util/`: Common utilities which generally may be accessed from both Node.js or the client (web browser)
-  - `upd8.js`: Main entry point which controls and directs site generation from start to finish
-  - `gen-thumbs.js`: Standalone utility also called every time HSMusic is run (unless `--skip-thumbs` is provided) which keeps a persistent cache of media MD5s and (re)generates thumbnails for new or updated image files
-  - `listing-spec.js`: Descriptors for computations and HTML templates used for the Listings part of the site
-  - `url-spec.js`: Index of output paths where generated HTML ends up; also controls where `<a>`, `<img>`, etc tags link
-  - `file-size-preloader.js`: Simple utility for calculating size of files in media directory
-  - `strings-default.json`: Template for localization strings and index of default (English) strings used all across the site layout
-
-## Forking
-
-hsmusic is a relatively generic music wiki software, so you're more than encouraged to create a fork for your own archival or cataloguing purposes! You're encouraged to [drop us a link][feedback] if you do - we'd love to hear from you.
-
-## Pull Requests
-
-As mentioned, part of the focus of the hsmusic.wiki release, as well as most development since, has been to create a more modular and developer-friendly repository. So, on the curious chance anyone would like to contribute code to the repo, that's more possible now than it used to be!
-
-Still, for larger additions, we encourage you to [drop the main devs an email][feedback] or, better yet, [pop by the Discord][discord] before writing all the implementation code: besides code tips which might make your life a bit easier (questions are welcome), we also love to discuss feature designs and values while they're still being brainstormed! That way, nobody has to tell you there are fundamental ideas or implementation details that should be rebuilt from the ground up - the last thing we want is anyone putting hours into code that has to be replaced by another implementation before it ever ends up part of the wiki!
+    #!/usr/bin/env zsh
+    base=${0:a:h}
+    export HSMUSIC_CACHE="$base/cache/"
+    export HSMUSIC_DATA="$base/data/"
+    export HSMUSIC_MEDIA="$base/media/"
+    export HSMUSIC_OUT="$base/out/"
 
-As ever, feedback is always welcome, and may be shared via the usual links. Thank you for checking the repository out!
+Then use `source env.sh` when starting work from the CLI to get access to all the convenient environment variables.
 
   [discord]: https://hsmusic.wiki/discord/
   [fandom]: https://homestuck-and-mspa-music.fandom.com/wiki/Homestuck_and_MSPA_Music_Wiki
-  [feedback]: https://hsmusic.wiki/feedback/
-  [github]: https://github.com/hsmusic/hsmusic-wiki
+  [cgit-code]: https://nebula.ed1.club/git/hsmusic-wiki
+  [cgit-data]: https://nebula.ed1.club/git/hsmusic-data
   [github-code]: https://github.com/hsmusic/hsmusic-wiki
   [github-data]: https://github.com/hsmusic/hsmusic-data
   [github-lang]: https://github.com/hsmusic/hsmusic-lang
   [github-media]: https://github.com/hsmusic/hsmusic-media
   [hsmusic]: https://hsmusic.wiki
+  [notabug-code]: https://notabug.org/towerofnix/hsmusic-wiki
+  [notabug-data]: https://notabug.org/towerofnix/hsmusic-data
   [nsnd]: https://homestuck.net/music/references.html
diff --git a/package-lock.json b/package-lock.json
index ad7c5abf..7d5d3152 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,11 +12,14 @@
                 "@js-temporal/polyfill": "^0.4.4",
                 "chroma-js": "^2.4.2",
                 "command-exists": "^1.2.9",
+                "compress-json": "^3.0.5",
                 "eslint": "^8.37.0",
+                "flexsearch": "^0.7.43",
                 "he": "^1.2.0",
                 "image-size": "^1.0.2",
                 "js-yaml": "^4.1.0",
                 "marked": "^10.0.0",
+                "msgpackr": "^1.10.2",
                 "striptags": "^4.0.0-alpha.4",
                 "word-wrap": "^1.2.3"
             },
@@ -25,7 +28,7 @@
             },
             "devDependencies": {
                 "chokidar": "^3.5.3",
-                "tap": "^18.4.0",
+                "tap": "^19.0.2",
                 "tcompare": "^6.0.0"
             },
             "engines": {
@@ -208,9 +211,9 @@
             }
         },
         "node_modules/@isaacs/ts-node-temp-fork-for-pr-2009": {
-            "version": "10.9.5",
-            "resolved": "https://registry.npmjs.org/@isaacs/ts-node-temp-fork-for-pr-2009/-/ts-node-temp-fork-for-pr-2009-10.9.5.tgz",
-            "integrity": "sha512-hEDlwpHhIabtB+Urku8muNMEkGui0LVGlYLS3KoB9QBDf0Pw3r7q0RrfoQmFuk8CvRpGzErO3/vLQd9Ys+/g4g==",
+            "version": "10.9.7",
+            "resolved": "https://registry.npmjs.org/@isaacs/ts-node-temp-fork-for-pr-2009/-/ts-node-temp-fork-for-pr-2009-10.9.7.tgz",
+            "integrity": "sha512-9f0bhUr9TnwwpgUhEpr3FjxSaH/OHaARkE2F9fM0lS4nIs2GNerrvGwQz493dk0JKlTaGYVrKbq36vA/whZ34g==",
             "dev": true,
             "dependencies": {
                 "@cspotcode/source-map-support": "^0.8.0",
@@ -266,9 +269,9 @@
             }
         },
         "node_modules/@jridgewell/resolve-uri": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
-            "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+            "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
             "dev": true,
             "engines": {
                 "node": ">=6.0.0"
@@ -302,6 +305,78 @@
                 "node": ">=12"
             }
         },
+        "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz",
+            "integrity": "sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==",
+            "cpu": [
+                "arm64"
+            ],
+            "optional": true,
+            "os": [
+                "darwin"
+            ]
+        },
+        "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.2.tgz",
+            "integrity": "sha512-lwriRAHm1Yg4iDf23Oxm9n/t5Zpw1lVnxYU3HnJPTi2lJRkKTrps1KVgvL6m7WvmhYVt/FIsssWay+k45QHeuw==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "darwin"
+            ]
+        },
+        "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.2.tgz",
+            "integrity": "sha512-MOI9Dlfrpi2Cuc7i5dXdxPbFIgbDBGgKR5F2yWEa6FVEtSWncfVNKW5AKjImAQ6CZlBK9tympdsZJ2xThBiWWA==",
+            "cpu": [
+                "arm"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.2.tgz",
+            "integrity": "sha512-FU20Bo66/f7He9Fp9sP2zaJ1Q8L9uLPZQDub/WlUip78JlPeMbVL8546HbZfcW9LNciEXc8d+tThSJjSC+tmsg==",
+            "cpu": [
+                "arm64"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.2.tgz",
+            "integrity": "sha512-gsWNDCklNy7Ajk0vBBf9jEx04RUxuDQfBse918Ww+Qb9HCPoGzS+XJTLe96iN3BVK7grnLiYghP/M4L8VsaHeA==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.2.tgz",
+            "integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "win32"
+            ]
+        },
         "node_modules/@nodelib/fs.scandir": {
             "version": "2.1.5",
             "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -335,25 +410,25 @@
             }
         },
         "node_modules/@npmcli/agent": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.0.tgz",
-            "integrity": "sha512-2yThA1Es98orMkpSLVqlDZAMPK3jHJhifP2gnNUdk1754uZ8yI5c+ulCoVG+WlntQA6MzhrURMXjSd9Z7dJ2/Q==",
+            "version": "2.2.2",
+            "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz",
+            "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==",
             "dev": true,
             "dependencies": {
                 "agent-base": "^7.1.0",
                 "http-proxy-agent": "^7.0.0",
                 "https-proxy-agent": "^7.0.1",
                 "lru-cache": "^10.0.1",
-                "socks-proxy-agent": "^8.0.1"
+                "socks-proxy-agent": "^8.0.3"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
             }
         },
         "node_modules/@npmcli/fs": {
-            "version": "3.1.0",
-            "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz",
-            "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==",
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz",
+            "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==",
             "dev": true,
             "dependencies": {
                 "semver": "^7.3.5"
@@ -363,15 +438,15 @@
             }
         },
         "node_modules/@npmcli/git": {
-            "version": "5.0.4",
-            "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.4.tgz",
-            "integrity": "sha512-nr6/WezNzuYUppzXRaYu/W4aT5rLxdXqEFupbh6e/ovlYFQ8hpu1UUPV3Ir/YTl+74iXl2ZOMlGzudh9ZPUchQ==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.7.tgz",
+            "integrity": "sha512-WaOVvto604d5IpdCRV2KjQu8PzkfE96d50CQGKgywXh2GxXmDeUO5EWcBC4V57uFyrNqx83+MewuJh3WTR3xPA==",
             "dev": true,
             "dependencies": {
                 "@npmcli/promise-spawn": "^7.0.0",
                 "lru-cache": "^10.0.1",
                 "npm-pick-manifest": "^9.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "promise-inflight": "^1.0.1",
                 "promise-retry": "^2.0.1",
                 "semver": "^7.3.5",
@@ -406,16 +481,16 @@
             }
         },
         "node_modules/@npmcli/installed-package-contents": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz",
-            "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==",
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.1.0.tgz",
+            "integrity": "sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w==",
             "dev": true,
             "dependencies": {
                 "npm-bundled": "^3.0.0",
                 "npm-normalize-package-bin": "^3.0.0"
             },
             "bin": {
-                "installed-package-contents": "lib/index.js"
+                "installed-package-contents": "bin/index.js"
             },
             "engines": {
                 "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
@@ -430,10 +505,74 @@
                 "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
             }
         },
+        "node_modules/@npmcli/package-json": {
+            "version": "5.1.1",
+            "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.1.1.tgz",
+            "integrity": "sha512-uTq5j/UqUzbOaOxVy+osfOhpqOiLfUZ0Ut33UbcyyAPJbZcJsf4Mrsyb8r58FoIFlofw0iOFsuCA/oDK14VDJQ==",
+            "dev": true,
+            "dependencies": {
+                "@npmcli/git": "^5.0.0",
+                "glob": "^10.2.2",
+                "hosted-git-info": "^7.0.0",
+                "json-parse-even-better-errors": "^3.0.0",
+                "normalize-package-data": "^6.0.0",
+                "proc-log": "^4.0.0",
+                "semver": "^7.5.3"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@npmcli/package-json/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/@npmcli/package-json/node_modules/glob": {
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
+            "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.18"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/@npmcli/package-json/node_modules/minimatch": {
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
+            "dev": true,
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
         "node_modules/@npmcli/promise-spawn": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz",
-            "integrity": "sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz",
+            "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==",
             "dev": true,
             "dependencies": {
                 "which": "^4.0.0"
@@ -466,16 +605,25 @@
                 "node": "^16.13.0 || >=18.0.0"
             }
         },
+        "node_modules/@npmcli/redact": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-1.1.0.tgz",
+            "integrity": "sha512-PfnWuOkQgu7gCbnSsAisaX7hKOdZ4wSAhAzH3/ph5dSGau52kCRrMMGbiSQLwyTZpgldkZ49b0brkOr1AzGBHQ==",
+            "dev": true,
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
         "node_modules/@npmcli/run-script": {
-            "version": "7.0.3",
-            "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.3.tgz",
-            "integrity": "sha512-ZMWGLHpzMq3rBGIwPyeaoaleaLMvrBrH8nugHxTi5ACkJZXTxXPtVuEH91ifgtss5hUwJQ2VDnzDBWPmz78rvg==",
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.4.tgz",
+            "integrity": "sha512-9ApYM/3+rBt9V80aYg6tZfzj3UWdiYyCt7gJUD1VJKvWF5nwKDSICXbYIQbspFTq6TOpbsEtIC0LArB8d9PFmg==",
             "dev": true,
             "dependencies": {
                 "@npmcli/node-gyp": "^3.0.0",
+                "@npmcli/package-json": "^5.0.0",
                 "@npmcli/promise-spawn": "^7.0.0",
                 "node-gyp": "^10.0.0",
-                "read-package-json-fast": "^3.0.0",
                 "which": "^4.0.0"
             },
             "engines": {
@@ -517,72 +665,98 @@
             }
         },
         "node_modules/@sigstore/bundle": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz",
-            "integrity": "sha512-89uOo6yh/oxaU8AeOUnVrTdVMcGk9Q1hJa7Hkvalc6G3Z3CupWk4Xe9djSgJm9fMkH69s0P0cVHUoKSOemLdng==",
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz",
+            "integrity": "sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==",
             "dev": true,
             "dependencies": {
-                "@sigstore/protobuf-specs": "^0.2.1"
+                "@sigstore/protobuf-specs": "^0.3.2"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
             }
         },
+        "node_modules/@sigstore/core": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-1.1.0.tgz",
+            "integrity": "sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg==",
+            "dev": true,
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
         "node_modules/@sigstore/protobuf-specs": {
-            "version": "0.2.1",
-            "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz",
-            "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==",
+            "version": "0.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz",
+            "integrity": "sha512-c6B0ehIWxMI8wiS/bj6rHMPqeFvngFV7cDU/MY+B16P9Z3Mp9k8L93eYZ7BYzSickzuqAQqAq0V956b3Ju6mLw==",
             "dev": true,
             "engines": {
-                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+                "node": "^16.14.0 || >=18.0.0"
             }
         },
         "node_modules/@sigstore/sign": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.2.0.tgz",
-            "integrity": "sha512-AAbmnEHDQv6CSfrWA5wXslGtzLPtAtHZleKOgxdQYvx/s76Fk6T6ZVt7w2IGV9j1UrFeBocTTQxaXG2oRrDhYA==",
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.3.2.tgz",
+            "integrity": "sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA==",
             "dev": true,
             "dependencies": {
-                "@sigstore/bundle": "^2.1.0",
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "make-fetch-happen": "^13.0.0"
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.0.0",
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "make-fetch-happen": "^13.0.1",
+                "proc-log": "^4.2.0",
+                "promise-retry": "^2.0.1"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
             }
         },
         "node_modules/@sigstore/tuf": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.2.0.tgz",
-            "integrity": "sha512-KKATZ5orWfqd9ZG6MN8PtCIx4eevWSuGRKQvofnWXRpyMyUEpmrzg5M5BrCpjM+NfZ0RbNGOh5tCz/P2uoRqOA==",
+            "version": "2.3.4",
+            "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.4.tgz",
+            "integrity": "sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw==",
             "dev": true,
             "dependencies": {
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "tuf-js": "^2.1.0"
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "tuf-js": "^2.2.1"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@sigstore/verify": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.2.1.tgz",
+            "integrity": "sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==",
+            "dev": true,
+            "dependencies": {
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.1.0",
+                "@sigstore/protobuf-specs": "^0.3.2"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
             }
         },
         "node_modules/@tapjs/after": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/after/-/after-1.1.17.tgz",
-            "integrity": "sha512-14qeP+mHZ8nIMDGtdCwTgvKclLlHxfARMTasb9fw//tmF/8ZDZhTemtCDxAP75wihxy5P7nzVZo/6TpVeOZrwg==",
+            "version": "1.1.24",
+            "resolved": "https://registry.npmjs.org/@tapjs/after/-/after-1.1.24.tgz",
+            "integrity": "sha512-Qys3CtftkfHGC7thDGm9TBzRCBLAoJKrXufF1zQxI1oNUjclWZP/s8CtHH0mwUTISOTehmBLV3wPPHSslD67Ng==",
             "dev": true,
             "dependencies": {
-                "is-actual-promise": "^1.0.0"
+                "is-actual-promise": "^1.0.1"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/after-each": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/after-each/-/after-each-1.1.17.tgz",
-            "integrity": "sha512-ia8sr00Wilni+2+wO4MKYCYikeRwUC41HamV8EPN63R2UmiBEOe/cMSf+KYADIh56JvxAiH7Xa0+GSFU+N2FQQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/after-each/-/after-each-2.0.1.tgz",
+            "integrity": "sha512-3JXIJ4g9LPjyXmn/1VuIMC0vh7uBgUpQPksjffxv0rL8wq4C8lvmqt8Qu/fVImJucqzA+WrRqVG1b2Ab0ocDOw==",
             "dev": true,
             "dependencies": {
                 "function-loop": "^4.0.0"
@@ -591,18 +765,18 @@
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/asserts": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/asserts/-/asserts-1.1.17.tgz",
-            "integrity": "sha512-eKmbWBORDXu9bUHtPTu7qFrXNj5UeeH2nABJeP9BGHIn2ydmTgMEWCO3E+ljf7tisHchY5/x672lr99+O/mbTQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/asserts/-/asserts-2.0.1.tgz",
+            "integrity": "sha512-v2xYDLUwMGt8pzoY5LIjDCaw2NM+G01NW4pC3RcpsZLZbzQv1x/phi2RAX0ixI0nCmZZybqRygFKuMcJamS+gg==",
             "dev": true,
             "dependencies": {
-                "@tapjs/stack": "1.2.7",
-                "is-actual-promise": "^1.0.0",
-                "tcompare": "6.4.5",
+                "@tapjs/stack": "2.0.1",
+                "is-actual-promise": "^1.0.1",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
             },
             "engines": {
@@ -612,28 +786,41 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
+            }
+        },
+        "node_modules/@tapjs/asserts/node_modules/tcompare": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+            "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^5.2.0",
+                "react-element-to-jsx-string": "^15.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             }
         },
         "node_modules/@tapjs/before": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/before/-/before-1.1.17.tgz",
-            "integrity": "sha512-pAmEAIMIqF9MPNUgEsnuWCM00iD/FJOX0P5eXSsWexWHjuZAkv5tIT/4qpXO9KYj+9c51Lh+7YSY2Xvk1Jjolw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/before/-/before-2.0.1.tgz",
+            "integrity": "sha512-GgnlWPm2PbuyYuG4gkkO2KAvT/BbGnpKs60U4XzPSJ2w73Qc/IYWP0Kz6qfCWongpiLteoco67M89ujUQApYJw==",
             "dev": true,
             "dependencies": {
-                "is-actual-promise": "^1.0.0"
+                "is-actual-promise": "^1.0.1"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/before-each": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/before-each/-/before-each-1.1.17.tgz",
-            "integrity": "sha512-d2Um3Y2j0m563QNsSxczh+QeSg5sBngnBFGOelUtQVqmq91oNWU/7mY1pwN6ip8mMIQYD75CIhq5/Z57DGomWQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/before-each/-/before-each-2.0.1.tgz",
+            "integrity": "sha512-gG1nYkvCHtWwhkueulO475KczdQZ3vBRgdkta/Qi42ZjZo6SNhYVjNc/+LRGV5vZoESrvgSd+JrDRGufd+j43w==",
             "dev": true,
             "dependencies": {
                 "function-loop": "^4.0.0"
@@ -642,21 +829,21 @@
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/config": {
-            "version": "2.4.14",
-            "resolved": "https://registry.npmjs.org/@tapjs/config/-/config-2.4.14.tgz",
-            "integrity": "sha512-dkjPVJGbLJC9BxCAxudAGiijnKc6XcQbpBSMAGJ/+VoRSqXlPkMWz0d8Ad3rNt7s+g2GBEWBx1kV7wcKtLlxmw==",
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/config/-/config-3.0.1.tgz",
+            "integrity": "sha512-gAYFzErdSuPQ3afW6iRR99hiJmRLU+x9T+NE89z9UM45iPxglWLrRv1PFfh3tmtX6rpzwD5RY4/FVPcP2+/1LQ==",
             "dev": true,
             "dependencies": {
-                "@tapjs/core": "1.4.6",
-                "@tapjs/test": "1.3.17",
+                "@tapjs/core": "2.0.1",
+                "@tapjs/test": "2.0.1",
                 "chalk": "^5.2.0",
-                "jackspeak": "^2.3.6",
+                "jackspeak": "^3.1.2",
                 "polite-json": "^4.0.1",
-                "tap-yaml": "2.2.1",
+                "tap-yaml": "2.2.2",
                 "walk-up-path": "^3.0.1"
             },
             "engines": {
@@ -666,8 +853,8 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6",
-                "@tapjs/test": "1.3.17"
+                "@tapjs/core": "2.0.1",
+                "@tapjs/test": "2.0.1"
             }
         },
         "node_modules/@tapjs/config/node_modules/chalk": {
@@ -683,35 +870,48 @@
             }
         },
         "node_modules/@tapjs/core": {
-            "version": "1.4.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/core/-/core-1.4.6.tgz",
-            "integrity": "sha512-cAKtdGJslrziwi/RJBU7jF930P/eSsemv295t6yLekNVP0XUCNtLFYirxuS1Xwob0nt0g/k+94xXB7o1wdTQvA==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/core/-/core-2.0.1.tgz",
+            "integrity": "sha512-q+8d+ohw5kudktIqgP5ETBcPWAPip+kMIxs2eL2G3dV+7Gc8WrH43cCPrbSGPRITIOSIDPrtpQZEcZwQNqDdQw==",
             "dev": true,
             "dependencies": {
-                "@tapjs/processinfo": "^3.1.6",
-                "@tapjs/stack": "1.2.7",
-                "@tapjs/test": "1.3.17",
+                "@tapjs/processinfo": "^3.1.7",
+                "@tapjs/stack": "2.0.1",
+                "@tapjs/test": "2.0.1",
                 "async-hook-domain": "^4.0.1",
-                "diff": "^5.1.0",
-                "is-actual-promise": "^1.0.0",
-                "minipass": "^7.0.3",
+                "diff": "^5.2.0",
+                "is-actual-promise": "^1.0.1",
+                "minipass": "^7.0.4",
                 "signal-exit": "4.1",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5",
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             }
         },
+        "node_modules/@tapjs/core/node_modules/tcompare": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+            "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^5.2.0",
+                "react-element-to-jsx-string": "^15.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            }
+        },
         "node_modules/@tapjs/error-serdes": {
-            "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/@tapjs/error-serdes/-/error-serdes-1.2.1.tgz",
-            "integrity": "sha512-/7eLEcrGo+Qz3eWrjkhDC+VSEOjabkkzr9eRADeU+OLFeZaik8L/GRk0SGhnp4YsQkv0jcNV00A42bEx2HIZcw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/error-serdes/-/error-serdes-2.0.1.tgz",
+            "integrity": "sha512-P+M4rtcfkDsUveKKmoRNF+07xpbPnRY5KrstIUOnyn483clQ7BJhsnWr162yYNCsyOj4zEfZmAJI1f8Bi7h/ZA==",
             "dev": true,
             "dependencies": {
-                "minipass": "^7.0.3"
+                "minipass": "^7.0.4"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
@@ -721,9 +921,9 @@
             }
         },
         "node_modules/@tapjs/filter": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/filter/-/filter-1.2.17.tgz",
-            "integrity": "sha512-ytsqoPThV92ML1+M+cHlhAS7nOQpDNRBJiPqw20/GmNeoQXsDzVUlWR89DP3WNNUPrr/c1pCVr9XHVhCIeYk0w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/filter/-/filter-2.0.1.tgz",
+            "integrity": "sha512-muKEeXK7Tz6VR4hjXfT2qXPvjYES575mtiRerjHf+8qP8D7MvmC8qDZJjzFdo1nZHKhF8snvFosIVuI1BAhvsw==",
             "dev": true,
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
@@ -732,13 +932,13 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/fixture": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/fixture/-/fixture-1.2.17.tgz",
-            "integrity": "sha512-eOOQxtsEcQ/sBxaZhpqdF9DCNxXAvLuiE5HgyL6d1eB4eceu57uIUKK7NDtFVv+vlbQH/NoiSTxmN/IBRbKT8w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/fixture/-/fixture-2.0.1.tgz",
+            "integrity": "sha512-MLgEwsBlCD69iUbZcnKBehP2js5cV4p5GrFoOKSudMuH2DQJInaF/g2bkijue61cVZwPj/MRPCqAlkwA94epjg==",
             "dev": true,
             "dependencies": {
                 "mkdirp": "^3.0.0",
@@ -751,7 +951,7 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/fixture/node_modules/brace-expansion": {
@@ -764,31 +964,31 @@
             }
         },
         "node_modules/@tapjs/fixture/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/@tapjs/fixture/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -801,9 +1001,9 @@
             }
         },
         "node_modules/@tapjs/fixture/node_modules/rimraf": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
             "dev": true,
             "dependencies": {
                 "glob": "^10.3.7"
@@ -812,36 +1012,36 @@
                 "rimraf": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=14"
+                "node": ">=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/@tapjs/intercept": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/intercept/-/intercept-1.2.17.tgz",
-            "integrity": "sha512-CNuYBxiFBMNALS1PxH3yGI10H8ObxOoD67C2xGWyzXeYrPJ/R4x31Sda9bqaoK3uf/vj28bC9kSECCFjRsNAEg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/intercept/-/intercept-2.0.1.tgz",
+            "integrity": "sha512-BZgXE3zCAbv4lfbph1r85gihtI3kXltHlFQ8Bf3Yy9fx27DKQlBvXnD7T69ke8kQLRzhz+wTMcR/mcQjo1fa7w==",
             "dev": true,
             "dependencies": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/stack": "1.2.7"
+                "@tapjs/after": "1.1.24",
+                "@tapjs/stack": "2.0.1"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/mock": {
-            "version": "1.2.15",
-            "resolved": "https://registry.npmjs.org/@tapjs/mock/-/mock-1.2.15.tgz",
-            "integrity": "sha512-uXfVNDAMAbCGOu46B9jbryTau2pLSQjCdWnkAm/OUgZh/OtO0i7OORz9HdEPfEF2tuy1tLo9+vsCZm3lPU5F7w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/mock/-/mock-2.0.1.tgz",
+            "integrity": "sha512-i1vkwNgO7uEuQW3+hTuE2L64aC9xk0cC3PtC6DZKqyApk2IstNgoIS38nfsI6v2kvEgZNuWlsNcRAYNDOIEhzA==",
             "dev": true,
             "dependencies": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/stack": "1.2.7",
+                "@tapjs/after": "1.1.24",
+                "@tapjs/stack": "2.0.1",
                 "resolve-import": "^1.4.5",
                 "walk-up-path": "^3.0.1"
             },
@@ -852,18 +1052,18 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/node-serialize": {
-            "version": "1.2.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/node-serialize/-/node-serialize-1.2.6.tgz",
-            "integrity": "sha512-xj1OJEsdTr0pQFlirfe/apN0dHUCMCx2Nm5H3SoiSOW4D1/FUKS65VZpWgo3mXMPxRyb/2T1DH3xON1eSGq4ww==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/node-serialize/-/node-serialize-2.0.1.tgz",
+            "integrity": "sha512-1GtHDa7AXpk8y08llIPfUKRTDNsq+BhXxz7wiIfVEAOEB09kGyfpWteOg+cmvb+aHU1Ays3z+medXTIBm0D5Kg==",
             "dev": true,
             "dependencies": {
-                "@tapjs/error-serdes": "1.2.1",
-                "@tapjs/stack": "1.2.7",
-                "tap-parser": "15.3.1"
+                "@tapjs/error-serdes": "2.0.1",
+                "@tapjs/stack": "2.0.1",
+                "tap-parser": "16.0.1"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
@@ -872,13 +1072,13 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/processinfo": {
-            "version": "3.1.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/processinfo/-/processinfo-3.1.6.tgz",
-            "integrity": "sha512-ktDsaf79wJsLaoG1Pp+stHSRf6a1k/JydoRAaYVG5iJnd3DooL6yewZsciUi2yiN/WQc5tAXCIFTXL4uXGB8LA==",
+            "version": "3.1.7",
+            "resolved": "https://registry.npmjs.org/@tapjs/processinfo/-/processinfo-3.1.7.tgz",
+            "integrity": "sha512-SI5RJQ5HnUKEWnHSAF6hOm6XPdnjZ+CJzIaVHdFebed8iDAPTqb+IwMVu9yq9+VQ7FRsMMlgLL2SW4rss2iJbQ==",
             "dev": true,
             "dependencies": {
                 "pirates": "^4.0.5",
@@ -891,24 +1091,24 @@
             }
         },
         "node_modules/@tapjs/reporter": {
-            "version": "1.3.15",
-            "resolved": "https://registry.npmjs.org/@tapjs/reporter/-/reporter-1.3.15.tgz",
-            "integrity": "sha512-us1vXd6TW1V8wJxxnP2a8DNSP1WFTpODyYukqWg7ym5nCalREYnz2MFsn65rRNu/xJlmqsmv+9P63rupud7Zlg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/reporter/-/reporter-2.0.1.tgz",
+            "integrity": "sha512-fCdl4vg8vnlqIYtTQ9dc3zOqeXrA5QbATbT4dsPIiPuCM3gvKTbntaNBeaWWZkPx697Dj+b8TIxT/xhNMNv7jQ==",
             "dev": true,
             "dependencies": {
-                "@tapjs/config": "2.4.14",
-                "@tapjs/stack": "1.2.7",
+                "@tapjs/config": "3.0.1",
+                "@tapjs/stack": "2.0.1",
                 "chalk": "^5.2.0",
                 "ink": "^4.4.1",
-                "minipass": "^7.0.3",
+                "minipass": "^7.0.4",
                 "ms": "^2.1.3",
                 "patch-console": "^2.0.0",
                 "prismjs-terminal": "^1.2.3",
                 "react": "^18.2.0",
                 "string-length": "^6.0.0",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5"
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
@@ -917,7 +1117,7 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/reporter/node_modules/chalk": {
@@ -938,36 +1138,49 @@
             "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
             "dev": true
         },
+        "node_modules/@tapjs/reporter/node_modules/tcompare": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+            "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^5.2.0",
+                "react-element-to-jsx-string": "^15.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            }
+        },
         "node_modules/@tapjs/run": {
-            "version": "1.4.16",
-            "resolved": "https://registry.npmjs.org/@tapjs/run/-/run-1.4.16.tgz",
-            "integrity": "sha512-ZTESjBDj5SitZgWz2hQdzfBoxgaFs89jQjWzqobcdfro0iF7TVRpSrvpz9GTMdo2Tu9aeFfMNfmaAtwNWnDabw==",
-            "dev": true,
-            "dependencies": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/config": "2.4.14",
-                "@tapjs/processinfo": "^3.1.6",
-                "@tapjs/reporter": "1.3.15",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/test": "1.3.17",
-                "c8": "^8.0.1",
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/@tapjs/run/-/run-2.0.2.tgz",
+            "integrity": "sha512-2hPGlabqbLb3hh4BHHvwE8R9a9OiWumkCkHw5QQUZurDsVOpB94FfteqW9mktTVjZJnN0go+sN3GN2jZUaPWGQ==",
+            "dev": true,
+            "dependencies": {
+                "@tapjs/after": "1.1.24",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/config": "3.0.1",
+                "@tapjs/processinfo": "^3.1.7",
+                "@tapjs/reporter": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/test": "2.0.1",
+                "c8": "^9.1.0",
                 "chalk": "^5.3.0",
-                "chokidar": "^3.5.3",
+                "chokidar": "^3.6.0",
                 "foreground-child": "^3.1.1",
-                "glob": "^10.3.10",
-                "minipass": "^7.0.3",
+                "glob": "^10.3.16",
+                "minipass": "^7.0.4",
                 "mkdirp": "^3.0.1",
                 "opener": "^1.5.2",
-                "pacote": "^17.0.3",
+                "pacote": "^17.0.6",
                 "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.5",
-                "semver": "^7.5.4",
+                "semver": "^7.6.0",
                 "signal-exit": "^4.1.0",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5",
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0",
                 "which": "^4.0.0"
             },
@@ -981,7 +1194,7 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/run/node_modules/brace-expansion": {
@@ -1006,22 +1219,22 @@
             }
         },
         "node_modules/@tapjs/run/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
@@ -1037,9 +1250,9 @@
             }
         },
         "node_modules/@tapjs/run/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -1052,9 +1265,9 @@
             }
         },
         "node_modules/@tapjs/run/node_modules/rimraf": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
             "dev": true,
             "dependencies": {
                 "glob": "^10.3.7"
@@ -1063,12 +1276,25 @@
                 "rimraf": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=14"
+                "node": ">=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
+        "node_modules/@tapjs/run/node_modules/tcompare": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+            "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^5.2.0",
+                "react-element-to-jsx-string": "^15.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            }
+        },
         "node_modules/@tapjs/run/node_modules/which": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
@@ -1085,13 +1311,13 @@
             }
         },
         "node_modules/@tapjs/snapshot": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/snapshot/-/snapshot-1.2.17.tgz",
-            "integrity": "sha512-xDHys854ZA8s/1uCkE5PgBz4H1vYKChD6a4xjLVkaoRxpBHVp/IJZCD+8d69DRGnyuA4x2MGh0JLClTA9bLGrA==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/snapshot/-/snapshot-2.0.1.tgz",
+            "integrity": "sha512-ZnbCxL+9fiJ38tec6wvRtRBZz9ChRUq0Bov7dltdZMNkXqudKyB+Zzbg25bqDEIgcczyp6A9hOwTX6VybDGqpg==",
             "dev": true,
             "dependencies": {
-                "is-actual-promise": "^1.0.0",
-                "tcompare": "6.4.5",
+                "is-actual-promise": "^1.0.1",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
             },
             "engines": {
@@ -1101,25 +1327,38 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
+            }
+        },
+        "node_modules/@tapjs/snapshot/node_modules/tcompare": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+            "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^5.2.0",
+                "react-element-to-jsx-string": "^15.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             }
         },
         "node_modules/@tapjs/spawn": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/spawn/-/spawn-1.1.17.tgz",
-            "integrity": "sha512-Bbyxd91bgXEcglvXYKrRl2MaNHk00RajTZJ1kKe3Scr1ivaYv0maE6ZInAl4UE0a4SJl4Dskec+uKoZY3qGUYQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/spawn/-/spawn-2.0.1.tgz",
+            "integrity": "sha512-3VaQKJjHV5frMZj3Ef+QlJyB6b7VsGMil223zAEz8Ttgy2hDYtcb29nvsLPUcowFyOUrsydnXEnHgpR79wEPOA==",
             "dev": true,
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/stack": {
-            "version": "1.2.7",
-            "resolved": "https://registry.npmjs.org/@tapjs/stack/-/stack-1.2.7.tgz",
-            "integrity": "sha512-7qUDWDmd+y7ZQ0vTrDTvFlWnJ+ND32NemS5HVuT1ZggHtBwJ62PQHIyCx/B5RopETBb6NvFPfUE21yTiex9Jkw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/stack/-/stack-2.0.1.tgz",
+            "integrity": "sha512-3rKbZkRkLeJl9ilV/6b80YfI4C4+OYf7iEz5/d0MIVhmVvxv0ttIy5JnZutAc4Gy9eRp5Ne5UTAIFOVY5k36cg==",
             "dev": true,
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
@@ -1129,48 +1368,49 @@
             }
         },
         "node_modules/@tapjs/stdin": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/stdin/-/stdin-1.1.17.tgz",
-            "integrity": "sha512-mDutFFPDnlVM2oYDAfyYKA+fC+aEiyz5n08D8x6YAbwZNbTIVp+h6ucyp7ygJ04fshd4l3s1HUmCZLSmHb2xEw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/stdin/-/stdin-2.0.1.tgz",
+            "integrity": "sha512-5Oe13Fzpnt9seAi8h3bsMxtJp8S+DQI6ncBD9JBcS91XKLbqyKrb1bNzeXQN2PrHBs6Atw8cOzFZh0TjL+bIaA==",
             "dev": true,
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/test": {
-            "version": "1.3.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/test/-/test-1.3.17.tgz",
-            "integrity": "sha512-yQ4uHC2GaDS+Gr5qwx9uMGxqvpYgnlVY+QexBReSeYZthWIN0KD8HDvnVt4An5Sx/Qhd7UlnNpNMBd6AkvPEew==",
-            "dev": true,
-            "dependencies": {
-                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5",
-                "@tapjs/after": "1.1.17",
-                "@tapjs/after-each": "1.1.17",
-                "@tapjs/asserts": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/before-each": "1.1.17",
-                "@tapjs/filter": "1.2.17",
-                "@tapjs/fixture": "1.2.17",
-                "@tapjs/intercept": "1.2.17",
-                "@tapjs/mock": "1.2.15",
-                "@tapjs/node-serialize": "1.2.6",
-                "@tapjs/snapshot": "1.2.17",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/typescript": "1.3.6",
-                "@tapjs/worker": "1.1.17",
-                "glob": "^10.3.10",
-                "jackspeak": "^2.3.6",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/test/-/test-2.0.1.tgz",
+            "integrity": "sha512-PKazf7r4+bLFATML2f/h8glGcSirXmzXUYlhFuxb4xHoOhHojyKgo1p8kSj+Ksxb3hVSCQlvyXgM8QYYaoMwog==",
+            "dev": true,
+            "dependencies": {
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.7",
+                "@tapjs/after": "1.1.24",
+                "@tapjs/after-each": "2.0.1",
+                "@tapjs/asserts": "2.0.1",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/before-each": "2.0.1",
+                "@tapjs/filter": "2.0.1",
+                "@tapjs/fixture": "2.0.1",
+                "@tapjs/intercept": "2.0.1",
+                "@tapjs/mock": "2.0.1",
+                "@tapjs/node-serialize": "2.0.1",
+                "@tapjs/snapshot": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/typescript": "1.4.6",
+                "@tapjs/worker": "2.0.1",
+                "glob": "^10.3.16",
+                "jackspeak": "^3.1.2",
                 "mkdirp": "^3.0.0",
                 "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.5",
                 "sync-content": "^1.0.1",
-                "tap-parser": "15.3.1",
-                "tshy": "^1.2.2",
-                "typescript": "5.2"
+                "tap-parser": "16.0.1",
+                "tshy": "^1.14.0",
+                "typescript": "5.4",
+                "walk-up-path": "^3.0.1"
             },
             "bin": {
                 "generate-tap-test-class": "scripts/build.mjs"
@@ -1179,7 +1419,7 @@
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/test/node_modules/brace-expansion": {
@@ -1192,31 +1432,31 @@
             }
         },
         "node_modules/@tapjs/test/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/@tapjs/test/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -1229,9 +1469,9 @@
             }
         },
         "node_modules/@tapjs/test/node_modules/rimraf": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
             "dev": true,
             "dependencies": {
                 "glob": "^10.3.7"
@@ -1240,61 +1480,61 @@
                 "rimraf": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=14"
+                "node": ">=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/@tapjs/typescript": {
-            "version": "1.3.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/typescript/-/typescript-1.3.6.tgz",
-            "integrity": "sha512-bHqQb06HcD1vFvSwElH0WK4cnCNthvA5OX/KBs5w1TNFHIeRHemp/hsSnGSNDwYwDETuOxD68rDZNTpNbzysBg==",
+            "version": "1.4.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/typescript/-/typescript-1.4.6.tgz",
+            "integrity": "sha512-6jxUQ7Mdb+Y2q8RJcwgZZ6dCR+X2u3hCL+xb1GDAtO7k1+B6z2b+z+I+FdhuO4YgrP0SLRjocL5rJM/xi9K7qw==",
             "dev": true,
             "dependencies": {
-                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5"
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.7"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/worker": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/worker/-/worker-1.1.17.tgz",
-            "integrity": "sha512-DCRzEBT+OgP518rQqzlX6KawvGTegkeEjPVa/TB6Iifj8WOHJ+XtunkR7riIRGEoCEOMD49DCJXj70c+XP0jNw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/worker/-/worker-2.0.1.tgz",
+            "integrity": "sha512-wegz8IxNEPIIAA+R76/avZgNmZ4iC7QGFbtXKGBU962/1lXTITxshRV6e21r0IBa7YLkSVgDuVSVB3+Qzve0Yg==",
             "dev": true,
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tsconfig/node14": {
-            "version": "14.1.0",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.0.tgz",
-            "integrity": "sha512-VmsCG04YR58ciHBeJKBDNMWWfYbyP8FekWVuTlpstaUPlat1D0x/tXzkWP7yCMU0eSz9V4OZU0LBWTFJ3xZf6w==",
+            "version": "14.1.2",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.2.tgz",
+            "integrity": "sha512-1vncsbfCZ3TBLPxesRYz02Rn7SNJfbLoDVkcZ7F/ixOV6nwxwgdhD1mdPcc5YQ413qBJ8CvMxXMFfJ7oawjo7Q==",
             "dev": true
         },
         "node_modules/@tsconfig/node16": {
-            "version": "16.1.1",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.1.tgz",
-            "integrity": "sha512-+pio93ejHN4nINX4pXqfnR/fPLRtJBaT4ORaa5RH0Oc1zoYmo2B2koG+M328CQhHKn1Wj6FcOxCDFXAot9NhvA==",
+            "version": "16.1.3",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.3.tgz",
+            "integrity": "sha512-9nTOUBn+EMKO6rtSZJk+DcqsfgtlERGT9XPJ5PRj/HNENPCBY1yu/JEj5wT6GLtbCLBO2k46SeXDaY0pjMqypw==",
             "dev": true
         },
         "node_modules/@tsconfig/node18": {
-            "version": "18.2.2",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.2.tgz",
-            "integrity": "sha512-d6McJeGsuoRlwWZmVIeE8CUA27lu6jLjvv1JzqmpsytOYYbVi1tHZEnwCNVOXnj4pyLvneZlFlpXUK+X9wBWyw==",
+            "version": "18.2.4",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.4.tgz",
+            "integrity": "sha512-5xxU8vVs9/FNcvm3gE07fPbn9tl6tqGGWA9tSlwsUEkBxtRnTsNmwrV8gasZ9F/EobaSv9+nu8AxUKccw77JpQ==",
             "dev": true
         },
         "node_modules/@tsconfig/node20": {
-            "version": "20.1.2",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.2.tgz",
-            "integrity": "sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ==",
+            "version": "20.1.4",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz",
+            "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==",
             "dev": true
         },
         "node_modules/@tufjs/canonical-json": {
@@ -1307,13 +1547,13 @@
             }
         },
         "node_modules/@tufjs/models": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.0.tgz",
-            "integrity": "sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.1.tgz",
+            "integrity": "sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==",
             "dev": true,
             "dependencies": {
                 "@tufjs/canonical-json": "2.0.0",
-                "minimatch": "^9.0.3"
+                "minimatch": "^9.0.4"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
@@ -1329,9 +1569,9 @@
             }
         },
         "node_modules/@tufjs/models/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -1350,9 +1590,9 @@
             "dev": true
         },
         "node_modules/@types/node": {
-            "version": "20.10.6",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz",
-            "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==",
+            "version": "20.13.0",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.13.0.tgz",
+            "integrity": "sha512-FM6AOb3khNkNIXPnHFDYaHerSv8uN22C91z098AnGccVu+Pcdhi+pNUFDi0iLmPIsVE0JBD0KVS7mzUYt4nRzQ==",
             "dev": true,
             "peer": true,
             "dependencies": {
@@ -1388,18 +1628,18 @@
             }
         },
         "node_modules/acorn-walk": {
-            "version": "8.3.1",
-            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz",
-            "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==",
+            "version": "8.3.2",
+            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
+            "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
             "dev": true,
             "engines": {
                 "node": ">=0.4.0"
             }
         },
         "node_modules/agent-base": {
-            "version": "7.1.0",
-            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
-            "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==",
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
+            "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
             "dev": true,
             "dependencies": {
                 "debug": "^4.3.4"
@@ -1446,24 +1686,9 @@
             }
         },
         "node_modules/ansi-escapes": {
-            "version": "6.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz",
-            "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==",
-            "dev": true,
-            "dependencies": {
-                "type-fest": "^3.0.0"
-            },
-            "engines": {
-                "node": ">=14.16"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/ansi-escapes/node_modules/type-fest": {
-            "version": "3.13.1",
-            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
-            "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz",
+            "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==",
             "dev": true,
             "engines": {
                 "node": ">=14.16"
@@ -1574,29 +1799,19 @@
                 "node": ">=8"
             }
         },
-        "node_modules/builtins": {
-            "version": "5.0.1",
-            "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz",
-            "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==",
-            "dev": true,
-            "dependencies": {
-                "semver": "^7.0.0"
-            }
-        },
         "node_modules/c8": {
-            "version": "8.0.1",
-            "resolved": "https://registry.npmjs.org/c8/-/c8-8.0.1.tgz",
-            "integrity": "sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==",
+            "version": "9.1.0",
+            "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz",
+            "integrity": "sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==",
             "dev": true,
             "dependencies": {
                 "@bcoe/v8-coverage": "^0.2.3",
                 "@istanbuljs/schema": "^0.1.3",
                 "find-up": "^5.0.0",
-                "foreground-child": "^2.0.0",
+                "foreground-child": "^3.1.1",
                 "istanbul-lib-coverage": "^3.2.0",
                 "istanbul-lib-report": "^3.0.1",
                 "istanbul-reports": "^3.1.6",
-                "rimraf": "^3.0.2",
                 "test-exclude": "^6.0.0",
                 "v8-to-istanbul": "^9.0.0",
                 "yargs": "^17.7.2",
@@ -1606,32 +1821,13 @@
                 "c8": "bin/c8.js"
             },
             "engines": {
-                "node": ">=12"
-            }
-        },
-        "node_modules/c8/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": {
-                "cross-spawn": "^7.0.0",
-                "signal-exit": "^3.0.2"
-            },
-            "engines": {
-                "node": ">=8.0.0"
+                "node": ">=14.14.0"
             }
         },
-        "node_modules/c8/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/cacache": {
-            "version": "18.0.2",
-            "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.2.tgz",
-            "integrity": "sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw==",
+            "version": "18.0.3",
+            "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.3.tgz",
+            "integrity": "sha512-qXCd4rh6I07cnDqh8V48/94Tc/WSfj+o3Gn6NZ0aZovS255bUx8O13uKxRFd2eWG0xgsco7+YItQNPaa5E85hg==",
             "dev": true,
             "dependencies": {
                 "@npmcli/fs": "^3.1.0",
@@ -1661,31 +1857,31 @@
             }
         },
         "node_modules/cacache/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/cacache/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -1721,16 +1917,10 @@
             }
         },
         "node_modules/chokidar": {
-            "version": "3.5.3",
-            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
-            "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+            "version": "3.6.0",
+            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+            "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
             "dev": true,
-            "funding": [
-                {
-                    "type": "individual",
-                    "url": "https://paulmillr.com/funding/"
-                }
-            ],
             "dependencies": {
                 "anymatch": "~3.1.2",
                 "braces": "~3.0.2",
@@ -1743,6 +1933,9 @@
             "engines": {
                 "node": ">= 8.10.0"
             },
+            "funding": {
+                "url": "https://paulmillr.com/funding/"
+            },
             "optionalDependencies": {
                 "fsevents": "~2.3.2"
             }
@@ -1961,6 +2154,11 @@
             "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
             "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="
         },
+        "node_modules/compress-json": {
+            "version": "3.0.5",
+            "resolved": "https://registry.npmjs.org/compress-json/-/compress-json-3.0.5.tgz",
+            "integrity": "sha512-HYiJvE0cTIygI9zXqY5fkRr7H3NV3UAME0enzwN5M0JkzMOtUcjSyaH7HxVRzXsn7IIXD0STA9M5jyWkxERSLg=="
+        },
         "node_modules/concat-map": {
             "version": "0.0.1",
             "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2016,9 +2214,9 @@
             "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
         },
         "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==",
+            "version": "5.2.0",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
+            "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
             "dev": true,
             "engines": {
                 "node": ">=0.3.1"
@@ -2073,9 +2271,9 @@
             "dev": true
         },
         "node_modules/escalade": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
-            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
+            "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
             "dev": true,
             "engines": {
                 "node": ">=6"
@@ -2318,6 +2516,11 @@
             "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
             "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
         },
+        "node_modules/flexsearch": {
+            "version": "0.7.43",
+            "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.7.43.tgz",
+            "integrity": "sha512-c5o/+Um8aqCSOXGcZoqZOm+NqtVwNsvVpWv6lfmSclU954O3wvQKxxK8zj74fPaSJbXpSLTs4PRhh+wnoCXnKg=="
+        },
         "node_modules/foreground-child": {
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
@@ -2473,9 +2676,9 @@
             }
         },
         "node_modules/hasown": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
-            "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+            "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
             "dev": true,
             "dependencies": {
                 "function-bind": "^1.1.2"
@@ -2493,9 +2696,9 @@
             }
         },
         "node_modules/hosted-git-info": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz",
-            "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
+            "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==",
             "dev": true,
             "dependencies": {
                 "lru-cache": "^10.0.1"
@@ -2517,9 +2720,9 @@
             "dev": true
         },
         "node_modules/http-proxy-agent": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz",
-            "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+            "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
             "dev": true,
             "dependencies": {
                 "agent-base": "^7.1.0",
@@ -2530,9 +2733,9 @@
             }
         },
         "node_modules/https-proxy-agent": {
-            "version": "7.0.2",
-            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz",
-            "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==",
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz",
+            "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==",
             "dev": true,
             "dependencies": {
                 "agent-base": "^7.0.2",
@@ -2564,9 +2767,9 @@
             }
         },
         "node_modules/ignore-walk": {
-            "version": "6.0.4",
-            "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz",
-            "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==",
+            "version": "6.0.5",
+            "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.5.tgz",
+            "integrity": "sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A==",
             "dev": true,
             "dependencies": {
                 "minimatch": "^9.0.0"
@@ -2585,9 +2788,9 @@
             }
         },
         "node_modules/ignore-walk/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -2741,21 +2944,25 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/ip": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
-            "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
-            "dev": true
-        },
-        "node_modules/is-actual-promise": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/is-actual-promise/-/is-actual-promise-1.0.1.tgz",
-            "integrity": "sha512-PlsL4tNv62lx5yN2HSqaRSTgIpUAPW7U6+crVB8HfWm5161rZpeqWbl0ZSqH2MAfRKXWSZVPRNbE/r8qPcb13g==",
+        "node_modules/ip-address": {
+            "version": "9.0.5",
+            "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
+            "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
             "dev": true,
             "dependencies": {
-                "tshy": "^1.7.0"
+                "jsbn": "1.1.0",
+                "sprintf-js": "^1.1.3"
+            },
+            "engines": {
+                "node": ">= 12"
             }
         },
+        "node_modules/is-actual-promise": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/is-actual-promise/-/is-actual-promise-1.0.2.tgz",
+            "integrity": "sha512-xsFiO1of0CLsQnPZ1iXHNTyR9YszOeWKYv+q6n8oSFW3ipooFJ1j1lbRMgiMCr+pp2gLruESI4zb5Ak6eK5OnQ==",
+            "dev": true
+        },
         "node_modules/is-binary-path": {
             "version": "2.1.0",
             "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2902,9 +3109,9 @@
             }
         },
         "node_modules/istanbul-reports": {
-            "version": "3.1.6",
-            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz",
-            "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==",
+            "version": "3.1.7",
+            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
+            "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
             "dev": true,
             "dependencies": {
                 "html-escaper": "^2.0.0",
@@ -2915,9 +3122,9 @@
             }
         },
         "node_modules/jackspeak": {
-            "version": "2.3.6",
-            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
-            "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.1.2.tgz",
+            "integrity": "sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==",
             "dev": true,
             "dependencies": {
                 "@isaacs/cliui": "^8.0.2"
@@ -2963,10 +3170,16 @@
             "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
             "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
         },
+        "node_modules/jsbn": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
+            "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
+            "dev": true
+        },
         "node_modules/json-parse-even-better-errors": {
-            "version": "3.0.1",
-            "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz",
-            "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==",
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz",
+            "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==",
             "dev": true,
             "engines": {
                 "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
@@ -3041,9 +3254,9 @@
             }
         },
         "node_modules/lru-cache": {
-            "version": "10.1.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz",
-            "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==",
+            "version": "10.2.2",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
+            "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
             "dev": true,
             "engines": {
                 "node": "14 || >=16.14"
@@ -3071,9 +3284,9 @@
             "dev": true
         },
         "node_modules/make-fetch-happen": {
-            "version": "13.0.0",
-            "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz",
-            "integrity": "sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==",
+            "version": "13.0.1",
+            "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz",
+            "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==",
             "dev": true,
             "dependencies": {
                 "@npmcli/agent": "^2.0.0",
@@ -3085,6 +3298,7 @@
                 "minipass-flush": "^1.0.5",
                 "minipass-pipeline": "^1.2.4",
                 "negotiator": "^0.6.3",
+                "proc-log": "^4.2.0",
                 "promise-retry": "^2.0.1",
                 "ssri": "^10.0.0"
             },
@@ -3124,9 +3338,9 @@
             }
         },
         "node_modules/minipass": {
-            "version": "7.0.4",
-            "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
-            "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
+            "version": "7.1.2",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+            "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
             "dev": true,
             "engines": {
                 "node": ">=16 || 14 >=14.17"
@@ -3145,9 +3359,9 @@
             }
         },
         "node_modules/minipass-fetch": {
-            "version": "3.0.4",
-            "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz",
-            "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==",
+            "version": "3.0.5",
+            "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz",
+            "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==",
             "dev": true,
             "dependencies": {
                 "minipass": "^7.0.3",
@@ -3300,6 +3514,35 @@
             "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
             "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
         },
+        "node_modules/msgpackr": {
+            "version": "1.10.2",
+            "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.10.2.tgz",
+            "integrity": "sha512-L60rsPynBvNE+8BWipKKZ9jHcSGbtyJYIwjRq0VrIvQ08cRjntGXJYW/tmciZ2IHWIY8WEW32Qa2xbh5+SKBZA==",
+            "optionalDependencies": {
+                "msgpackr-extract": "^3.0.2"
+            }
+        },
+        "node_modules/msgpackr-extract": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.2.tgz",
+            "integrity": "sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==",
+            "hasInstallScript": true,
+            "optional": true,
+            "dependencies": {
+                "node-gyp-build-optional-packages": "5.0.7"
+            },
+            "bin": {
+                "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
+            },
+            "optionalDependencies": {
+                "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.2"
+            }
+        },
         "node_modules/natural-compare": {
             "version": "1.4.0",
             "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -3315,9 +3558,9 @@
             }
         },
         "node_modules/node-gyp": {
-            "version": "10.0.1",
-            "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz",
-            "integrity": "sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg==",
+            "version": "10.1.0",
+            "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.1.0.tgz",
+            "integrity": "sha512-B4J5M1cABxPc5PwfjhbV5hoy2DP9p8lFXASnEN6hugXOa61416tnTZ29x9sSwAd0o99XNIcpvDDy1swAExsVKA==",
             "dev": true,
             "dependencies": {
                 "env-paths": "^2.2.0",
@@ -3338,6 +3581,17 @@
                 "node": "^16.14.0 || >=18.0.0"
             }
         },
+        "node_modules/node-gyp-build-optional-packages": {
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz",
+            "integrity": "sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==",
+            "optional": true,
+            "bin": {
+                "node-gyp-build-optional-packages": "bin.js",
+                "node-gyp-build-optional-packages-optional": "optional.js",
+                "node-gyp-build-optional-packages-test": "build-test.js"
+            }
+        },
         "node_modules/node-gyp/node_modules/brace-expansion": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -3348,22 +3602,22 @@
             }
         },
         "node_modules/node-gyp/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
@@ -3379,9 +3633,9 @@
             }
         },
         "node_modules/node-gyp/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -3393,6 +3647,15 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
+        "node_modules/node-gyp/node_modules/proc-log": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",
+            "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==",
+            "dev": true,
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
         "node_modules/node-gyp/node_modules/which": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
@@ -3409,9 +3672,9 @@
             }
         },
         "node_modules/nopt": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz",
-            "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==",
+            "version": "7.2.1",
+            "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
+            "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
             "dev": true,
             "dependencies": {
                 "abbrev": "^2.0.0"
@@ -3424,9 +3687,9 @@
             }
         },
         "node_modules/normalize-package-data": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.0.tgz",
-            "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==",
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.1.tgz",
+            "integrity": "sha512-6rvCfeRW+OEZagAB4lMLSNuTNYZWLVtKccK79VSTf//yTY5VOCgcpH80O+bZK8Neps7pUnd5G+QlMg1yV/2iZQ==",
             "dev": true,
             "dependencies": {
                 "hosted-git-info": "^7.0.0",
@@ -3448,9 +3711,9 @@
             }
         },
         "node_modules/npm-bundled": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz",
-            "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==",
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.1.tgz",
+            "integrity": "sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==",
             "dev": true,
             "dependencies": {
                 "npm-normalize-package-bin": "^3.0.0"
@@ -3481,13 +3744,13 @@
             }
         },
         "node_modules/npm-package-arg": {
-            "version": "11.0.1",
-            "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.1.tgz",
-            "integrity": "sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ==",
+            "version": "11.0.2",
+            "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.2.tgz",
+            "integrity": "sha512-IGN0IAwmhDJwy13Wc8k+4PEbTPhpJnMtfR53ZbOyjkvmEcLS4nCwp6mvMWjS5sUjeiW3mpx6cHmuhKEu9XmcQw==",
             "dev": true,
             "dependencies": {
                 "hosted-git-info": "^7.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "semver": "^7.3.5",
                 "validate-npm-package-name": "^5.0.0"
             },
@@ -3496,9 +3759,9 @@
             }
         },
         "node_modules/npm-packlist": {
-            "version": "8.0.1",
-            "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.1.tgz",
-            "integrity": "sha512-MQpL27ZrsJQ2kiAuQPpZb5LtJwydNRnI15QWXsf3WHERu4rzjRj6Zju/My2fov7tLuu3Gle/uoIX/DDZ3u4O4Q==",
+            "version": "8.0.2",
+            "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz",
+            "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==",
             "dev": true,
             "dependencies": {
                 "ignore-walk": "^6.0.4"
@@ -3508,9 +3771,9 @@
             }
         },
         "node_modules/npm-pick-manifest": {
-            "version": "9.0.0",
-            "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.0.tgz",
-            "integrity": "sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg==",
+            "version": "9.0.1",
+            "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.1.tgz",
+            "integrity": "sha512-Udm1f0l2nXb3wxDpKjfohwgdFUSV50UVwzEIpDXVsbDMXVIEF81a/i0UhuQbhrPMMmdiq3+YMFLFIRVLs3hxQw==",
             "dev": true,
             "dependencies": {
                 "npm-install-checks": "^6.0.0",
@@ -3523,18 +3786,19 @@
             }
         },
         "node_modules/npm-registry-fetch": {
-            "version": "16.1.0",
-            "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.1.0.tgz",
-            "integrity": "sha512-PQCELXKt8Azvxnt5Y85GseQDJJlglTFM9L9U9gkv2y4e9s0k3GVDdOx3YoB6gm2Do0hlkzC39iCGXby+Wve1Bw==",
+            "version": "16.2.1",
+            "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.2.1.tgz",
+            "integrity": "sha512-8l+7jxhim55S85fjiDGJ1rZXBWGtRLi1OSb4Z3BPLObPuIaeKRlPRiYMSHU4/81ck3t71Z+UwDDl47gcpmfQQA==",
             "dev": true,
             "dependencies": {
+                "@npmcli/redact": "^1.1.0",
                 "make-fetch-happen": "^13.0.0",
                 "minipass": "^7.0.2",
                 "minipass-fetch": "^3.0.0",
                 "minipass-json-stream": "^1.0.1",
                 "minizlib": "^2.1.2",
                 "npm-package-arg": "^11.0.0",
-                "proc-log": "^3.0.0"
+                "proc-log": "^4.0.0"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
@@ -3632,9 +3896,9 @@
             }
         },
         "node_modules/pacote": {
-            "version": "17.0.5",
-            "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.5.tgz",
-            "integrity": "sha512-TAE0m20zSDMnchPja9vtQjri19X3pZIyRpm2TJVeI+yU42leJBBDTRYhOcWFsPhaMxf+3iwQkFiKz16G9AEeeA==",
+            "version": "17.0.7",
+            "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.7.tgz",
+            "integrity": "sha512-sgvnoUMlkv9xHwDUKjKQFXVyUi8dtJGKp3vg6sYy+TxbDic5RjZCHF3ygv0EJgNRZ2GfRONjlKPUfokJ9lDpwQ==",
             "dev": true,
             "dependencies": {
                 "@npmcli/git": "^5.0.0",
@@ -3648,11 +3912,11 @@
                 "npm-packlist": "^8.0.0",
                 "npm-pick-manifest": "^9.0.0",
                 "npm-registry-fetch": "^16.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "promise-retry": "^2.0.1",
                 "read-package-json": "^7.0.0",
                 "read-package-json-fast": "^3.0.0",
-                "sigstore": "^2.0.0",
+                "sigstore": "^2.2.0",
                 "ssri": "^10.0.0",
                 "tar": "^6.1.11"
             },
@@ -3708,16 +3972,16 @@
             }
         },
         "node_modules/path-scurry": {
-            "version": "1.10.1",
-            "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
-            "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
+            "version": "1.11.1",
+            "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+            "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
             "dev": true,
             "dependencies": {
-                "lru-cache": "^9.1.1 || ^10.0.0",
+                "lru-cache": "^10.2.0",
                 "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
@@ -3803,9 +4067,9 @@
             }
         },
         "node_modules/proc-log": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",
-            "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==",
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz",
+            "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==",
             "dev": true,
             "engines": {
                 "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
@@ -3878,9 +4142,9 @@
             ]
         },
         "node_modules/react": {
-            "version": "18.2.0",
-            "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
-            "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+            "version": "18.3.1",
+            "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+            "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
             "dev": true,
             "dependencies": {
                 "loose-envify": "^1.1.0"
@@ -3925,25 +4189,26 @@
             "dev": true
         },
         "node_modules/react-reconciler": {
-            "version": "0.29.0",
-            "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz",
-            "integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==",
+            "version": "0.29.2",
+            "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
+            "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
             "dev": true,
             "dependencies": {
                 "loose-envify": "^1.1.0",
-                "scheduler": "^0.23.0"
+                "scheduler": "^0.23.2"
             },
             "engines": {
                 "node": ">=0.10.0"
             },
             "peerDependencies": {
-                "react": "^18.2.0"
+                "react": "^18.3.1"
             }
         },
         "node_modules/read-package-json": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.0.tgz",
-            "integrity": "sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg==",
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.1.tgz",
+            "integrity": "sha512-8PcDiZ8DXUjLf687Ol4BR8Bpm2umR7vhoZOzNRt+uxD9GpBh/K+CAAALVIiYFknmvlmyg7hM7BSNUXPaCCqd0Q==",
+            "deprecated": "This package is no longer supported. Please use @npmcli/package-json instead.",
             "dev": true,
             "dependencies": {
                 "glob": "^10.2.2",
@@ -3978,31 +4243,31 @@
             }
         },
         "node_modules/read-package-json/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/read-package-json/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -4069,31 +4334,31 @@
             }
         },
         "node_modules/resolve-import/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/resolve-import/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -4189,22 +4454,19 @@
             "optional": true
         },
         "node_modules/scheduler": {
-            "version": "0.23.0",
-            "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
-            "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+            "version": "0.23.2",
+            "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+            "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
             "dev": true,
             "dependencies": {
                 "loose-envify": "^1.1.0"
             }
         },
         "node_modules/semver": {
-            "version": "7.5.4",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
-            "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+            "version": "7.6.2",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+            "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
             "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
             "bin": {
                 "semver": "bin/semver.js"
             },
@@ -4212,18 +4474,6 @@
                 "node": ">=10"
             }
         },
-        "node_modules/semver/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/shebang-command": {
             "version": "2.0.0",
             "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4256,15 +4506,17 @@
             }
         },
         "node_modules/sigstore": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.1.0.tgz",
-            "integrity": "sha512-kPIj+ZLkyI3QaM0qX8V/nSsweYND3W448pwkDgS6CQ74MfhEkIR8ToK5Iyx46KJYRjseVcD3Rp9zAmUAj6ZjPw==",
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.3.1.tgz",
+            "integrity": "sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==",
             "dev": true,
             "dependencies": {
-                "@sigstore/bundle": "^2.1.0",
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "@sigstore/sign": "^2.1.0",
-                "@sigstore/tuf": "^2.1.0"
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.0.0",
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "@sigstore/sign": "^2.3.2",
+                "@sigstore/tuf": "^2.3.4",
+                "@sigstore/verify": "^1.2.1"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
@@ -4309,26 +4561,26 @@
             }
         },
         "node_modules/socks": {
-            "version": "2.7.1",
-            "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz",
-            "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==",
+            "version": "2.8.3",
+            "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
+            "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==",
             "dev": true,
             "dependencies": {
-                "ip": "^2.0.0",
+                "ip-address": "^9.0.5",
                 "smart-buffer": "^4.2.0"
             },
             "engines": {
-                "node": ">= 10.13.0",
+                "node": ">= 10.0.0",
                 "npm": ">= 3.0.0"
             }
         },
         "node_modules/socks-proxy-agent": {
-            "version": "8.0.2",
-            "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz",
-            "integrity": "sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==",
+            "version": "8.0.3",
+            "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.3.tgz",
+            "integrity": "sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A==",
             "dev": true,
             "dependencies": {
-                "agent-base": "^7.0.2",
+                "agent-base": "^7.1.1",
                 "debug": "^4.3.4",
                 "socks": "^2.7.1"
             },
@@ -4347,9 +4599,9 @@
             }
         },
         "node_modules/spdx-exceptions": {
-            "version": "2.3.0",
-            "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
-            "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+            "version": "2.5.0",
+            "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
+            "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
             "dev": true
         },
         "node_modules/spdx-expression-parse": {
@@ -4363,15 +4615,21 @@
             }
         },
         "node_modules/spdx-license-ids": {
-            "version": "3.0.16",
-            "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz",
-            "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==",
+            "version": "3.0.18",
+            "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz",
+            "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==",
+            "dev": true
+        },
+        "node_modules/sprintf-js": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+            "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
             "dev": true
         },
         "node_modules/ssri": {
-            "version": "10.0.5",
-            "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz",
-            "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==",
+            "version": "10.0.6",
+            "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz",
+            "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==",
             "dev": true,
             "dependencies": {
                 "minipass": "^7.0.3"
@@ -4599,31 +4857,31 @@
             }
         },
         "node_modules/sync-content/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/sync-content/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -4636,9 +4894,9 @@
             }
         },
         "node_modules/sync-content/node_modules/rimraf": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
             "dev": true,
             "dependencies": {
                 "glob": "^10.3.7"
@@ -4647,36 +4905,36 @@
                 "rimraf": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=14"
+                "node": ">=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/tap": {
-            "version": "18.6.1",
-            "resolved": "https://registry.npmjs.org/tap/-/tap-18.6.1.tgz",
-            "integrity": "sha512-5cBQhJ1gdbsrTR3tA5kZZTts0HyOML6bcM7pEF7GF8d6y1ajfRMjbInS1Ty7/x2Ip0ko3cY1dYjPJ9JFNPsm7w==",
-            "dev": true,
-            "dependencies": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/after-each": "1.1.17",
-                "@tapjs/asserts": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/before-each": "1.1.17",
-                "@tapjs/core": "1.4.6",
-                "@tapjs/filter": "1.2.17",
-                "@tapjs/fixture": "1.2.17",
-                "@tapjs/intercept": "1.2.17",
-                "@tapjs/mock": "1.2.15",
-                "@tapjs/node-serialize": "1.2.6",
-                "@tapjs/run": "1.4.16",
-                "@tapjs/snapshot": "1.2.17",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/test": "1.3.17",
-                "@tapjs/typescript": "1.3.6",
-                "@tapjs/worker": "1.1.17",
+            "version": "19.0.2",
+            "resolved": "https://registry.npmjs.org/tap/-/tap-19.0.2.tgz",
+            "integrity": "sha512-SRGulk1RKlVuYtnPeephj+xyE0sG9CvGlKYP4lymBZykLtkwBPnEBjQ2iQmLX5z0BFEMfKh8G4bvZkhoSJb3kg==",
+            "dev": true,
+            "dependencies": {
+                "@tapjs/after": "1.1.24",
+                "@tapjs/after-each": "2.0.1",
+                "@tapjs/asserts": "2.0.1",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/before-each": "2.0.1",
+                "@tapjs/core": "2.0.1",
+                "@tapjs/filter": "2.0.1",
+                "@tapjs/fixture": "2.0.1",
+                "@tapjs/intercept": "2.0.1",
+                "@tapjs/mock": "2.0.1",
+                "@tapjs/node-serialize": "2.0.1",
+                "@tapjs/run": "2.0.2",
+                "@tapjs/snapshot": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/test": "2.0.1",
+                "@tapjs/typescript": "1.4.6",
+                "@tapjs/worker": "2.0.1",
                 "resolve-import": "^1.4.5"
             },
             "bin": {
@@ -4690,13 +4948,13 @@
             }
         },
         "node_modules/tap-parser": {
-            "version": "15.3.1",
-            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-15.3.1.tgz",
-            "integrity": "sha512-hwAtXX5TBGt2MJeYvASc7DjP48PUzA7P8RTbLxQcgKCEH7ICD5IsRco7l5YvkzjHlZbUbeI9wzO8B4hw2sKgnQ==",
+            "version": "16.0.1",
+            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-16.0.1.tgz",
+            "integrity": "sha512-vKianJzSSzLkJ3bHBwzvZDDRi9yGMwkRANJxwPAjAue50owB8rlluYySmTN4tZVH0nsh6stvrQbg9kuCL5svdg==",
             "dev": true,
             "dependencies": {
                 "events-to-array": "^2.0.3",
-                "tap-yaml": "2.2.1"
+                "tap-yaml": "2.2.2"
             },
             "bin": {
                 "tap-parser": "bin/cmd.cjs"
@@ -4706,12 +4964,12 @@
             }
         },
         "node_modules/tap-yaml": {
-            "version": "2.2.1",
-            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-2.2.1.tgz",
-            "integrity": "sha512-ovZuUMLAIH59jnFHXKEGJ+WyDYl6Cuduwg9qpvnqkZOUA1nU84q02Sry1HT0KXcdv2uB91bEKKxnIybBgrb6oA==",
+            "version": "2.2.2",
+            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-2.2.2.tgz",
+            "integrity": "sha512-MWG4OpAKtNoNVjCz/BqlDJiwTM99tiHRhHPS4iGOe1ZS0CgM4jSFH92lthSFvvy4EdDjQZDV7uYqUFlU9JuNhw==",
             "dev": true,
             "dependencies": {
-                "yaml": "^2.3.0",
+                "yaml": "^2.4.1",
                 "yaml-types": "^0.3.0"
             },
             "engines": {
@@ -4719,9 +4977,9 @@
             }
         },
         "node_modules/tar": {
-            "version": "6.2.0",
-            "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
-            "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+            "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
             "dev": true,
             "dependencies": {
                 "chownr": "^2.0.0",
@@ -4834,19 +5092,21 @@
             }
         },
         "node_modules/tshy": {
-            "version": "1.8.2",
-            "resolved": "https://registry.npmjs.org/tshy/-/tshy-1.8.2.tgz",
-            "integrity": "sha512-aGlSY+jkZYAv0YDgtdv1U2vvbGTUdlXmhVP4uegujlJ/wuznmJqSu5cUV/6IW7N7a3HFRhofWvIS/FquYN9zgA==",
+            "version": "1.14.0",
+            "resolved": "https://registry.npmjs.org/tshy/-/tshy-1.14.0.tgz",
+            "integrity": "sha512-YiUujgi4Jb+t2I48LwSRzHkBpniH9WjjktNozn+nlsGmVemKSjDNY7EwBRPvPCr5zAC/3ITAYWH9Z7kUinGSrw==",
             "dev": true,
             "dependencies": {
                 "chalk": "^5.3.0",
-                "chokidar": "^3.5.3",
+                "chokidar": "^3.6.0",
                 "foreground-child": "^3.1.1",
+                "minimatch": "^9.0.4",
                 "mkdirp": "^3.0.1",
-                "resolve-import": "^1.4.4",
+                "polite-json": "^4.0.1",
+                "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.1",
                 "sync-content": "^1.0.2",
-                "typescript": "5.2",
+                "typescript": "^5.4.5",
                 "walk-up-path": "^3.0.1"
             },
             "bin": {
@@ -4878,31 +5138,31 @@
             }
         },
         "node_modules/tshy/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/tshy/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -4915,9 +5175,9 @@
             }
         },
         "node_modules/tshy/node_modules/rimraf": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
             "dev": true,
             "dependencies": {
                 "glob": "^10.3.7"
@@ -4926,7 +5186,7 @@
                 "rimraf": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=14"
+                "node": ">=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
@@ -4938,14 +5198,14 @@
             "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
         },
         "node_modules/tuf-js": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.1.0.tgz",
-            "integrity": "sha512-eD7YPPjVlMzdggrOeE8zwoegUaG/rt6Bt3jwoQPunRiNVzgcCE009UDFJKJjG+Gk9wFu6W/Vi+P5d/5QpdD9jA==",
+            "version": "2.2.1",
+            "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz",
+            "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==",
             "dev": true,
             "dependencies": {
-                "@tufjs/models": "2.0.0",
+                "@tufjs/models": "2.0.1",
                 "debug": "^4.3.4",
-                "make-fetch-happen": "^13.0.0"
+                "make-fetch-happen": "^13.0.1"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
@@ -4974,9 +5234,9 @@
             }
         },
         "node_modules/typescript": {
-            "version": "5.2.2",
-            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
-            "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
+            "version": "5.4.5",
+            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
+            "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
             "dev": true,
             "bin": {
                 "tsc": "bin/tsc",
@@ -5055,9 +5315,9 @@
             }
         },
         "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": {
-            "version": "0.3.20",
-            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
-            "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
+            "version": "0.3.25",
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+            "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
             "dev": true,
             "dependencies": {
                 "@jridgewell/resolve-uri": "^3.1.0",
@@ -5075,13 +5335,10 @@
             }
         },
         "node_modules/validate-npm-package-name": {
-            "version": "5.0.0",
-            "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz",
-            "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==",
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz",
+            "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==",
             "dev": true,
-            "dependencies": {
-                "builtins": "^5.0.0"
-            },
             "engines": {
                 "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
             }
@@ -5238,9 +5495,9 @@
             "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
         },
         "node_modules/ws": {
-            "version": "8.16.0",
-            "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
-            "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
+            "version": "8.17.0",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz",
+            "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==",
             "dev": true,
             "engines": {
                 "node": ">=10.0.0"
@@ -5274,10 +5531,13 @@
             "dev": true
         },
         "node_modules/yaml": {
-            "version": "2.3.4",
-            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
-            "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
+            "version": "2.4.2",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
+            "integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
             "dev": true,
+            "bin": {
+                "yaml": "bin.mjs"
+            },
             "engines": {
                 "node": ">= 14"
             }
@@ -5495,9 +5755,9 @@
             }
         },
         "@isaacs/ts-node-temp-fork-for-pr-2009": {
-            "version": "10.9.5",
-            "resolved": "https://registry.npmjs.org/@isaacs/ts-node-temp-fork-for-pr-2009/-/ts-node-temp-fork-for-pr-2009-10.9.5.tgz",
-            "integrity": "sha512-hEDlwpHhIabtB+Urku8muNMEkGui0LVGlYLS3KoB9QBDf0Pw3r7q0RrfoQmFuk8CvRpGzErO3/vLQd9Ys+/g4g==",
+            "version": "10.9.7",
+            "resolved": "https://registry.npmjs.org/@isaacs/ts-node-temp-fork-for-pr-2009/-/ts-node-temp-fork-for-pr-2009-10.9.7.tgz",
+            "integrity": "sha512-9f0bhUr9TnwwpgUhEpr3FjxSaH/OHaARkE2F9fM0lS4nIs2GNerrvGwQz493dk0JKlTaGYVrKbq36vA/whZ34g==",
             "dev": true,
             "requires": {
                 "@cspotcode/source-map-support": "^0.8.0",
@@ -5528,9 +5788,9 @@
             "dev": true
         },
         "@jridgewell/resolve-uri": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
-            "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+            "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
             "dev": true
         },
         "@jridgewell/sourcemap-codec": {
@@ -5558,6 +5818,42 @@
                 "tslib": "^2.4.1"
             }
         },
+        "@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz",
+            "integrity": "sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==",
+            "optional": true
+        },
+        "@msgpackr-extract/msgpackr-extract-darwin-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.2.tgz",
+            "integrity": "sha512-lwriRAHm1Yg4iDf23Oxm9n/t5Zpw1lVnxYU3HnJPTi2lJRkKTrps1KVgvL6m7WvmhYVt/FIsssWay+k45QHeuw==",
+            "optional": true
+        },
+        "@msgpackr-extract/msgpackr-extract-linux-arm": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.2.tgz",
+            "integrity": "sha512-MOI9Dlfrpi2Cuc7i5dXdxPbFIgbDBGgKR5F2yWEa6FVEtSWncfVNKW5AKjImAQ6CZlBK9tympdsZJ2xThBiWWA==",
+            "optional": true
+        },
+        "@msgpackr-extract/msgpackr-extract-linux-arm64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.2.tgz",
+            "integrity": "sha512-FU20Bo66/f7He9Fp9sP2zaJ1Q8L9uLPZQDub/WlUip78JlPeMbVL8546HbZfcW9LNciEXc8d+tThSJjSC+tmsg==",
+            "optional": true
+        },
+        "@msgpackr-extract/msgpackr-extract-linux-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.2.tgz",
+            "integrity": "sha512-gsWNDCklNy7Ajk0vBBf9jEx04RUxuDQfBse918Ww+Qb9HCPoGzS+XJTLe96iN3BVK7grnLiYghP/M4L8VsaHeA==",
+            "optional": true
+        },
+        "@msgpackr-extract/msgpackr-extract-win32-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.2.tgz",
+            "integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==",
+            "optional": true
+        },
         "@nodelib/fs.scandir": {
             "version": "2.1.5",
             "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5582,37 +5878,37 @@
             }
         },
         "@npmcli/agent": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.0.tgz",
-            "integrity": "sha512-2yThA1Es98orMkpSLVqlDZAMPK3jHJhifP2gnNUdk1754uZ8yI5c+ulCoVG+WlntQA6MzhrURMXjSd9Z7dJ2/Q==",
+            "version": "2.2.2",
+            "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz",
+            "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==",
             "dev": true,
             "requires": {
                 "agent-base": "^7.1.0",
                 "http-proxy-agent": "^7.0.0",
                 "https-proxy-agent": "^7.0.1",
                 "lru-cache": "^10.0.1",
-                "socks-proxy-agent": "^8.0.1"
+                "socks-proxy-agent": "^8.0.3"
             }
         },
         "@npmcli/fs": {
-            "version": "3.1.0",
-            "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz",
-            "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==",
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz",
+            "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==",
             "dev": true,
             "requires": {
                 "semver": "^7.3.5"
             }
         },
         "@npmcli/git": {
-            "version": "5.0.4",
-            "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.4.tgz",
-            "integrity": "sha512-nr6/WezNzuYUppzXRaYu/W4aT5rLxdXqEFupbh6e/ovlYFQ8hpu1UUPV3Ir/YTl+74iXl2ZOMlGzudh9ZPUchQ==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.7.tgz",
+            "integrity": "sha512-WaOVvto604d5IpdCRV2KjQu8PzkfE96d50CQGKgywXh2GxXmDeUO5EWcBC4V57uFyrNqx83+MewuJh3WTR3xPA==",
             "dev": true,
             "requires": {
                 "@npmcli/promise-spawn": "^7.0.0",
                 "lru-cache": "^10.0.1",
                 "npm-pick-manifest": "^9.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "promise-inflight": "^1.0.1",
                 "promise-retry": "^2.0.1",
                 "semver": "^7.3.5",
@@ -5637,9 +5933,9 @@
             }
         },
         "@npmcli/installed-package-contents": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz",
-            "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==",
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.1.0.tgz",
+            "integrity": "sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w==",
             "dev": true,
             "requires": {
                 "npm-bundled": "^3.0.0",
@@ -5652,10 +5948,58 @@
             "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==",
             "dev": true
         },
+        "@npmcli/package-json": {
+            "version": "5.1.1",
+            "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.1.1.tgz",
+            "integrity": "sha512-uTq5j/UqUzbOaOxVy+osfOhpqOiLfUZ0Ut33UbcyyAPJbZcJsf4Mrsyb8r58FoIFlofw0iOFsuCA/oDK14VDJQ==",
+            "dev": true,
+            "requires": {
+                "@npmcli/git": "^5.0.0",
+                "glob": "^10.2.2",
+                "hosted-git-info": "^7.0.0",
+                "json-parse-even-better-errors": "^3.0.0",
+                "normalize-package-data": "^6.0.0",
+                "proc-log": "^4.0.0",
+                "semver": "^7.5.3"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "glob": {
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
+                    "dev": true,
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                }
+            }
+        },
         "@npmcli/promise-spawn": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz",
-            "integrity": "sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz",
+            "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==",
             "dev": true,
             "requires": {
                 "which": "^4.0.0"
@@ -5678,16 +6022,22 @@
                 }
             }
         },
+        "@npmcli/redact": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-1.1.0.tgz",
+            "integrity": "sha512-PfnWuOkQgu7gCbnSsAisaX7hKOdZ4wSAhAzH3/ph5dSGau52kCRrMMGbiSQLwyTZpgldkZ49b0brkOr1AzGBHQ==",
+            "dev": true
+        },
         "@npmcli/run-script": {
-            "version": "7.0.3",
-            "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.3.tgz",
-            "integrity": "sha512-ZMWGLHpzMq3rBGIwPyeaoaleaLMvrBrH8nugHxTi5ACkJZXTxXPtVuEH91ifgtss5hUwJQ2VDnzDBWPmz78rvg==",
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.4.tgz",
+            "integrity": "sha512-9ApYM/3+rBt9V80aYg6tZfzj3UWdiYyCt7gJUD1VJKvWF5nwKDSICXbYIQbspFTq6TOpbsEtIC0LArB8d9PFmg==",
             "dev": true,
             "requires": {
                 "@npmcli/node-gyp": "^3.0.0",
+                "@npmcli/package-json": "^5.0.0",
                 "@npmcli/promise-spawn": "^7.0.0",
                 "node-gyp": "^10.0.0",
-                "read-package-json-fast": "^3.0.0",
                 "which": "^4.0.0"
             },
             "dependencies": {
@@ -5716,101 +6066,133 @@
             "optional": true
         },
         "@sigstore/bundle": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz",
-            "integrity": "sha512-89uOo6yh/oxaU8AeOUnVrTdVMcGk9Q1hJa7Hkvalc6G3Z3CupWk4Xe9djSgJm9fMkH69s0P0cVHUoKSOemLdng==",
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz",
+            "integrity": "sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==",
             "dev": true,
             "requires": {
-                "@sigstore/protobuf-specs": "^0.2.1"
+                "@sigstore/protobuf-specs": "^0.3.2"
             }
         },
+        "@sigstore/core": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-1.1.0.tgz",
+            "integrity": "sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg==",
+            "dev": true
+        },
         "@sigstore/protobuf-specs": {
-            "version": "0.2.1",
-            "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz",
-            "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==",
+            "version": "0.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz",
+            "integrity": "sha512-c6B0ehIWxMI8wiS/bj6rHMPqeFvngFV7cDU/MY+B16P9Z3Mp9k8L93eYZ7BYzSickzuqAQqAq0V956b3Ju6mLw==",
             "dev": true
         },
         "@sigstore/sign": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.2.0.tgz",
-            "integrity": "sha512-AAbmnEHDQv6CSfrWA5wXslGtzLPtAtHZleKOgxdQYvx/s76Fk6T6ZVt7w2IGV9j1UrFeBocTTQxaXG2oRrDhYA==",
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.3.2.tgz",
+            "integrity": "sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA==",
             "dev": true,
             "requires": {
-                "@sigstore/bundle": "^2.1.0",
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "make-fetch-happen": "^13.0.0"
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.0.0",
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "make-fetch-happen": "^13.0.1",
+                "proc-log": "^4.2.0",
+                "promise-retry": "^2.0.1"
             }
         },
         "@sigstore/tuf": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.2.0.tgz",
-            "integrity": "sha512-KKATZ5orWfqd9ZG6MN8PtCIx4eevWSuGRKQvofnWXRpyMyUEpmrzg5M5BrCpjM+NfZ0RbNGOh5tCz/P2uoRqOA==",
+            "version": "2.3.4",
+            "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.4.tgz",
+            "integrity": "sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw==",
+            "dev": true,
+            "requires": {
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "tuf-js": "^2.2.1"
+            }
+        },
+        "@sigstore/verify": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.2.1.tgz",
+            "integrity": "sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==",
             "dev": true,
             "requires": {
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "tuf-js": "^2.1.0"
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.1.0",
+                "@sigstore/protobuf-specs": "^0.3.2"
             }
         },
         "@tapjs/after": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/after/-/after-1.1.17.tgz",
-            "integrity": "sha512-14qeP+mHZ8nIMDGtdCwTgvKclLlHxfARMTasb9fw//tmF/8ZDZhTemtCDxAP75wihxy5P7nzVZo/6TpVeOZrwg==",
+            "version": "1.1.24",
+            "resolved": "https://registry.npmjs.org/@tapjs/after/-/after-1.1.24.tgz",
+            "integrity": "sha512-Qys3CtftkfHGC7thDGm9TBzRCBLAoJKrXufF1zQxI1oNUjclWZP/s8CtHH0mwUTISOTehmBLV3wPPHSslD67Ng==",
             "dev": true,
             "requires": {
-                "is-actual-promise": "^1.0.0"
+                "is-actual-promise": "^1.0.1"
             }
         },
         "@tapjs/after-each": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/after-each/-/after-each-1.1.17.tgz",
-            "integrity": "sha512-ia8sr00Wilni+2+wO4MKYCYikeRwUC41HamV8EPN63R2UmiBEOe/cMSf+KYADIh56JvxAiH7Xa0+GSFU+N2FQQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/after-each/-/after-each-2.0.1.tgz",
+            "integrity": "sha512-3JXIJ4g9LPjyXmn/1VuIMC0vh7uBgUpQPksjffxv0rL8wq4C8lvmqt8Qu/fVImJucqzA+WrRqVG1b2Ab0ocDOw==",
             "dev": true,
             "requires": {
                 "function-loop": "^4.0.0"
             }
         },
         "@tapjs/asserts": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/asserts/-/asserts-1.1.17.tgz",
-            "integrity": "sha512-eKmbWBORDXu9bUHtPTu7qFrXNj5UeeH2nABJeP9BGHIn2ydmTgMEWCO3E+ljf7tisHchY5/x672lr99+O/mbTQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/asserts/-/asserts-2.0.1.tgz",
+            "integrity": "sha512-v2xYDLUwMGt8pzoY5LIjDCaw2NM+G01NW4pC3RcpsZLZbzQv1x/phi2RAX0ixI0nCmZZybqRygFKuMcJamS+gg==",
             "dev": true,
             "requires": {
-                "@tapjs/stack": "1.2.7",
-                "is-actual-promise": "^1.0.0",
-                "tcompare": "6.4.5",
+                "@tapjs/stack": "2.0.1",
+                "is-actual-promise": "^1.0.1",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
+            },
+            "dependencies": {
+                "tcompare": {
+                    "version": "7.0.1",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+                    "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^5.2.0",
+                        "react-element-to-jsx-string": "^15.0.0"
+                    }
+                }
             }
         },
         "@tapjs/before": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/before/-/before-1.1.17.tgz",
-            "integrity": "sha512-pAmEAIMIqF9MPNUgEsnuWCM00iD/FJOX0P5eXSsWexWHjuZAkv5tIT/4qpXO9KYj+9c51Lh+7YSY2Xvk1Jjolw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/before/-/before-2.0.1.tgz",
+            "integrity": "sha512-GgnlWPm2PbuyYuG4gkkO2KAvT/BbGnpKs60U4XzPSJ2w73Qc/IYWP0Kz6qfCWongpiLteoco67M89ujUQApYJw==",
             "dev": true,
             "requires": {
-                "is-actual-promise": "^1.0.0"
+                "is-actual-promise": "^1.0.1"
             }
         },
         "@tapjs/before-each": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/before-each/-/before-each-1.1.17.tgz",
-            "integrity": "sha512-d2Um3Y2j0m563QNsSxczh+QeSg5sBngnBFGOelUtQVqmq91oNWU/7mY1pwN6ip8mMIQYD75CIhq5/Z57DGomWQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/before-each/-/before-each-2.0.1.tgz",
+            "integrity": "sha512-gG1nYkvCHtWwhkueulO475KczdQZ3vBRgdkta/Qi42ZjZo6SNhYVjNc/+LRGV5vZoESrvgSd+JrDRGufd+j43w==",
             "dev": true,
             "requires": {
                 "function-loop": "^4.0.0"
             }
         },
         "@tapjs/config": {
-            "version": "2.4.14",
-            "resolved": "https://registry.npmjs.org/@tapjs/config/-/config-2.4.14.tgz",
-            "integrity": "sha512-dkjPVJGbLJC9BxCAxudAGiijnKc6XcQbpBSMAGJ/+VoRSqXlPkMWz0d8Ad3rNt7s+g2GBEWBx1kV7wcKtLlxmw==",
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/config/-/config-3.0.1.tgz",
+            "integrity": "sha512-gAYFzErdSuPQ3afW6iRR99hiJmRLU+x9T+NE89z9UM45iPxglWLrRv1PFfh3tmtX6rpzwD5RY4/FVPcP2+/1LQ==",
             "dev": true,
             "requires": {
-                "@tapjs/core": "1.4.6",
-                "@tapjs/test": "1.3.17",
+                "@tapjs/core": "2.0.1",
+                "@tapjs/test": "2.0.1",
                 "chalk": "^5.2.0",
-                "jackspeak": "^2.3.6",
+                "jackspeak": "^3.1.2",
                 "polite-json": "^4.0.1",
-                "tap-yaml": "2.2.1",
+                "tap-yaml": "2.2.2",
                 "walk-up-path": "^3.0.1"
             },
             "dependencies": {
@@ -5823,45 +6205,57 @@
             }
         },
         "@tapjs/core": {
-            "version": "1.4.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/core/-/core-1.4.6.tgz",
-            "integrity": "sha512-cAKtdGJslrziwi/RJBU7jF930P/eSsemv295t6yLekNVP0XUCNtLFYirxuS1Xwob0nt0g/k+94xXB7o1wdTQvA==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/core/-/core-2.0.1.tgz",
+            "integrity": "sha512-q+8d+ohw5kudktIqgP5ETBcPWAPip+kMIxs2eL2G3dV+7Gc8WrH43cCPrbSGPRITIOSIDPrtpQZEcZwQNqDdQw==",
             "dev": true,
             "requires": {
-                "@tapjs/processinfo": "^3.1.6",
-                "@tapjs/stack": "1.2.7",
-                "@tapjs/test": "1.3.17",
+                "@tapjs/processinfo": "^3.1.7",
+                "@tapjs/stack": "2.0.1",
+                "@tapjs/test": "2.0.1",
                 "async-hook-domain": "^4.0.1",
-                "diff": "^5.1.0",
-                "is-actual-promise": "^1.0.0",
-                "minipass": "^7.0.3",
+                "diff": "^5.2.0",
+                "is-actual-promise": "^1.0.1",
+                "minipass": "^7.0.4",
                 "signal-exit": "4.1",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5",
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
+            },
+            "dependencies": {
+                "tcompare": {
+                    "version": "7.0.1",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+                    "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^5.2.0",
+                        "react-element-to-jsx-string": "^15.0.0"
+                    }
+                }
             }
         },
         "@tapjs/error-serdes": {
-            "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/@tapjs/error-serdes/-/error-serdes-1.2.1.tgz",
-            "integrity": "sha512-/7eLEcrGo+Qz3eWrjkhDC+VSEOjabkkzr9eRADeU+OLFeZaik8L/GRk0SGhnp4YsQkv0jcNV00A42bEx2HIZcw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/error-serdes/-/error-serdes-2.0.1.tgz",
+            "integrity": "sha512-P+M4rtcfkDsUveKKmoRNF+07xpbPnRY5KrstIUOnyn483clQ7BJhsnWr162yYNCsyOj4zEfZmAJI1f8Bi7h/ZA==",
             "dev": true,
             "requires": {
-                "minipass": "^7.0.3"
+                "minipass": "^7.0.4"
             }
         },
         "@tapjs/filter": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/filter/-/filter-1.2.17.tgz",
-            "integrity": "sha512-ytsqoPThV92ML1+M+cHlhAS7nOQpDNRBJiPqw20/GmNeoQXsDzVUlWR89DP3WNNUPrr/c1pCVr9XHVhCIeYk0w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/filter/-/filter-2.0.1.tgz",
+            "integrity": "sha512-muKEeXK7Tz6VR4hjXfT2qXPvjYES575mtiRerjHf+8qP8D7MvmC8qDZJjzFdo1nZHKhF8snvFosIVuI1BAhvsw==",
             "dev": true,
             "requires": {}
         },
         "@tapjs/fixture": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/fixture/-/fixture-1.2.17.tgz",
-            "integrity": "sha512-eOOQxtsEcQ/sBxaZhpqdF9DCNxXAvLuiE5HgyL6d1eB4eceu57uIUKK7NDtFVv+vlbQH/NoiSTxmN/IBRbKT8w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/fixture/-/fixture-2.0.1.tgz",
+            "integrity": "sha512-MLgEwsBlCD69iUbZcnKBehP2js5cV4p5GrFoOKSudMuH2DQJInaF/g2bkijue61cVZwPj/MRPCqAlkwA94epjg==",
             "dev": true,
             "requires": {
                 "mkdirp": "^3.0.0",
@@ -5878,31 +6272,31 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
                 },
                 "rimraf": {
-                    "version": "5.0.5",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+                    "version": "5.0.7",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+                    "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
                     "dev": true,
                     "requires": {
                         "glob": "^10.3.7"
@@ -5911,42 +6305,42 @@
             }
         },
         "@tapjs/intercept": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/intercept/-/intercept-1.2.17.tgz",
-            "integrity": "sha512-CNuYBxiFBMNALS1PxH3yGI10H8ObxOoD67C2xGWyzXeYrPJ/R4x31Sda9bqaoK3uf/vj28bC9kSECCFjRsNAEg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/intercept/-/intercept-2.0.1.tgz",
+            "integrity": "sha512-BZgXE3zCAbv4lfbph1r85gihtI3kXltHlFQ8Bf3Yy9fx27DKQlBvXnD7T69ke8kQLRzhz+wTMcR/mcQjo1fa7w==",
             "dev": true,
             "requires": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/stack": "1.2.7"
+                "@tapjs/after": "1.1.24",
+                "@tapjs/stack": "2.0.1"
             }
         },
         "@tapjs/mock": {
-            "version": "1.2.15",
-            "resolved": "https://registry.npmjs.org/@tapjs/mock/-/mock-1.2.15.tgz",
-            "integrity": "sha512-uXfVNDAMAbCGOu46B9jbryTau2pLSQjCdWnkAm/OUgZh/OtO0i7OORz9HdEPfEF2tuy1tLo9+vsCZm3lPU5F7w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/mock/-/mock-2.0.1.tgz",
+            "integrity": "sha512-i1vkwNgO7uEuQW3+hTuE2L64aC9xk0cC3PtC6DZKqyApk2IstNgoIS38nfsI6v2kvEgZNuWlsNcRAYNDOIEhzA==",
             "dev": true,
             "requires": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/stack": "1.2.7",
+                "@tapjs/after": "1.1.24",
+                "@tapjs/stack": "2.0.1",
                 "resolve-import": "^1.4.5",
                 "walk-up-path": "^3.0.1"
             }
         },
         "@tapjs/node-serialize": {
-            "version": "1.2.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/node-serialize/-/node-serialize-1.2.6.tgz",
-            "integrity": "sha512-xj1OJEsdTr0pQFlirfe/apN0dHUCMCx2Nm5H3SoiSOW4D1/FUKS65VZpWgo3mXMPxRyb/2T1DH3xON1eSGq4ww==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/node-serialize/-/node-serialize-2.0.1.tgz",
+            "integrity": "sha512-1GtHDa7AXpk8y08llIPfUKRTDNsq+BhXxz7wiIfVEAOEB09kGyfpWteOg+cmvb+aHU1Ays3z+medXTIBm0D5Kg==",
             "dev": true,
             "requires": {
-                "@tapjs/error-serdes": "1.2.1",
-                "@tapjs/stack": "1.2.7",
-                "tap-parser": "15.3.1"
+                "@tapjs/error-serdes": "2.0.1",
+                "@tapjs/stack": "2.0.1",
+                "tap-parser": "16.0.1"
             }
         },
         "@tapjs/processinfo": {
-            "version": "3.1.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/processinfo/-/processinfo-3.1.6.tgz",
-            "integrity": "sha512-ktDsaf79wJsLaoG1Pp+stHSRf6a1k/JydoRAaYVG5iJnd3DooL6yewZsciUi2yiN/WQc5tAXCIFTXL4uXGB8LA==",
+            "version": "3.1.7",
+            "resolved": "https://registry.npmjs.org/@tapjs/processinfo/-/processinfo-3.1.7.tgz",
+            "integrity": "sha512-SI5RJQ5HnUKEWnHSAF6hOm6XPdnjZ+CJzIaVHdFebed8iDAPTqb+IwMVu9yq9+VQ7FRsMMlgLL2SW4rss2iJbQ==",
             "dev": true,
             "requires": {
                 "pirates": "^4.0.5",
@@ -5956,24 +6350,24 @@
             }
         },
         "@tapjs/reporter": {
-            "version": "1.3.15",
-            "resolved": "https://registry.npmjs.org/@tapjs/reporter/-/reporter-1.3.15.tgz",
-            "integrity": "sha512-us1vXd6TW1V8wJxxnP2a8DNSP1WFTpODyYukqWg7ym5nCalREYnz2MFsn65rRNu/xJlmqsmv+9P63rupud7Zlg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/reporter/-/reporter-2.0.1.tgz",
+            "integrity": "sha512-fCdl4vg8vnlqIYtTQ9dc3zOqeXrA5QbATbT4dsPIiPuCM3gvKTbntaNBeaWWZkPx697Dj+b8TIxT/xhNMNv7jQ==",
             "dev": true,
             "requires": {
-                "@tapjs/config": "2.4.14",
-                "@tapjs/stack": "1.2.7",
+                "@tapjs/config": "3.0.1",
+                "@tapjs/stack": "2.0.1",
                 "chalk": "^5.2.0",
                 "ink": "^4.4.1",
-                "minipass": "^7.0.3",
+                "minipass": "^7.0.4",
                 "ms": "^2.1.3",
                 "patch-console": "^2.0.0",
                 "prismjs-terminal": "^1.2.3",
                 "react": "^18.2.0",
                 "string-length": "^6.0.0",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5"
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1"
             },
             "dependencies": {
                 "chalk": {
@@ -5987,39 +6381,49 @@
                     "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
                     "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
                     "dev": true
+                },
+                "tcompare": {
+                    "version": "7.0.1",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+                    "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^5.2.0",
+                        "react-element-to-jsx-string": "^15.0.0"
+                    }
                 }
             }
         },
         "@tapjs/run": {
-            "version": "1.4.16",
-            "resolved": "https://registry.npmjs.org/@tapjs/run/-/run-1.4.16.tgz",
-            "integrity": "sha512-ZTESjBDj5SitZgWz2hQdzfBoxgaFs89jQjWzqobcdfro0iF7TVRpSrvpz9GTMdo2Tu9aeFfMNfmaAtwNWnDabw==",
-            "dev": true,
-            "requires": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/config": "2.4.14",
-                "@tapjs/processinfo": "^3.1.6",
-                "@tapjs/reporter": "1.3.15",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/test": "1.3.17",
-                "c8": "^8.0.1",
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/@tapjs/run/-/run-2.0.2.tgz",
+            "integrity": "sha512-2hPGlabqbLb3hh4BHHvwE8R9a9OiWumkCkHw5QQUZurDsVOpB94FfteqW9mktTVjZJnN0go+sN3GN2jZUaPWGQ==",
+            "dev": true,
+            "requires": {
+                "@tapjs/after": "1.1.24",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/config": "3.0.1",
+                "@tapjs/processinfo": "^3.1.7",
+                "@tapjs/reporter": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/test": "2.0.1",
+                "c8": "^9.1.0",
                 "chalk": "^5.3.0",
-                "chokidar": "^3.5.3",
+                "chokidar": "^3.6.0",
                 "foreground-child": "^3.1.1",
-                "glob": "^10.3.10",
-                "minipass": "^7.0.3",
+                "glob": "^10.3.16",
+                "minipass": "^7.0.4",
                 "mkdirp": "^3.0.1",
                 "opener": "^1.5.2",
-                "pacote": "^17.0.3",
+                "pacote": "^17.0.6",
                 "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.5",
-                "semver": "^7.5.4",
+                "semver": "^7.6.0",
                 "signal-exit": "^4.1.0",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5",
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0",
                 "which": "^4.0.0"
             },
@@ -6040,16 +6444,16 @@
                     "dev": true
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "isexe": {
@@ -6059,23 +6463,33 @@
                     "dev": true
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
                 },
                 "rimraf": {
-                    "version": "5.0.5",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+                    "version": "5.0.7",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+                    "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
                     "dev": true,
                     "requires": {
                         "glob": "^10.3.7"
                     }
                 },
+                "tcompare": {
+                    "version": "7.0.1",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+                    "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^5.2.0",
+                        "react-element-to-jsx-string": "^15.0.0"
+                    }
+                },
                 "which": {
                     "version": "4.0.0",
                     "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
@@ -6088,67 +6502,80 @@
             }
         },
         "@tapjs/snapshot": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/snapshot/-/snapshot-1.2.17.tgz",
-            "integrity": "sha512-xDHys854ZA8s/1uCkE5PgBz4H1vYKChD6a4xjLVkaoRxpBHVp/IJZCD+8d69DRGnyuA4x2MGh0JLClTA9bLGrA==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/snapshot/-/snapshot-2.0.1.tgz",
+            "integrity": "sha512-ZnbCxL+9fiJ38tec6wvRtRBZz9ChRUq0Bov7dltdZMNkXqudKyB+Zzbg25bqDEIgcczyp6A9hOwTX6VybDGqpg==",
             "dev": true,
             "requires": {
-                "is-actual-promise": "^1.0.0",
-                "tcompare": "6.4.5",
+                "is-actual-promise": "^1.0.1",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
+            },
+            "dependencies": {
+                "tcompare": {
+                    "version": "7.0.1",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+                    "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^5.2.0",
+                        "react-element-to-jsx-string": "^15.0.0"
+                    }
+                }
             }
         },
         "@tapjs/spawn": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/spawn/-/spawn-1.1.17.tgz",
-            "integrity": "sha512-Bbyxd91bgXEcglvXYKrRl2MaNHk00RajTZJ1kKe3Scr1ivaYv0maE6ZInAl4UE0a4SJl4Dskec+uKoZY3qGUYQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/spawn/-/spawn-2.0.1.tgz",
+            "integrity": "sha512-3VaQKJjHV5frMZj3Ef+QlJyB6b7VsGMil223zAEz8Ttgy2hDYtcb29nvsLPUcowFyOUrsydnXEnHgpR79wEPOA==",
             "dev": true,
             "requires": {}
         },
         "@tapjs/stack": {
-            "version": "1.2.7",
-            "resolved": "https://registry.npmjs.org/@tapjs/stack/-/stack-1.2.7.tgz",
-            "integrity": "sha512-7qUDWDmd+y7ZQ0vTrDTvFlWnJ+ND32NemS5HVuT1ZggHtBwJ62PQHIyCx/B5RopETBb6NvFPfUE21yTiex9Jkw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/stack/-/stack-2.0.1.tgz",
+            "integrity": "sha512-3rKbZkRkLeJl9ilV/6b80YfI4C4+OYf7iEz5/d0MIVhmVvxv0ttIy5JnZutAc4Gy9eRp5Ne5UTAIFOVY5k36cg==",
             "dev": true
         },
         "@tapjs/stdin": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/stdin/-/stdin-1.1.17.tgz",
-            "integrity": "sha512-mDutFFPDnlVM2oYDAfyYKA+fC+aEiyz5n08D8x6YAbwZNbTIVp+h6ucyp7ygJ04fshd4l3s1HUmCZLSmHb2xEw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/stdin/-/stdin-2.0.1.tgz",
+            "integrity": "sha512-5Oe13Fzpnt9seAi8h3bsMxtJp8S+DQI6ncBD9JBcS91XKLbqyKrb1bNzeXQN2PrHBs6Atw8cOzFZh0TjL+bIaA==",
             "dev": true,
             "requires": {}
         },
         "@tapjs/test": {
-            "version": "1.3.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/test/-/test-1.3.17.tgz",
-            "integrity": "sha512-yQ4uHC2GaDS+Gr5qwx9uMGxqvpYgnlVY+QexBReSeYZthWIN0KD8HDvnVt4An5Sx/Qhd7UlnNpNMBd6AkvPEew==",
-            "dev": true,
-            "requires": {
-                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5",
-                "@tapjs/after": "1.1.17",
-                "@tapjs/after-each": "1.1.17",
-                "@tapjs/asserts": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/before-each": "1.1.17",
-                "@tapjs/filter": "1.2.17",
-                "@tapjs/fixture": "1.2.17",
-                "@tapjs/intercept": "1.2.17",
-                "@tapjs/mock": "1.2.15",
-                "@tapjs/node-serialize": "1.2.6",
-                "@tapjs/snapshot": "1.2.17",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/typescript": "1.3.6",
-                "@tapjs/worker": "1.1.17",
-                "glob": "^10.3.10",
-                "jackspeak": "^2.3.6",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/test/-/test-2.0.1.tgz",
+            "integrity": "sha512-PKazf7r4+bLFATML2f/h8glGcSirXmzXUYlhFuxb4xHoOhHojyKgo1p8kSj+Ksxb3hVSCQlvyXgM8QYYaoMwog==",
+            "dev": true,
+            "requires": {
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.7",
+                "@tapjs/after": "1.1.24",
+                "@tapjs/after-each": "2.0.1",
+                "@tapjs/asserts": "2.0.1",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/before-each": "2.0.1",
+                "@tapjs/filter": "2.0.1",
+                "@tapjs/fixture": "2.0.1",
+                "@tapjs/intercept": "2.0.1",
+                "@tapjs/mock": "2.0.1",
+                "@tapjs/node-serialize": "2.0.1",
+                "@tapjs/snapshot": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/typescript": "1.4.6",
+                "@tapjs/worker": "2.0.1",
+                "glob": "^10.3.16",
+                "jackspeak": "^3.1.2",
                 "mkdirp": "^3.0.0",
                 "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.5",
                 "sync-content": "^1.0.1",
-                "tap-parser": "15.3.1",
-                "tshy": "^1.2.2",
-                "typescript": "5.2"
+                "tap-parser": "16.0.1",
+                "tshy": "^1.14.0",
+                "typescript": "5.4",
+                "walk-up-path": "^3.0.1"
             },
             "dependencies": {
                 "brace-expansion": {
@@ -6161,31 +6588,31 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
                 },
                 "rimraf": {
-                    "version": "5.0.5",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+                    "version": "5.0.7",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+                    "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
                     "dev": true,
                     "requires": {
                         "glob": "^10.3.7"
@@ -6194,43 +6621,43 @@
             }
         },
         "@tapjs/typescript": {
-            "version": "1.3.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/typescript/-/typescript-1.3.6.tgz",
-            "integrity": "sha512-bHqQb06HcD1vFvSwElH0WK4cnCNthvA5OX/KBs5w1TNFHIeRHemp/hsSnGSNDwYwDETuOxD68rDZNTpNbzysBg==",
+            "version": "1.4.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/typescript/-/typescript-1.4.6.tgz",
+            "integrity": "sha512-6jxUQ7Mdb+Y2q8RJcwgZZ6dCR+X2u3hCL+xb1GDAtO7k1+B6z2b+z+I+FdhuO4YgrP0SLRjocL5rJM/xi9K7qw==",
             "dev": true,
             "requires": {
-                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5"
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.7"
             }
         },
         "@tapjs/worker": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/worker/-/worker-1.1.17.tgz",
-            "integrity": "sha512-DCRzEBT+OgP518rQqzlX6KawvGTegkeEjPVa/TB6Iifj8WOHJ+XtunkR7riIRGEoCEOMD49DCJXj70c+XP0jNw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/worker/-/worker-2.0.1.tgz",
+            "integrity": "sha512-wegz8IxNEPIIAA+R76/avZgNmZ4iC7QGFbtXKGBU962/1lXTITxshRV6e21r0IBa7YLkSVgDuVSVB3+Qzve0Yg==",
             "dev": true,
             "requires": {}
         },
         "@tsconfig/node14": {
-            "version": "14.1.0",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.0.tgz",
-            "integrity": "sha512-VmsCG04YR58ciHBeJKBDNMWWfYbyP8FekWVuTlpstaUPlat1D0x/tXzkWP7yCMU0eSz9V4OZU0LBWTFJ3xZf6w==",
+            "version": "14.1.2",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.2.tgz",
+            "integrity": "sha512-1vncsbfCZ3TBLPxesRYz02Rn7SNJfbLoDVkcZ7F/ixOV6nwxwgdhD1mdPcc5YQ413qBJ8CvMxXMFfJ7oawjo7Q==",
             "dev": true
         },
         "@tsconfig/node16": {
-            "version": "16.1.1",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.1.tgz",
-            "integrity": "sha512-+pio93ejHN4nINX4pXqfnR/fPLRtJBaT4ORaa5RH0Oc1zoYmo2B2koG+M328CQhHKn1Wj6FcOxCDFXAot9NhvA==",
+            "version": "16.1.3",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.3.tgz",
+            "integrity": "sha512-9nTOUBn+EMKO6rtSZJk+DcqsfgtlERGT9XPJ5PRj/HNENPCBY1yu/JEj5wT6GLtbCLBO2k46SeXDaY0pjMqypw==",
             "dev": true
         },
         "@tsconfig/node18": {
-            "version": "18.2.2",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.2.tgz",
-            "integrity": "sha512-d6McJeGsuoRlwWZmVIeE8CUA27lu6jLjvv1JzqmpsytOYYbVi1tHZEnwCNVOXnj4pyLvneZlFlpXUK+X9wBWyw==",
+            "version": "18.2.4",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.4.tgz",
+            "integrity": "sha512-5xxU8vVs9/FNcvm3gE07fPbn9tl6tqGGWA9tSlwsUEkBxtRnTsNmwrV8gasZ9F/EobaSv9+nu8AxUKccw77JpQ==",
             "dev": true
         },
         "@tsconfig/node20": {
-            "version": "20.1.2",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.2.tgz",
-            "integrity": "sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ==",
+            "version": "20.1.4",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz",
+            "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==",
             "dev": true
         },
         "@tufjs/canonical-json": {
@@ -6240,13 +6667,13 @@
             "dev": true
         },
         "@tufjs/models": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.0.tgz",
-            "integrity": "sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.1.tgz",
+            "integrity": "sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==",
             "dev": true,
             "requires": {
                 "@tufjs/canonical-json": "2.0.0",
-                "minimatch": "^9.0.3"
+                "minimatch": "^9.0.4"
             },
             "dependencies": {
                 "brace-expansion": {
@@ -6259,9 +6686,9 @@
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
@@ -6276,9 +6703,9 @@
             "dev": true
         },
         "@types/node": {
-            "version": "20.10.6",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz",
-            "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==",
+            "version": "20.13.0",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.13.0.tgz",
+            "integrity": "sha512-FM6AOb3khNkNIXPnHFDYaHerSv8uN22C91z098AnGccVu+Pcdhi+pNUFDi0iLmPIsVE0JBD0KVS7mzUYt4nRzQ==",
             "dev": true,
             "peer": true,
             "requires": {
@@ -6303,15 +6730,15 @@
             "requires": {}
         },
         "acorn-walk": {
-            "version": "8.3.1",
-            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz",
-            "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==",
+            "version": "8.3.2",
+            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
+            "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
             "dev": true
         },
         "agent-base": {
-            "version": "7.1.0",
-            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
-            "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==",
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
+            "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
             "dev": true,
             "requires": {
                 "debug": "^4.3.4"
@@ -6347,21 +6774,10 @@
             }
         },
         "ansi-escapes": {
-            "version": "6.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz",
-            "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==",
-            "dev": true,
-            "requires": {
-                "type-fest": "^3.0.0"
-            },
-            "dependencies": {
-                "type-fest": {
-                    "version": "3.13.1",
-                    "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
-                    "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
-                    "dev": true
-                }
-            }
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz",
+            "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==",
+            "dev": true
         },
         "ansi-regex": {
             "version": "5.0.1",
@@ -6438,57 +6854,29 @@
                 "fill-range": "^7.0.1"
             }
         },
-        "builtins": {
-            "version": "5.0.1",
-            "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz",
-            "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==",
-            "dev": true,
-            "requires": {
-                "semver": "^7.0.0"
-            }
-        },
         "c8": {
-            "version": "8.0.1",
-            "resolved": "https://registry.npmjs.org/c8/-/c8-8.0.1.tgz",
-            "integrity": "sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==",
+            "version": "9.1.0",
+            "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz",
+            "integrity": "sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==",
             "dev": true,
             "requires": {
                 "@bcoe/v8-coverage": "^0.2.3",
                 "@istanbuljs/schema": "^0.1.3",
                 "find-up": "^5.0.0",
-                "foreground-child": "^2.0.0",
+                "foreground-child": "^3.1.1",
                 "istanbul-lib-coverage": "^3.2.0",
                 "istanbul-lib-report": "^3.0.1",
                 "istanbul-reports": "^3.1.6",
-                "rimraf": "^3.0.2",
                 "test-exclude": "^6.0.0",
                 "v8-to-istanbul": "^9.0.0",
                 "yargs": "^17.7.2",
                 "yargs-parser": "^21.1.1"
-            },
-            "dependencies": {
-                "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": {
-                        "cross-spawn": "^7.0.0",
-                        "signal-exit": "^3.0.2"
-                    }
-                },
-                "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
-                }
             }
         },
         "cacache": {
-            "version": "18.0.2",
-            "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.2.tgz",
-            "integrity": "sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw==",
+            "version": "18.0.3",
+            "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.3.tgz",
+            "integrity": "sha512-qXCd4rh6I07cnDqh8V48/94Tc/WSfj+o3Gn6NZ0aZovS255bUx8O13uKxRFd2eWG0xgsco7+YItQNPaa5E85hg==",
             "dev": true,
             "requires": {
                 "@npmcli/fs": "^3.1.0",
@@ -6515,22 +6903,22 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
@@ -6553,9 +6941,9 @@
             }
         },
         "chokidar": {
-            "version": "3.5.3",
-            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
-            "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+            "version": "3.6.0",
+            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+            "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
             "dev": true,
             "requires": {
                 "anymatch": "~3.1.2",
@@ -6719,6 +7107,11 @@
             "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
             "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="
         },
+        "compress-json": {
+            "version": "3.0.5",
+            "resolved": "https://registry.npmjs.org/compress-json/-/compress-json-3.0.5.tgz",
+            "integrity": "sha512-HYiJvE0cTIygI9zXqY5fkRr7H3NV3UAME0enzwN5M0JkzMOtUcjSyaH7HxVRzXsn7IIXD0STA9M5jyWkxERSLg=="
+        },
         "concat-map": {
             "version": "0.0.1",
             "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -6760,9 +7153,9 @@
             "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
         },
         "diff": {
-            "version": "5.1.0",
-            "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
-            "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
+            "version": "5.2.0",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
+            "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
             "dev": true
         },
         "doctrine": {
@@ -6808,9 +7201,9 @@
             "dev": true
         },
         "escalade": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
-            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
+            "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
             "dev": true
         },
         "escape-string-regexp": {
@@ -6990,6 +7383,11 @@
             "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
             "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
         },
+        "flexsearch": {
+            "version": "0.7.43",
+            "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.7.43.tgz",
+            "integrity": "sha512-c5o/+Um8aqCSOXGcZoqZOm+NqtVwNsvVpWv6lfmSclU954O3wvQKxxK8zj74fPaSJbXpSLTs4PRhh+wnoCXnKg=="
+        },
         "foreground-child": {
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
@@ -7091,9 +7489,9 @@
             "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
         },
         "hasown": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
-            "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+            "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
             "dev": true,
             "requires": {
                 "function-bind": "^1.1.2"
@@ -7105,9 +7503,9 @@
             "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
         },
         "hosted-git-info": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz",
-            "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
+            "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==",
             "dev": true,
             "requires": {
                 "lru-cache": "^10.0.1"
@@ -7126,9 +7524,9 @@
             "dev": true
         },
         "http-proxy-agent": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz",
-            "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+            "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
             "dev": true,
             "requires": {
                 "agent-base": "^7.1.0",
@@ -7136,9 +7534,9 @@
             }
         },
         "https-proxy-agent": {
-            "version": "7.0.2",
-            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz",
-            "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==",
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz",
+            "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==",
             "dev": true,
             "requires": {
                 "agent-base": "^7.0.2",
@@ -7161,9 +7559,9 @@
             "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ=="
         },
         "ignore-walk": {
-            "version": "6.0.4",
-            "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz",
-            "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==",
+            "version": "6.0.5",
+            "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.5.tgz",
+            "integrity": "sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A==",
             "dev": true,
             "requires": {
                 "minimatch": "^9.0.0"
@@ -7179,9 +7577,9 @@
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
@@ -7284,21 +7682,22 @@
                 }
             }
         },
-        "ip": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
-            "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
-            "dev": true
-        },
-        "is-actual-promise": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/is-actual-promise/-/is-actual-promise-1.0.1.tgz",
-            "integrity": "sha512-PlsL4tNv62lx5yN2HSqaRSTgIpUAPW7U6+crVB8HfWm5161rZpeqWbl0ZSqH2MAfRKXWSZVPRNbE/r8qPcb13g==",
+        "ip-address": {
+            "version": "9.0.5",
+            "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
+            "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
             "dev": true,
             "requires": {
-                "tshy": "^1.7.0"
+                "jsbn": "1.1.0",
+                "sprintf-js": "^1.1.3"
             }
         },
+        "is-actual-promise": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/is-actual-promise/-/is-actual-promise-1.0.2.tgz",
+            "integrity": "sha512-xsFiO1of0CLsQnPZ1iXHNTyR9YszOeWKYv+q6n8oSFW3ipooFJ1j1lbRMgiMCr+pp2gLruESI4zb5Ak6eK5OnQ==",
+            "dev": true
+        },
         "is-binary-path": {
             "version": "2.1.0",
             "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -7409,9 +7808,9 @@
             }
         },
         "istanbul-reports": {
-            "version": "3.1.6",
-            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz",
-            "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==",
+            "version": "3.1.7",
+            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
+            "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
             "dev": true,
             "requires": {
                 "html-escaper": "^2.0.0",
@@ -7419,9 +7818,9 @@
             }
         },
         "jackspeak": {
-            "version": "2.3.6",
-            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
-            "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.1.2.tgz",
+            "integrity": "sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==",
             "dev": true,
             "requires": {
                 "@isaacs/cliui": "^8.0.2",
@@ -7452,10 +7851,16 @@
             "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
             "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
         },
+        "jsbn": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
+            "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
+            "dev": true
+        },
         "json-parse-even-better-errors": {
-            "version": "3.0.1",
-            "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz",
-            "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==",
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz",
+            "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==",
             "dev": true
         },
         "json-schema-traverse": {
@@ -7512,9 +7917,9 @@
             }
         },
         "lru-cache": {
-            "version": "10.1.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz",
-            "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==",
+            "version": "10.2.2",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
+            "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
             "dev": true
         },
         "make-dir": {
@@ -7533,9 +7938,9 @@
             "dev": true
         },
         "make-fetch-happen": {
-            "version": "13.0.0",
-            "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz",
-            "integrity": "sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==",
+            "version": "13.0.1",
+            "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz",
+            "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==",
             "dev": true,
             "requires": {
                 "@npmcli/agent": "^2.0.0",
@@ -7547,6 +7952,7 @@
                 "minipass-flush": "^1.0.5",
                 "minipass-pipeline": "^1.2.4",
                 "negotiator": "^0.6.3",
+                "proc-log": "^4.2.0",
                 "promise-retry": "^2.0.1",
                 "ssri": "^10.0.0"
             }
@@ -7571,9 +7977,9 @@
             }
         },
         "minipass": {
-            "version": "7.0.4",
-            "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
-            "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
+            "version": "7.1.2",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+            "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
             "dev": true
         },
         "minipass-collect": {
@@ -7586,9 +7992,9 @@
             }
         },
         "minipass-fetch": {
-            "version": "3.0.4",
-            "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz",
-            "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==",
+            "version": "3.0.5",
+            "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz",
+            "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==",
             "dev": true,
             "requires": {
                 "encoding": "^0.1.13",
@@ -7710,6 +8116,29 @@
             "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
             "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
         },
+        "msgpackr": {
+            "version": "1.10.2",
+            "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.10.2.tgz",
+            "integrity": "sha512-L60rsPynBvNE+8BWipKKZ9jHcSGbtyJYIwjRq0VrIvQ08cRjntGXJYW/tmciZ2IHWIY8WEW32Qa2xbh5+SKBZA==",
+            "requires": {
+                "msgpackr-extract": "^3.0.2"
+            }
+        },
+        "msgpackr-extract": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.2.tgz",
+            "integrity": "sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==",
+            "optional": true,
+            "requires": {
+                "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.2",
+                "node-gyp-build-optional-packages": "5.0.7"
+            }
+        },
         "natural-compare": {
             "version": "1.4.0",
             "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -7722,9 +8151,9 @@
             "dev": true
         },
         "node-gyp": {
-            "version": "10.0.1",
-            "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz",
-            "integrity": "sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg==",
+            "version": "10.1.0",
+            "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.1.0.tgz",
+            "integrity": "sha512-B4J5M1cABxPc5PwfjhbV5hoy2DP9p8lFXASnEN6hugXOa61416tnTZ29x9sSwAd0o99XNIcpvDDy1swAExsVKA==",
             "dev": true,
             "requires": {
                 "env-paths": "^2.2.0",
@@ -7749,16 +8178,16 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "isexe": {
@@ -7768,14 +8197,20 @@
                     "dev": true
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
                 },
+                "proc-log": {
+                    "version": "3.0.0",
+                    "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",
+                    "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==",
+                    "dev": true
+                },
                 "which": {
                     "version": "4.0.0",
                     "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
@@ -7787,19 +8222,25 @@
                 }
             }
         },
+        "node-gyp-build-optional-packages": {
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz",
+            "integrity": "sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==",
+            "optional": true
+        },
         "nopt": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz",
-            "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==",
+            "version": "7.2.1",
+            "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
+            "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
             "dev": true,
             "requires": {
                 "abbrev": "^2.0.0"
             }
         },
         "normalize-package-data": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.0.tgz",
-            "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==",
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.1.tgz",
+            "integrity": "sha512-6rvCfeRW+OEZagAB4lMLSNuTNYZWLVtKccK79VSTf//yTY5VOCgcpH80O+bZK8Neps7pUnd5G+QlMg1yV/2iZQ==",
             "dev": true,
             "requires": {
                 "hosted-git-info": "^7.0.0",
@@ -7815,9 +8256,9 @@
             "dev": true
         },
         "npm-bundled": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz",
-            "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==",
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.1.tgz",
+            "integrity": "sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==",
             "dev": true,
             "requires": {
                 "npm-normalize-package-bin": "^3.0.0"
@@ -7839,30 +8280,30 @@
             "dev": true
         },
         "npm-package-arg": {
-            "version": "11.0.1",
-            "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.1.tgz",
-            "integrity": "sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ==",
+            "version": "11.0.2",
+            "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.2.tgz",
+            "integrity": "sha512-IGN0IAwmhDJwy13Wc8k+4PEbTPhpJnMtfR53ZbOyjkvmEcLS4nCwp6mvMWjS5sUjeiW3mpx6cHmuhKEu9XmcQw==",
             "dev": true,
             "requires": {
                 "hosted-git-info": "^7.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "semver": "^7.3.5",
                 "validate-npm-package-name": "^5.0.0"
             }
         },
         "npm-packlist": {
-            "version": "8.0.1",
-            "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.1.tgz",
-            "integrity": "sha512-MQpL27ZrsJQ2kiAuQPpZb5LtJwydNRnI15QWXsf3WHERu4rzjRj6Zju/My2fov7tLuu3Gle/uoIX/DDZ3u4O4Q==",
+            "version": "8.0.2",
+            "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz",
+            "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==",
             "dev": true,
             "requires": {
                 "ignore-walk": "^6.0.4"
             }
         },
         "npm-pick-manifest": {
-            "version": "9.0.0",
-            "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.0.tgz",
-            "integrity": "sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg==",
+            "version": "9.0.1",
+            "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.1.tgz",
+            "integrity": "sha512-Udm1f0l2nXb3wxDpKjfohwgdFUSV50UVwzEIpDXVsbDMXVIEF81a/i0UhuQbhrPMMmdiq3+YMFLFIRVLs3hxQw==",
             "dev": true,
             "requires": {
                 "npm-install-checks": "^6.0.0",
@@ -7872,18 +8313,19 @@
             }
         },
         "npm-registry-fetch": {
-            "version": "16.1.0",
-            "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.1.0.tgz",
-            "integrity": "sha512-PQCELXKt8Azvxnt5Y85GseQDJJlglTFM9L9U9gkv2y4e9s0k3GVDdOx3YoB6gm2Do0hlkzC39iCGXby+Wve1Bw==",
+            "version": "16.2.1",
+            "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.2.1.tgz",
+            "integrity": "sha512-8l+7jxhim55S85fjiDGJ1rZXBWGtRLi1OSb4Z3BPLObPuIaeKRlPRiYMSHU4/81ck3t71Z+UwDDl47gcpmfQQA==",
             "dev": true,
             "requires": {
+                "@npmcli/redact": "^1.1.0",
                 "make-fetch-happen": "^13.0.0",
                 "minipass": "^7.0.2",
                 "minipass-fetch": "^3.0.0",
                 "minipass-json-stream": "^1.0.1",
                 "minizlib": "^2.1.2",
                 "npm-package-arg": "^11.0.0",
-                "proc-log": "^3.0.0"
+                "proc-log": "^4.0.0"
             }
         },
         "once": {
@@ -7948,9 +8390,9 @@
             }
         },
         "pacote": {
-            "version": "17.0.5",
-            "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.5.tgz",
-            "integrity": "sha512-TAE0m20zSDMnchPja9vtQjri19X3pZIyRpm2TJVeI+yU42leJBBDTRYhOcWFsPhaMxf+3iwQkFiKz16G9AEeeA==",
+            "version": "17.0.7",
+            "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.7.tgz",
+            "integrity": "sha512-sgvnoUMlkv9xHwDUKjKQFXVyUi8dtJGKp3vg6sYy+TxbDic5RjZCHF3ygv0EJgNRZ2GfRONjlKPUfokJ9lDpwQ==",
             "dev": true,
             "requires": {
                 "@npmcli/git": "^5.0.0",
@@ -7964,11 +8406,11 @@
                 "npm-packlist": "^8.0.0",
                 "npm-pick-manifest": "^9.0.0",
                 "npm-registry-fetch": "^16.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "promise-retry": "^2.0.1",
                 "read-package-json": "^7.0.0",
                 "read-package-json-fast": "^3.0.0",
-                "sigstore": "^2.0.0",
+                "sigstore": "^2.2.0",
                 "ssri": "^10.0.0",
                 "tar": "^6.1.11"
             }
@@ -8003,12 +8445,12 @@
             "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
         },
         "path-scurry": {
-            "version": "1.10.1",
-            "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
-            "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
+            "version": "1.11.1",
+            "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+            "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
             "dev": true,
             "requires": {
-                "lru-cache": "^9.1.1 || ^10.0.0",
+                "lru-cache": "^10.2.0",
                 "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
             }
         },
@@ -8061,9 +8503,9 @@
             }
         },
         "proc-log": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",
-            "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==",
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz",
+            "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==",
             "dev": true
         },
         "process-on-spawn": {
@@ -8110,9 +8552,9 @@
             "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
         },
         "react": {
-            "version": "18.2.0",
-            "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
-            "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+            "version": "18.3.1",
+            "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+            "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
             "dev": true,
             "requires": {
                 "loose-envify": "^1.1.0"
@@ -8147,19 +8589,19 @@
             "dev": true
         },
         "react-reconciler": {
-            "version": "0.29.0",
-            "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz",
-            "integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==",
+            "version": "0.29.2",
+            "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
+            "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
             "dev": true,
             "requires": {
                 "loose-envify": "^1.1.0",
-                "scheduler": "^0.23.0"
+                "scheduler": "^0.23.2"
             }
         },
         "read-package-json": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.0.tgz",
-            "integrity": "sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg==",
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.1.tgz",
+            "integrity": "sha512-8PcDiZ8DXUjLf687Ol4BR8Bpm2umR7vhoZOzNRt+uxD9GpBh/K+CAAALVIiYFknmvlmyg7hM7BSNUXPaCCqd0Q==",
             "dev": true,
             "requires": {
                 "glob": "^10.2.2",
@@ -8178,22 +8620,22 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
@@ -8251,22 +8693,22 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
@@ -8327,33 +8769,19 @@
             "optional": true
         },
         "scheduler": {
-            "version": "0.23.0",
-            "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
-            "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+            "version": "0.23.2",
+            "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+            "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
             "dev": true,
             "requires": {
                 "loose-envify": "^1.1.0"
             }
         },
         "semver": {
-            "version": "7.5.4",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
-            "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
-            "dev": true,
-            "requires": {
-                "lru-cache": "^6.0.0"
-            },
-            "dependencies": {
-                "lru-cache": {
-                    "version": "6.0.0",
-                    "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-                    "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-                    "dev": true,
-                    "requires": {
-                        "yallist": "^4.0.0"
-                    }
-                }
-            }
+            "version": "7.6.2",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+            "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
+            "dev": true
         },
         "shebang-command": {
             "version": "2.0.0",
@@ -8375,15 +8803,17 @@
             "dev": true
         },
         "sigstore": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.1.0.tgz",
-            "integrity": "sha512-kPIj+ZLkyI3QaM0qX8V/nSsweYND3W448pwkDgS6CQ74MfhEkIR8ToK5Iyx46KJYRjseVcD3Rp9zAmUAj6ZjPw==",
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.3.1.tgz",
+            "integrity": "sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==",
             "dev": true,
             "requires": {
-                "@sigstore/bundle": "^2.1.0",
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "@sigstore/sign": "^2.1.0",
-                "@sigstore/tuf": "^2.1.0"
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.0.0",
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "@sigstore/sign": "^2.3.2",
+                "@sigstore/tuf": "^2.3.4",
+                "@sigstore/verify": "^1.2.1"
             }
         },
         "slice-ansi": {
@@ -8411,22 +8841,22 @@
             "dev": true
         },
         "socks": {
-            "version": "2.7.1",
-            "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz",
-            "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==",
+            "version": "2.8.3",
+            "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
+            "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==",
             "dev": true,
             "requires": {
-                "ip": "^2.0.0",
+                "ip-address": "^9.0.5",
                 "smart-buffer": "^4.2.0"
             }
         },
         "socks-proxy-agent": {
-            "version": "8.0.2",
-            "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz",
-            "integrity": "sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==",
+            "version": "8.0.3",
+            "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.3.tgz",
+            "integrity": "sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A==",
             "dev": true,
             "requires": {
-                "agent-base": "^7.0.2",
+                "agent-base": "^7.1.1",
                 "debug": "^4.3.4",
                 "socks": "^2.7.1"
             }
@@ -8442,9 +8872,9 @@
             }
         },
         "spdx-exceptions": {
-            "version": "2.3.0",
-            "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
-            "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+            "version": "2.5.0",
+            "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
+            "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
             "dev": true
         },
         "spdx-expression-parse": {
@@ -8458,15 +8888,21 @@
             }
         },
         "spdx-license-ids": {
-            "version": "3.0.16",
-            "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz",
-            "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==",
+            "version": "3.0.18",
+            "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz",
+            "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==",
+            "dev": true
+        },
+        "sprintf-js": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+            "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
             "dev": true
         },
         "ssri": {
-            "version": "10.0.5",
-            "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz",
-            "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==",
+            "version": "10.0.6",
+            "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz",
+            "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==",
             "dev": true,
             "requires": {
                 "minipass": "^7.0.3"
@@ -8625,31 +9061,31 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
                 },
                 "rimraf": {
-                    "version": "5.0.5",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+                    "version": "5.0.7",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+                    "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
                     "dev": true,
                     "requires": {
                         "glob": "^10.3.7"
@@ -8658,56 +9094,56 @@
             }
         },
         "tap": {
-            "version": "18.6.1",
-            "resolved": "https://registry.npmjs.org/tap/-/tap-18.6.1.tgz",
-            "integrity": "sha512-5cBQhJ1gdbsrTR3tA5kZZTts0HyOML6bcM7pEF7GF8d6y1ajfRMjbInS1Ty7/x2Ip0ko3cY1dYjPJ9JFNPsm7w==",
-            "dev": true,
-            "requires": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/after-each": "1.1.17",
-                "@tapjs/asserts": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/before-each": "1.1.17",
-                "@tapjs/core": "1.4.6",
-                "@tapjs/filter": "1.2.17",
-                "@tapjs/fixture": "1.2.17",
-                "@tapjs/intercept": "1.2.17",
-                "@tapjs/mock": "1.2.15",
-                "@tapjs/node-serialize": "1.2.6",
-                "@tapjs/run": "1.4.16",
-                "@tapjs/snapshot": "1.2.17",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/test": "1.3.17",
-                "@tapjs/typescript": "1.3.6",
-                "@tapjs/worker": "1.1.17",
+            "version": "19.0.2",
+            "resolved": "https://registry.npmjs.org/tap/-/tap-19.0.2.tgz",
+            "integrity": "sha512-SRGulk1RKlVuYtnPeephj+xyE0sG9CvGlKYP4lymBZykLtkwBPnEBjQ2iQmLX5z0BFEMfKh8G4bvZkhoSJb3kg==",
+            "dev": true,
+            "requires": {
+                "@tapjs/after": "1.1.24",
+                "@tapjs/after-each": "2.0.1",
+                "@tapjs/asserts": "2.0.1",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/before-each": "2.0.1",
+                "@tapjs/core": "2.0.1",
+                "@tapjs/filter": "2.0.1",
+                "@tapjs/fixture": "2.0.1",
+                "@tapjs/intercept": "2.0.1",
+                "@tapjs/mock": "2.0.1",
+                "@tapjs/node-serialize": "2.0.1",
+                "@tapjs/run": "2.0.2",
+                "@tapjs/snapshot": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/test": "2.0.1",
+                "@tapjs/typescript": "1.4.6",
+                "@tapjs/worker": "2.0.1",
                 "resolve-import": "^1.4.5"
             }
         },
         "tap-parser": {
-            "version": "15.3.1",
-            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-15.3.1.tgz",
-            "integrity": "sha512-hwAtXX5TBGt2MJeYvASc7DjP48PUzA7P8RTbLxQcgKCEH7ICD5IsRco7l5YvkzjHlZbUbeI9wzO8B4hw2sKgnQ==",
+            "version": "16.0.1",
+            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-16.0.1.tgz",
+            "integrity": "sha512-vKianJzSSzLkJ3bHBwzvZDDRi9yGMwkRANJxwPAjAue50owB8rlluYySmTN4tZVH0nsh6stvrQbg9kuCL5svdg==",
             "dev": true,
             "requires": {
                 "events-to-array": "^2.0.3",
-                "tap-yaml": "2.2.1"
+                "tap-yaml": "2.2.2"
             }
         },
         "tap-yaml": {
-            "version": "2.2.1",
-            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-2.2.1.tgz",
-            "integrity": "sha512-ovZuUMLAIH59jnFHXKEGJ+WyDYl6Cuduwg9qpvnqkZOUA1nU84q02Sry1HT0KXcdv2uB91bEKKxnIybBgrb6oA==",
+            "version": "2.2.2",
+            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-2.2.2.tgz",
+            "integrity": "sha512-MWG4OpAKtNoNVjCz/BqlDJiwTM99tiHRhHPS4iGOe1ZS0CgM4jSFH92lthSFvvy4EdDjQZDV7uYqUFlU9JuNhw==",
             "dev": true,
             "requires": {
-                "yaml": "^2.3.0",
+                "yaml": "^2.4.1",
                 "yaml-types": "^0.3.0"
             }
         },
         "tar": {
-            "version": "6.2.0",
-            "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
-            "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+            "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
             "dev": true,
             "requires": {
                 "chownr": "^2.0.0",
@@ -8794,19 +9230,21 @@
             "dev": true
         },
         "tshy": {
-            "version": "1.8.2",
-            "resolved": "https://registry.npmjs.org/tshy/-/tshy-1.8.2.tgz",
-            "integrity": "sha512-aGlSY+jkZYAv0YDgtdv1U2vvbGTUdlXmhVP4uegujlJ/wuznmJqSu5cUV/6IW7N7a3HFRhofWvIS/FquYN9zgA==",
+            "version": "1.14.0",
+            "resolved": "https://registry.npmjs.org/tshy/-/tshy-1.14.0.tgz",
+            "integrity": "sha512-YiUujgi4Jb+t2I48LwSRzHkBpniH9WjjktNozn+nlsGmVemKSjDNY7EwBRPvPCr5zAC/3ITAYWH9Z7kUinGSrw==",
             "dev": true,
             "requires": {
                 "chalk": "^5.3.0",
-                "chokidar": "^3.5.3",
+                "chokidar": "^3.6.0",
                 "foreground-child": "^3.1.1",
+                "minimatch": "^9.0.4",
                 "mkdirp": "^3.0.1",
-                "resolve-import": "^1.4.4",
+                "polite-json": "^4.0.1",
+                "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.1",
                 "sync-content": "^1.0.2",
-                "typescript": "5.2",
+                "typescript": "^5.4.5",
                 "walk-up-path": "^3.0.1"
             },
             "dependencies": {
@@ -8826,31 +9264,31 @@
                     "dev": true
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
                 },
                 "rimraf": {
-                    "version": "5.0.5",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+                    "version": "5.0.7",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+                    "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
                     "dev": true,
                     "requires": {
                         "glob": "^10.3.7"
@@ -8864,14 +9302,14 @@
             "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
         },
         "tuf-js": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.1.0.tgz",
-            "integrity": "sha512-eD7YPPjVlMzdggrOeE8zwoegUaG/rt6Bt3jwoQPunRiNVzgcCE009UDFJKJjG+Gk9wFu6W/Vi+P5d/5QpdD9jA==",
+            "version": "2.2.1",
+            "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz",
+            "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==",
             "dev": true,
             "requires": {
-                "@tufjs/models": "2.0.0",
+                "@tufjs/models": "2.0.1",
                 "debug": "^4.3.4",
-                "make-fetch-happen": "^13.0.0"
+                "make-fetch-happen": "^13.0.1"
             }
         },
         "type-check": {
@@ -8888,9 +9326,9 @@
             "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="
         },
         "typescript": {
-            "version": "5.2.2",
-            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
-            "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
+            "version": "5.4.5",
+            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
+            "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
             "dev": true
         },
         "undici-types": {
@@ -8950,9 +9388,9 @@
             },
             "dependencies": {
                 "@jridgewell/trace-mapping": {
-                    "version": "0.3.20",
-                    "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
-                    "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
+                    "version": "0.3.25",
+                    "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+                    "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
                     "dev": true,
                     "requires": {
                         "@jridgewell/resolve-uri": "^3.1.0",
@@ -8972,13 +9410,10 @@
             }
         },
         "validate-npm-package-name": {
-            "version": "5.0.0",
-            "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz",
-            "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==",
-            "dev": true,
-            "requires": {
-                "builtins": "^5.0.0"
-            }
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz",
+            "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==",
+            "dev": true
         },
         "walk-up-path": {
             "version": "3.0.1",
@@ -9084,9 +9519,9 @@
             "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
         },
         "ws": {
-            "version": "8.16.0",
-            "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
-            "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
+            "version": "8.17.0",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz",
+            "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==",
             "dev": true,
             "requires": {}
         },
@@ -9103,9 +9538,9 @@
             "dev": true
         },
         "yaml": {
-            "version": "2.3.4",
-            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
-            "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
+            "version": "2.4.2",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
+            "integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
             "dev": true
         },
         "yaml-types": {
diff --git a/package.json b/package.json
index fc755f41..95fdafba 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
         "#composite/things/flash": "./src/data/composite/things/flash/index.js",
         "#composite/things/flash-act": "./src/data/composite/things/flash-act/index.js",
         "#composite/things/track": "./src/data/composite/things/track/index.js",
+        "#composite/things/track-section": "./src/data/composite/things/track-section/index.js",
         "#content-dependencies": "./src/content/dependencies/index.js",
         "#content-function": "./src/content-function.js",
         "#cli": "./src/util/cli.js",
@@ -31,20 +32,24 @@
         "#external-links": "./src/util/external-links.js",
         "#find": "./src/find.js",
         "#html": "./src/util/html.js",
+        "#import-heck": "./src/import-heck.js",
         "#language": "./src/data/language.js",
         "#page-specs": "./src/page/index.js",
         "#node-utils": "./src/util/node-utils.js",
         "#repl": "./src/write/build-modes/repl.js",
         "#replacer": "./src/util/replacer.js",
+        "#search": "./src/search.js",
+        "#search-spec": "./src/util/search-spec.js",
         "#serialize": "./src/data/serialize.js",
-        "#sugar": "./src/util/sugar.js",
         "#sort": "./src/util/sort.js",
+        "#sugar": "./src/util/sugar.js",
         "#test-lib": "./test/lib/index.js",
         "#thing": "./src/data/thing.js",
         "#things": "./src/data/things/index.js",
         "#thumbs": "./src/gen-thumbs.js",
         "#urls": "./src/util/urls.js",
         "#validators": "./src/data/validators.js",
+        "#web-routes": "./src/web-routes.js",
         "#wiki-data": "./src/util/wiki-data.js",
         "#yaml": "./src/data/yaml.js"
     },
@@ -55,18 +60,21 @@
         "@js-temporal/polyfill": "^0.4.4",
         "chroma-js": "^2.4.2",
         "command-exists": "^1.2.9",
+        "compress-json": "^3.0.5",
         "eslint": "^8.37.0",
+        "flexsearch": "^0.7.43",
         "he": "^1.2.0",
         "image-size": "^1.0.2",
         "js-yaml": "^4.1.0",
         "marked": "^10.0.0",
+        "msgpackr": "^1.10.2",
         "striptags": "^4.0.0-alpha.4",
         "word-wrap": "^1.2.3"
     },
-    "license": "GPL-3.0",
+    "license": "MIT",
     "devDependencies": {
         "chokidar": "^3.5.3",
-        "tap": "^18.4.0",
+        "tap": "^19.0.2",
         "tcompare": "^6.0.0"
     },
     "tap": {
diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js
deleted file mode 100644
index 9e119bce..00000000
--- a/src/content/dependencies/generateAdditionalFilesShortcut.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import {empty} from '#sugar';
-
-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/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 751a0c91..05dbdcf3 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -1,16 +1,15 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAlbumCommentarySidebar',
     'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
-    'generateAlbumSidebarTrackSection',
     'generateAlbumStyleRules',
     'generateCommentaryEntry',
     'generateContentHeading',
     'generateTrackCoverArtwork',
     'generatePageLayout',
-    'generatePageSidebar',
     'linkAlbum',
     'linkExternal',
     'linkTrack',
@@ -25,7 +24,7 @@ export default {
       relation('generatePageLayout');
 
     relations.sidebar =
-      relation('generatePageSidebar');
+      relation('generateAlbumCommentarySidebar', album);
 
     relations.albumStyleRules =
       relation('generateAlbumStyleRules', album, null);
@@ -86,13 +85,6 @@ export default {
           track.commentary
             .map(entry => relation('generateCommentaryEntry', entry)));
 
-    relations.sidebarAlbumLink =
-      relation('linkAlbum', album);
-
-    relations.sidebarTrackSections =
-      album.trackSections.map(trackSection =>
-        relation('generateAlbumSidebarTrackSection', album, null, trackSection));
-
     return relations;
   },
 
@@ -174,17 +166,22 @@ export default {
                   album: relations.albumCommentaryLink,
                 }),
 
+              stickyTitle:
+                language.$('albumCommentaryPage.entry.title.albumCommentary.sticky', {
+                  album: data.name,
+                }),
+
               accent:
-                !empty(relations.albumCommentaryListeningLinks) &&
-                  language.$('albumCommentaryPage.entry.title.albumCommentary.accent', {
-                    listeningLinks:
-                      language.formatUnitList(
-                        relations.albumCommentaryListeningLinks
-                          .map(link => link.slots({
-                            context: 'album',
-                            tab: 'separate',
-                          }))),
-                  }),
+                language.$('albumCommentaryPage.entry.title.albumCommentary.accent', {
+                  [language.onlyIfOptions]: ['listeningLinks'],
+                  listeningLinks:
+                    language.formatUnitList(
+                      relations.albumCommentaryListeningLinks
+                        .map(link => link.slots({
+                          context: 'album',
+                          tab: 'separate',
+                        }))),
+                }),
             }),
 
             relations.albumCommentaryCover
@@ -212,7 +209,7 @@ export default {
             }) => [
               heading.slots({
                 tag: 'h3',
-                id: directory,
+                attributes: {id: directory},
                 color,
 
                 title:
@@ -221,13 +218,13 @@ export default {
                   }),
 
                 accent:
-                  !empty(listeningLinks) &&
-                    language.$('albumCommentaryPage.entry.title.trackCommentary.accent', {
-                      listeningLinks:
-                        language.formatUnitList(
-                          listeningLinks.map(link =>
-                            link.slot('tab', 'separate'))),
-                    }),
+                  language.$('albumCommentaryPage.entry.title.trackCommentary.accent', {
+                    [language.onlyIfOptions]: ['listeningLinks'],
+                    listeningLinks:
+                      language.formatUnitList(
+                        listeningLinks.map(link =>
+                          link.slot('tab', 'separate'))),
+                  }),
               }),
 
               cover?.slots({mode: 'commentary'}),
@@ -253,22 +250,7 @@ export default {
           },
         ],
 
-        leftSidebar:
-          relations.sidebar.slots({
-            attributes: {class: 'commentary-track-list-sidebar-box'},
-
-            stickyMode: 'column',
-
-            content: [
-              html.tag('h1', relations.sidebarAlbumLink),
-              relations.sidebarTrackSections.map(section =>
-                section.slots({
-                  anchor: true,
-                  open: true,
-                  mode: 'commentary',
-                })),
-            ],
-          }),
+        leftSidebar: relations.sidebar,
       });
   },
 };
diff --git a/src/content/dependencies/generateAlbumCommentarySidebar.js b/src/content/dependencies/generateAlbumCommentarySidebar.js
new file mode 100644
index 00000000..435860cb
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCommentarySidebar.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarTrackSection',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackSections:
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection',
+          album,
+          null,
+          trackSection)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.sidebar.slots({
+      stickyMode: 'column',
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'commentary-track-list-sidebar-box'},
+          content: [
+            html.tag('h1', relations.albumLink),
+            relations.trackSections.map(section =>
+              section.slots({
+                anchor: true,
+                open: true,
+                mode: 'commentary',
+              })),
+          ],
+        }),
+      ]
+    }),
+}
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index b4f9268c..aa025688 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -30,7 +30,7 @@ export default {
       const allCoverArtistArrays =
         tracksWithUniqueCoverArt
           .map(track => track.coverArtistContribs)
-          .map(contribs => contribs.map(contrib => contrib.who));
+          .map(contribs => contribs.map(contrib => contrib.artist));
 
       const allSameCoverArtists =
         allCoverArtistArrays
@@ -116,7 +116,7 @@ export default {
 
     data.coverArtists = [
       (album.hasCoverArt
-        ? album.coverArtistContribs.map(({who: artist}) => artist.name)
+        ? album.coverArtistContribs.map(({artist}) => artist.name)
         : null),
 
       ...
@@ -126,7 +126,7 @@ export default {
           }
 
           if (track.hasUniqueCoverArt) {
-            return track.coverArtistContribs.map(({who: artist}) => artist.name);
+            return track.coverArtistContribs.map(({artist}) => artist.name);
           }
 
           return null;
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index e0f23bd0..d4ea52de 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -5,7 +5,6 @@ import getChronologyRelations from '../util/getChronologyRelations.js';
 
 export default {
   contentDependencies: [
-    'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumBanner',
     'generateAlbumCoverArtwork',
@@ -107,11 +106,6 @@ export default {
         relation('linkAlbumCommentary', album);
     }
 
-    if (!empty(album.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', album.additionalFiles);
-    }
-
     // Section: Track list
 
     relations.trackList =
@@ -180,7 +174,12 @@ export default {
             {[html.joinChildren]: html.tag('br')},
 
             [
-              sec.extra.additionalFilesShortcut,
+              sec.additionalFiles &&
+                language.$('releaseInfo.additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#additional-files'},
+                    language.$('releaseInfo.additionalFiles.shortcut.link')),
+                }),
 
               sec.extra.galleryLink && sec.extra.commentaryLink &&
                 language.$('releaseInfo.viewGalleryOrCommentary', {
@@ -214,21 +213,17 @@ export default {
             {[html.joinChildren]: html.tag('br')},
 
             [
-              data.dateAddedToWiki &&
-                language.$('releaseInfo.addedToWiki', {
-                  date: language.formatDate(data.dateAddedToWiki),
-                }),
+              language.$('releaseInfo.addedToWiki', {
+                [language.onlyIfOptions]: ['date'],
+                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}),
-                  }),
+                attributes: {id: 'additional-files'},
+                title: language.$('releaseInfo.additionalFiles.heading'),
               }),
 
             sec.additionalFiles.additionalFilesList,
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 6fc1375b..1cd638ce 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -1,4 +1,4 @@
-import {accumulateSum, empty} from '#sugar';
+import {accumulateSum} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -23,11 +23,9 @@ export default {
     relations.bannerArtistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
 
-    if (!empty(album.urls)) {
-      relations.externalLinks =
-        album.urls.map(url =>
-          relation('linkExternal', url));
-    }
+    relations.externalLinks =
+      album.urls.map(url =>
+        relation('linkExternal', url));
 
     return relations;
   },
@@ -70,41 +68,42 @@ export default {
           relations.bannerArtistContributionsLine
             .slots({stringKey: 'releaseInfo.bannerArtBy'}),
 
-          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,
-                }),
-            }),
+          language.$('releaseInfo.released', {
+            [language.onlyIfOptions]: ['date'],
+            date: language.formatDate(data.date),
+          }),
+
+          language.$('releaseInfo.artReleased', {
+            [language.onlyIfOptions]: ['date'],
+            date: language.formatDate(data.coverArtDate),
+          }),
+
+          language.$('releaseInfo.duration', {
+            [language.onlyIfOptions]: ['duration'],
+            duration:
+              language.formatDuration(data.duration, {
+                approximate: data.durationApproximate,
+              }),
+          }),
         ]),
 
-      relations.externalLinks &&
-        html.tag('p',
-          language.$('releaseInfo.listenOn', {
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link =>
-                    link.slot('context', [
-                      'album',
-                      (data.numTracks === 0
-                        ? 'albumNoTracks'
-                     : data.numTracks === 1
-                        ? 'albumOneTrack'
-                        : 'albumMultipleTracks'),
-                    ]))),
-          })),
+      html.tag('p',
+        {[html.onlyIfContent]: true},
+        language.$('releaseInfo.listenOn', {
+          [language.onlyIfOptions]: ['links'],
+          links:
+            language.formatDisjunctionList(
+              relations.externalLinks
+                .map(link =>
+                  link.slot('context', [
+                    'album',
+                    (data.numTracks === 0
+                      ? 'albumNoTracks'
+                   : data.numTracks === 1
+                      ? 'albumOneTrack'
+                      : 'albumMultipleTracks'),
+                  ]))),
+        })),
     ]);
   },
 };
diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js
index 400420ba..d6ff8a0a 100644
--- a/src/content/dependencies/generateAlbumSecondaryNav.js
+++ b/src/content/dependencies/generateAlbumSecondaryNav.js
@@ -59,11 +59,11 @@ export default {
       relation('generateSecondaryNav');
 
     relations.groupLinks =
-      album.groups
+      query.groups
         .map(group => relation('linkGroup', group));
 
     relations.colorStyles =
-      album.groups
+      query.groups
         .map(group => relation('generateColorStyleAttribute', group.color));
 
     if (album.date) {
@@ -102,7 +102,7 @@ export default {
   generate(relations, slots, {html, language}) {
     const navLinksShouldShowPreviousNext =
       (slots.mode === 'track'
-        ? Array.from(relations.previousNextLinks, () => false)
+        ? Array.from(relations.previousNextLinks ?? [], () => false)
         : stitchArrays({
             previousAlbumLink: relations.previousAlbumLinks ?? null,
             nextAlbumLink: relations.nextAlbumLinks ?? null,
@@ -151,11 +151,8 @@ export default {
       stitchArrays({
         content: navLinkContents,
         colorStyle: relations.colorStyles,
-      }).map(({content, colorStyle}, index) =>
+      }).map(({content, colorStyle}) =>
           html.tag('span', {class: 'nav-link'},
-            index > 0 &&
-              {class: 'has-divider'},
-
             colorStyle.slot('context', 'primary-only'),
 
             content));
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index 00a96c31..cc9b2c13 100644
--- a/src/content/dependencies/generateAlbumSidebarGroupBox.js
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -1,5 +1,5 @@
 import {sortChronologically} from '#sort';
-import {atOffset, empty} from '#sugar';
+import {atOffset} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -89,26 +89,31 @@ export default {
           relations.description
             ?.slot('mode', 'multiline'),
 
-        !empty(relations.externalLinks) &&
-          html.tag('p',
-            language.$('releaseInfo.visitOn', {
-              links:
-                language.formatDisjunctionList(
-                  relations.externalLinks
-                    .map(link => link.slot('context', 'group'))),
-            })),
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+
+          language.$('releaseInfo.visitOn', {
+            [language.onlyIfOptions]: ['links'],
+
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link => link.slot('context', 'group'))),
+          })),
 
         slots.mode === 'album' &&
-        relations.nextAlbumLink &&
           html.tag('p', {class: 'group-chronology-link'},
+            {[html.onlyIfContent]: true},
             language.$('albumSidebar.groupBox.next', {
+              [language.onlyIfOptions]: ['album'],
               album: relations.nextAlbumLink,
             })),
 
         slots.mode === 'album' &&
-        relations.previousAlbumLink &&
           html.tag('p', {class: 'group-chronology-link'},
+            {[html.onlyIfContent]: true},
             language.$('albumSidebar.groupBox.previous', {
+              [language.onlyIfOptions]: ['album'],
               album: relations.previousAlbumLink,
             })),
       ],
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
index ee06b9e6..dd3e85e3 100644
--- a/src/content/dependencies/generateAlbumTrackList.js
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -149,6 +149,7 @@ export default {
             }) => [
               heading.slots({
                 tag: 'dt',
+
                 title:
                   (duration === 0
                     ? language.$('trackList.section', {
@@ -161,6 +162,11 @@ export default {
                             approximate: durationApproximate,
                           }),
                       })),
+
+                stickyTitle:
+                  language.$('trackList.section.sticky', {
+                    section: name,
+                  }),
               }),
 
               html.tag('dd',
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index 18980740..7190fb4c 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -64,8 +64,8 @@ export default {
       !empty(track.artistContribs) &&
        (empty(album.artistContribs) ||
         !compareArrays(
-          track.artistContribs.map(c => c.who),
-          album.artistContribs.map(c => c.who),
+          track.artistContribs.map(contrib => contrib.artist),
+          album.artistContribs.map(contrib => contrib.artist),
           {checkOrder: false}));
 
     return data;
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index 338d18fe..eae48f05 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -80,7 +80,7 @@ export default {
     data.coverArtists =
       query.things.map(thing =>
         thing.coverArtistContribs
-          .map(({who: artist}) => artist.name));
+          .map(({artist}) => artist.name));
 
     return data;
   },
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index 36343c18..db8f123f 100644
--- a/src/content/dependencies/generateArtistGalleryPage.js
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -75,8 +75,8 @@ export default {
       query.things.map(thing =>
         (thing.coverArtistContribs.length > 1
           ? thing.coverArtistContribs
-              .filter(({who}) => who !== artist)
-              .map(({who}) => who.name)
+              .filter(({artist: otherArtist}) => otherArtist !== artist)
+              .map(({artist: otherArtist}) => otherArtist.name)
           : null));
 
     return data;
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index 1725d4b9..ef81739d 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -161,9 +161,15 @@ export default {
       slots.visible && 'visible',
     ];
 
+    // TODO: It feels pretty awkward that this component is the only one that
+    // has enough knowledge to decide if the sort button is even applicable...
+    const switchingSortPossible =
+      !empty(relations.groupLinksSortedByCount) &&
+      !empty(relations.groupLinksSortedByDuration);
+
     return html.tags([
       html.tag('dt', {class: topLevelClasses},
-        (slots.showSortButton
+        (switchingSortPossible && slots.showSortButton
           ? language.$('artistPage.groupContributions.title.withSortButton', {
               title: slots.title,
               sort:
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index ac9209a7..28b9e1d3 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -153,7 +153,9 @@ export default {
 
         mainContent: [
           sec.contextNotes && [
-            html.tag('p', language.$('releaseInfo.note')),
+            html.tag('p',
+              language.$('releaseInfo.note')),
+
             html.tag('blockquote',
               sec.contextNotes.content),
           ],
@@ -206,7 +208,7 @@ export default {
             sec.tracks.heading
               .slots({
                 tag: 'h2',
-                id: 'tracks',
+                attributes: {id: 'tracks'},
                 title: language.$('artistPage.trackList.title'),
               }),
 
@@ -251,7 +253,7 @@ export default {
             sec.artworks.heading
               .slots({
                 tag: 'h2',
-                id: 'art',
+                attributes: {id: 'art'},
                 title: language.$('artistPage.artList.title'),
               }),
 
@@ -280,7 +282,7 @@ export default {
             sec.flashes.heading
               .slots({
                 tag: 'h2',
-                id: 'flashes',
+                attributes: {id: 'flashes'},
                 title: language.$('artistPage.flashList.title'),
               }),
 
@@ -291,7 +293,7 @@ export default {
             sec.commentary.heading
               .slots({
                 tag: 'h2',
-                id: 'commentary',
+                attributes: {id: 'commentary'},
                 title: language.$('artistPage.commentaryList.title'),
               }),
 
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
index 0beeb271..44fb42f2 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -171,8 +171,8 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk.map(({contribs}) =>
             contribs
-              .find(({who}) => who === artist)
-              .what)),
+              .find(contrib => contrib.artist === artist)
+              .annotation)),
     };
   },
 
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
index 88a97af2..447e697e 100644
--- a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
@@ -92,8 +92,8 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk.map(({contribs}) =>
             contribs
-              .find(({who}) => who === artist)
-              .what)),
+              .find(contrib => contrib.artist === artist)
+              .annotation)),
     };
   },
 
diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
index dea7742a..471ee26c 100644
--- a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
+++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
@@ -4,7 +4,8 @@ export default {
   contentDependencies: ['linkArtist'],
 
   relations(relation, contribs, artist) {
-    const otherArtistContribs = contribs.filter(({who}) => who !== artist);
+    const otherArtistContribs =
+      contribs.filter(contrib => contrib.artist !== artist);
 
     if (empty(otherArtistContribs)) {
       return {};
@@ -12,7 +13,7 @@ export default {
 
     const otherArtistLinks =
       otherArtistContribs
-        .map(({who}) => relation('linkArtist', who));
+        .map(contrib => relation('linkArtist', contrib.artist));
 
     return {otherArtistLinks};
   },
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
index f003779d..bce6cedf 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -150,7 +150,7 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk
             .map(({contribs}) =>
-              contribs.filter(({who}) => who === artist))
+              contribs.filter(contrib => contrib.artist === artist))
             .map(ownContribs => ({
               creditedAsArtist:
                 ownContribs
@@ -162,7 +162,7 @@ export default {
 
               annotatedContribs:
                 ownContribs
-                  .filter(({what}) => what),
+                  .filter(({annotation}) => annotation),
             }))
             .map(({annotatedContribs, ...rest}) => ({
               ...rest,
@@ -203,7 +203,7 @@ export default {
               ];
             })
             .map(contribs =>
-              contribs.map(({what}) => what))
+              contribs.map(({annotation}) => annotation))
             .map(contributions =>
               (empty(contributions)
                 ? null
diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js
index 8ec6ee0a..7f24ded7 100644
--- a/src/content/dependencies/generateChronologyLinks.js
+++ b/src/content/dependencies/generateChronologyLinks.js
@@ -4,6 +4,16 @@ export default {
   extraDependencies: ['html', 'language'],
 
   slots: {
+    allowCollapsing: {
+      type: 'boolean',
+      default: true,
+    },
+
+    showOnly: {
+      type: 'boolean',
+      default: false,
+    },
+
     chronologyInfoSets: {
       validate: v =>
         v.strictArrayOf(
@@ -11,6 +21,8 @@ export default {
             headingString: v.isString,
             contributions: v.strictArrayOf(v.validateProperties({
               index: v.isCountingNumber,
+              only: v.isBoolean,
+              artistDirectory: v.isDirectory,
               artistLink: v.isHTML,
               previousLink: v.isHTML,
               nextLink: v.isHTML,
@@ -24,22 +36,35 @@ export default {
       return html.blank();
     }
 
+    let infoSets = slots.chronologyInfoSets;
+
+    if (!slots.showOnly) {
+      infoSets = infoSets
+        .map(({contributions, ...entry}) => ({
+          ...entry,
+          contributions:
+            contributions
+              .filter(({only}) => !only),
+        }))
+        .filter(({contributions}) => !empty(contributions));
+    }
+
     const totalContributionCount =
       accumulateSum(
-        slots.chronologyInfoSets,
+        infoSets,
         ({contributions}) => contributions.length);
 
     if (totalContributionCount === 0) {
       return html.blank();
     }
 
-    if (totalContributionCount > 8) {
+    if (slots.allowCollapsing && totalContributionCount > 8) {
       return html.tag('div', {class: 'chronology'},
         language.$('misc.chronology.seeArtistPages'));
     }
 
     return html.tags(
-      slots.chronologyInfoSets.map(({
+      infoSets.map(({
         headingString,
         contributions,
       }) =>
@@ -48,16 +73,21 @@ export default {
           artistLink,
           previousLink,
           nextLink,
+          only,
         }) => {
           const heading =
             html.tag('span', {class: 'heading'},
               language.$(headingString, {
-                index: language.formatIndex(index),
+                index:
+                  (only
+                    ? language.formatString('misc.chronology.heading.onlyIndex')
+                    : language.formatIndex(index)),
+
                 artist: artistLink,
               }));
 
           const navigation =
-            (previousLink || nextLink) &&
+            !only &&
               html.tag('span', {class: 'buttons'},
                 language.formatUnitList([
                   previousLink?.slots({
diff --git a/src/content/dependencies/generateChronologyLinksScopeSwitcher.js b/src/content/dependencies/generateChronologyLinksScopeSwitcher.js
new file mode 100644
index 00000000..23c44268
--- /dev/null
+++ b/src/content/dependencies/generateChronologyLinksScopeSwitcher.js
@@ -0,0 +1,67 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    scopes: {
+      validate: v => v.strictArrayOf(v.isStringNonEmpty),
+    },
+
+    contents: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    open: {
+      type: 'boolean',
+      default: true,
+    },
+  },
+
+  generate(slots, {html, language}) {
+    // TODO: Manual [html.onlyIfContent]-alike here is a bit unfortunate.
+    // We can't use a normal [html.onlyIfContent] because the summary counts
+    // as content - we'd need to encode that we want to exclude it from the
+    // content check (for the <details> element), somehow.
+    if (slots.contents.every(content => html.isBlank(content))) {
+      return html.blank();
+    }
+
+    const summary =
+      html.tag('summary',
+        {class: 'underline-white'},
+
+        html.tag('span',
+          language.$('trackPage.nav.chronology.scope.title', {
+            scope:
+              slots.scopes.map((scope, index) =>
+                html.tag('a', {class: 'switcher-link'},
+                  {href: '#'},
+
+                  (index === 0
+                    ? {style: 'display: inline'}
+                    : {style: 'display: none'}),
+
+                  language.$('trackPage.nav.chronology.scope', scope))),
+          })));
+
+    const scopeContents =
+      stitchArrays({
+        scope: slots.scopes,
+        content: slots.contents,
+      }).map(({scope, content}, index) =>
+          html.tag('div', {class: 'scope-' + scope},
+            (index === 0
+              ? {style: 'display: block'}
+              : {style: 'display: none'}),
+
+            content));
+
+    return (
+      html.tag('details', {class: 'scoped-chronology-switcher'},
+        slots.open &&
+          {open: true},
+
+        [summary, scopeContents]));
+  },
+};
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
index 069d85dd..5270dbe4 100644
--- a/src/content/dependencies/generateColorStyleVariables.js
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -32,6 +32,7 @@ export default {
       dim,
       deep,
       deepGhost,
+      lightGhost,
       bg,
       bgBlack,
       shadow,
@@ -43,6 +44,7 @@ export default {
       `--dim-color: ${dim}`,
       `--deep-color: ${deep}`,
       `--deep-ghost-color: ${deepGhost}`,
+      `--light-ghost-color: ${lightGhost}`,
       `--bg-color: ${bg}`,
       `--bg-black-color: ${bgBlack}`,
       `--shadow-color: ${shadow}`,
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
index 522a0284..036f8a6f 100644
--- a/src/content/dependencies/generateCommentaryEntry.js
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -61,19 +61,14 @@ export default {
         relations.annotationContent.slot('mode', 'inline');
     }
 
-    if (data.date) {
-      accentParts.push('withDate');
-      accentOptions.date =
-        language.formatDate(data.date);
-    }
-
     const accent =
       (accentParts.length > 1
         ? html.tag('span', {class: 'commentary-entry-accent'},
             language.$(...accentParts, accentOptions))
         : null);
 
-    const titleParts = ['misc.artistCommentary.entry.title'];
+    const titlePrefix = 'misc.artistCommentary.entry.title';
+    const titleParts = [titlePrefix];
     const titleOptions = {artists: artistsSpan};
 
     if (accent) {
@@ -88,7 +83,16 @@ export default {
     return html.tags([
       html.tag('p', {class: 'commentary-entry-heading'},
         style,
-        language.$(...titleParts, titleOptions)),
+        [
+          html.tag('time',
+            {[html.onlyIfContent]: true},
+            language.$(titlePrefix, 'date', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.date),
+            })),
+
+          language.$(...titleParts, titleOptions),
+        ]),
 
       html.tag('blockquote', {class: 'commentary-entry-body'},
         style,
diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js
index 8ae1b2d0..39727360 100644
--- a/src/content/dependencies/generateCommentarySection.js
+++ b/src/content/dependencies/generateCommentarySection.js
@@ -16,12 +16,23 @@ export default {
         relation('generateCommentaryEntry', entry)),
   }),
 
-  generate: (relations, {html, language}) =>
+  data: (entries) => ({
+    firstEntryIsDated:
+      (entries[0]
+        ? !!entries[0].date
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) =>
     html.tags([
       relations.heading
         .slots({
-          id: 'artist-commentary',
-          title: language.$('misc.artistCommentary')
+          title: language.$('misc.artistCommentary'),
+          attributes: [
+            {id: 'artist-commentary'},
+            data.firstEntryIsDated &&
+              {class: 'first-entry-is-dated'},
+          ],
         }),
 
       relations.entries,
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
index 469db876..eafe77d8 100644
--- a/src/content/dependencies/generateContentHeading.js
+++ b/src/content/dependencies/generateContentHeading.js
@@ -12,23 +12,34 @@ export default {
       mutable: false,
     },
 
+    stickyTitle: {
+      type: 'html',
+      mutable: false,
+    },
+
     accent: {
       type: 'html',
       mutable: false,
     },
 
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
     color: {validate: v => v.isColor},
 
-    id: {type: 'string'},
-    tag: {type: 'string', default: 'p'},
+    tag: {
+      type: 'string',
+      default: 'p',
+    },
   },
 
   generate: (relations, slots, {html}) =>
     html.tag(slots.tag, {class: 'content-heading'},
       {tabindex: '0'},
 
-      slots.id &&
-        {id: slots.id},
+      slots.attributes,
 
       slots.color &&
         relations.colorStyle.slot('color', slots.color),
@@ -38,6 +49,10 @@ export default {
           {[html.onlyIfContent]: true},
           slots.title),
 
+        html.tag('template', {class: 'content-heading-sticky-title'},
+          {[html.onlyIfContent]: true},
+          slots.stickyTitle),
+
         html.tag('span', {class: 'content-heading-accent'},
           {[html.onlyIfContent]: true},
           slots.accent),
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 90c9db98..3d5a614f 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: ['image', 'linkArtTag'],
@@ -89,14 +89,15 @@ export default {
             ...sizeSlots,
           }),
 
-          !empty(relations.tagLinks) &&
-            html.tag('ul', {class: 'image-details'},
-              stitchArrays({
-                tagLink: relations.tagLinks,
-                preferShortName: data.preferShortName,
-              }).map(({tagLink, preferShortName}) =>
-                  html.tag('li',
-                    tagLink.slot('preferShortName', preferShortName)))),
+          html.tag('ul', {class: 'image-details'},
+            {[html.onlyIfContent]: true},
+
+            stitchArrays({
+              tagLink: relations.tagLinks,
+              preferShortName: data.preferShortName,
+            }).map(({tagLink, preferShortName}) =>
+                html.tag('li',
+                  tagLink.slot('preferShortName', preferShortName)))),
         ]);
 
       case 'thumbnail':
diff --git a/src/content/dependencies/generateFlashCoverArtwork.js b/src/content/dependencies/generateFlashCoverArtwork.js
index 374fa3f8..af03ae6b 100644
--- a/src/content/dependencies/generateFlashCoverArtwork.js
+++ b/src/content/dependencies/generateFlashCoverArtwork.js
@@ -1,12 +1,26 @@
 export default {
   contentDependencies: ['generateCoverArtwork'],
 
-  relations: (relation) =>
-    ({coverArtwork: relation('generateCoverArtwork')}),
+  relations: (relation) => ({
+    coverArtwork:
+      relation('generateCoverArtwork'),
+  }),
 
-  data: (flash) =>
-    ({path: ['media.flashArt', flash.directory, flash.coverArtFileExtension]}),
+  data: (flash) => ({
+    path:
+      ['media.flashArt', flash.directory, flash.coverArtFileExtension],
+
+    color:
+      flash.color,
+
+    dimensions:
+      flash.coverArtDimensions,
+  }),
 
   generate: (data, relations) =>
-    relations.coverArtwork.slot('path', data.path),
+    relations.coverArtwork.slots({
+      path: data.path,
+      color: data.color,
+      dimensions: data.dimensions,
+    }),
 };
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index 36bfabae..eaea7e9c 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -87,11 +87,13 @@ export default {
 
       mainClasses: ['flash-index'],
       mainContent: [
-        !empty(data.jumpLinkLabels) && [
+        html.tags([
           html.tag('p', {class: 'quick-info'},
+            {[html.onlyIfSiblings]: true},
             language.$('misc.jumpTo')),
 
           html.tag('ul', {class: 'quick-info'},
+            {[html.onlyIfContent]: true},
             stitchArrays({
               colorStyle: relations.jumpLinkColorStyles,
               anchor: data.jumpLinkAnchors,
@@ -102,7 +104,7 @@ export default {
                     {href: '#' + anchor},
                     colorStyle,
                     label)))),
-        ],
+        ]),
 
         stitchArrays({
           colorStyle: relations.actColorStyles,
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 05964936..eec32157 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -19,16 +19,14 @@ export default {
   query(flash) {
     const query = {};
 
-    if (flash.page || !empty(flash.urls)) {
-      query.urls = [];
+    query.urls = [];
 
-      if (flash.page) {
-        query.urls.push(`https://homestuck.com/story/${flash.page}`);
-      }
+    if (flash.page) {
+      query.urls.push(`https://homestuck.com/story/${flash.page}`);
+    }
 
-      if (!empty(flash.urls)) {
-        query.urls.push(...flash.urls);
-      }
+    if (!empty(flash.urls)) {
+      query.urls.push(...flash.urls);
     }
 
     return query;
@@ -44,10 +42,9 @@ export default {
     relations.sidebar =
       relation('generateFlashActSidebar', flash.act, flash);
 
-    if (query.urls) {
-      relations.externalLinks =
-        query.urls.map(url => relation('linkExternal', url));
-    }
+    relations.externalLinks =
+      query.urls
+        .map(url => relation('linkExternal', url));
 
     // TODO: Flashes always have cover art (#175)
     /* eslint-disable-next-line no-constant-condition */
@@ -135,14 +132,15 @@ export default {
             date: language.formatDate(data.date),
           })),
 
-        relations.externalLinks &&
-          html.tag('p',
-            language.$('releaseInfo.playOn', {
-              links:
-                language.formatDisjunctionList(
-                  relations.externalLinks
-                    .map(link => link.slot('context', 'flash'))),
-            })),
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('releaseInfo.playOn', {
+            [language.onlyIfOptions]: ['links'],
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link => link.slot('context', 'flash'))),
+          })),
 
         html.tag('p',
           {[html.onlyIfContent]: true},
@@ -160,7 +158,7 @@ export default {
         sec.featuredTracks && [
           sec.featuredTracks.heading
             .slots({
-              id: 'features',
+              attributes: {id: 'features'},
               title:
                 language.$('releaseInfo.tracksFeatured', {
                   flash: html.tag('i', data.name),
@@ -173,7 +171,7 @@ export default {
         sec.contributors && [
           sec.contributors.heading
             .slots({
-              id: 'contributors',
+              attributes: {id: 'contributors'},
               title: language.$('releaseInfo.contributors'),
             }),
 
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index b5b456aa..e6b0ded1 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -69,11 +69,9 @@ export default {
 
     sec.info = {};
 
-    if (!empty(group.urls)) {
-      sec.info.visitLinks =
-        group.urls
-          .map(url => relation('linkExternal', url));
-    }
+    sec.info.visitLinks =
+      group.urls
+        .map(url => relation('linkExternal', url));
 
     if (group.description) {
       sec.info.description =
@@ -131,14 +129,15 @@ export default {
         color: data.color,
 
         mainContent: [
-          sec.info.visitLinks &&
-            html.tag('p',
-              language.$('releaseInfo.visitOn', {
-                links:
-                  language.formatDisjunctionList(
-                    sec.info.visitLinks
-                      .map(link => link.slot('context', 'group'))),
-              })),
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
+              links:
+                language.formatDisjunctionList(
+                  sec.info.visitLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
 
           html.tag('blockquote',
             {[html.onlyIfContent]: true},
diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js
index 17eb5083..a4f81313 100644
--- a/src/content/dependencies/generateGroupSecondaryNav.js
+++ b/src/content/dependencies/generateGroupSecondaryNav.js
@@ -69,12 +69,16 @@ export default {
   }),
 
   generate(data, relations, {html, language}) {
-    const {content: previousNextPart} =
-      relations.previousNextLinks.slots({
-        previousLink: relations.previousGroupLink,
-        nextLink: relations.nextGroupLink,
-        id: true,
-      });
+    const previousNextPart =
+      (relations.previousNextLinks
+        ? relations.previousNextLinks
+            .slots({
+              previousLink: relations.previousGroupLink,
+              nextLink: relations.nextGroupLink,
+              id: true,
+            })
+            .content /* TODO: Kludge. */
+        : null);
 
     const {categoryLink} = relations;
 
@@ -83,7 +87,7 @@ export default {
     return relations.secondaryNav.slots({
       class: 'nav-links-groups',
       content:
-        (relations.previousGroupLink || relations.nextGroupLink
+        (previousNextPart
           ? html.tag('span', {class: 'nav-link'},
               relations.colorStyle.slot('context', 'primary-only'),
 
diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js
index 3abb3392..0888cbbe 100644
--- a/src/content/dependencies/generateGroupSidebar.js
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -2,6 +2,7 @@ export default {
   contentDependencies: [
     'generateGroupSidebarCategoryDetails',
     'generatePageSidebar',
+    'generatePageSidebarBox',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -12,6 +13,9 @@ export default {
     sidebar:
       relation('generatePageSidebar'),
 
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
     categoryDetails:
       sprawl.groupCategoryData.map(category =>
         relation('generateGroupSidebarCategoryDetails', category, group)),
@@ -25,15 +29,18 @@ export default {
 
   generate: (relations, slots, {html, language}) =>
     relations.sidebar.slots({
-      attributes: {class: 'category-map-sidebar-box'},
-
-      content: [
-        html.tag('h1',
-          language.$('groupSidebar.title')),
-
-        relations.categoryDetails
-          .map(details =>
-            details.slot('currentExtra', slots.currentExtra)),
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'category-map-sidebar-box'},
+          content: [
+            html.tag('h1',
+              language.$('groupSidebar.title')),
+
+            relations.categoryDetails
+              .map(details =>
+                details.slot('currentExtra', slots.currentExtra)),
+          ],
+        }),
       ],
     }),
 };
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index 23377afb..5f9a99a9 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -34,13 +34,15 @@ export default {
       relations.sameTargetListingLinks =
         listing.target.listings
           .map(listing => relation('linkListing', listing));
+    } else {
+      relations.sameTargetListingLinks = [];
     }
 
-    if (!empty(listing.seeAlso)) {
-      relations.seeAlsoLinks =
-        listing.seeAlso
-          .map(listing => relation('linkListing', listing));
-    }
+    relations.seeAlsoLinks =
+      (!empty(listing.seeAlso)
+        ? listing.seeAlso
+            .map(listing => relation('linkListing', listing))
+        : []);
 
     return relations;
   },
@@ -167,33 +169,37 @@ export default {
       headingMode: 'sticky',
 
       mainContent: [
-        relations.sameTargetListingLinks &&
-          html.tag('p',
-            language.$('listingPage.listingsFor', {
-              target:
-                language.$('listingPage.target', data.targetStringsKey),
-
-              listings:
-                language.formatUnitList(
-                  stitchArrays({
-                    link: relations.sameTargetListingLinks,
-                    stringsKey: data.sameTargetListingStringsKeys,
-                  }).map(({link, stringsKey}, index) =>
-                      html.tag('span',
-                        index === data.sameTargetListingsCurrentIndex &&
-                          {class: 'current'},
-
-                        link.slots({
-                          attributes: {class: 'nowrap'},
-                          content: language.$('listingPage', stringsKey, 'title.short'),
-                        })))),
-            })),
-
-        relations.seeAlsoLinks &&
-          html.tag('p',
-            language.$('listingPage.seeAlso', {
-              listings: language.formatUnitList(relations.seeAlsoLinks),
-            })),
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('listingPage.listingsFor', {
+            [language.onlyIfOptions]: ['listings'],
+
+            target:
+              language.$('listingPage.target', data.targetStringsKey),
+
+            listings:
+              language.formatUnitList(
+                stitchArrays({
+                  link: relations.sameTargetListingLinks,
+                  stringsKey: data.sameTargetListingStringsKeys,
+                }).map(({link, stringsKey}, index) =>
+                    html.tag('span',
+                      index === data.sameTargetListingsCurrentIndex &&
+                        {class: 'current'},
+
+                      link.slots({
+                        attributes: {class: 'nowrap'},
+                        content: language.$('listingPage', stringsKey, 'title.short'),
+                      })))),
+          })),
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('listingPage.seeAlso', {
+            [language.onlyIfOptions]: ['listings'],
+            listings:
+              language.formatUnitList(relations.seeAlsoLinks),
+          })),
 
         slots.content,
 
@@ -243,7 +249,7 @@ export default {
                   .clone()
                   .slots({
                     tag: 'dt',
-                    id,
+                    attributes: [id && {id}],
 
                     title:
                       formatListingString({
diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js
index 1e5c8bfc..aeac05cf 100644
--- a/src/content/dependencies/generateListingSidebar.js
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -2,6 +2,7 @@ export default {
   contentDependencies: [
     'generateListingIndexList',
     'generatePageSidebar',
+    'generatePageSidebarBox',
     'linkListingIndex',
   ],
 
@@ -11,6 +12,9 @@ export default {
     sidebar:
       relation('generatePageSidebar'),
 
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
     listingIndexLink:
       relation('linkListingIndex'),
 
@@ -20,10 +24,14 @@ export default {
 
   generate: (relations, {html}) =>
     relations.sidebar.slots({
-      attributes: {class: 'listing-map-sidebar-box'},
-      content: [
-        html.tag('h1', relations.listingIndexLink),
-        relations.listingIndexList.slot('mode', 'sidebar'),
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'listing-map-sidebar-box'},
+          content: [
+            html.tag('h1', relations.listingIndexLink),
+            relations.listingIndexList.slot('mode', 'sidebar'),
+          ],
+        }),
       ],
     }),
 };
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index cbfc905a..e138a981 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -5,12 +5,13 @@ export default {
   contentDependencies: [
     'generateColorStyleRules',
     'generateFooterLocalizationLinks',
+    'generatePageSidebar',
+    'generateSearchSidebarBox',
     'generateStickyHeadingContainer',
     'transformContent',
   ],
 
   extraDependencies: [
-    'cachebust',
     'getColors',
     'html',
     'language',
@@ -21,6 +22,7 @@ export default {
 
   sprawl({wikiInfo}) {
     return {
+      enableSearch: wikiInfo.enableSearch,
       footerContent: wikiInfo.footerContent,
       wikiColor: wikiInfo.color,
       wikiName: wikiInfo.nameShort,
@@ -43,6 +45,14 @@ export default {
     relations.stickyHeadingContainer =
       relation('generateStickyHeadingContainer');
 
+    relations.sidebar =
+      relation('generatePageSidebar');
+
+    if (sprawl.enableSearch) {
+      relations.searchBox =
+        relation('generateSearchSidebarBox');
+    }
+
     if (sprawl.footerContent) {
       relations.defaultFooterContent =
         relation('transformContent', sprawl.footerContent);
@@ -65,6 +75,11 @@ export default {
       default: true,
     },
 
+    showSearch: {
+      type: 'boolean',
+      default: true,
+    },
+
     additionalNames: {
       type: 'html',
       mutable: false,
@@ -209,7 +224,6 @@ export default {
   },
 
   generate(data, relations, slots, {
-    cachebust,
     getColors,
     html,
     language,
@@ -348,9 +362,6 @@ export default {
                     showAsCurrent &&
                       {class: 'current'},
 
-                    i > 0 &&
-                      {class: 'has-divider'},
-
                     [
                       html.tag('span', {class: 'nav-link-content'},
                         // Use inline-block styling on the content span,
@@ -377,29 +388,54 @@ export default {
             slots.navContent),
         ]);
 
-    const getSidebar = (side, id) =>
-      (html.isBlank(slots[side])
-        ? html.blank()
-        : slots[side].slots({
-            attributes:
-              slots[side]
-                .getSlotValue('attributes')
-                .with({id}),
-          }));
+    const getSidebar = (side, id, needed) => {
+      const sidebar =
+        (html.isBlank(slots[side])
+          ? (needed
+              ? relations.sidebar.clone()
+              : html.blank())
+          : slots[side]);
+
+      if (html.isBlank(sidebar) && !needed) {
+        return sidebar;
+      }
+
+      return sidebar.slots({
+        attributes:
+          sidebar
+            .getSlotValue('attributes')
+            .with({id}),
+      });
+    }
+
+    const willShowSearch =
+      slots.showSearch && relations.searchBox;
+
+    let showingSidebarLeft;
+    let showingSidebarRight;
+
+    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left', willShowSearch);
+    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right', false);
 
-    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left');
-    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right');
+    if (willShowSearch) {
+      if (html.isBlank(leftSidebar)) {
+        leftSidebar.setSlot('initiallyHidden', true);
+        showingSidebarLeft = false;
+      }
+
+      leftSidebar.setSlot(
+        'boxes',
+        html.tags([
+          relations.searchBox,
+          leftSidebar.getSlotValue('boxes'),
+        ]));
+    }
 
     const hasSidebarLeft = !html.isBlank(html.resolve(leftSidebar));
     const hasSidebarRight = !html.isBlank(html.resolve(rightSidebar));
 
-    const collapseSidebars =
-      (hasSidebarLeft
-        ? leftSidebar.getSlotValue('collapse')
-        : true) &&
-      (hasSidebarRight
-        ? rightSidebar.getSlotValue('collapse')
-        : true);
+    showingSidebarLeft ??= hasSidebarLeft;
+    showingSidebarRight ??= hasSidebarRight;
 
     const processSkippers = skipperList =>
       skipperList
@@ -407,8 +443,11 @@ export default {
           (condition === undefined
             ? hasID(id)
             : condition))
+
         .map(({id, string}) =>
           html.tag('span', {class: 'skipper'},
+            {'data-for': id},
+
             html.tag('a',
               {href: `#${id}`},
               language.$('misc.skippers', string))));
@@ -512,15 +551,11 @@ export default {
 
       slots.secondaryNav,
 
-      html.tag('div', {class: 'layout-columns'},
-        !collapseSidebars &&
-          {class: 'vertical-when-thin'},
-
-        [
-          leftSidebar,
-          mainHTML,
-          rightSidebar,
-        ]),
+      html.tag('div', {class: 'layout-columns'}, [
+        leftSidebar,
+        mainHTML,
+        rightSidebar,
+      ]),
 
       slots.bannerPosition === 'bottom' &&
         slots.banner,
@@ -543,6 +578,8 @@ export default {
         {'data-rebase-localized': to('localized.root')},
         {'data-rebase-shared': to('shared.root')},
         {'data-rebase-media': to('media.root')},
+        {'data-rebase-thumb': to('thumb.root')},
+        {'data-rebase-lib': to('staticLib.root')},
         {'data-rebase-data': to('data.root')},
 
         [
@@ -613,7 +650,7 @@ export default {
 
             html.tag('link', {
               rel: 'stylesheet',
-              href: to('shared.staticFile', 'site6.css', cachebust),
+              href: to('staticCSS.path', 'site.css'),
             }),
 
             html.tag('style', [
@@ -623,25 +660,29 @@ export default {
             ]),
 
             html.tag('script', {
-              src: to('shared.staticFile', 'lazy-loading.js', cachebust),
+              src: to('staticLib.path', 'chroma-js/chroma.min.js'),
+            }),
+
+            html.tag('script', {
+              blocking: 'render',
+              src: to('staticJS.path', 'lazy-loading.js'),
+            }),
+
+            html.tag('script', {
+              blocking: 'render',
+              type: 'module',
+              src: to('staticJS.path', 'client.js'),
             }),
           ]),
 
           html.tag('body',
             [
               html.tag('div', {id: 'page-container'},
-                (hasSidebarLeft || hasSidebarRight
-                  ? {class: 'has-one-sidebar'}
-                  : {class: 'has-zero-sidebars'}),
-
-                hasSidebarLeft && hasSidebarRight &&
-                  {class: 'has-two-sidebars'},
+                showingSidebarLeft &&
+                  {class: 'showing-sidebar-left'},
 
-                hasSidebarLeft &&
-                  {class: 'has-sidebar-left'},
-
-                hasSidebarRight &&
-                  {class: 'has-sidebar-right'},
+                showingSidebarRight &&
+                  {class: 'showing-sidebar-right'},
 
                 [
                   skippersHTML,
@@ -650,11 +691,6 @@ export default {
 
               // infoCardHTML,
               imageOverlayHTML,
-
-              html.tag('script', {
-                type: 'module',
-                src: to('shared.staticFile', 'client3.js', cachebust),
-              }),
             ]),
         ])
     ]).toString();
diff --git a/src/content/dependencies/generatePageSidebar.js b/src/content/dependencies/generatePageSidebar.js
index a7da3d1d..d3b55580 100644
--- a/src/content/dependencies/generatePageSidebar.js
+++ b/src/content/dependencies/generatePageSidebar.js
@@ -1,29 +1,16 @@
 export default {
-  contentDependencies: ['generatePageSidebarBox'],
   extraDependencies: ['html'],
 
-  relations: (relation) => ({
-    box:
-      relation('generatePageSidebarBox'),
-  }),
-
   slots: {
-    // Content is a flat HTML array. It'll all be placed into one sidebar box
-    // if specified.
-    content: {
-      type: 'html',
-      mutable: false,
-    },
-
-    // Attributes to apply to the whole sidebar. If specifying multiple
-    // sections, this be added to the containing sidebar-column, arr - specify
-    // attributes on each section if that's more suitable.
+    // Attributes to apply to the whole sidebar. This be added to the
+    // containing sidebar-column, arr - specify attributes on each section if
+    // that's more suitable.
     attributes: {
       type: 'attributes',
       mutable: false,
     },
 
-    // Chunks of content to be split into separate boxes in the sidebar.
+    // Content boxes to line up vertically in the sidebar.
     boxes: {
       type: 'html',
       mutable: false,
@@ -32,27 +19,16 @@ export default {
     // Sticky mode controls which sidebar sections, 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
     // 'static' - 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).
     stickyMode: {
-      validate: v => v.is('last', 'column', 'static'),
+      validate: v => v.is('column', 'static'),
       default: '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.
-    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.
@@ -60,9 +36,19 @@ export default {
       type: 'boolean',
       default: false,
     },
+
+    // Provide to include all the HTML for the sidebar in place as usual,
+    // but start it out totally invisible. This is mainly so client-side
+    // JavaScript can show the sidebar if it needs to (and has a target
+    // to slot its own content into). If there are no boxes and this
+    // option *isn't* provided, then the sidebar will just be blank.
+    initiallyHidden: {
+      type: 'boolean',
+      default: false,
+    },
   },
 
-  generate(relations, slots, {html}) {
+  generate(slots, {html}) {
     const attributes =
       html.attributes({class: [
         'sidebar-column',
@@ -71,33 +57,34 @@ export default {
 
     attributes.add(slots.attributes);
 
-    if (slots.class) {
-      attributes.add('class', slots.class);
-    }
-
     if (slots.wide) {
       attributes.add('class', 'wide');
     }
 
-    if (!slots.collapse) {
-      attributes.add('class', 'no-hide');
-    }
-
     if (slots.stickyMode !== 'static') {
       attributes.add('class', `sticky-${slots.stickyMode}`);
     }
 
-    const boxes =
-      (!html.isBlank(slots.boxes)
-        ? slots.boxes
-     : !html.isBlank(slots.content)
-        ? relations.box.slot('content', slots.content)
-        : html.blank());
+    const {content: boxes} = html.smooth(slots.boxes);
+
+    const allBoxesCollapsible =
+      boxes.every(box =>
+        html.resolve(box)
+          .attributes
+          .has('class', 'collapsible'));
+
+    if (allBoxesCollapsible) {
+      attributes.add('class', 'all-boxes-collapsible');
+    }
+
+    if (slots.initiallyHidden) {
+      attributes.add('class', 'initially-hidden');
+    }
 
-    if (html.isBlank(boxes)) {
+    if (html.isBlank(slots.boxes) && !slots.initiallyHidden) {
       return html.blank();
     } else {
-      return html.tag('div', attributes, boxes);
+      return html.tag('div', attributes, slots.boxes);
     }
   },
 };
diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js
index 51835452..e11efc3f 100644
--- a/src/content/dependencies/generatePageSidebarBox.js
+++ b/src/content/dependencies/generatePageSidebarBox.js
@@ -11,10 +11,18 @@ export default {
       type: 'attributes',
       mutable: false,
     },
+
+    collapsible: {
+      type: 'boolean',
+      default: true,
+    },
   },
 
   generate: (slots, {html}) =>
     html.tag('div', {class: 'sidebar'},
+      slots.collapsible &&
+        {class: 'collapsible'},
+
       slots.attributes,
       slots.content),
 };
diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js
new file mode 100644
index 00000000..6607c789
--- /dev/null
+++ b/src/content/dependencies/generateSearchSidebarBox.js
@@ -0,0 +1,57 @@
+export default {
+  contentDependencies: ['generatePageSidebarBox'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    relations.sidebarBox.slots({
+      attributes: {class: 'wiki-search-sidebar-box'},
+      collapsible: false,
+
+      content: [
+        html.tag('input', {class: 'wiki-search-input'},
+          {
+            placeholder:
+              language.$('misc.search.placeholder').toString(),
+          },
+          {type: 'search'}),
+
+        html.tag('template', {class: 'wiki-search-preparing-string'},
+          language.$('misc.search.preparing')),
+
+        html.tag('template', {class: 'wiki-search-loading-data-string'},
+          language.$('misc.search.loadingData')),
+
+        html.tag('template', {class: 'wiki-search-searching-string'},
+          language.$('misc.search.searching')),
+
+        html.tag('template', {class: 'wiki-search-failed-string'},
+          language.$('misc.search.failed')),
+
+        html.tag('template', {class: 'wiki-search-no-results-string'},
+          language.$('misc.search.noResults')),
+
+        html.tag('template', {class: 'wiki-search-current-result-string'},
+          language.$('misc.search.currentResult')),
+
+        html.tag('template', {class: 'wiki-search-end-search-string'},
+          language.$('misc.search.endSearch')),
+
+        html.tag('template', {class: 'wiki-search-album-result-kind-string'},
+          language.$('misc.search.resultKind.album')),
+
+        html.tag('template', {class: 'wiki-search-artist-result-kind-string'},
+          language.$('misc.search.resultKind.artist')),
+
+        html.tag('template', {class: 'wiki-search-group-result-kind-string'},
+          language.$('misc.search.resultKind.group')),
+
+        html.tag('template', {class: 'wiki-search-tag-result-kind-string'},
+          language.$('misc.search.resultKind.artTag')),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
index 9becfb26..7f271715 100644
--- a/src/content/dependencies/generateStickyHeadingContainer.js
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -22,10 +22,17 @@ export default {
         html.tag('div', {class: 'content-sticky-heading-row'}, [
           html.tag('h1', slots.title),
 
-          !html.isBlank(slots.cover) &&
-            html.tag('div', {class: 'content-sticky-heading-cover-container'},
-              html.tag('div', {class: 'content-sticky-heading-cover'},
-                slots.cover.slot('mode', 'thumbnail'))),
+          html.tag('div', {class: 'content-sticky-heading-cover-container'},
+            {[html.onlyIfContent]: true},
+
+            html.tag('div', {class: 'content-sticky-heading-cover'},
+              {[html.onlyIfContent]: true},
+
+              // TODO: We shouldn't need to do an isBlank check here,
+              // but a live blank value doesn't have a slot functions, so.
+              (html.isBlank(slots.cover)
+                ? html.blank()
+                : slots.cover.slot('mode', 'thumbnail')))),
         ]),
 
         html.tag('div', {class: 'content-sticky-subheading-row'},
diff --git a/src/content/dependencies/generateTrackChronologyLinks.js b/src/content/dependencies/generateTrackChronologyLinks.js
new file mode 100644
index 00000000..5f6b0771
--- /dev/null
+++ b/src/content/dependencies/generateTrackChronologyLinks.js
@@ -0,0 +1,166 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {accumulateSum, stitchArrays} from '#sugar';
+
+import getChronologyRelations from '../util/getChronologyRelations.js';
+
+export default {
+  contentDependencies: [
+    'generateChronologyLinks',
+    'generateChronologyLinksScopeSwitcher',
+    'linkAlbum',
+    'linkArtist',
+    'linkTrack',
+  ],
+
+  relations(relation, track) {
+    function getScopedRelations(album) {
+      const albumFilter =
+        (album
+          ? track => track.album === album
+          : () => true);
+
+      return {
+        chronologyLinks:
+          relation('generateChronologyLinks'),
+
+        artistChronologyContributions:
+          getChronologyRelations(track, {
+            contributions: [
+              ...track.artistContribs ?? [],
+              ...track.contributorContribs ?? [],
+            ],
+
+            linkArtist: artist => relation('linkArtist', artist),
+            linkThing: track => relation('linkTrack', track),
+
+            getThings(artist) {
+              const getDate = thing => thing.date;
+
+              const things =
+                ([
+                  ...artist.tracksAsArtist,
+                  ...artist.tracksAsContributor,
+                ]).filter(getDate)
+                  .filter(albumFilter);
+
+              return sortAlbumsTracksChronologically(things, {getDate});
+            },
+          }),
+
+        coverArtistChronologyContributions:
+          getChronologyRelations(track, {
+            contributions: track.coverArtistContribs ?? [],
+
+            linkArtist: artist => relation('linkArtist', artist),
+
+            linkThing: trackOrAlbum =>
+              (trackOrAlbum.album
+                ? relation('linkTrack', trackOrAlbum)
+                : relation('linkAlbum', trackOrAlbum)),
+
+            getThings(artist) {
+              const getDate = thing => thing.coverArtDate ?? thing.date;
+
+              const things =
+                ([
+                  ...artist.albumsAsCoverArtist,
+                  ...artist.tracksAsCoverArtist,
+                ]).filter(getDate)
+                  .filter(albumFilter);
+
+              return sortAlbumsTracksChronologically(things, {getDate});
+            },
+          }),
+      };
+    }
+
+    const relations = {};
+
+    relations.scopeSwitcher =
+      relation('generateChronologyLinksScopeSwitcher');
+
+    relations.wiki =
+      getScopedRelations(null);
+
+    relations.album =
+      getScopedRelations(track.album);
+
+    for (const setKey of [
+      'artistChronologyContributions',
+      'coverArtistChronologyContributions',
+    ]) {
+      const wikiSet = relations.wiki[setKey];
+      const albumSet = relations.album[setKey];
+
+      const wikiArtistDirectories =
+        wikiSet
+          .map(({artistDirectory}) => artistDirectory);
+
+      albumSet.sort((a, b) =>
+        (a.only === b.only && a.index === b.index
+          ? (wikiArtistDirectories.indexOf(a.artistDirectory)
+           - wikiArtistDirectories.indexOf(b.artistDirectory))
+          : 0));
+    }
+
+    return relations;
+  },
+
+  generate(relations) {
+    function slotScopedRelations({content, artworkHeadingString}) {
+      return content.chronologyLinks.slots({
+        showOnly: true,
+        allowCollapsing: false,
+
+        chronologyInfoSets: [
+          {
+            headingString: 'misc.chronology.heading.track',
+            contributions: content.artistChronologyContributions,
+          },
+          {
+            headingString: `misc.chronology.heading.${artworkHeadingString}`,
+            contributions: content.coverArtistChronologyContributions,
+          },
+        ],
+      });
+    }
+
+    const scopes = [
+      'wiki',
+      'album',
+    ];
+
+    const contents = [
+      relations.wiki,
+      relations.album,
+    ];
+
+    const artworkHeadingStrings = [
+      'coverArt',
+      'trackArt',
+    ];
+
+    const totalContributionCount =
+      Math.max(...
+        contents.map(content =>
+          accumulateSum([
+            content.artistChronologyContributions,
+            content.coverArtistChronologyContributions,
+          ], contributions => contributions.length)));
+
+    relations.scopeSwitcher.setSlots({
+      scopes,
+
+      open:
+        totalContributionCount <= 5,
+
+      contents:
+        stitchArrays({
+          content: contents,
+          artworkHeadingString: artworkHeadingStrings,
+        }).map(slotScopedRelations),
+    });
+
+    return relations.scopeSwitcher;
+  },
+};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 1b5fbbf8..ec93ab71 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,18 +1,14 @@
-import {sortAlbumsTracksChronologically, sortFlashesChronologically}
-  from '#sort';
+import {sortFlashesChronologically} from '#sort';
 import {empty, stitchArrays} from '#sugar';
 
-import getChronologyRelations from '../util/getChronologyRelations.js';
-
 export default {
   contentDependencies: [
     'generateAbsoluteDatetimestamp',
-    'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
     'generateAlbumStyleRules',
-    'generateChronologyLinks',
     'generateColorStyleAttribute',
     'generateCommentarySection',
     'generateContentHeading',
@@ -20,13 +16,13 @@ export default {
     'generatePageLayout',
     'generateRelativeDatetimestamp',
     'generateTrackAdditionalNamesBox',
+    'generateTrackChronologyLinks',
     'generateTrackCoverArtwork',
     'generateTrackList',
     'generateTrackListDividedByGroups',
     'generateTrackReleaseInfo',
     'generateTrackSocialEmbed',
     'linkAlbum',
-    'linkArtist',
     'linkFlash',
     'linkTrack',
     'transformContent',
@@ -55,51 +51,6 @@ export default {
     relations.socialEmbed =
       relation('generateTrackSocialEmbed', track);
 
-    relations.artistChronologyContributions =
-      getChronologyRelations(track, {
-        contributions: [
-          ...track.artistContribs ?? [],
-          ...track.contributorContribs ?? [],
-        ],
-
-        linkArtist: artist => relation('linkArtist', artist),
-        linkThing: track => relation('linkTrack', track),
-
-        getThings(artist) {
-          const getDate = thing => thing.date;
-
-          const things = [
-            ...artist.tracksAsArtist,
-            ...artist.tracksAsContributor,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      });
-
-    relations.coverArtistChronologyContributions =
-      getChronologyRelations(track, {
-        contributions: track.coverArtistContribs ?? [],
-
-        linkArtist: artist => relation('linkArtist', artist),
-
-        linkThing: trackOrAlbum =>
-          (trackOrAlbum.album
-            ? relation('linkTrack', trackOrAlbum)
-            : relation('linkAlbum', trackOrAlbum)),
-
-        getThings(artist) {
-          const getDate = thing => thing.coverArtDate ?? thing.date;
-
-          const things = [
-            ...artist.albumsAsCoverArtist,
-            ...artist.tracksAsCoverArtist,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      }),
-
     relations.albumLink =
       relation('linkAlbum', track.album);
 
@@ -109,8 +60,11 @@ export default {
     relations.albumNavAccent =
       relation('generateAlbumNavAccent', track.album, track);
 
-    relations.chronologyLinks =
-      relation('generateChronologyLinks');
+    relations.trackChronologyLinks =
+      relation('generateTrackChronologyLinks', track);
+
+    relations.secondaryNav =
+      relation('generateAlbumSecondaryNav', track.album);
 
     relations.sidebar =
       relation('generateAlbumSidebar', track.album, track);
@@ -134,15 +88,6 @@ export default {
     relations.releaseInfo =
       relation('generateTrackReleaseInfo', track);
 
-    // Section: Extra links
-
-    const extra = sections.extra = {};
-
-    if (!empty(track.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', track.additionalFiles);
-    }
-
     // Section: Other releases
 
     if (!empty(track.otherReleases)) {
@@ -371,7 +316,11 @@ export default {
                 }),
 
               sec.additionalFiles &&
-                sec.extra.additionalFilesShortcut,
+                language.$('releaseInfo.additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#midi-project-files'},
+                    language.$('releaseInfo.additionalFiles.shortcut.link')),
+                }),
 
               sec.artistCommentary &&
                 language.$('releaseInfo.readCommentary', {
@@ -384,7 +333,7 @@ export default {
           sec.otherReleases && [
             sec.otherReleases.heading
               .slots({
-                id: 'also-released-as',
+                attributes: {id: 'also-released-as'},
                 title: language.$('releaseInfo.alsoReleasedAs'),
               }),
 
@@ -425,7 +374,7 @@ export default {
           sec.contributors && [
             sec.contributors.heading
               .slots({
-                id: 'contributors',
+                attributes: {id: 'contributors'},
                 title: language.$('releaseInfo.contributors'),
               }),
 
@@ -435,11 +384,15 @@ export default {
           sec.references && [
             sec.references.heading
               .slots({
-                id: 'references',
+                attributes: {id: 'references'},
+
                 title:
                   language.$('releaseInfo.tracksReferenced', {
                     track: html.tag('i', data.name),
                   }),
+
+                stickyTitle:
+                  language.$('releaseInfo.tracksReferenced.sticky'),
               }),
 
             sec.references.list,
@@ -448,11 +401,15 @@ export default {
           sec.samples && [
             sec.samples.heading
               .slots({
-                id: 'samples',
+                attributes: {id: 'samples'},
+
                 title:
                   language.$('releaseInfo.tracksSampled', {
                     track: html.tag('i', data.name),
                   }),
+
+                stickyTitle:
+                  language.$('releaseInfo.tracksSampled.sticky'),
               }),
 
             sec.samples.list,
@@ -461,37 +418,55 @@ export default {
           sec.referencedBy && [
             sec.referencedBy.heading
               .slots({
-                id: 'referenced-by',
+                attributes: {id: 'referenced-by'},
+
                 title:
                   language.$('releaseInfo.tracksThatReference', {
                     track: html.tag('i', data.name),
                   }),
+
+                stickyTitle:
+                  language.$('releaseInfo.tracksThatReference.sticky'),
               }),
 
-            sec.referencedBy.list,
+            sec.referencedBy.list
+              .slots({
+                headingString: 'releaseInfo.tracksThatReference',
+              }),
           ],
 
           sec.sampledBy && [
             sec.sampledBy.heading
               .slots({
-                id: 'referenced-by',
+                attributes: {id: 'referenced-by'},
+
                 title:
                   language.$('releaseInfo.tracksThatSample', {
                     track: html.tag('i', data.name),
                   }),
+
+                stickyTitle:
+                  language.$('releaseInfo.tracksThatSample.sticky'),
               }),
 
-            sec.sampledBy.list,
+            sec.sampledBy.list
+              .slots({
+                headingString: 'releaseInfo.tracksThatSample',
+              }),
           ],
 
           sec.flashesThatFeature && [
             sec.flashesThatFeature.heading
               .slots({
-                id: 'featured-in',
+                attributes: {id: 'featured-in'},
+
                 title:
                   language.$('releaseInfo.flashesThatFeature', {
                     track: html.tag('i', data.name),
                   }),
+
+                stickyTitle:
+                  language.$('releaseInfo.flashesThatFeature.sticky'),
               }),
 
             html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) =>
@@ -510,7 +485,7 @@ export default {
           sec.lyrics && [
             sec.lyrics.heading
               .slots({
-                id: 'lyrics',
+                attributes: {id: 'lyrics'},
                 title: language.$('releaseInfo.lyrics'),
               }),
 
@@ -522,7 +497,7 @@ export default {
           sec.sheetMusicFiles && [
             sec.sheetMusicFiles.heading
               .slots({
-                id: 'sheet-music-files',
+                attributes: {id: 'sheet-music-files'},
                 title: language.$('releaseInfo.sheetMusicFiles.heading'),
               }),
 
@@ -532,7 +507,7 @@ export default {
           sec.midiProjectFiles && [
             sec.midiProjectFiles.heading
               .slots({
-                id: 'midi-project-files',
+                attributes: {id: 'midi-project-files'},
                 title: language.$('releaseInfo.midiProjectFiles.heading'),
               }),
 
@@ -542,12 +517,8 @@ export default {
           sec.additionalFiles && [
             sec.additionalFiles.heading
               .slots({
-                id: 'additional-files',
-                title:
-                  language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles:
-                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
-                  }),
+                attributes: {id: 'additional-files'},
+                title: language.$('releaseInfo.additionalFiles.heading'),
               }),
 
             sec.additionalFiles.list,
@@ -582,18 +553,11 @@ export default {
           }),
 
         navContent:
-          relations.chronologyLinks.slots({
-            chronologyInfoSets: [
-              {
-                headingString: 'misc.chronology.heading.track',
-                contributions: relations.artistChronologyContributions,
-              },
-              {
-                headingString: 'misc.chronology.heading.coverArt',
-                contributions: relations.coverArtistChronologyContributions,
-              },
-            ],
-          }),
+          relations.trackChronologyLinks,
+
+        secondaryNav:
+          relations.secondaryNav
+            .slot('mode', 'track'),
 
         leftSidebar: relations.sidebar,
 
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
index e070ac35..327865f0 100644
--- a/src/content/dependencies/generateTrackListDividedByGroups.js
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -1,53 +1,132 @@
-import {empty} from '#sugar';
-
-import groupTracksByGroup from '../util/groupTracksByGroup.js';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
 
 export default {
-  contentDependencies: ['generateTrackList', 'linkGroup'],
+  contentDependencies: [
+    'generateContentHeading',
+    'generateTrackList',
+    'linkGroup',
+  ],
+
   extraDependencies: ['html', 'language'],
 
-  relations(relation, tracks, groups) {
-    if (empty(tracks)) {
-      return {};
+  query(tracks, dividingGroups) {
+    const groupings = new Map();
+    const ungroupedTracks = [];
+
+    // Entry order matters! Add blank lists for each group
+    // in the order that those groups are provided.
+    for (const group of dividingGroups) {
+      groupings.set(group, []);
     }
 
-    if (empty(groups)) {
-      return {
-        flatList:
-          relation('generateTrackList', tracks),
-      };
+    for (const track of tracks) {
+      const firstMatchingGroup =
+        dividingGroups.find(group => group.albums.includes(track.album));
+
+      if (firstMatchingGroup) {
+        groupings.get(firstMatchingGroup).push(track);
+      } else {
+        ungroupedTracks.push(track);
+      }
     }
 
-    const lists = groupTracksByGroup(tracks, groups);
+    const groups = Array.from(groupings.keys());
+    const groupedTracks = Array.from(groupings.values());
 
-    return {
-      groupedLists:
-        Array.from(lists.entries()).map(([groupOrOther, tracks]) => ({
-          ...(groupOrOther === 'other'
-                ? {other: true}
-                : {groupLink: relation('linkGroup', groupOrOther)}),
+    // Drop the empty lists, so just the groups which
+    // at least a single track matched are left.
+    filterMultipleArrays(
+      groups,
+      groupedTracks,
+      (_group, tracks) => !empty(tracks));
 
-          list:
-            relation('generateTrackList', tracks),
-        })),
-    };
+    return {groups, groupedTracks, ungroupedTracks};
   },
 
-  generate(relations, {html, language}) {
-    if (relations.flatList) {
-      return relations.flatList;
-    }
+  relations: (relation, query, tracks, groups) => ({
+    flatList:
+      (empty(groups)
+        ? relation('generateTrackList', tracks)
+        : null),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    groupLinks:
+      query.groups
+        .map(group => relation('linkGroup', group)),
+
+    groupedTrackLists:
+      query.groupedTracks
+        .map(tracks => relation('generateTrackList', tracks)),
+
+    ungroupedTrackList:
+      (empty(query.ungroupedTracks)
+        ? null
+        : relation('generateTrackList', query.ungroupedTracks)),
+  }),
 
-    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),
-      ]));
+  data: (query) => ({
+    groupNames:
+      query.groups
+        .map(group => group.name),
+  }),
+
+  slots: {
+    headingString: {
+      type: 'string',
+    },
   },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.flatList ??
+    html.tag('dl', [
+      stitchArrays({
+        groupName: data.groupNames,
+        groupLink: relations.groupLinks,
+        trackList: relations.groupedTrackLists,
+      }).map(({
+          groupName,
+          groupLink,
+          trackList,
+        }) => [
+          (slots.headingString
+            ? relations.contentHeading.clone().slots({
+                tag: 'dt',
+
+                title:
+                  language.$('trackList.fromGroup', {
+                    group: groupLink
+                  }),
+
+                stickyTitle:
+                  language.$(slots.headingString, 'sticky', 'fromGroup', {
+                    group: groupName,
+                  }),
+              })
+            : html.tag('dt',
+                language.$('trackList.fromGroup', {
+                  group: groupLink
+                }))),
+
+          html.tag('dd', trackList),
+        ]),
+
+      relations.ungroupedTrackList && [
+        (slots.headingString
+          ? relations.contentHeading.clone().slots({
+              tag: 'dt',
+
+              title:
+                language.$('trackList.fromOther'),
+
+              stickyTitle:
+                language.$(slots.headingString, 'sticky', 'fromOther'),
+            })
+          : html.tag('dt',
+              language.$('trackList.fromOther'))),
+
+        html.tag('dd', relations.ungroupedTrackList),
+      ],
+    ]),
 };
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 3bdeaa4f..88a4cdc7 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -59,20 +59,20 @@ export default {
           relations.coverArtistContributionsLine
             ?.slots({stringKey: 'releaseInfo.coverArtBy'}),
 
-          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),
-            }),
+          language.$('releaseInfo.released', {
+            [language.onlyIfOptions]: ['date'],
+            date: language.formatDate(data.date),
+          }),
+
+          language.$('releaseInfo.artReleased', {
+            [language.onlyIfOptions]: ['date'],
+            date: language.formatDate(data.coverArtDate),
+          }),
+
+          language.$('releaseInfo.duration', {
+            [language.onlyIfOptions]: ['duration'],
+            duration: language.formatDuration(data.duration),
+          }),
         ]),
 
       html.tag('p',
diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js
index a19f104c..16c22bb3 100644
--- a/src/content/dependencies/generateWikiHomeAlbumsRow.js
+++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js
@@ -113,10 +113,10 @@ export default {
           image.slots({
             path,
             missingSourceContent:
-              name &&
-                language.$('misc.albumGrid.noCoverArt', {
-                  album: name,
-                }),
+              language.$('misc.albumGrid.noCoverArt', {
+                [language.onlyIfOptions]: ['album'],
+                album: name,
+              }),
             }));
 
     commonSlots.actionLinks =
diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js
index e054edda..bd0e4797 100644
--- a/src/content/dependencies/generateWikiHomeNewsBox.js
+++ b/src/content/dependencies/generateWikiHomeNewsBox.js
@@ -46,6 +46,8 @@ export default {
 
     return relations.box.slots({
       attributes: {class: 'latest-news-sidebar-box'},
+      collapsible: false,
+
       content: [
         html.tag('h1', language.$('homepage.news.title')),
 
diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js
index 35461d03..ee14a587 100644
--- a/src/content/dependencies/generateWikiHomePage.js
+++ b/src/content/dependencies/generateWikiHomePage.js
@@ -79,13 +79,14 @@ export default {
 
       leftSidebar:
         relations.sidebar.slots({
-          collapse: false,
           wide: true,
 
           boxes: [
             relations.customSidebarContent &&
               relations.customSidebarBox.slots({
                 attributes: {class: 'custom-content-sidebar-box'},
+                collapsible: false,
+
                 content:
                   relations.customSidebarContent
                     .slot('mode', 'multiline'),
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 6b24f386..b1f02819 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -1,9 +1,8 @@
-import {logInfo, logWarn} from '#cli';
+import {logWarn} from '#cli';
 import {empty} from '#sugar';
 
 export default {
   extraDependencies: [
-    'cachebust',
     'checkIfImagePathHasCachedThumbnails',
     'getDimensionsOfImagePath',
     'getSizeOfImagePath',
@@ -82,7 +81,6 @@ export default {
   },
 
   generate(data, relations, slots, {
-    cachebust,
     checkIfImagePathHasCachedThumbnails,
     getDimensionsOfImagePath,
     getSizeOfImagePath,
@@ -119,10 +117,6 @@ export default {
     const isMissingImageFile =
       missingImagePaths.includes(mediaSrc);
 
-    if (isMissingImageFile) {
-      logInfo`No image file for ${mediaSrc} - build again for list of missing images.`;
-    }
-
     const willLink =
       !isMissingImageFile &&
       (typeof slots.link === 'string' || slots.link);
@@ -137,15 +131,8 @@ export default {
       !isMissingImageFile &&
       !empty(contentWarnings);
 
-    const hasBothDimensions =
-      !!(slots.dimensions &&
-         slots.dimensions[0] !== null &&
-         slots.dimensions[1] !== null);
-
     const willSquare =
-      (hasBothDimensions
-        ? slots.dimensions[0] === slots.dimensions[1]
-        : slots.square);
+      slots.square;
 
     const imgAttributes = html.attributes([
       {class: 'image'},
@@ -156,7 +143,7 @@ export default {
         {width: slots.dimensions[0]},
 
       slots.dimensions?.[1] &&
-        {width: slots.dimensions[1]},
+        {height: slots.dimensions[1]},
     ]);
 
     const isPlaceholder =
@@ -176,7 +163,7 @@ export default {
     if (willReveal) {
       reveal = [
         html.tag('img', {class: 'reveal-symbol'},
-          {src: to('shared.staticFile', 'warning.svg', cachebust)}),
+          {src: to('staticMisc.path', 'warning.svg')}),
 
         html.tag('br'),
 
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index 41ce1146..1a51c387 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -14,7 +14,7 @@ export default {
     const relations = {};
 
     relations.artistLink =
-      relation('linkArtist', contribution.who);
+      relation('linkArtist', contribution.artist);
 
     relations.textWithTooltip =
       relation('generateTextWithTooltip');
@@ -22,9 +22,9 @@ export default {
     relations.tooltip =
       relation('generateTooltip');
 
-    if (!empty(contribution.who.urls)) {
+    if (!empty(contribution.artist.urls)) {
       relations.artistIcons =
-        contribution.who.urls
+        contribution.artist.urls
           .map(url => relation('linkExternalAsIcon', url));
     }
 
@@ -33,8 +33,8 @@ export default {
 
   data(contribution) {
     return {
-      what: contribution.what,
-      urls: contribution.who.urls,
+      contribution: contribution.annotation,
+      urls: contribution.artist.urls,
     };
   },
 
@@ -50,7 +50,7 @@ export default {
   },
 
   generate(data, relations, slots, {html, language}) {
-    const hasContribution = !!(slots.showContribution && data.what);
+    const hasContribution = !!(slots.showContribution && data.contribution);
     const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
 
     const parts = ['misc.artistLink'];
@@ -111,7 +111,7 @@ export default {
 
     if (hasContribution) {
       parts.push('withContribution');
-      options.contrib = data.what;
+      options.contrib = data.contribution;
     }
 
     if (hasExternalIcons && slots.iconMode === 'inline') {
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
index 6f37529e..e2ce4b3c 100644
--- a/src/content/dependencies/linkExternalAsIcon.js
+++ b/src/content/dependencies/linkExternalAsIcon.js
@@ -37,7 +37,7 @@ export default {
             html.tag('title', platformText),
 
           html.tag('use', {
-            href: to('shared.staticIcon', iconId),
+            href: to('staticMisc.icon', iconId),
           }),
         ]),
 
diff --git a/src/content/dependencies/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js
index f677d82c..66fab8be 100644
--- a/src/content/dependencies/listArtistsByDuration.js
+++ b/src/content/dependencies/listArtistsByDuration.js
@@ -1,5 +1,5 @@
 import {sortAlphabetically, sortByCount} from '#sort';
-import {filterByCount, stitchArrays} from '#sugar';
+import {filterByCount, stitchArrays, unique} from '#sugar';
 import {getTotalDuration} from '#wiki-data';
 
 export default {
@@ -17,10 +17,12 @@ export default {
 
     const durations =
       artists.map(artist =>
-        getTotalDuration([
-          ...(artist.tracksAsArtist ?? []),
-          ...(artist.tracksAsContributor ?? []),
-        ], {originalReleasesOnly: true}));
+        getTotalDuration(
+          unique([
+            ...(artist.tracksAsArtist ?? []),
+            ...(artist.tracksAsContributor ?? []),
+          ]),
+          {originalReleasesOnly: true}));
 
     filterByCount(artists, durations);
     sortByCount(artists, durations, {greatestFirst: true});
diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js
index 30884d24..f221fe8c 100644
--- a/src/content/dependencies/listArtistsByGroup.js
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -1,6 +1,11 @@
 import {sortAlphabetically} from '#sort';
-import {empty, filterMultipleArrays, stitchArrays, unique} from '#sugar';
-import {getArtistNumContributions} from '#wiki-data';
+import {
+  empty,
+  filterByCount,
+  filterMultipleArrays,
+  stitchArrays,
+  transposeArrays,
+} from '#sugar';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
@@ -15,29 +20,52 @@ export default {
       sortAlphabetically(
         sprawl.artistData.filter(artist => !artist.isAlias));
 
-    const groups =
+    const interestingGroups =
       sprawl.wikiInfo.divideTrackListsByGroups;
 
-    if (empty(groups)) {
-      return {spec, artists};
+    if (empty(interestingGroups)) {
+      return {spec};
     }
 
-    const artistGroups =
+    // We don't actually care about *which* things belong to each group, only
+    // how many belong to each group. So we'll just compute a list of all the
+    // (interesting) groups that each of each artists' things belongs to.
+    const artistThingGroups =
       artists.map(artist =>
-        unique(
-          unique([
-            ...artist.albumsAsAny,
-            ...artist.tracksAsAny.map(track => track.album),
-          ]).flatMap(album => album.groups)))
-
-    const artistsByGroup =
-      groups.map(group =>
-        artists.filter((artist, index) => artistGroups[index].includes(group)));
-
-    filterMultipleArrays(groups, artistsByGroup,
-      (group, artists) => !empty(artists));
-
-    return {spec, groups, artistsByGroup};
+        ([...artist.albumsAsAny.map(album => album.groups),
+          ...artist.tracksAsAny.map(track => track.album.groups)])
+            .map(groups => groups
+              .filter(group => interestingGroups.includes(group))));
+
+    const [artistsByGroup, countsByGroup] =
+      transposeArrays(interestingGroups.map(group => {
+        const counts =
+          artistThingGroups
+            .map(thingGroups => thingGroups
+              .filter(thingGroups => thingGroups.includes(group))
+              .length);
+
+        const filteredArtists = artists.slice();
+
+        filterByCount(filteredArtists, counts);
+
+        return [filteredArtists, counts];
+      }));
+
+    const groups = interestingGroups;
+
+    filterMultipleArrays(
+      groups,
+      artistsByGroup,
+      countsByGroup,
+      (_group, artists, _counts) => !empty(artists));
+
+    return {
+      spec,
+      groups,
+      artistsByGroup,
+      countsByGroup,
+    };
   },
 
   relations(relation, query) {
@@ -46,12 +74,6 @@ export default {
     relations.page =
       relation('generateListingPage', query.spec);
 
-    if (query.artists) {
-      relations.artistLinks =
-        query.artists
-          .map(artist => relation('linkArtist', artist));
-    }
-
     if (query.artistsByGroup) {
       relations.groupLinks =
         query.groups
@@ -69,65 +91,43 @@ export default {
   data(query) {
     const data = {};
 
-    if (query.artists) {
-      data.counts =
-        query.artists
-          .map(artist => getArtistNumContributions(artist));
-    }
-
     if (query.artistsByGroup) {
       data.groupDirectories =
         query.groups
           .map(group => group.directory);
 
       data.countsByGroup =
-        query.artistsByGroup
-          .map(artists => artists
-            .map(artist => getArtistNumContributions(artist)));
+        query.countsByGroup;
     }
 
     return data;
   },
 
-  generate(data, relations, {language}) {
-    return (
-      (relations.artistLinksByGroup
-        ? relations.page.slots({
-            type: 'chunks',
-
-            showSkipToSection: true,
-            chunkIDs:
-              data.groupDirectories
-                .map(directory => `contributed-to-${directory}`),
-
-            chunkTitles:
-              relations.groupLinks.map(groupLink => ({
-                group: groupLink,
-              })),
-
-            chunkRows:
-              stitchArrays({
-                artistLinks: relations.artistLinksByGroup,
-                counts: data.countsByGroup,
-              }).map(({artistLinks, counts}) =>
-                  stitchArrays({
-                    link: artistLinks,
-                    count: counts,
-                  }).map(({link, count}) => ({
-                      artist: link,
-                      contributions: language.countContributions(count, {unit: true}),
-                    }))),
-          })
-        : relations.page.slots({
-            type: 'rows',
-            rows:
-              stitchArrays({
-                link: relations.artistLinks,
-                count: data.counts,
-              }).map(({link, count}) => ({
-                  artist: link,
-                  contributions: language.countContributions(count, {unit: true}),
-                })),
-          })));
-  },
+  generate: (data, relations, {language}) =>
+    relations.page.slots({
+      type: 'chunks',
+
+      showSkipToSection: true,
+      chunkIDs:
+        data.groupDirectories
+          .map(directory => `contributed-to-${directory}`),
+
+      chunkTitles:
+        relations.groupLinks.map(groupLink => ({
+          group: groupLink,
+        })),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: relations.artistLinksByGroup,
+          counts: data.countsByGroup,
+        }).map(({artistLinks, counts}) =>
+            stitchArrays({
+              link: artistLinks,
+              count: counts,
+            }).map(({link, count}) => ({
+                artist: link,
+                contributions: language.countContributions(count, {unit: true}),
+              }))),
+    }),
 };
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 0f709577..27a2faa3 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -83,7 +83,8 @@ export default {
       });
     };
 
-    const getArtists = (thing, key) => thing[key].map(({who}) => who);
+    const getArtists = (thing, key) =>
+      thing[key].map(({artist}) => artist);
 
     const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice());
     const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice());
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
index 01ce4e2d..0a2bfd6c 100644
--- a/src/content/dependencies/listTracksByDate.js
+++ b/src/content/dependencies/listTracksByDate.js
@@ -15,7 +15,8 @@ export default {
 
       chunks:
         chunkByProperties(
-          sortAlbumsTracksChronologically(trackData.slice()),
+          sortAlbumsTracksChronologically(
+            trackData.filter(track => track.date)),
           ['album', 'date']),
     };
   },
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 0904cde6..84fbe361 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -262,6 +262,10 @@ export default {
                   height && {height},
                   style && {style},
 
+                  align === 'center' &&
+                  !link &&
+                    {class: 'align-center'},
+
                   pixelate &&
                     {class: 'pixelate'});
 
@@ -271,6 +275,9 @@ export default {
                     {href: link},
                     {target: '_blank'},
 
+                    align === 'center' &&
+                      {class: 'align-center'},
+
                     {title:
                       language.$('misc.external.opensInNewTab', {
                         link:
@@ -530,9 +537,9 @@ export default {
           // Expand line breaks which don't follow a list, quote,
           // or <br> / "  ", and which don't precede or follow
           // indented text (by at least two spaces).
-          .replace(/(?<!^ *-.*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
+          .replace(/(?<!^ *(?:-|\d\.).*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
           // Expand line breaks which are at the end of a list.
-          .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n')
+          .replace(/(?<=^ *(?:-|\d\.).*)\n+(?!^ *(?:-|\d\.))/gm, '\n\n')
           // Expand line breaks which are at the end of a quote.
           .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n');
 
diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js
index 67d6d5fa..c601a990 100644
--- a/src/content/util/getChronologyRelations.js
+++ b/src/content/util/getChronologyRelations.js
@@ -18,17 +18,17 @@ export default function getChronologyRelations(thing, {
 
   const artistsSoFar = new Set();
 
-  contributions = contributions.filter(({who}) => {
-    if (artistsSoFar.has(who)) {
+  contributions = contributions.filter(({artist}) => {
+    if (artistsSoFar.has(artist)) {
       return false;
     } else {
-      artistsSoFar.add(who);
+      artistsSoFar.add(artist);
       return true;
     }
   });
 
-  return contributions.map(({who}) => {
-    const things = Array.from(new Set(getThings(who)));
+  return contributions.map(({artist}) => {
+    const things = Array.from(new Set(getThings(artist)));
 
     // Don't show a line if this contribution isn't part of the artist's
     // chronology at all (usually because this thing isn't dated).
@@ -37,19 +37,21 @@ export default function getChronologyRelations(thing, {
       return;
     }
 
-    // Don't show a line if this contribution is the *only* item in the
-    // artist's chronology (since there's nothing to navigate there).
     const previous = things[index - 1];
     const next = things[index + 1];
-    if (!previous && !next) {
-      return;
-    }
 
     return {
       index: index + 1,
-      artistLink: linkArtist(who),
+      artistDirectory: artist.directory,
+      only: !(previous || next),
+
+      artistLink: linkArtist(artist),
       previousLink: previous ? linkThing(previous) : null,
       nextLink: next ? linkThing(next) : null,
     };
-  }).filter(Boolean);
+  }).filter(Boolean)
+    .sort((a, b) =>
+      (a.only === b.only ?  b.index - a.index
+     : a.only            ? +1
+                         : -1))
 }
diff --git a/src/content/util/groupTracksByGroup.js b/src/content/util/groupTracksByGroup.js
deleted file mode 100644
index 4e189007..00000000
--- a/src/content/util/groupTracksByGroup.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import {empty} from '#sugar';
-
-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 {
-      lists.get('other').push(track);
-    }
-  }
-
-  for (const [key, tracks] of lists.entries()) {
-    if (empty(tracks)) {
-      lists.delete(key);
-    }
-  }
-
-  return lists;
-}
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index 1e7c7aa8..71dc5bde 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -83,6 +83,8 @@ function inspect(value) {
 }
 
 export default class CacheableObject {
+  static propertyDescriptors = Symbol.for('CacheableObject.propertyDescriptors');
+
   #propertyUpdateValues = Object.create(null);
   #propertyUpdateCacheInvalidators = Object.create(null);
 
@@ -113,28 +115,41 @@ export default class CacheableObject {
     }
   }
 
+  #withEachPropertyDescriptor(callback) {
+    const {[CacheableObject.propertyDescriptors]: propertyDescriptors} =
+      this.constructor;
+
+    for (const property of Reflect.ownKeys(propertyDescriptors)) {
+      callback(property, propertyDescriptors[property]);
+    }
+  }
+
   #initializeUpdatingPropertyValues() {
-    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
+    this.#withEachPropertyDescriptor((property, descriptor) => {
       const {flags, update} = descriptor;
 
       if (!flags.update) {
-        continue;
+        return;
       }
 
-      if (update?.default) {
+      if (
+        typeof update === 'object' &&
+        update !== null &&
+        'default' in update
+      ) {
         this[property] = update?.default;
       } else {
         this[property] = null;
       }
-    }
+    });
   }
 
   #defineProperties() {
-    if (!this.constructor.propertyDescriptors) {
-      throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`);
+    if (!this.constructor[CacheableObject.propertyDescriptors]) {
+      throw new Error(`Expected constructor ${this.constructor.name} to provide CacheableObject.propertyDescriptors`);
     }
 
-    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
+    this.#withEachPropertyDescriptor((property, descriptor) => {
       const {flags} = descriptor;
 
       const definition = {
@@ -151,7 +166,7 @@ export default class CacheableObject {
       }
 
       Object.defineProperty(this, property, definition);
-    }
+    });
 
     Object.seal(this);
   }
@@ -191,7 +206,7 @@ export default class CacheableObject {
   }
 
   #getPropertyDescriptor(property) {
-    return this.constructor.propertyDescriptors[property];
+    return this.constructor[CacheableObject.propertyDescriptors][property];
   }
 
   #invalidateCachesDependentUpon(property) {
@@ -244,7 +259,8 @@ export default class CacheableObject {
 
     if (expose.dependencies?.length > 0) {
       const dependencyKeys = expose.dependencies.slice();
-      const shouldReflect = dependencyKeys.includes('this');
+      const shouldReflectObject = dependencyKeys.includes('this');
+      const shouldReflectProperty = dependencyKeys.includes('thisProperty');
 
       getAllDependencies = () => {
         const dependencies = Object.create(null);
@@ -253,10 +269,14 @@ export default class CacheableObject {
           dependencies[key] = this.#propertyUpdateValues[key];
         }
 
-        if (shouldReflect) {
+        if (shouldReflectObject) {
           dependencies.this = this;
         }
 
+        if (shouldReflectProperty) {
+          dependencies.thisProperty = property;
+        }
+
         return dependencies;
       };
     } else {
@@ -311,16 +331,16 @@ export default class CacheableObject {
       return;
     }
 
-    const {propertyDescriptors} = obj.constructor;
+    const {[CacheableObject.propertyDescriptors]: propertyDescriptors} =
+      obj.constructor;
 
     if (!propertyDescriptors) {
       console.warn('Missing property descriptors:', obj);
       return;
     }
 
-    for (const [property, descriptor] of Object.entries(propertyDescriptors)) {
-      const {flags} = descriptor;
-
+    for (const property of Reflect.ownKeys(propertyDescriptors)) {
+      const {flags} = propertyDescriptors[property];
       if (!flags.expose) {
         continue;
       }
diff --git a/src/data/checks.js b/src/data/checks.js
index 44f3efd7..ad621bab 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -24,20 +24,30 @@ function inspect(value, opts = {}) {
   return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
 
-// Warn about directories which are reused across more than one of the same type
-// of Thing. Directories are the unique identifier for most data objects across
-// the wiki, so we have to make sure they aren't duplicated!
-export function reportDuplicateDirectories(wikiData, {
+// Warn about problems to do with directories.
+//
+// * Duplicate directories: these are the unique identifier for referencable
+//   data objects across the wiki, so duplicates introduce ambiguity where it
+//   can't fit.
+//
+// * Missing directories: in almost all cases directories can be computed,
+//   but in particularly brutal internal cases, it might not be possible, and
+//   a thing's directory is just null. This leaves it unable to be referenced.
+//
+export function reportDirectoryErrors(wikiData, {
   getAllFindSpecs,
 }) {
   const duplicateSets = [];
+  const missingDirectoryThings = new Set();
 
   for (const findSpec of Object.values(getAllFindSpecs())) {
     if (!findSpec.bindTo) continue;
 
     const directoryPlaces = Object.create(null);
     const duplicateDirectories = new Set();
+
     const thingData = wikiData[findSpec.bindTo];
+    if (!thingData) continue;
 
     for (const thing of thingData) {
       if (findSpec.include && !findSpec.include(thing)) {
@@ -50,6 +60,11 @@ export function reportDuplicateDirectories(wikiData, {
           : [thing.directory]);
 
       for (const directory of directories) {
+        if (directory === null || directory === undefined) {
+          missingDirectoryThings.add(thing);
+          continue;
+        }
+
         if (directory in directoryPlaces) {
           directoryPlaces[directory].push(thing);
           duplicateDirectories.add(directory);
@@ -59,8 +74,6 @@ export function reportDuplicateDirectories(wikiData, {
       }
     }
 
-    if (empty(duplicateDirectories)) continue;
-
     const sortedDuplicateDirectories =
       Array.from(duplicateDirectories)
         .sort((a, b) => {
@@ -75,8 +88,6 @@ export function reportDuplicateDirectories(wikiData, {
     }
   }
 
-  if (empty(duplicateSets)) return;
-
   // Multiple find functions may effectively have duplicates across the same
   // things. These only need to be reported once, because resolving one of them
   // will resolve the rest, so cut out duplicate sets before reporting.
@@ -84,6 +95,7 @@ export function reportDuplicateDirectories(wikiData, {
   const seenDuplicateSets = new Map();
   const deduplicateDuplicateSets = [];
 
+  iterateSets:
   for (const set of duplicateSets) {
     if (seenDuplicateSets.has(set.directory)) {
       const placeLists = seenDuplicateSets.get(set.directory);
@@ -95,7 +107,7 @@ export function reportDuplicateDirectories(wikiData, {
         // Two artists named Foodog aren't going to match two tracks named
         // Foodog.
         if (compareArrays(places, set.places, {checkOrder: false})) {
-          continue;
+          continue iterateSets;
         }
       }
 
@@ -107,12 +119,20 @@ export function reportDuplicateDirectories(wikiData, {
     deduplicateDuplicateSets.push(set);
   }
 
-  withAggregate({message: `Duplicate directories found`}, ({push}) => {
+  withAggregate({message: `Directory errors detected`}, ({push}) => {
     for (const {directory, places} of deduplicateDuplicateSets) {
       push(new Error(
         `Duplicate directory ${colors.green(`"${directory}"`)}:\n` +
         places.map(thing => ` - ` + inspect(thing)).join('\n')));
     }
+
+    if (!empty(missingDirectoryThings)) {
+      push(new Error(
+        `Couldn't figure out an implicit directory for:\n` +
+        Array.from(missingDirectoryThings)
+          .map(thing => `- ` + inspect(thing))
+          .join('\n')));
+    }
   });
 }
 
@@ -261,7 +281,7 @@ export function filterReferenceErrors(wikiData, {
                 break;
 
               case '_contrib':
-                findFn = contribRef => findArtistOrAlias(contribRef.who);
+                findFn = contribRef => findArtistOrAlias(contribRef.artist);
                 break;
 
               case '_homepageSourceGroup':
diff --git a/src/data/composite.js b/src/data/composite.js
index 7a98c424..ea7a3480 100644
--- a/src/data/composite.js
+++ b/src/data/composite.js
@@ -29,6 +29,7 @@ input.value = _valueIntoToken('input.value');
 input.dependency = _valueIntoToken('input.dependency');
 
 input.myself = () => Symbol.for(`hsmusic.composite.input.myself`);
+input.thisProperty = () => Symbol.for('hsmusic.composite.input.thisProperty');
 
 input.updateValue = _valueIntoToken('input.updateValue');
 
@@ -284,6 +285,7 @@ export function templateCompositeFrom(description) {
               'input.value',
               'input.dependency',
               'input.myself',
+              'input.thisProperty',
               'input.updateValue',
             ].includes(tokenShape)) {
               expectedValueProvidingTokenInputNames.push(name);
@@ -567,6 +569,8 @@ export function compositeFrom(description) {
             return token;
           case 'input.myself':
             return 'this';
+          case 'input.thisProperty':
+            return 'thisProperty';
           default:
             return null;
         }
@@ -721,6 +725,8 @@ export function compositeFrom(description) {
               return (tokenValue.startsWith('#') ? null : tokenValue);
             case 'input.myself':
               return 'this';
+            case 'input.thisProperty':
+              return 'thisProperty';
             default:
               return null;
           }
@@ -752,6 +758,9 @@ export function compositeFrom(description) {
     anyStepsUseUpdateValue ||
     anyStepsUpdate;
 
+  const stepsFirstTimeCalling =
+    Array.from({length: steps.length}).fill(true);
+
   const stepEntries = stitchArrays({
     step: steps,
     stepComposes: stepsCompose,
@@ -774,16 +783,9 @@ export function compositeFrom(description) {
       (step.annotation ? ` (${step.annotation})` : ``);
 
     aggregate.nest({message}, ({push}) => {
-      if (isBase && stepComposes !== compositionNests) {
-        return push(new TypeError(
-          (compositionNests
-            ? `Base must compose, this composition is nestable`
-            : `Base must not compose, this composition isn't nestable`)));
-      } else if (!isBase && !stepComposes) {
+      if (!isBase && !stepComposes) {
         return push(new TypeError(
-          (compositionNests
-            ? `All steps must compose`
-            : `All steps (except base) must compose`)));
+          `All steps leading up to base must compose`));
       }
 
       if (
@@ -877,6 +879,8 @@ export function compositeFrom(description) {
               return valueSoFar;
             case 'input.myself':
               return initialDependencies['this'];
+            case 'input.thisProperty':
+              return initialDependencies['thisProperty'];
             case 'input':
               return initialDependencies[token];
             default:
@@ -907,8 +911,16 @@ export function compositeFrom(description) {
       debug(() => colors.bright(`begin composition - not transforming`));
     }
 
-    for (let i = 0; i < steps.length; i++) {
-      const step = steps[i];
+    for (
+      const [i, {
+        step,
+        stepComposes,
+      }] of
+        stitchArrays({
+          step: steps,
+          stepComposes: stepsCompose,
+        }).entries()
+    ) {
       const isBase = i === steps.length - 1;
 
       debug(() => [
@@ -968,7 +980,16 @@ export function compositeFrom(description) {
           (expectingTransform
             ? {[input.updateValue()]: valueSoFar}
             : {}),
-        [input.myself()]: initialDependencies?.['this'] ?? null,
+
+        [input.myself()]:
+          (initialDependencies && Object.hasOwn(initialDependencies, 'this')
+            ? initialDependencies.this
+            : null),
+
+        [input.thisProperty()]:
+          (initialDependencies && Object.hasOwn(initialDependencies, 'thisProperty')
+            ? initialDependencies.thisProperty
+            : null),
       };
 
       const selectDependencies =
@@ -983,6 +1004,8 @@ export function compositeFrom(description) {
               return dependency;
             case 'input.myself':
               return input.myself();
+            case 'input.thisProperty':
+              return input.thisProperty();
             case 'input.dependency':
               return tokenValue;
             case 'input.updateValue':
@@ -1016,26 +1039,175 @@ export function compositeFrom(description) {
       const naturalEvaluate = () => {
         const [name, ...argsLayout] = getExpectedEvaluation();
 
-        let args;
+        let args = argsLayout;
 
-        if (isBase && !compositionNests) {
-          args =
-            argsLayout.filter(arg => arg !== continuationSymbol);
+        let effectiveDependencies;
+        let reviewAccessedDependencies;
+
+        if (stepsFirstTimeCalling[i]) {
+          const expressedDependencies =
+            selectDependencies;
+
+          const remainingDependencies =
+            new Set(expressedDependencies);
+
+          const unavailableDependencies = [];
+          const accessedDependencies = [];
+
+          effectiveDependencies =
+            new Proxy(filteredDependencies, {
+              get(target, key) {
+                accessedDependencies.push(key);
+                remainingDependencies.delete(key);
+
+                const value = target[key];
+
+                if (value === undefined) {
+                  unavailableDependencies.push(key);
+                }
+
+                return value;
+              },
+            });
+
+          reviewAccessedDependencies = () => {
+            const topAggregate =
+              openAggregate({
+                message: `Errors in accessed dependencies`,
+              });
+
+            const showDependency = dependency =>
+              (isInputToken(dependency)
+                ? getInputTokenShape(dependency) +
+                  `(` +
+                  inspect(getInputTokenValue(dependency), {compact: true}) +
+                  ')'
+                : dependency.toString());
+
+            let anyErrors = false;
+
+            for (const dependency of remainingDependencies) {
+              topAggregate.push(new Error(
+                `Expected to access ${showDependency(dependency)}`));
+
+              anyErrors = true;
+            }
+
+            for (const dependency of unavailableDependencies) {
+              const subAggregate =
+                openAggregate({
+                  message:
+                    `Accessed ${showDependency(dependency)}, which is unavailable`,
+                });
+
+              let reason = false;
+
+              if (!expressedDependencies.includes(dependency)) {
+                subAggregate.push(new Error(
+                  `Missing from step's expressed dependencies`));
+                reason = true;
+              }
+
+              if (filterableDependencies[dependency] === undefined) {
+                subAggregate.push(
+                  new Error(
+                    `Not available` +
+                    (isInputToken(dependency)
+                      ? ` in input()-type dependencies`
+                   : dependency.startsWith('#')
+                      ? ` in local dependencies`
+                      : ` on object dependencies`)));
+                reason = true;
+              }
+
+              if (!reason) {
+                subAggregate.push(new Error(
+                  `Not sure why this is unavailable, sorry!`));
+              }
+
+              topAggregate.call(subAggregate.close);
+
+              anyErrors = true;
+            }
+
+            if (anyErrors) {
+              topAggregate.push(new Error(
+                `These dependencies, in total, were accessed:` +
+                (empty(accessedDependencies)
+                  ? ` (none)`
+               : accessedDependencies.length === 1
+                  ? showDependency(accessedDependencies[0])
+                  : `\n` +
+                    accessedDependencies
+                      .map(showDependency)
+                      .map(line => `  - ${line}`)
+                      .join('\n'))));
+            }
+
+            topAggregate.close();
+          };
         } else {
+          effectiveDependencies = filteredDependencies;
+          reviewAccessedDependencies = null;
+        }
+
+        args =
+          args.map(arg =>
+            (arg === filteredDependencies
+              ? effectiveDependencies
+              : arg));
+
+        if (stepComposes) {
           let continuation;
 
           ({continuation, continuationStorage} =
             _prepareContinuation(callingTransformForThisStep));
 
           args =
-            argsLayout.map(arg =>
+            args.map(arg =>
               (arg === continuationSymbol
                 ? continuation
                 : arg));
+        } else {
+          args =
+            args.filter(arg => arg !== continuationSymbol);
         }
 
-        return expose[name](...args);
-      }
+        let stepError;
+        try {
+          return expose[name](...args);
+        } catch (error) {
+          stepError = error;
+        } finally {
+          stepsFirstTimeCalling[i] = false;
+
+          let reviewError;
+          if (reviewAccessedDependencies) {
+            try {
+              reviewAccessedDependencies();
+            } catch (error) {
+              reviewError = error;
+            }
+          }
+
+          const stepPart =
+            `step ${i+1}` +
+            (isBase
+              ? ` (base)`
+              : ` of ${steps.length}`) +
+            (step.annotation ? `, ${step.annotation}` : ``);
+
+          if (stepError && reviewError) {
+            throw new AggregateError(
+              [stepError, reviewError],
+              `Errors in ${stepPart}`);
+          } else if (stepError || reviewError) {
+            throw new Error(
+              `Error in ${stepPart}`,
+              {cause: stepError || reviewError});
+          }
+        }
+      };
 
       switch (step.cache) {
         // Warning! Highly WIP!
@@ -1091,11 +1263,6 @@ export function compositeFrom(description) {
 
       if (result !== continuationSymbol) {
         debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]);
-
-        if (compositionNests) {
-          throw new TypeError(`Inferred early-exit is disallowed in nested compositions`);
-        }
-
         debug(() => colors.bright(`end composition - exit (inferred)`));
 
         return result;
@@ -1216,6 +1383,7 @@ export function compositeFrom(description) {
           `Error computing composition` +
           (annotation ? ` ${annotation}` : ''));
         error.cause = thrownError;
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
         throw error;
       }
     };
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
index 8139f10e..0ef91b87 100644
--- a/src/data/composite/things/album/index.js
+++ b/src/data/composite/things/album/index.js
@@ -1,2 +1,2 @@
-export {default as withTracks} from './withTracks.js';
 export {default as withTrackSections} from './withTrackSections.js';
+export {default as withTracks} from './withTracks.js';
diff --git a/src/data/composite/things/album/withTrackSections.js b/src/data/composite/things/album/withTrackSections.js
index 0a1ebebc..a56bda31 100644
--- a/src/data/composite/things/album/withTrackSections.js
+++ b/src/data/composite/things/album/withTrackSections.js
@@ -1,127 +1,21 @@
 import {input, templateCompositeFrom} from '#composite';
+
 import find from '#find';
-import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
-import {isTrackSectionList} from '#validators';
 
-import {exitWithoutDependency, exitWithoutUpdateValue}
-  from '#composite/control-flow';
 import {withResolvedReferenceList} from '#composite/wiki-data';
 
-import {
-  fillMissingListItems,
-  withFlattenedList,
-  withPropertiesFromList,
-  withUnflattenedList,
-} from '#composite/data';
-
 export default templateCompositeFrom({
   annotation: `withTrackSections`,
 
   outputs: ['#trackSections'],
 
   steps: () => [
-    exitWithoutDependency({
-      dependency: 'ownTrackData',
-      value: input.value([]),
-    }),
-
-    exitWithoutUpdateValue({
-      mode: input.value('empty'),
-      value: input.value([]),
-    }),
-
-    // TODO: input.updateValue description down here is a kludge.
-    withPropertiesFromList({
-      list: input.updateValue({
-        validate: isTrackSectionList,
-      }),
-      prefix: input.value('#sections'),
-      properties: input.value([
-        'tracks',
-        'dateOriginallyReleased',
-        'isDefaultTrackSection',
-        'name',
-        'color',
-      ]),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.tracks',
-      fill: input.value([]),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.isDefaultTrackSection',
-      fill: input.value(false),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.name',
-      fill: input.value('Unnamed Track Section'),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.color',
-      fill: input.dependency('color'),
-    }),
-
-    withFlattenedList({
-      list: '#sections.tracks',
-    }).outputs({
-      ['#flattenedList']: '#trackRefs',
-      ['#flattenedIndices']: '#sections.startIndex',
-    }),
-
     withResolvedReferenceList({
-      list: '#trackRefs',
-      data: 'ownTrackData',
-      notFoundMode: input.value('null'),
-      find: input.value(find.track),
+      list: 'trackSections',
+      data: 'ownTrackSectionData',
+      find: input.value(find.unqualifiedTrackSection),
     }).outputs({
-      ['#resolvedReferenceList']: '#tracks',
+      ['#resolvedReferenceList']: '#trackSections',
     }),
-
-    withUnflattenedList({
-      list: '#tracks',
-      indices: '#sections.startIndex',
-    }).outputs({
-      ['#unflattenedList']: '#sections.tracks',
-    }),
-
-    {
-      dependencies: [
-        '#sections.tracks',
-        '#sections.name',
-        '#sections.color',
-        '#sections.dateOriginallyReleased',
-        '#sections.isDefaultTrackSection',
-        '#sections.startIndex',
-      ],
-
-      compute: (continuation, {
-        '#sections.tracks': tracks,
-        '#sections.name': name,
-        '#sections.color': color,
-        '#sections.dateOriginallyReleased': dateOriginallyReleased,
-        '#sections.isDefaultTrackSection': isDefaultTrackSection,
-        '#sections.startIndex': startIndex,
-      }) => {
-        filterMultipleArrays(
-          tracks, name, color, dateOriginallyReleased, isDefaultTrackSection, startIndex,
-          tracks => !empty(tracks));
-
-        return continuation({
-          ['#trackSections']:
-            stitchArrays({
-              tracks,
-              name,
-              color,
-              dateOriginallyReleased,
-              isDefaultTrackSection,
-              startIndex,
-            }),
-        });
-      },
-    },
   ],
 });
diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js
index fff3d5ae..c8d27c4c 100644
--- a/src/data/composite/things/album/withTracks.js
+++ b/src/data/composite/things/album/withTracks.js
@@ -1,51 +1,27 @@
 import {input, templateCompositeFrom} from '#composite';
-import find from '#find';
 
-import {exitWithoutDependency, raiseOutputWithoutDependency}
-  from '#composite/control-flow';
+import {withFlattenedList, withPropertyFromList} from '#composite/data';
 import {withResolvedReferenceList} from '#composite/wiki-data';
 
+import withTrackSections from './withTrackSections.js';
+
 export default templateCompositeFrom({
   annotation: `withTracks`,
 
   outputs: ['#tracks'],
 
   steps: () => [
-    exitWithoutDependency({
-      dependency: 'ownTrackData',
-      value: input.value([]),
-    }),
+    withTrackSections(),
 
-    raiseOutputWithoutDependency({
-      dependency: 'trackSections',
-      mode: input.value('empty'),
-      output: input.value({
-        ['#tracks']: [],
-      }),
+    withPropertyFromList({
+      list: '#trackSections',
+      property: input.value('tracks'),
     }),
 
-    {
-      dependencies: ['trackSections'],
-      compute: (continuation, {trackSections}) =>
-        continuation({
-          '#trackRefs': trackSections
-            .flatMap(section => section.tracks ?? []),
-        }),
-    },
-
-    withResolvedReferenceList({
-      list: '#trackRefs',
-      data: 'ownTrackData',
-      find: input.value(find.track),
+    withFlattenedList({
+      list: '#trackSections.tracks',
+    }).outputs({
+      ['#flattenedList']: '#tracks',
     }),
-
-    {
-      dependencies: ['#resolvedReferenceList'],
-      compute: (continuation, {
-        ['#resolvedReferenceList']: resolvedReferenceList,
-      }) => continuation({
-        ['#tracks']: resolvedReferenceList,
-      })
-    },
   ],
 });
diff --git a/src/data/composite/things/track-section/index.js b/src/data/composite/things/track-section/index.js
new file mode 100644
index 00000000..3202ed49
--- /dev/null
+++ b/src/data/composite/things/track-section/index.js
@@ -0,0 +1 @@
+export {default as withAlbum} from './withAlbum.js';
diff --git a/src/data/composite/things/track-section/withAlbum.js b/src/data/composite/things/track-section/withAlbum.js
new file mode 100644
index 00000000..608cc0cd
--- /dev/null
+++ b/src/data/composite/things/track-section/withAlbum.js
@@ -0,0 +1,22 @@
+// Gets the track section's album. This will early exit if ownAlbumData is
+// missing. If there's no album whose list of track sections includes this one,
+// the output dependency will be null.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withAlbum`,
+
+  outputs: ['#album'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      data: 'ownAlbumData',
+      list: input.value('trackSections'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#album',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
index cc723a24..8959de9f 100644
--- a/src/data/composite/things/track/index.js
+++ b/src/data/composite/things/track/index.js
@@ -9,3 +9,4 @@ export {default as withContainingTrackSection} from './withContainingTrackSectio
 export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js';
 export {default as withOtherReleases} from './withOtherReleases.js';
 export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js';
+export {default as withPropertyFromOriginalRelease} from './withPropertyFromOriginalRelease.js';
diff --git a/src/data/composite/things/track/inheritFromOriginalRelease.js b/src/data/composite/things/track/inheritFromOriginalRelease.js
index 27ed1387..38ab06be 100644
--- a/src/data/composite/things/track/inheritFromOriginalRelease.js
+++ b/src/data/composite/things/track/inheritFromOriginalRelease.js
@@ -1,8 +1,6 @@
-// Early exits with a value inherited from the original release, if
-// this track is a rerelease, and otherwise continues with no further
-// dependencies provided. If allowOverride is true, then the continuation
-// will also be called if the original release exposed the requested
-// property as null.
+// Early exits with the value for the same property as specified on the
+// original release, if this track is a rerelease, and otherwise continues
+// without providing any further dependencies.
 //
 // Like withOriginalRelease, this will early exit (with notFoundValue) if the
 // original release is specified by reference and that reference doesn't
@@ -10,41 +8,34 @@
 
 import {input, templateCompositeFrom} from '#composite';
 
-import withOriginalRelease from './withOriginalRelease.js';
+import {exposeDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+
+import withPropertyFromOriginalRelease
+  from './withPropertyFromOriginalRelease.js';
 
 export default templateCompositeFrom({
   annotation: `inheritFromOriginalRelease`,
 
   inputs: {
-    property: input({type: 'string'}),
-    allowOverride: input({type: 'boolean', defaultValue: false}),
-    notFoundValue: input({defaultValue: null}),
+    notFoundValue: input({
+      defaultValue: null,
+    }),
   },
 
   steps: () => [
-    withOriginalRelease({
+    withPropertyFromOriginalRelease({
+      property: input.thisProperty(),
       notFoundValue: input('notFoundValue'),
     }),
 
-    {
-      dependencies: [
-        '#originalRelease',
-        input('property'),
-        input('allowOverride'),
-      ],
-
-      compute: (continuation, {
-        ['#originalRelease']: originalRelease,
-        [input('property')]: originalProperty,
-        [input('allowOverride')]: allowOverride,
-      }) => {
-        if (!originalRelease) return continuation();
-
-        const value = originalRelease[originalProperty];
-        if (allowOverride && value === null) return continuation();
-
-        return continuation.exit(value);
-      },
-    },
+    raiseOutputWithoutDependency({
+      dependency: '#isRerelease',
+      mode: input.value('falsy'),
+    }),
+
+    exposeDependency({
+      dependency: '#originalValue',
+    }),
   ],
 });
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
index fac8e213..e01720b4 100644
--- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -7,11 +7,15 @@ import {input, templateCompositeFrom} from '#composite';
 import find from '#find';
 import {isBoolean} from '#validators';
 
-import {exitWithoutDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 import {withResolvedReference} from '#composite/wiki-data';
 
+import {
+  exitWithoutDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
 export default templateCompositeFrom({
   annotation: `withAlwaysReferenceByDirectory`,
 
@@ -22,6 +26,29 @@ export default templateCompositeFrom({
       validate: input.value(isBoolean),
     }),
 
+    // withAlwaysReferenceByDirectory is sort of a fragile area - we can't
+    // find the track's album the normal way because albums' track lists
+    // recurse back into alwaysReferenceByDirectory!
+    withResolvedReference({
+      ref: 'dataSourceAlbum',
+      data: 'albumData',
+      find: input.value(find.album),
+    }).outputs({
+      '#resolvedReference': '#album',
+    }),
+
+    withPropertyFromObject({
+      object: '#album',
+      property: input.value('alwaysReferenceTracksByDirectory'),
+    }),
+
+    // Falsy mode means this exposes true if the album's property is true,
+    // but continues if the property is false (which is also the default).
+    exposeDependencyOrContinue({
+      dependency: '#album.alwaysReferenceTracksByDirectory',
+      mode: input.value('falsy'),
+    }),
+
     // Remaining code is for defaulting to true if this track is a rerelease of
     // another with the same name, so everything further depends on access to
     // trackData as well as originalReleaseTrack.
diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js
index eaac14de..2c42709b 100644
--- a/src/data/composite/things/track/withContainingTrackSection.js
+++ b/src/data/composite/things/track/withContainingTrackSection.js
@@ -30,7 +30,6 @@ export default templateCompositeFrom({
 
       compute: (continuation, {
         [input.myself()]: track,
-        [input('notFoundMode')]: notFoundMode,
         ['#album.trackSections']: trackSections,
       }) => continuation({
         ['#trackSection']:
diff --git a/src/data/composite/things/track/withPropertyFromOriginalRelease.js b/src/data/composite/things/track/withPropertyFromOriginalRelease.js
new file mode 100644
index 00000000..fd37f6de
--- /dev/null
+++ b/src/data/composite/things/track/withPropertyFromOriginalRelease.js
@@ -0,0 +1,86 @@
+// Provides a value inherited from the original release, if applicable, and a
+// flag indicating if this track is a rerelase or not.
+//
+// Like withOriginalRelease, this will early exit (with notFoundValue) if the
+// original release is specified by reference and that reference doesn't
+// resolve to anything.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withOriginalRelease from './withOriginalRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritFromOriginalRelease`,
+
+  inputs: {
+    property: input({type: 'string'}),
+
+    notFoundValue: input({
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ({
+    [input.staticValue('property')]: property,
+  }) =>
+    ['#isRerelease'].concat(
+      (property
+        ? ['#original.' + property]
+        : ['#originalValue'])),
+
+  steps: () => [
+    withOriginalRelease({
+      notFoundValue: input('notFoundValue'),
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#originalRelease',
+    }),
+
+    {
+      dependencies: [
+        '#availability',
+        input.staticValue('property'),
+      ],
+
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input.staticValue('property')]: property,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutput(
+              Object.assign(
+                {'#isRerelease': false},
+                (property
+                  ? {['#original.' + property]: null}
+                  : {'#originalValue': null})))),
+    },
+
+    withPropertyFromObject({
+      object: '#originalRelease',
+      property: input('property'),
+    }),
+
+    {
+      dependencies: [
+        '#value',
+        input.staticValue('property'),
+      ],
+
+      compute: (continuation, {
+        ['#value']: value,
+        [input.staticValue('property')]: property,
+      }) =>
+        continuation.raiseOutput(
+          Object.assign(
+            {'#isRerelease': true},
+            (property
+              ? {['#original.' + property]: value}
+              : {'#originalValue': value}))),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index b4cf6d13..15ebaffa 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -6,6 +6,8 @@
 
 export {default as exitWithoutContribs} from './exitWithoutContribs.js';
 export {default as inputWikiData} from './inputWikiData.js';
+export {default as withDirectory} from './withDirectory.js';
+export {default as withDirectoryFromName} from './withDirectoryFromName.js';
 export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
 export {default as withResolvedContribs} from './withResolvedContribs.js';
 export {default as withResolvedReference} from './withResolvedReference.js';
diff --git a/src/data/composite/wiki-data/withDirectory.js b/src/data/composite/wiki-data/withDirectory.js
new file mode 100644
index 00000000..b08b6153
--- /dev/null
+++ b/src/data/composite/wiki-data/withDirectory.js
@@ -0,0 +1,55 @@
+// Select a directory, either using a manually specified directory, or
+// computing it from a name. By default these values are the current thing's
+// 'directory' and 'name' properties, so it can be used without any options
+// to get the current thing's effective directory (assuming no custom rules).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+
+import withDirectoryFromName from './withDirectoryFromName.js';
+
+export default templateCompositeFrom({
+  annotation: `withDirectory`,
+
+  inputs: {
+    directory: input({
+      validate: isDirectory,
+      defaultDependency: 'directory',
+      acceptsNull: true,
+    }),
+
+    name: input({
+      validate: isName,
+      defaultDependency: 'name',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('directory'),
+    }),
+
+    {
+      dependencies: ['#availability', input('directory')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('directory')]: directory,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#directory']: directory
+            })
+          : continuation()),
+    },
+
+    withDirectoryFromName({
+      name: input('name'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withDirectoryFromName.js b/src/data/composite/wiki-data/withDirectoryFromName.js
new file mode 100644
index 00000000..034464e4
--- /dev/null
+++ b/src/data/composite/wiki-data/withDirectoryFromName.js
@@ -0,0 +1,42 @@
+// Compute a directory from a name - by default the current thing's own name.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isName} from '#validators';
+import {getKebabCase} from '#wiki-data';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withDirectoryFromName`,
+
+  inputs: {
+    name: input({
+      validate: isName,
+      defaultDependency: 'name',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('name'),
+      mode: input.value('falsy'),
+      output: input.value({
+        ['#directory']: null,
+      }),
+    }),
+
+    {
+      dependencies: [input('name')],
+      compute: (continuation, {
+        [input('name')]: name,
+      }) => continuation({
+        ['#directory']:
+          getKebabCase(name),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js
index 77b0f96d..95266382 100644
--- a/src/data/composite/wiki-data/withResolvedContribs.js
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -1,7 +1,8 @@
 // Resolves the contribsByRef contained in the provided dependency,
 // providing (named by the second argument) the result. "Resolving"
-// means mapping the "who" reference of each contribution to an artist
-// object, and filtering out those whose "who" doesn't match any artist.
+// means mapping the artist reference of each contribution to an artist
+// object, and filtering out those whose artist reference doesn't match
+// any artist.
 
 import {input, templateCompositeFrom} from '#composite';
 import find from '#find';
@@ -46,29 +47,30 @@ export default templateCompositeFrom({
 
     withPropertiesFromList({
       list: input('from'),
-      properties: input.value(['who', 'what']),
+      properties: input.value(['artist', 'annotation']),
       prefix: input.value('#contribs'),
     }),
 
     withResolvedReferenceList({
-      list: '#contribs.who',
+      list: '#contribs.artist',
       data: 'artistData',
       find: input.value(find.artist),
       notFoundMode: input('notFoundMode'),
     }).outputs({
-      ['#resolvedReferenceList']: '#contribs.who',
+      ['#resolvedReferenceList']: '#contribs.artist',
     }),
 
     {
-      dependencies: ['#contribs.who', '#contribs.what'],
+      dependencies: ['#contribs.artist', '#contribs.annotation'],
 
       compute(continuation, {
-        ['#contribs.who']: who,
-        ['#contribs.what']: what,
+        ['#contribs.artist']: artist,
+        ['#contribs.annotation']: annotation,
       }) {
-        filterMultipleArrays(who, what, (who, _what) => who);
+        filterMultipleArrays(artist, annotation, (artist, _annotation) => artist);
         return continuation({
-          ['#resolvedContribs']: stitchArrays({who, what}),
+          ['#resolvedContribs']:
+            stitchArrays({artist, annotation}),
         });
       },
     },
diff --git a/src/data/composite/wiki-data/withReverseContributionList.js b/src/data/composite/wiki-data/withReverseContributionList.js
index eccb58b7..91e125e4 100644
--- a/src/data/composite/wiki-data/withReverseContributionList.js
+++ b/src/data/composite/wiki-data/withReverseContributionList.js
@@ -11,7 +11,8 @@
 
 import {input, templateCompositeFrom} from '#composite';
 
-import {exitWithoutDependency} from '#composite/control-flow';
+import {exitWithoutDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
 
 import inputWikiData from './inputWikiData.js';
 
@@ -32,10 +33,17 @@ export default templateCompositeFrom({
   outputs: ['#reverseContributionList'],
 
   steps: () => [
+    // Early exit with an empty array if the data list isn't available.
     exitWithoutDependency({
       dependency: input('data'),
       value: input.value([]),
+    }),
+
+    // Raise an empty array (don't early exit) if the data list is empty.
+    raiseOutputWithoutDependency({
+      dependency: input('data'),
       mode: input.value('empty'),
+      output: input.value({'#reverseContributionList': []}),
     }),
 
     {
@@ -58,10 +66,10 @@ export default templateCompositeFrom({
           for (const referencingThing of data) {
             const referenceList = referencingThing[list];
 
-            // Destructuring {who} is the only unique part of the
+            // Destructuring {artist} is the only unique part of the
             // withReverseContributionList implementation, compared to
             // withReverseReferneceList.
-            for (const {who: referencedThing} of referenceList) {
+            for (const {artist: referencedThing} of referenceList) {
               if (cacheRecord.has(referencedThing)) {
                 cacheRecord.get(referencedThing).push(referencingThing);
               } else {
diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js
index 2d7a421b..8cd540a5 100644
--- a/src/data/composite/wiki-data/withReverseReferenceList.js
+++ b/src/data/composite/wiki-data/withReverseReferenceList.js
@@ -13,7 +13,8 @@
 
 import {input, templateCompositeFrom} from '#composite';
 
-import {exitWithoutDependency} from '#composite/control-flow';
+import {exitWithoutDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
 
 import inputWikiData from './inputWikiData.js';
 
@@ -34,10 +35,17 @@ export default templateCompositeFrom({
   outputs: ['#reverseReferenceList'],
 
   steps: () => [
+    // Early exit with an empty array if the data list isn't available.
     exitWithoutDependency({
       dependency: input('data'),
       value: input.value([]),
+    }),
+
+    // Raise an empty array (don't early exit) if the data list is empty.
+    raiseOutputWithoutDependency({
+      dependency: input('data'),
       mode: input.value('empty'),
+      output: input.value({'#reverseReferenceList': []}),
     }),
 
     {
diff --git a/src/data/composite/wiki-data/withUniqueReferencingThing.js b/src/data/composite/wiki-data/withUniqueReferencingThing.js
index ce04f838..61c10618 100644
--- a/src/data/composite/wiki-data/withUniqueReferencingThing.js
+++ b/src/data/composite/wiki-data/withUniqueReferencingThing.js
@@ -21,11 +21,10 @@ export default templateCompositeFrom({
   outputs: ['#uniqueReferencingThing'],
 
   steps: () => [
-    // withReverseRefernceList does this check too, but it early exits with
-    // an empty array. That's no good here!
+    // Early exit with null (not an empty array) if the data list
+    // isn't available.
     exitWithoutDependency({
       dependency: input('data'),
-      mode: input.value('empty'),
     }),
 
     withReverseReferenceList({
diff --git a/src/data/composite/wiki-properties/contributionList.js b/src/data/composite/wiki-properties/contributionList.js
index 8fde2caa..aad12a2d 100644
--- a/src/data/composite/wiki-properties/contributionList.js
+++ b/src/data/composite/wiki-properties/contributionList.js
@@ -3,15 +3,15 @@
 // into one property. Update value will look something like this:
 //
 //   [
-//     {who: 'Artist Name', what: 'Viola'},
-//     {who: 'artist:john-cena', what: null},
+//     {artist: 'Artist Name', annotation: 'Viola'},
+//     {artist: 'artist:john-cena', annotation: null},
 //     ...
 //   ]
 //
 // ...typically as processed from YAML, spreadsheet, or elsewhere.
-// Exposes as the same, but with the "who" replaced with matches found in
-// artistData - which means this always depends on an `artistData` property
-// also existing on this object!
+// Exposes as the same, but with the artist property replaced with matches
+// found in artistData - which means this always depends on an `artistData`
+// property also existing on this object!
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/wiki-properties/directory.js b/src/data/composite/wiki-properties/directory.js
index 0b2181c9..41ce4b27 100644
--- a/src/data/composite/wiki-properties/directory.js
+++ b/src/data/composite/wiki-properties/directory.js
@@ -2,22 +2,32 @@
 // almost any data object. Also corresponds to a part of the URL which pages of
 // such objects are visited at.
 
-import {isDirectory} from '#validators';
-import {getKebabCase} from '#wiki-data';
-
-// TODO: Not templateCompositeFrom.
-
-export default function() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isDirectory},
-    expose: {
-      dependencies: ['name'],
-      transform(directory, {name}) {
-        if (directory === null && name === null) return null;
-        else if (directory === null) return getKebabCase(name);
-        else return directory;
-      },
-    },
-  };
-}
+import {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withDirectory} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `directory`,
+
+  compose: false,
+
+  inputs: {
+    name: input({
+      validate: isName,
+      defaultDependency: 'name',
+    }),
+  },
+
+  steps: () => [
+    withDirectory({
+      directory: input.updateValue({validate: isDirectory}),
+    }),
+
+    exposeDependency({
+      dependency: '#directory',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/referenceList.js b/src/data/composite/wiki-properties/referenceList.js
index af634a68..ebd5947c 100644
--- a/src/data/composite/wiki-properties/referenceList.js
+++ b/src/data/composite/wiki-properties/referenceList.js
@@ -1,5 +1,6 @@
 // Stores and exposes a list of references to other data objects; all items
-// must be references to the same type, which is specified on the class input.
+// must be references to the same type, which is either implied from the class
+// input, or explicitly set on the referenceType input.
 //
 // See also:
 //  - singleReference
@@ -18,7 +19,17 @@ export default templateCompositeFrom({
   compose: false,
 
   inputs: {
-    class: input.staticValue({validate: isThingClass}),
+    class: input.staticValue({
+      validate: isThingClass,
+      acceptsNull: true,
+      defaultValue: null,
+    }),
+
+    referenceType: input.staticValue({
+      type: 'string',
+      acceptsNull: true,
+      defaultValue: null,
+    }),
 
     data: inputWikiData({allowMixedTypes: false}),
 
@@ -27,10 +38,13 @@ export default templateCompositeFrom({
 
   update: ({
     [input.staticValue('class')]: thingClass,
+    [input.staticValue('referenceType')]: referenceType,
   }) => ({
     validate:
       validateReferenceList(
-        thingClass[Symbol.for('Thing.referenceType')]),
+        (thingClass
+          ? thingClass[Symbol.for('Thing.referenceType')]
+          : referenceType)),
   }),
 
   steps: () => [
diff --git a/src/data/serialize.js b/src/data/serialize.js
index 8cac3309..2ecbf76c 100644
--- a/src/data/serialize.js
+++ b/src/data/serialize.js
@@ -16,7 +16,10 @@ export function toRefs(things) {
 }
 
 export function toContribRefs(contribs) {
-  return contribs?.map(({who, what}) => ({who: toRef(who), what}));
+  return contribs?.map(({artist, annotation}) => ({
+    artist: toRef(artist),
+    annotation,
+  }));
 }
 
 export function toCommentaryRefs(entries) {
diff --git a/src/data/thing.js b/src/data/thing.js
index 706e893d..29f50d23 100644
--- a/src/data/thing.js
+++ b/src/data/thing.js
@@ -17,17 +17,52 @@ export default class Thing extends CacheableObject {
   static yamlDocumentSpec = Symbol.for('Thing.yamlDocumentSpec');
   static getYamlLoadingSpec = Symbol.for('Thing.getYamlLoadingSpec');
 
+  static isThingConstructor = Symbol.for('Thing.isThingConstructor');
+  static isThing = Symbol.for('Thing.isThing');
+
+  // To detect:
+  // Symbol.for('Thing.isThingConstructor') in constructor
+  static [Symbol.for('Thing.isThingConstructor')] = NaN;
+
+  static [CacheableObject.propertyDescriptors] = {
+    // To detect:
+    // Object.hasOwn(object, Symbol.for('Thing.isThing'))
+    [Symbol.for('Thing.isThing')]: {
+      flags: {expose: true},
+      expose: {compute: () => NaN},
+    },
+  };
+
+  static [Symbol.for('Thing.selectAll')] = _wikiData => [];
+
   // Default custom inspect function, which may be overridden by Thing
   // subclasses. This will be used when displaying aggregate errors and other
   // command-line logging - it's the place to provide information useful in
   // identifying the Thing being presented.
   [inspect.custom]() {
-    const cname = this.constructor.name;
+    const constructorName = this.constructor.name;
+
+    let name;
+    try {
+      if (this.name) {
+        name = colors.green(`"${this.name}"`);
+      }
+    } catch (error) {
+      name = colors.yellow(`couldn't get name`);
+    }
+
+    let reference;
+    try {
+      if (this.directory) {
+        reference = colors.blue(Thing.getReference(this));
+      }
+    } catch (error) {
+      reference = colors.yellow(`couldn't get reference`);
+    }
 
     return (
-      (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) +
-      (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '')
-    );
+      (name ? `${constructorName} ${name}` : `${constructorName}`) +
+      (reference ? ` (${reference})` : ''));
   }
 
   static getReference(thing) {
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 40cd4631..e9f55b2c 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -1,20 +1,25 @@
 export const DATA_ALBUM_DIRECTORY = 'album';
 
 import * as path from 'node:path';
+import {inspect} from 'node:util';
 
+import CacheableObject from '#cacheable-object';
+import {colors} from '#cli';
 import {input} from '#composite';
 import find from '#find';
 import {traverse} from '#node-utils';
 import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
-import {empty} from '#sugar';
+import {accumulateSum, empty} from '#sugar';
 import Thing from '#thing';
-import {isDate} from '#validators';
+import {isColor, isDate, validateWikiData} from '#validators';
 import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions}
   from '#yaml';
 
-import {exposeDependency, exposeUpdateValueOrContinue}
+import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
-import {exitWithoutContribs} from '#composite/wiki-data';
+import {withPropertyFromObject} from '#composite/data';
+import {exitWithoutContribs, withDirectory, withResolvedReference}
+  from '#composite/wiki-data';
 
 import {
   additionalFiles,
@@ -31,16 +36,24 @@ import {
   referenceList,
   simpleDate,
   simpleString,
+  singleReference,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withTracks, withTrackSections} from '#composite/things/album';
+import {withTracks} from '#composite/things/album';
+import {withAlbum} from '#composite/things/track-section';
 
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
 
-  static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({
+    ArtTag,
+    Artist,
+    Group,
+    Track,
+    TrackSection,
+  }) => ({
     // Update & expose
 
     name: name('Unnamed Album'),
@@ -48,6 +61,8 @@ export class Album extends Thing {
     directory: directory(),
     urls: urls(),
 
+    alwaysReferenceTracksByDirectory: flag(false),
+
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
 
@@ -109,10 +124,11 @@ export class Album extends Thing {
     commentary: commentary(),
     additionalFiles: additionalFiles(),
 
-    trackSections: [
-      withTrackSections(),
-      exposeDependency({dependency: '#trackSections'}),
-    ],
+    trackSections: referenceList({
+      referenceType: input.value('unqualified-track-section'),
+      data: 'ownTrackSectionData',
+      find: input.value(find.unqualifiedTrackSection),
+    }),
 
     artistContribs: contributionList(),
     coverArtistContribs: contributionList(),
@@ -153,11 +169,8 @@ export class Album extends Thing {
       class: input.value(Group),
     }),
 
-    // Only the tracks which belong to this album.
-    // Necessary for computing the track list, so provide this statically
-    // or keep it updated.
-    ownTrackData: wikiData({
-      class: input.value(Track),
+    ownTrackSectionData: wikiData({
+      class: input.value(TrackSection),
     }),
 
     // Expose only
@@ -226,6 +239,10 @@ export class Album extends Thing {
       'Album': {property: 'name'},
       'Directory': {property: 'directory'},
 
+      'Always Reference Tracks By Directory': {
+        property: 'alwaysReferenceTracksByDirectory',
+      },
+
       'Bandcamp Album ID': {
         property: 'bandcampAlbumIdentifier',
         transform: String,
@@ -339,68 +356,77 @@ export class Album extends Thing {
     headerDocumentThing: Album,
     entryDocumentThing: document =>
       ('Section' in document
-        ? TrackSectionHelper
+        ? TrackSection
         : Track),
 
     save(results) {
       const albumData = [];
+      const trackSectionData = [];
       const trackData = [];
 
       for (const {header: album, entries} of results) {
-        // We can't mutate an array once it's set as a property value,
-        // so prepare the track sections that will show up in a track list
-        // all the way before actually applying them. (It's okay to mutate
-        // an individual section before applying it, since those are just
-        // generic objects; they aren't Things in and of themselves.)
         const trackSections = [];
-        const ownTrackData = [];
 
-        let currentTrackSection = {
+        let currentTrackSection = new TrackSection();
+        let currentTrackSectionTracks = [];
+
+        Object.assign(currentTrackSection, {
           name: `Default Track Section`,
           isDefaultTrackSection: true,
-          tracks: [],
-        };
+        });
 
         const albumRef = Thing.getReference(album);
 
         const closeCurrentTrackSection = () => {
-          if (!empty(currentTrackSection.tracks)) {
-            trackSections.push(currentTrackSection);
+          if (
+            currentTrackSection.isDefaultTrackSection &&
+            empty(currentTrackSectionTracks)
+          ) {
+            return;
           }
+
+          currentTrackSection.tracks =
+            currentTrackSectionTracks
+              .map(track => Thing.getReference(track));
+
+          currentTrackSection.ownTrackData =
+            currentTrackSectionTracks;
+
+          currentTrackSection.ownAlbumData =
+            [album];
+
+          trackSections.push(currentTrackSection);
+          trackSectionData.push(currentTrackSection);
         };
 
         for (const entry of entries) {
-          if (entry instanceof TrackSectionHelper) {
+          if (entry instanceof TrackSection) {
             closeCurrentTrackSection();
-
-            currentTrackSection = {
-              name: entry.name,
-              color: entry.color,
-              dateOriginallyReleased: entry.dateOriginallyReleased,
-              isDefaultTrackSection: false,
-              tracks: [],
-            };
-
+            currentTrackSection = entry;
+            currentTrackSectionTracks = [];
             continue;
           }
 
+          currentTrackSectionTracks.push(entry);
           trackData.push(entry);
 
           entry.dataSourceAlbum = albumRef;
-
-          ownTrackData.push(entry);
-          currentTrackSection.tracks.push(Thing.getReference(entry));
         }
 
         closeCurrentTrackSection();
 
         albumData.push(album);
 
-        album.trackSections = trackSections;
-        album.ownTrackData = ownTrackData;
+        album.trackSections =
+          trackSections
+            .map(trackSection =>
+              `unqualified-track-section:` +
+              trackSection.unqualifiedDirectory);
+
+        album.ownTrackSectionData = trackSections;
       }
 
-      return {albumData, trackData};
+      return {albumData, trackSectionData, trackData};
     },
 
     sort({albumData, trackData}) {
@@ -410,15 +436,139 @@ export class Album extends Thing {
   });
 }
 
-export class TrackSectionHelper extends Thing {
+export class TrackSection extends Thing {
   static [Thing.friendlyName] = `Track Section`;
+  static [Thing.referenceType] = `track-section`;
+
+  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+    // Update & expose
 
-  static [Thing.getPropertyDescriptors] = () => ({
     name: name('Unnamed Track Section'),
-    color: color(),
+
+    unqualifiedDirectory: directory(),
+
+    color: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isColor),
+      }),
+
+      withAlbum(),
+
+      withPropertyFromObject({
+        object: '#album',
+        property: input.value('color'),
+      }),
+
+      exposeDependency({dependency: '#album.color'}),
+    ],
+
     dateOriginallyReleased: simpleDate(),
-    isDefaultTrackGroup: flag(false),
-  })
+
+    isDefaultTrackSection: flag(false),
+
+    album: [
+      withAlbum(),
+      exposeDependency({dependency: '#album'}),
+    ],
+
+    tracks: referenceList({
+      class: input.value(Track),
+      data: 'ownTrackData',
+      find: input.value(find.track),
+    }),
+
+    // Update only
+
+    ownAlbumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    ownTrackData: wikiData({
+      class: input.value(Track),
+    }),
+
+    // Expose only
+
+    directory: [
+      withAlbum(),
+
+      exitWithoutDependency({
+        dependency: '#album',
+      }),
+
+      withPropertyFromObject({
+        object: '#album',
+        property: input.value('directory'),
+      }),
+
+      withDirectory({
+        directory: 'unqualifiedDirectory',
+      }).outputs({
+        '#directory': '#unqualifiedDirectory',
+      }),
+
+      {
+        dependencies: ['#album.directory', '#unqualifiedDirectory'],
+        compute: ({
+          ['#album.directory']: albumDirectory,
+          ['#unqualifiedDirectory']: unqualifiedDirectory,
+        }) =>
+          albumDirectory + '/' + unqualifiedDirectory,
+      },
+    ],
+
+    startIndex: [
+      withAlbum(),
+
+      withPropertyFromObject({
+        object: '#album',
+        property: input.value('trackSections'),
+      }),
+
+      {
+        dependencies: ['#album.trackSections', input.myself()],
+        compute: (continuation, {
+          ['#album.trackSections']: trackSections,
+          [input.myself()]: myself,
+        }) => continuation({
+          ['#index']:
+            trackSections.indexOf(myself),
+        }),
+      },
+
+      exitWithoutDependency({
+        dependency: '#index',
+        mode: input.value('index'),
+        value: input.value(0),
+      }),
+
+      {
+        dependencies: ['#album.trackSections', '#index'],
+        compute: ({
+          ['#album.trackSections']: trackSections,
+          ['#index']: index,
+        }) =>
+          accumulateSum(
+            trackSections
+              .slice(0, index)
+              .map(section => section.tracks.length)),
+      },
+    ],
+  });
+
+  static [Thing.findSpecs] = {
+    trackSection: {
+      referenceTypes: ['track-section'],
+      bindTo: 'trackSectionData',
+    },
+
+    unqualifiedTrackSection: {
+      referenceTypes: ['unqualified-track-section'],
+
+      getMatchableDirectories: trackSection =>
+        [trackSection.unqualifiedDirectory],
+    },
+  };
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
@@ -431,4 +581,48 @@ export class TrackSectionHelper extends Thing {
       },
     },
   };
+
+  [inspect.custom](depth) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (depth >= 0) {
+      let album = null;
+      try {
+        album = this.album;
+      } catch {}
+
+      let first = null;
+      try {
+        first = this.startIndex;
+      } catch {}
+
+      let length = null;
+      try {
+        length = this.tracks.length;
+      } catch {}
+
+      album ??= CacheableObject.getUpdateValue(this, 'ownAlbumData')?.[0];
+
+      if (album) {
+        const albumName = album.name;
+        const albumIndex = album.trackSections.indexOf(this);
+
+        const num =
+          (albumIndex === -1
+            ? 'indeterminate position'
+            : `#${albumIndex + 1}`);
+
+        const range =
+          (albumIndex >= 0 && first !== null && length !== null
+            ? `: ${first + 1}-${first + length + 1}`
+            : '');
+
+        parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
+      }
+    }
+
+    return parts.join('');
+  }
 }
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index ceed79f7..7038df86 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -7,7 +7,7 @@ import {sortFlashesChronologically} from '#sort';
 import Thing from '#thing';
 import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
   from '#validators';
-import {parseDate, parseContributors} from '#yaml';
+import {parseContributors, parseDate, parseDimensions} from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
 
@@ -24,6 +24,7 @@ import {
   commentatorArtists,
   contentString,
   contributionList,
+  dimensions,
   directory,
   fileExtension,
   name,
@@ -89,6 +90,8 @@ export class Flash extends Thing {
 
     coverArtFileExtension: fileExtension('jpg'),
 
+    coverArtDimensions: dimensions(),
+
     contributorContribs: contributionList(),
 
     featuredTracks: referenceList({
@@ -171,6 +174,11 @@ export class Flash extends Thing {
 
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
       'Featured Tracks': {property: 'featuredTracks'},
 
       'Contributors': {
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 3bf84091..4f87f492 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -2,10 +2,10 @@ import * as path from 'node:path';
 import {fileURLToPath} from 'node:url';
 
 import {openAggregate, showAggregate} from '#aggregate';
+import CacheableObject from '#cacheable-object';
 import {logError} from '#cli';
 import {compositeFrom} from '#composite';
 import * as serialize from '#serialize';
-
 import Thing from '#thing';
 
 import * as albumClasses from './album.js';
@@ -142,7 +142,10 @@ function evaluatePropertyDescriptors() {
         }
       }
 
-      constructor.propertyDescriptors = results;
+      constructor[CacheableObject.propertyDescriptors] = {
+        ...constructor[CacheableObject.propertyDescriptors] ?? {},
+        ...results,
+      };
     },
 
     showFailedClasses(failedClasses) {
diff --git a/src/data/things/language.js b/src/data/things/language.js
index dbe1ff3d..f20927a4 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -127,6 +127,13 @@ export class Language extends Thing {
 
     // Expose only
 
+    onlyIfOptions: {
+      flags: {expose: true},
+      expose: {
+        compute: () => Symbol.for(`language.onlyIfOptions`),
+      },
+    },
+
     intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}),
     intl_number: this.#intlHelper(Intl.NumberFormat),
     intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}),
@@ -218,18 +225,42 @@ export class Language extends Thing {
       throw new Error(`Invalid key ${key} accessed`);
     }
 
+    const constantCasify = name =>
+      name
+        .replace(/[A-Z]/g, '_$&')
+        .toUpperCase();
+
     // These will be filled up as we iterate over the template, slotting in
     // each option (if it's present).
     const missingOptionNames = new Set();
 
+    // These will also be filled. It's a bit different of an error, indicating
+    // a provided option was *expected,* but its value was null, undefined, or
+    // blank HTML content.
+    const valuelessOptionNames = new Set();
+
+    // These *might* be missing, and if they are, that's OK!! Instead of adding
+    // to the valueless set above, we'll just mark to return a blank for the
+    // whole string.
+    const expectedValuelessOptionNames =
+      new Set(
+        (options[this.onlyIfOptions] ?? [])
+          .map(constantCasify));
+
+    let seenExpectedValuelessOption = false;
+
+    const isValueless =
+      value =>
+        value === null ||
+        value === undefined ||
+        html.isBlank(value);
+
     // And this will have entries deleted as they're encountered in the
     // template. Leftover entries are misplaced.
     const optionsMap =
       new Map(
         Object.entries(options).map(([name, value]) => [
-          name
-            .replace(/[A-Z]/g, '_$&')
-            .toUpperCase(),
+          constantCasify(name),
           value,
         ]));
 
@@ -239,32 +270,48 @@ export class Language extends Thing {
       match: languageOptionRegex,
 
       insert: ({name: optionName}, canceledForming) => {
-        if (optionsMap.has(optionName)) {
-          let optionValue;
-
-          // We'll only need the option's value if we're going to use it as
-          // part of the formed output (see below).
-          if (!canceledForming) {
-            optionValue = optionsMap.get(optionName);
-          }
-
-          // But we always have to delete expected options off the provided
-          // option map, since the leftovers are what will be used to tell
-          // which are misplaced.
-          optionsMap.delete(optionName);
+        if (!optionsMap.has(optionName)) {
+          missingOptionNames.add(optionName);
 
-          if (canceledForming) {
-            return undefined;
-          } else {
-            return optionValue;
-          }
-        } else {
           // We don't need to continue forming the output if we've hit a
           // missing option name, since the end result of this formatString
           // call will be a thrown error, and formed output won't be needed.
-          missingOptionNames.add(optionName);
+          // Return undefined to mark canceledForming for the following
+          // iterations (and exit early out of this iteration).
+          return undefined;
+        }
+
+        // Even if we're not actually forming the output anymore, we'll still
+        // have to access this option's value to check if it is invalid.
+        const optionValue = optionsMap.get(optionName);
+
+        // We always have to delete expected options off the provided option
+        // map, since the leftovers are what will be used to tell which are
+        // misplaced - information you want even (or doubly so) if we've
+        // already stopped forming the output thanks to missing options.
+        optionsMap.delete(optionName);
+
+        // Just like if an option is missing, a valueless option cancels
+        // forming the rest of the output.
+        if (isValueless(optionValue)) {
+          // It's also an error, *except* if this option is one of the ones
+          // that we're indicated to *expect* might be valueless! In that case,
+          // we still need to stop forming the string (and mark a separate flag
+          // so that we return a blank), but it's not an error.
+          if (expectedValuelessOptionNames.has(optionName)) {
+            seenExpectedValuelessOption = true;
+          } else {
+            valuelessOptionNames.add(optionName);
+          }
+
           return undefined;
         }
+
+        if (canceledForming) {
+          return undefined;
+        }
+
+        return optionValue;
       },
     });
 
@@ -272,17 +319,30 @@ export class Language extends Thing {
       Array.from(optionsMap.keys());
 
     withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => {
+      const names = set => Array.from(set).join(', ');
+
       if (!empty(missingOptionNames)) {
-        const names = Array.from(missingOptionNames).join(`, `);
-        push(new Error(`Missing options: ${names}`));
+        push(new Error(
+          `Missing options: ${names(missingOptionNames)}`));
+      }
+
+      if (!empty(valuelessOptionNames)) {
+        push(new Error(
+          `Valueless options: ${names(valuelessOptionNames)}`));
       }
 
       if (!empty(misplacedOptionNames)) {
-        const names = Array.from(misplacedOptionNames).join(`, `);
-        push(new Error(`Unexpected options: ${names}`));
+        push(new Error(
+          `Unexpected options: ${names(misplacedOptionNames)}`));
       }
     });
 
+    // If an option was valueless as marked to expect, then that indicates
+    // the whole string should be treated as blank content.
+    if (seenExpectedValuelessOption) {
+      return html.blank();
+    }
+
     return output;
   }
 
@@ -416,11 +476,32 @@ export class Language extends Thing {
   }
 
   formatDate(date) {
+    // Null or undefined date is blank content.
+    if (date === null || date === undefined) {
+      return html.blank();
+    }
+
     this.assertIntlAvailable('intl_date');
     return this.intl_date.format(date);
   }
 
   formatDateRange(startDate, endDate) {
+    // formatDateRange expects both values to be present, but if both are null
+    // or both are undefined, that's just blank content.
+    const hasStart = startDate !== null && startDate !== undefined;
+    const hasEnd = endDate !== null && endDate !== undefined;
+    if (!hasStart || !hasEnd) {
+      if (startDate === endDate) {
+        return html.blank();
+      } else if (hasStart) {
+        throw new Error(`Expected both start and end of date range, got only start`);
+      } else if (hasEnd) {
+        throw new Error(`Expected both start and end of date range, got only end`);
+      } else {
+        throw new Error(`Got mismatched ${startDate}/${endDate} for start and end`);
+      }
+    }
+
     this.assertIntlAvailable('intl_date');
     return this.intl_date.formatRange(startDate, endDate);
   }
@@ -431,6 +512,17 @@ export class Language extends Thing {
     days: numDays = 0,
     approximate = false,
   }) {
+    // Give up if any of years, months, or days is null or undefined.
+    // These default to zero, so something's gone pretty badly wrong to
+    // pass in all or partial missing values.
+    if (
+      numYears === undefined || numYears === null ||
+      numMonths === undefined || numMonths === null ||
+      numDays === undefined || numDays === null
+    ) {
+      throw new Error(`Expected values or default zero for years, months, and days`);
+    }
+
     let basis;
 
     const years = this.countYears(numYears, {unit: true});
@@ -468,6 +560,14 @@ export class Language extends Thing {
     approximate = true,
     absolute = true,
   } = {}) {
+    // Give up if current and/or reference date is null or undefined.
+    if (
+      currentDate === undefined || currentDate === null ||
+      referenceDate === undefined || referenceDate === null
+    ) {
+      throw new Error(`Expected values for currentDate and referenceDate`);
+    }
+
     const currentInstant = toTemporalInstant.apply(currentDate);
     const referenceInstant = toTemporalInstant.apply(referenceDate);
 
@@ -528,6 +628,12 @@ export class Language extends Thing {
   }
 
   formatDuration(secTotal, {approximate = false, unit = false} = {}) {
+    // Null or undefined duration is blank content.
+    if (secTotal === null || secTotal === undefined) {
+      return html.blank();
+    }
+
+    // Zero duration is a "missing" string.
     if (secTotal === 0) {
       return this.formatString('count.duration.missing');
     }
@@ -565,6 +671,11 @@ export class Language extends Thing {
       throw new TypeError(`externalLinkSpec unavailable`);
     }
 
+    // Null or undefined url is blank content.
+    if (url === null || url === undefined) {
+      return html.blank();
+    }
+
     isExternalLinkContext(context);
 
     if (style === 'all') {
@@ -589,16 +700,31 @@ export class Language extends Thing {
   }
 
   formatIndex(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     this.assertIntlAvailable('intl_pluralOrdinal');
     return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
   }
 
   formatNumber(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     this.assertIntlAvailable('intl_number');
     return this.intl_number.format(value);
   }
 
   formatWordCount(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     const num = this.formatNumber(
       value > 1000 ? Math.floor(value / 100) / 10 : value
     );
@@ -612,6 +738,11 @@ export class Language extends Thing {
   }
 
   #formatListHelper(array, processFn) {
+    // Empty lists, null, and undefined are blank content.
+    if (empty(array) || array === null || array === undefined) {
+      return html.blank();
+    }
+
     // Operate on "insertion markers" instead of the actual contents of the
     // array, because the process function (likely an Intl operation) is taken
     // to only operate on strings. We'll insert the contents of the array back
@@ -673,10 +804,22 @@ export class Language extends Thing {
 
   // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
   formatFileSize(bytes) {
-    if (!bytes) return '';
+    // Null or undefined bytes is blank content.
+    if (bytes === null || bytes === undefined) {
+      return html.blank();
+    }
+
+    // Zero bytes is blank content.
+    if (bytes === 0) {
+      return html.blank();
+    }
 
     bytes = parseInt(bytes);
-    if (isNaN(bytes)) return '';
+
+    // Non-number bytes is blank content! Wow.
+    if (isNaN(bytes)) {
+      return html.blank();
+    }
 
     const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10;
 
@@ -704,6 +847,11 @@ export class Language extends Thing {
 
 const countHelper = (stringKey, optionName = stringKey) =>
   function(value, {unit = false} = {}) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     return this.formatString(
       unit
         ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
diff --git a/src/data/things/track.js b/src/data/things/track.js
index cc49fc24..725b1bb7 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -168,10 +168,7 @@ export class Track extends Thing {
     commentary: commentary(),
 
     lyrics: [
-      inheritFromOriginalRelease({
-        property: input.value('lyrics'),
-      }),
-
+      inheritFromOriginalRelease(),
       contentString(),
     ],
 
@@ -196,7 +193,6 @@ export class Track extends Thing {
 
     artistContribs: [
       inheritFromOriginalRelease({
-        property: input.value('artistContribs'),
         notFoundValue: input.value([]),
       }),
 
@@ -220,7 +216,6 @@ export class Track extends Thing {
 
     contributorContribs: [
       inheritFromOriginalRelease({
-        property: input.value('contributorContribs'),
         notFoundValue: input.value([]),
       }),
 
@@ -255,7 +250,6 @@ export class Track extends Thing {
 
     referencedTracks: [
       inheritFromOriginalRelease({
-        property: input.value('referencedTracks'),
         notFoundValue: input.value([]),
       }),
 
@@ -268,7 +262,6 @@ export class Track extends Thing {
 
     sampledTracks: [
       inheritFromOriginalRelease({
-        property: input.value('sampledTracks'),
         notFoundValue: input.value([]),
       }),
 
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 316bd3bb..2a2c9986 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -3,8 +3,9 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml';
 import {input} from '#composite';
 import find from '#find';
 import Thing from '#thing';
-import {isColor, isLanguageCode, isName, isURL} from '#validators';
+import {isBoolean, isColor, isLanguageCode, isName, isURL} from '#validators';
 
+import {exitWithoutDependency} from '#composite/control-flow';
 import {contentString, flag, name, referenceList, wikiData}
   from '#composite/wiki-properties';
 
@@ -64,8 +65,26 @@ export class WikiInfo extends Thing {
     enableArtTagUI: flag(false),
     enableGroupUI: flag(false),
 
+    enableSearch: [
+      exitWithoutDependency({
+        dependency: 'searchDataAvailable',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      flag(true),
+    ],
+
     // Update only
 
+    searchDataAvailable: {
+      flags: {update: true},
+      update: {
+        validate: isBoolean,
+        default: false,
+      },
+    },
+
     groupData: wikiData({
       class: input.value(Group),
     }),
diff --git a/src/data/validators.js b/src/data/validators.js
index 987f806d..5d681311 100644
--- a/src/data/validators.js
+++ b/src/data/validators.js
@@ -443,24 +443,23 @@ for (const entry of illegalContentSpec) {
   }
 }
 
-const illegalContentRegexp =
-  new RegExp(
-    illegalContentSpec
-      .map(entry => entry.illegal)
-      .map(illegal => `${illegal}+`)
-      .join('|'),
-    'g');
-
-const illegalCharactersInContent =
+const illegalSequencesInContent =
   illegalContentSpec
     .map(entry => entry.illegal)
-    .join('');
+    .map(illegal =>
+      (illegal.length === 1
+        ? `${illegal}+`
+        : `(?:${illegal})+`))
+    .join('|');
+
+const illegalContentRegexp =
+  new RegExp(illegalSequencesInContent, 'g');
 
 const legalContentNearEndRegexp =
-  new RegExp(`[^\n${illegalCharactersInContent}]+$`);
+  new RegExp(`(?<=^|${illegalSequencesInContent})(?:(?!${illegalSequencesInContent}).)+$`);
 
 const legalContentNearStartRegexp =
-  new RegExp(`^[^\n${illegalCharactersInContent}]+`);
+  new RegExp(`^(?:(?!${illegalSequencesInContent}).)+`);
 
 const trimWhitespaceNearBothSidesRegexp =
   /^ +| +$/gm;
@@ -606,16 +605,37 @@ export function isContentString(content) {
 export function isThingClass(thingClass) {
   isFunction(thingClass);
 
-  if (!Object.hasOwn(thingClass, Symbol.for('Thing.referenceType'))) {
-    throw new TypeError(`Expected a Thing constructor, missing Thing.referenceType`);
+  // This is *expressly* no faster than an instanceof check, because it's
+  // deliberately still walking the prototype chain for the provided object.
+  // (This is necessary because the symbol we're checking is defined only on
+  // the Thing constructor, and not directly on each subclass.) However, it's
+  // preferred over an instanceof check anyway, because instanceof would
+  // require that the #validators module has access to #thing, which it
+  // currently doesn't!
+  if (!(Symbol.for('Thing.isThingConstructor') in thingClass)) {
+    throw new TypeError(`Expected a Thing constructor, missing Thing.isThingConstructor`);
+  }
+
+  return true;
+}
+
+export function isThing(thing) {
+  isObject(thing);
+
+  // This *is* faster than an instanceof check, because it doesn't walk the
+  // prototype chain. It works because this property is set as part of every
+  // Thing subclass's inherited "public class fields" - it's set directly on
+  // every constructed Thing.
+  if (!Object.hasOwn(thing, Symbol.for('Thing.isThing'))) {
+    throw new TypeError(`Expected a Thing, missing Thing.isThing`);
   }
 
   return true;
 }
 
 export const isContribution = validateProperties({
-  who: isArtistRef,
-  what: optional(isStringNonEmpty),
+  artist: isArtistRef,
+  annotation: optional(isStringNonEmpty),
 });
 
 export const isContributionList = validateArrayItems(isContribution);
@@ -734,12 +754,31 @@ export function validateReferenceList(type = '') {
   return validateArrayItems(validateReference(type));
 }
 
+export function validateThing({
+  referenceType: expectedReferenceType = '',
+} = {}) {
+  return (thing) => {
+    isThing(thing);
+
+    if (expectedReferenceType) {
+      const {[Symbol.for('Thing.referenceType')]: referenceType} =
+        thing.constructor;
+
+      if (referenceType !== expectedReferenceType) {
+        throw new TypeError(`Expected only ${expectedReferenceType}, got other type: ${referenceType}`);
+      }
+    }
+
+    return true;
+  };
+}
+
 const validateWikiData_cache = {};
 
 export function validateWikiData({
   referenceType = '',
   allowMixedTypes = false,
-}) {
+} = {}) {
   if (referenceType && allowMixedTypes) {
     throw new TypeError(`Don't specify both referenceType and allowMixedTypes`);
   }
@@ -768,25 +807,22 @@ export function validateWikiData({
       let foundOtherObject = false;
 
       for (const object of array) {
-        const {[Symbol.for('Thing.referenceType')]: referenceType} = object.constructor;
-
-        if (referenceType === undefined) {
-          foundOtherObject = true;
-
-          // Early-exit if a Thing has been found - nothing more can be learned.
-          if (foundThing) {
-            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
-          }
-        } else {
-          foundThing = true;
-
+        if (Object.hasOwn(object, Symbol.for('Thing.isThing'))) {
           // Early-exit if a non-Thing object has been found - nothing more can
           // be learned.
           if (foundOtherObject) {
             throw new TypeError(`Expected array of wiki data objects, got mixed items`);
           }
 
-          allRefTypes.add(referenceType);
+          foundThing = true;
+          allRefTypes.add(object.constructor[Symbol.for('Thing.referenceType')]);
+        } else {
+          // Early-exit if a Thing has been found - nothing more can be learned.
+          if (foundThing) {
+            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
+          }
+
+          foundOtherObject = true;
         }
       }
 
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 86f30143..7e470531 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -9,26 +9,32 @@ import yaml from 'js-yaml';
 
 import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
 import {sortByName} from '#sort';
-import {atOffset, empty, filterProperties, typeAppearance, withEntries}
-  from '#sugar';
 import Thing from '#thing';
 import thingConstructors from '#things';
 
 import {
-  filterReferenceErrors,
-  reportContentTextErrors,
-  reportDuplicateDirectories,
-} from '#data-checks';
-
-import {
   annotateErrorWithFile,
   decorateErrorWithIndex,
   decorateErrorWithAnnotation,
   openAggregate,
   showAggregate,
-  withAggregate,
 } from '#aggregate';
 
+import {
+  filterReferenceErrors,
+  reportContentTextErrors,
+  reportDirectoryErrors,
+} from '#data-checks';
+
+import {
+  atOffset,
+  empty,
+  filterProperties,
+  stitchArrays,
+  typeAppearance,
+  withEntries,
+} from '#sugar';
+
 function inspect(value, opts = {}) {
   return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
@@ -364,36 +370,53 @@ export function parseDuration(string) {
   }
 }
 
-export function parseAdditionalFiles(array) {
-  if (!Array.isArray(array)) {
-    // Error will be caught when validating against whatever this value is
-    return array;
-  }
-
-  return array.map((item) => ({
-    title: item['Title'],
-    description: item['Description'] ?? null,
-    files: item['Files'],
-  }));
-}
-
 export const extractAccentRegex =
   /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
 
 export const extractPrefixAccentRegex =
   /^(?:\((?<accent>.*)\) )?(?<main>.*?)$/;
 
-export function parseContributors(contributionStrings) {
+// TODO: Should this fit better within actual YAML loading infrastructure??
+export function parseArrayEntries(entries, mapFn) {
   // If this isn't something we can parse, just return it as-is.
   // The Thing object's validators will handle the data error better
   // than we're able to here.
-  if (!Array.isArray(contributionStrings)) {
-    return contributionStrings;
+  if (!Array.isArray(entries)) {
+    return entries;
+  }
+
+  // If the array is REALLY ACTUALLY empty (it's represented in YAML
+  // as literally an empty []), that's something we want to reflect.
+  if (empty(entries)) {
+    return entries;
   }
 
-  return contributionStrings.map(item => {
+  const nonNullEntries =
+    entries.filter(value => value !== null);
+
+  // On the other hand, if the array only contains null, it's just
+  // a placeholder, so skip over the field like it's not actually
+  // been put there yet.
+  if (empty(nonNullEntries)) {
+    return null;
+  }
+
+  return entries.map(mapFn);
+}
+
+export function parseContributors(entries) {
+  return parseArrayEntries(entries, item => {
     if (typeof item === 'object' && item['Who'])
-      return {who: item['Who'], what: item['What'] ?? null};
+      return {
+        artist: item['Who'],
+        annotation: item['What'] ?? null,
+      };
+
+    if (typeof item === 'object' && item['Artist'])
+      return {
+        artist: item['Artist'],
+        annotation: item['Annotation'] ?? null,
+      };
 
     if (typeof item !== 'string') return item;
 
@@ -401,18 +424,26 @@ export function parseContributors(contributionStrings) {
     if (!match) return item;
 
     return {
-      who: match.groups.main,
-      what: match.groups.accent ?? null,
+      artist: match.groups.main,
+      annotation: match.groups.accent ?? null,
     };
   });
 }
 
-export function parseAdditionalNames(additionalNameStrings) {
-  if (!Array.isArray(additionalNameStrings)) {
-    return additionalNameStrings;
-  }
+export function parseAdditionalFiles(entries) {
+  return parseArrayEntries(entries, item => {
+    if (typeof item !== 'object') return item;
+
+    return {
+      title: item['Title'],
+      description: item['Description'] ?? null,
+      files: item['Files'],
+    };
+  });
+}
 
-  return additionalNameStrings.map(item => {
+export function parseAdditionalNames(entries) {
+  return parseArrayEntries(entries, item => {
     if (typeof item === 'object' && item['Name'])
       return {name: item['Name'], annotation: item['Annotation'] ?? null};
 
@@ -523,7 +554,13 @@ export const documentModes = {
 //   them to each other, setting additional properties, etc). Input argument
 //   format depends on documentMode.
 //
-export const getDataSteps = () => {
+export function getAllDataSteps() {
+  try {
+    thingConstructors;
+  } catch (error) {
+    throw new Error(`Thing constructors aren't ready yet, can't get all data steps`);
+  }
+
   const steps = [];
 
   for (const thingConstructor of Object.values(thingConstructors)) {
@@ -539,376 +576,501 @@ export const getDataSteps = () => {
   sortByName(steps, {getName: step => step.title});
 
   return steps;
-};
-
-export async function loadAndProcessDataDocuments({dataPath}) {
-  const processDataAggregate = openAggregate({
-    message: `Errors processing data files`,
-  });
-  const wikiDataResult = {};
-
-  function decorateErrorWithFile(fn) {
-    return decorateErrorWithAnnotation(fn,
-      (caughtError, firstArg) =>
-        annotateErrorWithFile(
-          caughtError,
-          path.relative(
-            dataPath,
-            (typeof firstArg === 'object'
-              ? firstArg.file
-              : firstArg))));
-  }
+}
 
-  function asyncDecorateErrorWithFile(fn) {
-    return decorateErrorWithFile(fn).async;
-  }
+export async function getFilesFromDataStep(dataStep, {dataPath}) {
+  const {documentMode} = dataStep;
 
-  for (const dataStep of getDataSteps()) {
-    await processDataAggregate.nestAsync(
-      {
-        message: `Errors during data step: ${colors.bright(dataStep.title)}`,
-        translucent: true,
-      },
-      async ({call, callAsync, map, mapAsync, push}) => {
-        const {documentMode} = dataStep;
-
-        if (!Object.values(documentModes).includes(documentMode)) {
-          throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
-        }
+  switch (documentMode) {
+    case documentModes.allInOne:
+    case documentModes.oneDocumentTotal: {
+      if (!dataStep.file) {
+        throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
+      }
 
-        // Hear me out, it's been like 1200 years since I wrote the rest of
-        // this beautifully error-containing code and I don't know how to
-        // integrate this nicely. So I'm just returning the result and the
-        // error that should be thrown. Yes, we're back in callback hell,
-        // just without the callbacks. Thank you.
-        const filterBlankDocuments = documents => {
-          const aggregate = openAggregate({
-            message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`,
+      const localFile =
+        (typeof dataStep.file === 'function'
+          ? await dataStep.file(dataPath)
+          : dataStep.file);
+
+      const fileUnderDataPath =
+        path.join(dataPath, localFile);
+
+      const statResult =
+        await stat(fileUnderDataPath).then(
+          () => true,
+          error => {
+            if (error.code === 'ENOENT') {
+              return false;
+            } else {
+              throw error;
+            }
           });
 
-          const filteredDocuments =
-            documents
-              .filter(doc => doc !== null);
-
-          if (filteredDocuments.length !== documents.length) {
-            const blankIndexRangeInfo =
-              documents
-                .map((doc, index) => [doc, index])
-                .filter(([doc]) => doc === null)
-                .map(([doc, index]) => index)
-                .reduce((accumulator, index) => {
-                  if (accumulator.length === 0) {
-                    return [[index, index]];
-                  }
-                  const current = accumulator.at(-1);
-                  const rest = accumulator.slice(0, -1);
-                  if (current[1] === index - 1) {
-                    return rest.concat([[current[0], index]]);
-                  } else {
-                    return accumulator.concat([[index, index]]);
-                  }
-                }, [])
-                .map(([start, end]) => ({
-                  start,
-                  end,
-                  count: end - start + 1,
-                  previous: atOffset(documents, start, -1),
-                  next: atOffset(documents, end, +1),
-                }));
-
-            for (const {start, end, count, previous, next} of blankIndexRangeInfo) {
-              const parts = [];
-
-              if (count === 1) {
-                const range = `#${start + 1}`;
-                parts.push(`${count} document (${colors.yellow(range)}), `);
-              } else {
-                const range = `#${start + 1}-${end + 1}`;
-                parts.push(`${count} documents (${colors.yellow(range)}), `);
-              }
-
-              if (previous === null) {
-                parts.push(`at start of file`);
-              } else if (next === null) {
-                parts.push(`at end of file`);
-              } else {
-                const previousDescription = Object.entries(previous).at(0).join(': ');
-                const nextDescription = Object.entries(next).at(0).join(': ');
-                parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`);
-              }
-
-              aggregate.push(new Error(parts.join('')));
-            }
-          }
+      if (statResult) {
+        return [fileUnderDataPath];
+      } else {
+        return [];
+      }
+    }
 
-          return {documents: filteredDocuments, aggregate};
-        };
+    case documentModes.headerAndEntries:
+    case documentModes.onePerFile: {
+      if (!dataStep.files) {
+        throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
+      }
 
-        const processDocument = (document, thingClassOrFn) => {
-          const thingClass =
-            (thingClassOrFn.prototype instanceof Thing
-              ? thingClassOrFn
-              : thingClassOrFn(document));
+      const localFiles =
+        (typeof dataStep.files === 'function'
+          ? await dataStep.files(dataPath).then(
+              files => files,
+              error => {
+                if (error.code === 'ENOENT') {
+                  return [];
+                } else {
+                  throw error;
+                }
+              })
+          : dataStep.files);
 
-          if (typeof thingClass !== 'function') {
-            throw new Error(`Expected a thing class, got ${typeAppearance(thingClass)}`);
-          }
+      const filesUnderDataPath =
+        localFiles
+          .map(file => path.join(dataPath, file));
 
-          if (!(thingClass.prototype instanceof Thing)) {
-            throw new Error(`Expected a thing class, got ${thingClass.name}`);
-          }
+      return filesUnderDataPath;
+    }
 
-          const spec = thingClass[Thing.yamlDocumentSpec];
+    default:
+      throw new Error(`Unknown document mode ${documentMode.toString()}`);
+  }
+}
 
-          if (!spec) {
-            throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
-          }
+export async function loadYAMLDocumentsFromFile(file) {
+  let contents;
+  try {
+    contents = await readFile(file, 'utf-8');
+  } catch (caughtError) {
+    throw new Error(`Failed to read data file`, {cause: caughtError});
+  }
+
+  let documents;
+  try {
+    documents = yaml.loadAll(contents);
+  } catch (caughtError) {
+    throw new Error(`Failed to parse valid YAML`, {cause: caughtError});
+  }
+
+  const aggregate = openAggregate({
+    message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`,
+  });
 
-          // TODO: Making a function to only call it just like that is
-          // obviously pretty jank! It should be created once per data step.
-          const fn = makeProcessDocument(thingClass, spec);
-          return fn(document);
-        };
-
-        if (
-          documentMode === documentModes.allInOne ||
-          documentMode === documentModes.oneDocumentTotal
-        ) {
-          if (!dataStep.file) {
-            throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
+  const filteredDocuments =
+    documents
+      .filter(doc => doc !== null);
+
+  if (filteredDocuments.length !== documents.length) {
+    const blankIndexRangeInfo =
+      documents
+        .map((doc, index) => [doc, index])
+        .filter(([doc]) => doc === null)
+        .map(([doc, index]) => index)
+        .reduce((accumulator, index) => {
+          if (accumulator.length === 0) {
+            return [[index, index]];
           }
+          const current = accumulator.at(-1);
+          const rest = accumulator.slice(0, -1);
+          if (current[1] === index - 1) {
+            return rest.concat([[current[0], index]]);
+          } else {
+            return accumulator.concat([[index, index]]);
+          }
+        }, [])
+        .map(([start, end]) => ({
+          start,
+          end,
+          count: end - start + 1,
+          previous: atOffset(documents, start, -1),
+          next: atOffset(documents, end, +1),
+        }));
+
+    for (const {start, end, count, previous, next} of blankIndexRangeInfo) {
+      const parts = [];
+
+      if (count === 1) {
+        const range = `#${start + 1}`;
+        parts.push(`${count} document (${colors.yellow(range)}), `);
+      } else {
+        const range = `#${start + 1}-${end + 1}`;
+        parts.push(`${count} documents (${colors.yellow(range)}), `);
+      }
 
-          const file = path.join(
-            dataPath,
-            typeof dataStep.file === 'function'
-              ? await callAsync(dataStep.file, dataPath)
-              : dataStep.file);
+      if (previous === null) {
+        parts.push(`at start of file`);
+      } else if (next === null) {
+        parts.push(`at end of file`);
+      } else {
+        const previousDescription = Object.entries(previous).at(0).join(': ');
+        const nextDescription = Object.entries(next).at(0).join(': ');
+        parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`);
+      }
 
-          const statResult = await callAsync(() =>
-            stat(file).then(
-              () => true,
-              error => {
-                if (error.code === 'ENOENT') {
-                  return false;
-                } else {
-                  throw error;
-                }
-              }));
+      aggregate.push(new Error(parts.join('')));
+    }
+  }
 
-          if (statResult === false) {
-            const saveResult = call(dataStep.save, {
-              [documentModes.allInOne]: [],
-              [documentModes.oneDocumentTotal]: {},
-            }[documentMode]);
+  return {result: filteredDocuments, aggregate};
+}
 
-            if (!saveResult) return;
+// Mapping from dataStep (spec) object each to a sub-map, from thing class to
+// processDocument function.
+const processDocumentFns = new WeakMap();
 
-            Object.assign(wikiDataResult, saveResult);
+export function processThingsFromDataStep(documents, dataStep) {
+  let submap;
+  if (processDocumentFns.has(dataStep)) {
+    submap = processDocumentFns.get(dataStep);
+  } else {
+    submap = new Map();
+    processDocumentFns.set(dataStep, submap);
+  }
 
-            return;
-          }
+  function processDocument(document, thingClassOrFn) {
+    const thingClass =
+      (thingClassOrFn.prototype instanceof Thing
+        ? thingClassOrFn
+        : thingClassOrFn(document));
+
+    let fn;
+    if (submap.has(thingClass)) {
+      fn = submap.get(thingClass);
+    } else {
+      if (typeof thingClass !== 'function') {
+        throw new Error(`Expected a thing class, got ${typeAppearance(thingClass)}`);
+      }
 
-          const readResult = await callAsync(readFile, file, 'utf-8');
+      if (!(thingClass.prototype instanceof Thing)) {
+        throw new Error(`Expected a thing class, got ${thingClass.name}`);
+      }
 
-          if (!readResult) {
-            return;
-          }
+      const spec = thingClass[Thing.yamlDocumentSpec];
 
-          let processResults;
+      if (!spec) {
+        throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
+      }
 
-          switch (documentMode) {
-            case documentModes.oneDocumentTotal: {
-              const yamlResult = call(yaml.load, readResult);
+      fn = makeProcessDocument(thingClass, spec);
+      submap.set(thingClass, fn);
+    }
 
-              if (!yamlResult) {
-                processResults = null;
-                break;
-              }
+    return fn(document);
+  }
 
-              const {thing, aggregate} =
-                processDocument(yamlResult, dataStep.documentThing);
+  const {documentMode} = dataStep;
 
-              processResults = thing;
+  switch (documentMode) {
+    case documentModes.allInOne: {
+      const result = [];
+      const aggregate = openAggregate({message: `Errors processing documents`});
 
-              call(() => aggregate.close());
+      documents.forEach(
+        decorateErrorWithIndex(document => {
+          const {thing, aggregate: subAggregate} =
+            processDocument(document, dataStep.documentThing);
 
-              break;
-            }
+          result.push(thing);
+          aggregate.call(subAggregate.close);
+        }));
 
-            case documentModes.allInOne: {
-              const yamlResults = call(yaml.loadAll, readResult);
+      return {aggregate, result};
+    }
 
-              if (!yamlResults) {
-                processResults = [];
-                return;
-              }
+    case documentModes.oneDocumentTotal: {
+      if (documents.length > 1)
+        throw new Error(`Only expected one document to be present, got ${documents.length}`);
 
-              const {documents, aggregate: filterAggregate} =
-                filterBlankDocuments(yamlResults);
+      const {thing, aggregate} =
+        processDocument(documents[0], dataStep.documentThing);
 
-              call(filterAggregate.close);
+      return {aggregate, result: thing};
+    }
 
-              processResults = [];
+    case documentModes.headerAndEntries: {
+      const headerDocument = documents[0];
+      const entryDocuments = documents.slice(1).filter(Boolean);
 
-              map(documents, decorateErrorWithIndex(document => {
-                const {thing, aggregate} =
-                  processDocument(document, dataStep.documentThing);
+      if (!headerDocument)
+        throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
 
-                processResults.push(thing);
-                aggregate.close();
-              }), {message: `Errors processing documents`});
+      const aggregate = openAggregate({message: `Errors processing documents`});
 
-              break;
-            }
-          }
+      const {thing: headerThing, aggregate: headerAggregate} =
+        processDocument(headerDocument, dataStep.headerDocumentThing);
 
-          if (!processResults) return;
+      try {
+        headerAggregate.close();
+      } catch (caughtError) {
+        caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
+        aggregate.push(caughtError);
+      }
 
-          const saveResult = call(dataStep.save, processResults);
+      const entryThings = [];
 
-          if (!saveResult) return;
+      for (const [index, entryDocument] of entryDocuments.entries()) {
+        const {thing: entryThing, aggregate: entryAggregate} =
+          processDocument(entryDocument, dataStep.entryDocumentThing);
 
-          Object.assign(wikiDataResult, saveResult);
+        entryThings.push(entryThing);
 
-          return;
+        try {
+          entryAggregate.close();
+        } catch (caughtError) {
+          caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
+          aggregate.push(caughtError);
         }
+      }
 
-        if (!dataStep.files) {
-          throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
-        }
+      return {
+        aggregate,
+        result: {
+          header: headerThing,
+          entries: entryThings,
+        },
+      };
+    }
 
-        const filesFromDataStep =
-          (typeof dataStep.files === 'function'
-            ? await callAsync(() =>
-                dataStep.files(dataPath).then(
-                  files => files,
-                  error => {
-                    if (error.code === 'ENOENT') {
-                      return [];
-                    } else {
-                      throw error;
-                    }
-                  }))
-            : dataStep.files);
-
-        const filesUnderDataPath =
-          filesFromDataStep
-            .map(file => path.join(dataPath, file));
-
-        const yamlResults = [];
-
-        await mapAsync(filesUnderDataPath, {message: `Errors loading data files`},
-          asyncDecorateErrorWithFile(async file => {
-            let contents;
-            try {
-              contents = await readFile(file, 'utf-8');
-            } catch (caughtError) {
-              throw new Error(`Failed to read data file`, {cause: caughtError});
-            }
+    case documentModes.onePerFile: {
+      if (documents.length > 1)
+        throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
 
-            let documents;
-            try {
-              documents = yaml.loadAll(contents);
-            } catch (caughtError) {
-              throw new Error(`Failed to parse valid YAML`, {cause: caughtError});
-            }
+      if (empty(documents) || !documents[0])
+        throw new Error(`Expected a document, this file is empty`);
 
-            const {documents: filteredDocuments, aggregate: filterAggregate} =
-              filterBlankDocuments(documents);
-
-            try {
-              filterAggregate.close();
-            } catch (caughtError) {
-              // Blank documents aren't a critical error, they're just something
-              // that should be noted - the (filtered) documents still get pushed.
-              const pathToFile = path.relative(dataPath, file);
-              annotateErrorWithFile(caughtError, pathToFile);
-              push(caughtError);
-            }
+      const {thing, aggregate} =
+        processDocument(documents[0], dataStep.documentThing);
+
+      return {aggregate, result: thing};
+    }
+
+    default:
+      throw new Error(`Unknown document mode ${documentMode.toString()}`);
+  }
+}
+
+export function decorateErrorWithFileFromDataPath(fn, {dataPath}) {
+  return decorateErrorWithAnnotation(fn,
+    (caughtError, firstArg) =>
+      annotateErrorWithFile(
+        caughtError,
+        path.relative(
+          dataPath,
+          (typeof firstArg === 'object'
+            ? firstArg.file
+            : firstArg))));
+}
+
+// Loads a list of files for each data step, and a list of documents
+// for each file.
+export async function loadYAMLDocumentsFromDataSteps(dataSteps, {dataPath}) {
+  const aggregate =
+    openAggregate({
+      message: `Errors loading data files`,
+      translucent: true,
+    });
 
-            yamlResults.push({file, documents: filteredDocuments});
+  const fileLists =
+    await Promise.all(
+      dataSteps.map(dataStep =>
+        getFilesFromDataStep(dataStep, {dataPath})));
+
+  const filePromises =
+    fileLists
+      .map(files => files
+        .map(file =>
+          loadYAMLDocumentsFromFile(file).then(
+            ({result, aggregate}) => {
+              const close =
+                decorateErrorWithFileFromDataPath(aggregate.close, {dataPath});
+
+              aggregate.close = () =>
+                close({file});
+
+              return {result, aggregate};
+            },
+            (error) => {
+              const aggregate = {};
+
+              annotateErrorWithFile(error, path.relative(dataPath, file));
+
+              aggregate.close = () => {
+                throw error;
+              };
+
+              return {result: [], aggregate};
+            })));
+
+  const fileListPromises =
+    filePromises
+      .map(filePromises => Promise.all(filePromises));
+
+  const dataStepPromises =
+    stitchArrays({
+      dataStep: dataSteps,
+      fileListPromise: fileListPromises,
+    }).map(async ({dataStep, fileListPromise}) =>
+        openAggregate({
+          message: `Errors loading data files for data step: ${colors.bright(dataStep.title)}`,
+          translucent: true,
+        }).contain(await fileListPromise));
+
+  const documentLists =
+    aggregate
+      .receive(await Promise.all(dataStepPromises));
+
+  return {aggregate, result: {documentLists, fileLists}};
+}
+
+// Loads a list of things from a list of documents for each file
+// for each data step. Nesting!
+export async function processThingsFromDataSteps(documentLists, fileLists, dataSteps, {dataPath}) {
+  const aggregate =
+    openAggregate({
+      message: `Errors processing documents in data files`,
+      translucent: true,
+    });
+
+  const filePromises =
+    stitchArrays({
+      dataStep: dataSteps,
+      files: fileLists,
+      documentLists: documentLists,
+    }).map(({dataStep, files, documentLists}) =>
+        stitchArrays({
+          file: files,
+          documents: documentLists,
+        }).map(({file, documents}) => {
+            const {result, aggregate} =
+              processThingsFromDataStep(documents, dataStep);
+
+            const close = decorateErrorWithFileFromDataPath(aggregate.close, {dataPath});
+            aggregate.close = () => close({file});
+
+            return {result, aggregate};
           }));
 
-        const processResults = [];
-
-        switch (documentMode) {
-          case documentModes.headerAndEntries:
-            map(yamlResults, {message: `Errors processing documents in data files`, translucent: true},
-              decorateErrorWithFile(({documents}) => {
-                const headerDocument = documents[0];
-                const entryDocuments = documents.slice(1).filter(Boolean);
-
-                if (!headerDocument)
-                  throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
-
-                withAggregate({message: `Errors processing documents`}, ({push}) => {
-                  const {thing: headerObject, aggregate: headerAggregate} =
-                    processDocument(headerDocument, dataStep.headerDocumentThing);
-
-                  try {
-                    headerAggregate.close();
-                  } catch (caughtError) {
-                    caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
-                    push(caughtError);
-                  }
-
-                  const entryObjects = [];
-
-                  for (let index = 0; index < entryDocuments.length; index++) {
-                    const entryDocument = entryDocuments[index];
-
-                    const {thing: entryObject, aggregate: entryAggregate} =
-                      processDocument(entryDocument, dataStep.entryDocumentThing);
-
-                    entryObjects.push(entryObject);
-
-                    try {
-                      entryAggregate.close();
-                    } catch (caughtError) {
-                      caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
-                      push(caughtError);
-                    }
-                  }
-
-                  processResults.push({
-                    header: headerObject,
-                    entries: entryObjects,
-                  });
-                });
-              }));
-            break;
-
-          case documentModes.onePerFile:
-            map(yamlResults, {message: `Errors processing data files as valid documents`},
-              decorateErrorWithFile(({documents}) => {
-                if (documents.length > 1)
-                  throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
-
-                if (empty(documents) || !documents[0])
-                  throw new Error(`Expected a document, this file is empty`);
-
-                const {thing, aggregate} =
-                  processDocument(documents[0], dataStep.documentThing);
-
-                processResults.push(thing);
-                aggregate.close();
-              }));
-            break;
-        }
+  const fileListPromises =
+    filePromises
+      .map(filePromises => Promise.all(filePromises));
+
+  const dataStepPromises =
+    stitchArrays({
+      dataStep: dataSteps,
+      fileListPromise: fileListPromises,
+    }).map(async ({dataStep, fileListPromise}) =>
+        openAggregate({
+          message: `Errors loading data files for data step: ${colors.bright(dataStep.title)}`,
+          translucent: true,
+        }).contain(await fileListPromise));
+
+  const thingLists =
+    aggregate
+      .receive(await Promise.all(dataStepPromises));
+
+  return {aggregate, result: thingLists};
+}
 
-        const saveResult = call(dataStep.save, processResults);
+// Flattens a list of *lists* of things for a given data step (each list
+// corresponding to one YAML file) into results to be saved on the final
+// wikiData object, routing thing lists into the step's save() function.
+export function saveThingsFromDataStep(thingLists, dataStep) {
+  const {documentMode} = dataStep;
 
-        if (!saveResult) return;
+  switch (documentMode) {
+    case documentModes.allInOne: {
+      const things =
+        (empty(thingLists)
+          ? []
+          : thingLists[0]);
 
-        Object.assign(wikiDataResult, saveResult);
-      }
-    );
+      return dataStep.save(things);
+    }
+
+    case documentModes.oneDocumentTotal: {
+      const thing =
+        (empty(thingLists)
+          ? {}
+          : thingLists[0]);
+
+      return dataStep.save(thing);
+    }
+
+    case documentModes.headerAndEntries:
+    case documentModes.onePerFile: {
+      return dataStep.save(thingLists);
+    }
+
+    default:
+      throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
   }
+}
 
-  return {
-    aggregate: processDataAggregate,
-    result: wikiDataResult,
-  };
+// Flattens a list of *lists* of things for each data step (each list
+// corresponding to one YAML file) into the final wikiData object,
+// routing thing lists into each step's save() function.
+export function saveThingsFromDataSteps(thingLists, dataSteps) {
+  const aggregate =
+    openAggregate({
+      message: `Errors finalizing things from data files`,
+      translucent: true,
+    });
+
+  const wikiData = {};
+
+  stitchArrays({
+    dataStep: dataSteps,
+    thingLists: thingLists,
+  }).map(({dataStep, thingLists}) => {
+      try {
+        return saveThingsFromDataStep(thingLists, dataStep);
+      } catch (caughtError) {
+        const error = new Error(
+          `Error finalizing things for data step: ${colors.bright(dataStep.title)}`,
+          {cause: caughtError});
+
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+        aggregate.push(error);
+
+        return null;
+      }
+    })
+    .filter(Boolean)
+    .forEach(saveResult => {
+      Object.assign(wikiData, saveResult);
+    });
+
+  return {aggregate, result: wikiData};
+}
+
+export async function loadAndProcessDataDocuments(dataSteps, {dataPath}) {
+  const aggregate =
+    openAggregate({
+      message: `Errors processing data files`,
+    });
+
+  const {documentLists, fileLists} =
+    aggregate.receive(
+      await loadYAMLDocumentsFromDataSteps(dataSteps, {dataPath}));
+
+  const thingLists =
+    aggregate.receive(
+      await processThingsFromDataSteps(documentLists, fileLists, dataSteps, {dataPath}));
+
+  const wikiData =
+    aggregate.receive(
+      saveThingsFromDataSteps(thingLists, dataSteps));
+
+  return {aggregate, result: wikiData};
 }
 
 // Data linking! Basically, provide (portions of) wikiData to the Things which
@@ -988,15 +1150,13 @@ export function linkWikiDataArrays(wikiData) {
   }
 }
 
-export function sortWikiDataArrays(wikiData) {
+export function sortWikiDataArrays(dataSteps, wikiData) {
   for (const [key, value] of Object.entries(wikiData)) {
     if (!Array.isArray(value)) continue;
     wikiData[key] = value.slice();
   }
 
-  const steps = getDataSteps();
-
-  for (const step of steps) {
+  for (const step of dataSteps) {
     if (!step.sort) continue;
     step.sort(wikiData);
   }
@@ -1023,10 +1183,12 @@ export async function quickLoadAllFromYAML(dataPath, {
 }) {
   const showAggregate = customShowAggregate;
 
+  const dataSteps = getAllDataSteps();
+
   let wikiData;
 
   {
-    const {aggregate, result} = await loadAndProcessDataDocuments({dataPath});
+    const {aggregate, result} = await loadAndProcessDataDocuments(dataSteps, {dataPath});
 
     wikiData = result;
 
@@ -1042,7 +1204,7 @@ export async function quickLoadAllFromYAML(dataPath, {
   linkWikiDataArrays(wikiData);
 
   try {
-    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
+    reportDirectoryErrors(wikiData, {getAllFindSpecs});
     logInfo`No duplicate directories found. (complete data)`;
   } catch (error) {
     showAggregate(error);
@@ -1065,7 +1227,7 @@ export async function quickLoadAllFromYAML(dataPath, {
     logWarn`Content text errors found.`;
   }
 
-  sortWikiDataArrays(wikiData);
+  sortWikiDataArrays(dataSteps, wikiData);
 
   return wikiData;
 }
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 8a582693..d08726c7 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -91,7 +91,8 @@ const WARNING_DELAY_TIME = 10000;
 //   this particular thumbtack will be regenerated, but any others (whose
 //   `tackbust` listed below is equal or below the cache-recorded bust) will be
 //   reused. (Zero is a special value that means this tack's spec is still the
-//   same as it would've been generated prior to thumbtack versioning.)
+//   same as it would've been generated prior to thumbtack versioning; any new
+//   kinds of thumbnails should start counting up from one.)
 //
 // * `size` is the maximum length of the image. It will be scaled down,
 //   keeping aspect ratio, to fit in this dimension.
@@ -132,6 +133,12 @@ const thumbnailSpec = {
     quality: 85,
   },
 
+  'adorb': {
+    tackbust: 1,
+    size: 64,
+    quality: 90,
+  },
+
   'mini': {
     tackbust: 2,
     size: 8,
@@ -610,8 +617,11 @@ async function generateImageThumbnail(imagePath, thumbtack, {
 
 export async function determineMediaCachePath({
   mediaPath,
+  wikiCachePath,
   providedMediaCachePath,
+
   disallowDoubling = false,
+  regenerateMissingThumbnailCache = false,
 }) {
   if (!mediaPath) {
     return {
@@ -627,6 +637,13 @@ export async function determineMediaCachePath({
     };
   }
 
+  if (!wikiCachePath) {
+    return {
+      annotation: 'wiki cache path not provided',
+      mediaCachePath: null,
+    };
+  }
+
   let mediaIncludesThumbnailCache;
 
   try {
@@ -643,45 +660,127 @@ export async function determineMediaCachePath({
     };
   }
 
-  const inferredPath =
+  // Two inferred paths are possible - "adjacent" and "contained".
+  // "Contained" is the preferred format and we'll create it if
+  // neither of the inferred paths exists. (Of course, by this
+  // point we've already determined that the media path itself
+  // isn't doubling as the thumbnail cache.)
+
+  const containedInferredPath =
+    (wikiCachePath
+      ? path.join(wikiCachePath, 'media-cache')
+      : null);
+
+  const adjacentInferredPath =
     path.join(
       path.dirname(mediaPath),
       path.basename(mediaPath) + '-cache');
 
-  let inferredIncludesThumbnailCache;
+  let containedIncludesThumbnailCache;
+  let adjacentIncludesThumbnailCache;
 
   try {
-    const files = await readdir(inferredPath);
-    inferredIncludesThumbnailCache = files.includes(CACHE_FILE);
+    const files = await readdir(containedInferredPath);
+    containedIncludesThumbnailCache = files.includes(CACHE_FILE);
   } catch (error) {
     if (error.code === 'ENOENT') {
-      inferredIncludesThumbnailCache = null;
+      containedIncludesThumbnailCache = null;
     } else {
-      inferredIncludesThumbnailCache = undefined;
+      containedIncludesThumbnailCache = undefined;
     }
   }
 
-  if (inferredIncludesThumbnailCache === true) {
+  try {
+    const files = await readdir(adjacentInferredPath);
+    adjacentIncludesThumbnailCache = files.includes(CACHE_FILE);
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      adjacentIncludesThumbnailCache = null;
+    } else {
+      adjacentIncludesThumbnailCache = undefined;
+    }
+  }
+
+  // Go ahead with the contained path if it exists and contains a cache -
+  // no other conditions matter.
+  if (containedIncludesThumbnailCache === true) {
     return {
-      annotation: 'inferred path has cache',
-      mediaCachePath: inferredPath,
+      annotation: `contained path has cache`,
+      mediaCachePath: containedInferredPath,
     };
-  } else if (inferredIncludesThumbnailCache === false) {
+  }
+
+  // Reuse an existing adjacent cache before figuring out what to do
+  // if there's no extant cache at all.
+  if (adjacentIncludesThumbnailCache === true) {
     return {
-      annotation: 'inferred path does not have cache',
-      mediaCachePath: null,
+      annotation: `adjacent path has cache`,
+      mediaCachePath: adjacentInferredPath,
     };
-  } else if (inferredIncludesThumbnailCache === null) {
+  }
+
+  // Throw a very high-priority tantrum if the contained cache exists but
+  // isn't readable. It's the preferred cache and we can't tell if it's
+  // available for use or not!
+  if (containedIncludesThumbnailCache === undefined) {
     return {
-      annotation: 'inferred path will be created',
-      mediaCachePath: inferredPath,
+      annotation: `contained path not readable`,
+      mediaCachePath: null,
     };
-  } else {
+  }
+
+  // Throw a secondary tantrum if the adjacent cache exists but
+  // isn't readable. This is just as big of a problem, but if for
+  // some reason both the contained and adjacent caches exist,
+  // the contained one is the one we'd rather have addressed.
+  if (adjacentIncludesThumbnailCache === undefined) {
     return {
-      annotation: 'inferred path not readable',
+      annotation: `adjacent path not readable`,
       mediaCachePath: null,
     };
   }
+
+  // Throw a high-priority tantrum if the contained cache exists but is
+  // missing its cache file, again because it's the more preferred cache.
+  // Unless we're indicated to regenerate such a missing cache file!
+  if (containedIncludesThumbnailCache === false) {
+    if (regenerateMissingThumbnailCache) {
+      return {
+        annotation: `contained path will regenerate missing cache`,
+        mediaCachePath: containedInferredPath,
+      };
+    } else {
+      return {
+        annotation: `contained path does not have cache`,
+        mediaCachePath: null,
+      };
+    }
+  }
+
+  // Throw a secondary tantrum if the adjacent cache exists but is
+  // missing its cache file, because it's the less preferred cache.
+  // Unless we're indicated to regenerate a missing cache file!
+  if (adjacentIncludesThumbnailCache === false) {
+    if (regenerateMissingThumbnailCache) {
+      return {
+        annotation: `adjacent path will regenerate missing cache`,
+        mediaCachePath: adjacentInferredPath,
+      };
+    } else {
+      return {
+        annotation: `adjacent path does not have cache`,
+        mediaCachePath: null,
+      };
+    }
+  }
+
+  // If we haven't found any information about either inferred
+  // location (and so have fallen back to this base case), we'll
+  // create the contained cache during this run.
+  return {
+    annotation: `contained path will be created`,
+    mediaCachePath: containedInferredPath,
+  };
 }
 
 export async function migrateThumbsIntoDedicatedCacheDirectory({
diff --git a/src/import-heck.js b/src/import-heck.js
new file mode 100644
index 00000000..3470fbb5
--- /dev/null
+++ b/src/import-heck.js
@@ -0,0 +1,9 @@
+// Due to import time shenanigans, these imports have to come in the specified
+// order. This obviously needs fixing up.
+
+/* precede #find */
+import '#data-checks';
+
+import '#find';
+
+// End of import time shenanigans (hopefully)
diff --git a/src/listing-spec.js b/src/listing-spec.js
index 73fbee6d..bfea397c 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -67,7 +67,7 @@ listingSpec.push({
   contentFunction: 'listArtistsByDuration',
 });
 
-// TODO: hide if no groups...
+// TODO: hide if divideTrackListsByGroups empty...
 listingSpec.push({
   directory: 'artists/by-group',
   stringsKey: 'listArtists.byGroup',
diff --git a/src/search.js b/src/search.js
new file mode 100644
index 00000000..a2dae9e1
--- /dev/null
+++ b/src/search.js
@@ -0,0 +1,119 @@
+'use strict';
+
+import {createHash} from 'node:crypto';
+import {mkdir, writeFile} from 'node:fs/promises';
+import * as path from 'node:path';
+
+import {compress} from 'compress-json';
+import FlexSearch from 'flexsearch';
+import {pack} from 'msgpackr';
+
+import {logWarn} from '#cli';
+import {makeSearchIndex, populateSearchIndex, searchSpec} from '#search-spec';
+import {stitchArrays} from '#sugar';
+import {checkIfImagePathHasCachedThumbnails, getThumbnailEqualOrSmaller}
+  from '#thumbs';
+
+async function serializeIndex(index) {
+  const results = {};
+
+  await index.export((key, data) => {
+    if (data === undefined) {
+      return;
+    }
+
+    if (typeof data !== 'string') {
+      logWarn`Got something besides a string from index.export(), skipping:`;
+      console.warn(key, data);
+      return;
+    }
+
+    results[key] = JSON.parse(data);
+  });
+
+  return results;
+}
+
+export async function writeSearchData({
+  thumbsCache,
+  urls,
+  wikiCachePath,
+  wikiData,
+}) {
+  if (!wikiCachePath) {
+    throw new Error(`Expected wikiCachePath to write into`);
+  }
+
+  // Basic flow is:
+  // 1. Define schema for type
+  // 2. Add documents to index
+  // 3. Save index to exportable json
+
+  const keys =
+    Object.keys(searchSpec);
+
+  const descriptors =
+    Object.values(searchSpec);
+
+  const indexes =
+    descriptors
+      .map(descriptor =>
+        makeSearchIndex(descriptor, {FlexSearch}));
+
+  stitchArrays({
+    index: indexes,
+    descriptor: descriptors,
+  }).forEach(({index, descriptor}) =>
+      populateSearchIndex(index, descriptor, {
+        checkIfImagePathHasCachedThumbnails,
+        getThumbnailEqualOrSmaller,
+        thumbsCache,
+        urls,
+        wikiData,
+      }));
+
+  const serializedIndexes =
+    await Promise.all(indexes.map(serializeIndex));
+
+  const packedIndexes =
+    serializedIndexes
+      .map(data => compress(data))
+      .map(data => pack(data));
+
+  const outputDirectory =
+    path.join(wikiCachePath, 'search');
+
+  const mainIndexFile =
+    path.join(outputDirectory, 'index.json');
+
+  const mainIndexJSON =
+    JSON.stringify(
+      Object.fromEntries(
+        stitchArrays({
+          key: keys,
+          buffer: packedIndexes,
+        }).map(({key, buffer}) => {
+          const md5 = createHash('md5');
+          md5.write(buffer);
+
+          const value = {
+            md5: md5.digest('hex'),
+          };
+
+          return [key, value];
+        })));
+
+
+  await mkdir(outputDirectory, {recursive: true});
+
+  await Promise.all(
+    stitchArrays({
+      key: keys,
+      buffer: packedIndexes,
+    }).map(({key, buffer}) =>
+        writeFile(
+          path.join(outputDirectory, key + '.json.msgpack'),
+          buffer)));
+
+  await writeFile(mainIndexFile, mainIndexJSON);
+}
diff --git a/src/static/site-basic.css b/src/static/css/site-basic.css
index 586f37b5..586f37b5 100644
--- a/src/static/site-basic.css
+++ b/src/static/css/site-basic.css
diff --git a/src/static/site6.css b/src/static/css/site.css
index 73721956..e297993c 100644
--- a/src/static/site6.css
+++ b/src/static/css/site.css
@@ -32,7 +32,9 @@
 /* Layout - Common */
 
 body {
-  margin: 10px;
+  position: relative;
+  margin: 0;
+  padding: 10px;
   overflow-y: scroll;
 }
 
@@ -41,8 +43,8 @@ body::before {
   position: fixed;
   top: 0;
   left: 0;
-  width: 100%;
-  height: 100%;
+  width: 100vw;
+  height: 100vh;
   z-index: -1;
 
   /* NB: these are 100 LVW, "largest view width", etc.
@@ -56,7 +58,7 @@ body::before {
 
 #page-container {
   max-width: 1100px;
-  margin: 10px auto 50px;
+  margin: 0 auto 40px;
   padding: 15px 0;
 }
 
@@ -170,6 +172,10 @@ body::before {
   flex-grow: 1;
 }
 
+.sidebar-column.initially-hidden {
+  display: none;
+}
+
 .sidebar-multiple {
   display: flex;
   flex-direction: column;
@@ -224,7 +230,7 @@ body {
 }
 
 body::before {
-  background-image: url("../media/bg.jpg");
+  background-image: url("../../media/bg.jpg");
   background-position: center;
   background-size: cover;
   opacity: 0.5;
@@ -253,6 +259,11 @@ body::before {
   font-weight: 800;
 }
 
+#page-container:not(.showing-sidebar-left) #skippers .skipper[data-for=sidebar-left],
+#page-container:not(.showing-sidebar-right) #skippers .skipper[data-for=sidebar-right] {
+  display: none;
+}
+
 #banner {
   background: black;
   background-color: var(--dim-color);
@@ -392,6 +403,36 @@ summary > span:hover {
   text-decoration-color: var(--primary-color);
 }
 
+summary > span:hover a {
+  text-decoration: none !important;
+}
+
+summary > span:hover:has(a:hover),
+summary > span:hover:has(a.nested-hover),
+summary.has-nested-hover > span {
+  text-decoration: none !important;
+}
+
+summary > span:hover:has(a:hover) a,
+summary > span:hover:has(a.nested-hover) a,
+summary.has-nested-hover > span a {
+  text-decoration: underline !important;
+}
+
+summary.underline-white > span:hover {
+  text-decoration-color: white;
+}
+
+/* This link isn't supposed to be underlined *at all*
+ * when the summary (and not link) is hovered, but
+ * for some reason Safari is still applying its colored
+ * and dotted(!) underline. Get around the apparent
+ * effect by just making it white.
+ */
+summary.underline-white > span:hover a:not(:hover) {
+  text-decoration-color: white;
+}
+
 summary .group-name {
   color: var(--primary-color);
 }
@@ -430,6 +471,242 @@ summary .group-name {
   font-weight: normal;
 }
 
+.sidebar-column.search-showing-results {
+  position: sticky;
+  top: 5px;
+  align-self: flex-start !important; /* pls */
+}
+
+.wiki-search-sidebar-box {
+  padding: 1px 0 0 0;
+
+  z-index: 100;
+  max-height: calc(100vh - 20px);
+
+  display: flex;
+  flex-direction: column;
+
+  background-color: #000000c0;
+
+  -webkit-backdrop-filter:
+    brightness(1.2) blur(4px);
+
+          backdrop-filter:
+    brightness(1.2) blur(4px);
+}
+
+.wiki-search-sidebar-box.showing-results {
+  box-shadow:
+    0 4px 16px -8px var(--primary-color),
+    0 10px 6px var(--bg-black-color),
+    0 6px 4px #00000040;
+}
+
+/* This is to say, any sidebar that's *not*
+ * the first sidebar after the search box.
+ */
+.wiki-search-sidebar-box.showing-results + .sidebar ~ .sidebar {
+  margin-top: 5px;
+}
+
+.wiki-search-sidebar-box.showing-results ~ .sidebar:not(:hover) {
+  opacity: 0.8;
+  filter: brightness(0.7);
+}
+
+.wiki-search-input {
+  width: calc(100% - 4px);
+  padding: 2px 4px;
+  margin: 2px 2px 3px 2px;
+  box-sizing: border-box;
+
+  background: transparent;
+  border: 1px solid var(--dim-color);
+  border-radius: 3px;
+  color: inherit;
+}
+
+.wiki-search-input[disabled] {
+  opacity: 0.6;
+  cursor: not-allowed;
+}
+
+.wiki-search-sidebar-box hr {
+  border-color: var(--primary-color);
+  border-style: none none dotted none;
+  margin-top: 3px;
+  margin-bottom: 3px;
+}
+
+.wiki-search-progress-container {
+  padding: 2px 6px 4px 6px;
+  display: flex;
+  flex-direction: row;
+}
+
+.wiki-search-progress-label {
+  font-size: 0.9em;
+  font-style: oblique;
+  cursor: default;
+  margin-right: 1ch;
+}
+
+.wiki-search-progress-bar {
+  flex-grow: 1;
+}
+
+.wiki-search-failed-container {
+  padding: 2px 3px 4px 6px;
+}
+
+.wiki-search-failed-container p {
+  margin: 0;
+}
+
+.wiki-search-results-container {
+  margin-bottom: 0;
+  padding: 2px;
+}
+
+.wiki-search-no-results {
+  font-size: 0.9em;
+  padding: 2px 3px 4px 6px;
+  cursor: default;
+}
+
+.wiki-search-result {
+  position: relative;
+  display: flex;
+  padding: 4px 3px 4px 6px;
+}
+
+.wiki-search-result:hover {
+  text-decoration: none !important;
+}
+
+.wiki-search-result::before {
+  content: '';
+  position: absolute;
+  top: -2px;
+  bottom: -2px;
+  left: 0;
+  right: 0;
+
+  border: 1.5px solid var(--primary-color);
+  border-radius: 4px;
+  display: none;
+}
+
+.wiki-search-result.current-result {
+  background: var(--light-ghost-color);
+  border-top: 1px solid var(--dim-color);
+  border-bottom: 1px solid var(--dim-color);
+}
+
+.wiki-search-result:hover::before {
+  display: block;
+  background: var(--light-ghost-color);
+}
+
+.wiki-search-result.current-result:hover {
+  background: none;
+  border-color: transparent;
+}
+
+.wiki-search-result.current-result:hover .wiki-search-current-result-text {
+  filter: saturate(0.8) brightness(1.4);
+}
+
+.wiki-search-result-text-area {
+  align-self: center;
+  flex-grow: 1;
+  min-width: 0;
+  overflow-wrap: break-word;
+  padding-bottom: 2px;
+}
+
+.wiki-search-result-name {
+  margin-right: 0.25em;
+}
+
+.wiki-search-result:hover .wiki-search-result-name {
+  text-decoration: underline;
+}
+
+.wiki-search-current-result-text,
+.wiki-search-result-kind {
+  font-style: oblique;
+  opacity: 0.9;
+  display: inline-block;
+}
+
+.wiki-search-result-image-container {
+  align-self: flex-start;
+  flex-shrink: 0;
+  margin-right: 6px;
+  border-radius: 2px;
+  overflow: hidden;
+
+  background-color: var(--deep-color);
+  border: 2px solid var(--deep-color);
+}
+
+.wiki-search-results:not(:has(.wiki-search-result-image)) .wiki-search-result-image-container {
+  display: none;
+}
+
+.wiki-search-result-image,
+.wiki-search-result-image-placeholder {
+  display: block;
+  width: 1.8em;
+  height: 1.8em;
+  aspect-ratio: 1 / 1;
+  border-radius: 1.5px;
+}
+
+.wiki-search-result-image-placeholder {
+  background-color: #0004;
+  box-shadow: 0 1px 3px -1px #0008 inset;
+}
+
+.wiki-search-result-image.has-warning {
+  filter: blur(2px) brightness(0.8);
+}
+
+.wiki-search-end-search-line {
+  text-align: center;
+  margin-top: 6px;
+  margin-bottom: 2px;
+}
+
+.wiki-search-end-search-line a {
+  display: inline-block;
+  font-style: oblique;
+  opacity: 0.9;
+  padding: 3px 6px 4px 6px;
+  border-radius: 4px;
+  border: 1.5px solid transparent;
+}
+
+.wiki-search-end-search-line a:hover {
+  opacity: 1;
+  background: var(--light-ghost-color);
+  border-color: var(--deep-color);
+}
+
+.wiki-search-input:focus {
+  border-color: var(--primary-color);
+}
+
+.wiki-search-input::placeholder {
+  color: var(--primary-color);
+  font-style: oblique;
+}
+
+.wiki-search-input:focus::placeholder {
+  color: var(--dim-color);
+}
+
 #content {
   overflow-wrap: anywhere;
 }
@@ -474,6 +751,7 @@ a:not([href]):hover {
 
 .external-link.indicate-external::after {
   content: '\00a0➚';
+  font-style: normal;
 }
 
 .external-link.indicate-external:hover::after {
@@ -488,13 +766,13 @@ a:not([href]):hover {
   display: inline-block;
 }
 
-.nav-links-index .nav-link.has-divider::before,
-.nav-links-groups .nav-link.has-divider::before {
+.nav-links-index .nav-link:not(:first-child)::before,
+.nav-links-groups .nav-link:not(:first-child)::before {
   content: "\0020\00b7\0020";
   font-weight: 800;
 }
 
-.nav-links-hierarchical .nav-link.has-divider::before {
+.nav-links-hierarchical .nav-link:not(:first-child)::before {
   content: "\0020/\0020";
 }
 
@@ -503,6 +781,19 @@ a:not([href]):hover {
   white-space: nowrap;
 }
 
+#header .scoped-chronology {
+  display: none;
+}
+
+#header .scoped-chronology-switcher .switcher-link {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+#header .scoped-chronology-switcher > div {
+  margin-left: 20px;
+}
+
 #secondary-nav {
   text-align: center;
 }
@@ -720,6 +1011,10 @@ li:not(:first-child:last-child) .tooltip,
   color: var(--page-primary-color);
 }
 
+progress {
+  accent-color: var(--primary-color);
+}
+
 .content-columns {
   columns: 2;
 }
@@ -801,6 +1096,10 @@ ul.image-details li {
   content: " \00b7 ";
 }
 
+#artist-commentary.first-entry-is-dated {
+  clear: right;
+}
+
 .commentary-entry-heading {
   margin-left: 15px;
   padding-left: 5px;
@@ -813,6 +1112,19 @@ ul.image-details li {
   font-style: oblique;
 }
 
+.commentary-entry-heading time {
+  float: right;
+  padding-left: 0.5ch;
+  padding-right: 0.25ch;
+  margin-left: 0.75ch;
+  border-left: 1px dotted transparent;
+  transition: border-left-color 0.15s;
+}
+
+.commentary-entry-heading time:hover {
+  border-left-color: white;
+}
+
 .commentary-art {
   float: right;
   width: 30%;
@@ -844,6 +1156,17 @@ ul.image-details li {
   margin-bottom: 1.5em;
 }
 
+a.align-center, img.align-center {
+  display: block;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+center {
+  margin-top: 1em;
+  margin-bottom: 1em;
+}
+
 .content-image {
   display: inline-block !important;
 }
@@ -995,6 +1318,17 @@ p code {
   margin-bottom: 0;
 }
 
+#content blockquote h2 {
+  font-size: 1em;
+  font-weight: 800;
+}
+
+#content blockquote h3 {
+  font-size: 1em;
+  font-weight: normal;
+  font-style: oblique;
+}
+
 main.long-content {
   --long-content-padding-ratio: 0.10;
 }
@@ -1011,6 +1345,9 @@ dl dt {
 }
 
 dl dt {
+  /* Heads up, this affects the measurement
+   * for dl dt which are .content-heading!
+   */
   margin-bottom: 2px;
 }
 
@@ -1346,7 +1683,6 @@ img.pixelate, .pixelate img {
 
   font-size: 1.6em;
   opacity: 0.8;
-  background-image: url("warning.svg");
 }
 
 .reveal-interaction {
@@ -1837,6 +2173,13 @@ html[data-url-key="localized.home"] .carousel-container {
   animation-delay: 125ms;
 }
 
+dl dt.content-heading {
+  /* Basic margin-bottom for dt is 2px,
+   * so just subtract 3px from that.
+   */
+  margin-bottom: -1px;
+}
+
 h3.content-heading {
   clear: both;
 }
@@ -2030,40 +2373,40 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
 /* 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 {
+.sidebar-column:not(.sticky-column) {
   align-self: stretch;
 }
 
-.sidebar-multiple.sticky-column {
+.sidebar-column.sticky-column {
+  position: sticky;
+  top: 10px;
   align-self: flex-start;
+  max-height: calc(100vh - 20px);
+  display: flex;
+  flex-direction: column;
 }
 
-.sidebar-column.sidebar.sticky-column {
-  max-height: calc(100vh - 20px);
-  align-self: start;
-  padding-bottom: 0;
-  box-sizing: border-box;
-  flex-basis: 275px;
-  padding-top: 0;
+.sidebar-multiple.sticky-column .sidebar:last-child {
+  flex-shrink: 1;
+  overflow-y: scroll;
+  scrollbar-width: thin;
+  scrollbar-color: var(--dark-color);
+}
+
+.wiki-search-sidebar-box .wiki-search-results-container {
   overflow-y: scroll;
   scrollbar-width: thin;
   scrollbar-color: var(--dark-color);
 }
 
-.sidebar-column.sidebar.sticky-column::-webkit-scrollbar {
+.sidebar-column.sticky-column .sidebar:last-child::-webkit-scrollbar,
+.wiki-search-sidebar-box .wiki-search-results-container::-webkit-scrollbar {
   background: var(--dark-color);
   width: 12px;
 }
 
-.sidebar-column.sidebar.sticky-column::-webkit-scrollbar-thumb {
+.sidebar-column.sticky-column .sidebar:last-child::-webkit-scrollbar-thumb,
+.wiki-search-sidebar-box .wiki-search-results-container::-webkit-scrollbar-thumb {
   transition: background 0.2s;
   background: rgba(255, 255, 255, 0.2);
   border: 3px solid transparent;
@@ -2099,6 +2442,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   left: 0;
   right: 0;
   bottom: 0;
+  z-index: 4000;
 
   background: rgba(0, 0, 0, 0.8);
   color: white;
@@ -2250,7 +2594,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
 /* Layout - Wide (most computers) */
 
 @media (min-width: 900px) {
-  #page-container:not(.has-zero-sidebars) #secondary-nav {
+  #page-container.showing-sidebar-left #secondary-nav,
+  #page-container.showing-sidebar-left #secondary-nav {
     display: none;
   }
 }
@@ -2267,7 +2612,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
    * don't apply the similar layout change of widening the long-content area
    * if this page doesn't have a sidebar to hide in the first place.
    */
-  #page-container:not(.has-zero-sidebars) main.long-content {
+  #page-container.showing-sidebar-left main.long-content,
+  #page-container.showing-sidebar-right main.long-content {
     --long-content-padding-ratio: 0.06;
   }
 }
@@ -2299,12 +2645,12 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     z-index: 2;
   }
 
-  html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+7)) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+7)) {
     flex-basis: 23%;
     margin: 15px;
   }
 
-  html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:nth-child(n+7) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+7) {
     flex-basis: 18%;
     margin: 10px;
   }
@@ -2313,7 +2659,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
 /* Layout - Medium or Thin */
 
 @media (max-width: 899.98px) {
-  .sidebar-column:not(.no-hide) {
+  .sidebar.collapsible,
+  .sidebar-column.all-boxes-collapsible {
     display: none;
   }
 
@@ -2321,15 +2668,16 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     display: block;
   }
 
-  .layout-columns.vertical-when-thin {
+  .layout-columns {
     flex-direction: column;
   }
 
-  .layout-columns.vertical-when-thin > *:not(:last-child) {
+  .layout-columns > *:not(:last-child) {
     margin-bottom: 10px;
   }
 
-  .sidebar-column.no-hide {
+  .sidebar-column {
+    position: static !important;
     max-width: unset !important;
     flex-basis: unset !important;
     margin-right: 0 !important;
diff --git a/src/static/client3.js b/src/static/js/client.js
index 7d6544a0..68b1a013 100644
--- a/src/static/client3.js
+++ b/src/static/js/client.js
@@ -5,8 +5,19 @@
 // that cannot 8e done at static-site compile time, 8y its fundamentally
 // ephemeral nature.
 
-import {accumulateSum, empty, filterMultipleArrays, stitchArrays}
-  from '../util/sugar.js';
+import {getColors} from '../shared-util/colors.js';
+
+import {
+  accumulateSum,
+  atOffset,
+  empty,
+  filterMultipleArrays,
+  promiseWithResolvers,
+  stitchArrays,
+  withEntries,
+} from '../shared-util/sugar.js';
+
+import {fetchWithProgress} from './xhr-util.js';
 
 const clientInfo = window.hsmusicClientInfo = Object.create(null);
 
@@ -18,20 +29,142 @@ const clientSteps = {
   addPageListeners: [],
 };
 
-function initInfo(key, description) {
+function initInfo(infoKey, description) {
   const object = {...description};
 
   for (const obj of [
     object,
     object.state,
-    object.setting,
+    object.settings,
     object.event,
   ]) {
     if (!obj) continue;
     Object.preventExtensions(obj);
   }
 
-  clientInfo[key] = object;
+  if (object.session) {
+    const sessionSpecs = object.session;
+
+    object.session = {};
+
+    for (const [key, spec] of Object.entries(sessionSpecs)) {
+      const hasSpec =
+        typeof spec === 'object' && spec !== null;
+
+      const defaultValue =
+        (hasSpec
+          ? spec.default ?? null
+          : spec);
+
+      let formatRead = value => value;
+      let formatWrite = value => value;
+      if (hasSpec && spec.type) {
+        switch (spec.type) {
+          case 'number':
+            formatRead = parseFloat;
+            formatWrite = String;
+            break;
+
+          case 'boolean':
+            formatRead = Boolean;
+            formatWrite = String;
+            break;
+
+          case 'string':
+            formatRead = String;
+            formatWrite = String;
+            break;
+
+          case 'json':
+            formatRead = JSON.parse;
+            formatWrite = JSON.stringify;
+            break;
+
+          default:
+            throw new Error(`Unknown type for session storage spec "${spec.type}"`);
+        }
+      }
+
+      let getMaxLength =
+        (!hasSpec
+          ? () => Infinity
+       : typeof spec.maxLength === 'function'
+          ? (object.settings
+              ? () => spec.maxLength(object.settings)
+              : () => spec.maxLength())
+          : () => spec.maxLength);
+
+      const storageKey = `hsmusic.${infoKey}.${key}`;
+
+      let fallbackValue = defaultValue;
+
+      Object.defineProperty(object.session, key, {
+        get: () => {
+          let value;
+          try {
+            value = sessionStorage.getItem(storageKey) ?? defaultValue;
+          } catch (error) {
+            if (error instanceof DOMException) {
+              value = fallbackValue;
+            } else {
+              throw error;
+            }
+          }
+
+          if (value === null) {
+            return null;
+          }
+
+          return formatRead(value);
+        },
+
+        set: (value) => {
+          if (value !== null && value !== '') {
+            value = formatWrite(value);
+          }
+
+          if (value === null) {
+            value = '';
+          }
+
+          const maxLength = getMaxLength();
+          if (value.length > maxLength) {
+            console.warn(
+              `Requested to set session storage ${storageKey} ` +
+              `beyond maximum length ${maxLength}, ` +
+              `ignoring this value.`);
+            console.trace();
+            return;
+          }
+
+          let operation;
+          if (value === '') {
+            fallbackValue = null;
+            operation = () => {
+              sessionStorage.removeItem(storageKey);
+            };
+          } else {
+            fallbackValue = value;
+            operation = () => {
+              sessionStorage.setItem(storageKey, value);
+            };
+          }
+
+          try {
+            operation();
+          } catch (error) {
+            if (!(error instanceof DOMException)) {
+              throw error;
+            }
+          }
+        },
+      });
+    }
+
+    Object.preventExtensions(object.session);
+  }
+
+  clientInfo[infoKey] = object;
 
   return object;
 }
@@ -103,6 +236,18 @@ function cssProp(el, ...args) {
   }
 }
 
+function templateContent(el) {
+  if (el === null) {
+    return null;
+  }
+
+  if (el?.nodeName !== 'TEMPLATE') {
+    throw new Error(`Expected a <template> element`);
+  }
+
+  return el.content.cloneNode(true);
+}
+
 // Curry-style, so multiple points can more conveniently be tested at once.
 function pointIsOverAnyOf(elements) {
   return (clientX, clientY) => {
@@ -135,9 +280,12 @@ function getVisuallyContainingElement(child) {
 const getLinkHref = (type, directory) => rebase(`${type}/${directory}`);
 */
 
-const openAlbum = (d) => rebase(`album/${d}`);
-const openTrack = (d) => rebase(`track/${d}`);
-const openArtist = (d) => rebase(`artist/${d}`);
+const openAlbum = d => rebase(`album/${d}`);
+const openArtTag = d => rebase(`tag/${d}`);
+const openArtist = d => rebase(`artist/${d}`);
+const openFlash = d => rebase(`flash/${d}`);
+const openGroup = d => rebase(`group/${d}`);
+const openTrack = d => rebase(`track/${d}`);
 
 // TODO: This should also use urlSpec.
 
@@ -169,8 +317,8 @@ function dispatchInternalEvent(event, eventName, ...args) {
     try {
       results.push(listener(...args));
     } catch (error) {
-      console.warn(`Uncaught error in listener for ${infoName}.${eventName}`);
-      console.debug(error);
+      console.error(`Uncaught error in listener for ${infoName}.${eventName}`);
+      console.error(error);
       results.push(undefined);
     }
   }
@@ -1004,6 +1152,54 @@ if (
     });
 }
 
+// Links nested in summaries ------------------------------
+
+const summaryNestedLinksInfo = initInfo('summaryNestedLinksInfo', {
+  summaries: null,
+  links: null,
+});
+
+function getSummaryNestedLinksReferences() {
+  const info = summaryNestedLinksInfo;
+
+  info.summaries =
+    Array.from(document.getElementsByTagName('summary'));
+
+  info.links =
+    info.summaries
+      .map(summary =>
+        Array.from(summary.getElementsByTagName('a')));
+
+  filterMultipleArrays(
+    info.summaries,
+    info.links,
+    (_summary, links) => !empty(links));
+}
+
+function addSummaryNestedLinksPageListeners() {
+  const info = summaryNestedLinksInfo;
+
+  for (const {summary, links} of stitchArrays({
+    summary: info.summaries,
+    links: info.links,
+  })) {
+    for (const link of links) {
+      link.addEventListener('mouseover', () => {
+        link.classList.add('nested-hover');
+        summary.classList.add('has-nested-hover');
+      });
+
+      link.addEventListener('mouseout', () => {
+        link.classList.remove('nested-hover');
+        summary.classList.remove('has-nested-hover');
+      });
+    }
+  }
+}
+
+clientSteps.getPageReferences.push(getSummaryNestedLinksReferences);
+clientSteps.getPageReferences.push(addSummaryNestedLinksPageListeners);
+
 // Tooltip-style hover (infrastructure) -------------------
 
 const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', {
@@ -2510,9 +2706,10 @@ function updateStickySubheadingContent(index) {
     }
 
     const textContainer =
-      closestHeading.querySelector('.content-heading-main-title')
-        // Just for compatibility with older builds of the site.
-        ?? closestHeading;
+      templateContent(
+        closestHeading.querySelector('.content-heading-sticky-title')) ??
+      closestHeading.querySelector('.content-heading-main-title') ??
+      closestHeading;
 
     for (const child of textContainer.childNodes) {
       if (child.tagName === 'A') {
@@ -2613,7 +2810,7 @@ function addImageOverlayClickHandlers() {
   }
 }
 
-function handleImageLinkClicked(evt) {
+async function handleImageLinkClicked(evt) {
   if (evt.metaKey || evt.shiftKey || evt.altKey) {
     return;
   }
@@ -2680,26 +2877,46 @@ function handleImageLinkClicked(evt) {
   mainImage.addEventListener('load', handleMainImageLoaded);
   mainImage.addEventListener('error', handleMainImageErrored);
 
-  container.style.setProperty('--download-progress', '0%');
-  loadImage(mainSrc, progress => {
-    container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%');
-  }).then(
-    blobUrl => {
-      mainImage.src = blobUrl;
-      container.style.setProperty('--download-progress', '100%');
-    },
-    handleMainImageErrored);
+  const showProgress = amount => {
+    cssProp(container, '--download-progress', `${amount * 100}%`);
+  };
+
+  showProgress(0.00);
+
+  const response =
+    await fetchWithProgress(mainSrc, progress => {
+      if (progress === -1) {
+        // TODO: Indeterminate response progress cue
+        showProgress(0.00);
+      } else {
+        showProgress(0.20 + 0.80 * progress);
+      }
+    });
+
+  if (!response.status.toString().startsWith('2')) {
+    handleMainImageErrored();
+    return;
+  }
+
+  const blob = await response.blob();
+  const blobSrc = URL.createObjectURL(blob);
+
+  mainImage.src = blobSrc;
+  showProgress(1.00);
 
   function handleMainImageLoaded() {
-    mainImage.removeEventListener('load', handleMainImageLoaded);
-    mainImage.removeEventListener('error', handleMainImageErrored);
     container.classList.add('loaded');
+    removeEventListeners();
   }
 
   function handleMainImageErrored() {
+    container.classList.add('errored');
+    removeEventListeners();
+  }
+
+  function removeEventListeners() {
     mainImage.removeEventListener('load', handleMainImageLoaded);
     mainImage.removeEventListener('error', handleMainImageErrored);
-    container.classList.add('errored');
   }
 }
 
@@ -2799,67 +3016,6 @@ function updateFileSizeInformation(fileSize) {
 
 addImageOverlayClickHandlers();
 
-/**
- * Credits: Parziphal, Feb 13, 2017
- * https://stackoverflow.com/a/42196770
- *
- * Loads an image with progress callback.
- *
- * The `onprogress` callback will be called by XMLHttpRequest's onprogress
- * event, and will receive the loading progress ratio as an whole number.
- * However, if it's not possible to compute the progress ratio, `onprogress`
- * will be called only once passing -1 as progress value. This is useful to,
- * for example, change the progress animation to an undefined animation.
- *
- * @param  {string}   imageUrl   The image to load
- * @param  {Function} onprogress
- * @return {Promise}
- */
-function loadImage(imageUrl, onprogress) {
-  return new Promise((resolve, reject) => {
-    var xhr = new XMLHttpRequest();
-    var notifiedNotComputable = false;
-
-    xhr.open('GET', imageUrl, true);
-    xhr.responseType = 'arraybuffer';
-
-    xhr.onprogress = function(ev) {
-      if (ev.lengthComputable) {
-        onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10);
-      } else {
-        if (!notifiedNotComputable) {
-          notifiedNotComputable = true;
-          onprogress(-1);
-        }
-      }
-    }
-
-    xhr.onloadend = function() {
-      if (!xhr.status.toString().match(/^2/)) {
-        reject(xhr);
-      } else {
-        if (!notifiedNotComputable) {
-          onprogress(100);
-        }
-
-        var options = {}
-        var headers = xhr.getAllResponseHeaders();
-        var m = headers.match(/^Content-Type:\s*(.*?)$/mi);
-
-        if (m && m[1]) {
-          options.type = m[1];
-        }
-
-        var blob = new Blob([this.response], options);
-
-        resolve(window.URL.createObjectURL(blob));
-      }
-    }
-
-    xhr.send();
-  });
-}
-
 // "Additional names" box ---------------------------------
 
 const additionalNamesBoxInfo = initInfo('additionalNamesBox', {
@@ -2950,6 +3106,114 @@ clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences);
 clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners);
 clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners);
 
+// Scoped chronology links --------------------------------
+
+const scopedChronologyLinksInfo = initInfo('scopedChronologyLinksInfo', {
+  switcher: null,
+  containers: null,
+  switcherLinks: null,
+  modes: null,
+
+  session: {
+    selectedMode: 'wiki',
+  },
+});
+
+function getScopedChronologyLinksReferences() {
+  const info = scopedChronologyLinksInfo;
+
+  info.switcher =
+    document.querySelector('.scoped-chronology-switcher');
+
+  if (!info.switcher) {
+    return;
+  }
+
+  info.containers =
+    Array.from(info.switcher.querySelectorAll(':scope > div'));
+
+  info.switcherLinks =
+    Array.from(info.switcher.querySelectorAll('.switcher-link'));
+
+  info.modes =
+    info.containers
+      .map(container =>
+        Array.from(container.classList)
+          .find(className => className.startsWith('scope-'))
+          .slice('scope-'.length));
+}
+
+function addScopedChronologyLinksPageHandlers() {
+  const info = scopedChronologyLinksInfo;
+  const {session} = scopedChronologyLinksInfo;
+
+  if (!info.switcher) {
+    return;
+  }
+
+  for (const [index, {
+    container: currentContainer,
+    switcherLink: currentSwitcherLink,
+  }] of stitchArrays({
+    container: info.containers,
+    switcherLink: info.switcherLinks,
+  }).entries()) {
+    const nextContainer =
+      atOffset(info.containers, index, +1, {wrap: true});
+
+    const nextSwitcherLink =
+      atOffset(info.switcherLinks, index, +1, {wrap: true});
+
+    const nextMode =
+      atOffset(info.modes, index, +1, {wrap: true});
+
+    currentSwitcherLink.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+
+      cssProp(currentContainer, 'display', 'none');
+      cssProp(currentSwitcherLink, 'display', 'none');
+      cssProp(nextContainer, 'display', 'block');
+      cssProp(nextSwitcherLink, 'display', 'inline');
+
+      session.selectedMode = nextMode;
+    });
+  }
+}
+
+function mutateScopedChronologyLinksContent() {
+  const info = scopedChronologyLinksInfo;
+
+  if (!info.switcher) {
+    return;
+  }
+
+  const {selectedMode} = info.session;
+
+  if (info.modes.includes(selectedMode)) {
+    const selectedIndex = info.modes.indexOf(selectedMode);
+
+    for (const [index, {
+      container,
+      switcherLink,
+    }] of stitchArrays({
+      container: info.containers,
+      switcherLink: info.switcherLinks,
+    }).entries()) {
+      if (index === selectedIndex) {
+        cssProp(container, 'display', 'block');
+        cssProp(switcherLink, 'display', 'inline');
+      } else {
+        cssProp(container, 'display', 'none');
+        cssProp(switcherLink, 'display', 'none');
+      }
+    }
+  }
+}
+
+clientSteps.getPageReferences.push(getScopedChronologyLinksReferences);
+clientSteps.mutatePageContent.push(mutateScopedChronologyLinksContent);
+clientSteps.addPageListeners.push(addScopedChronologyLinksPageHandlers);
+
 // Group contributions table ------------------------------
 
 // TODO: Update to clientSteps style.
@@ -3260,6 +3524,1148 @@ clientSteps.getPageReferences.push(getArtistExternalLinkTooltipPageReferences);
 clientSteps.addInternalListeners.push(addArtistExternalLinkTooltipInternalListeners);
 clientSteps.addPageListeners.push(addArtistExternalLinkTooltipPageListeners);
 
+// Internal search functionality --------------------------
+
+const wikiSearchInfo = initInfo('wikiSearchInfo', {
+  state: {
+    worker: null,
+
+    workerReadyPromise: null,
+    workerReadyPromiseResolvers: null,
+
+    workerActionCounter: 0,
+    workerActionPromiseResolverMap: new Map(),
+
+    downloads: Object.create(null),
+  },
+
+  event: {
+    whenWorkerAlive: [],
+    whenWorkerReady: [],
+    whenWorkerFailsToInitialize: [],
+    whenWorkerHasRuntimeError: [],
+
+    whenDownloadBegins: [],
+    whenDownloadsBegin: [],
+    whenDownloadProgresses: [],
+    whenDownloadEnds: [],
+  },
+});
+
+async function initializeSearchWorker() {
+  const {state} = wikiSearchInfo;
+
+  if (state.worker) {
+    return await state.workerReadyPromise;
+  }
+
+  state.worker =
+    new Worker(
+      import.meta.resolve('./search-worker.js'),
+      {type: 'module'});
+
+  state.worker.onmessage = handleSearchWorkerMessage;
+
+  const {promise, resolve, reject} = promiseWithResolvers();
+
+  state.workerReadyPromiseResolvers = {resolve, reject};
+
+  return await (state.workerReadyPromise = promise);
+}
+
+function handleSearchWorkerMessage(message) {
+  switch (message.data.kind) {
+    case 'status':
+      handleSearchWorkerStatusMessage(message);
+      break;
+
+    case 'result':
+      handleSearchWorkerResultMessage(message);
+      break;
+
+    case 'download-begun':
+      handleSearchWorkerDownloadBegunMessage(message);
+      break;
+
+    case 'download-progress':
+      handleSearchWorkerDownloadProgressMessage(message);
+      break;
+
+    case 'download-complete':
+      handleSearchWorkerDownloadCompleteMessage(message);
+      break;
+
+    default:
+      console.warn(`Unknown message kind "${message.data.kind}" <- from search worker`);
+      break;
+  }
+}
+
+function handleSearchWorkerStatusMessage(message) {
+  const {state, event} = wikiSearchInfo;
+
+  switch (message.data.status) {
+    case 'alive':
+      console.debug(`Search worker is alive, but not yet ready.`);
+      dispatchInternalEvent(event, 'whenWorkerAlive');
+      break;
+
+    case 'ready':
+      console.debug(`Search worker has loaded corpuses and is ready.`);
+      state.workerReadyPromiseResolvers.resolve(state.worker);
+      dispatchInternalEvent(event, 'whenWorkerReady');
+      break;
+
+    case 'setup-error':
+      console.debug(`Search worker failed to initialize.`);
+      state.workerReadyPromiseResolvers.reject(new Error('Received "setup-error" status from worker'));
+      dispatchInternalEvent(event, 'whenWorkerFailsToInitialize');
+      break;
+
+    case 'runtime-error':
+      console.debug(`Search worker had an uncaught runtime error.`);
+      dispatchInternalEvent(event, 'whenWorkerHasRuntimeError');
+      break;
+
+    default:
+      console.warn(`Unknown status "${message.data.status}" <- from search worker`);
+      break;
+  }
+}
+
+function handleSearchWorkerResultMessage(message) {
+  const {state} = wikiSearchInfo;
+  const {id} = message.data;
+
+  if (!id) {
+    console.warn(`Result without id <- from search worker:`, message.data);
+    return;
+  }
+
+  if (!state.workerActionPromiseResolverMap.has(id)) {
+    console.warn(`Runaway result id <- from search worker:`, message.data);
+    return;
+  }
+
+  const {resolve, reject} =
+    state.workerActionPromiseResolverMap.get(id);
+
+  switch (message.data.status) {
+    case 'resolve':
+      resolve(message.data.value);
+      break;
+
+    case 'reject':
+      reject(message.data.value);
+      break;
+
+    default:
+      console.warn(`Unknown result status "${message.data.status}" <- from search worker`);
+      return;
+  }
+
+  state.workerActionPromiseResolverMap.delete(id);
+}
+
+function handleSearchWorkerDownloadBegunMessage(message) {
+  const {event} = wikiSearchInfo;
+  const {context: contextKey, keys} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey, true);
+
+  for (const key of keys) {
+    context[key] = 0.00;
+
+    dispatchInternalEvent(event, 'whenDownloadBegins', {
+      context: contextKey,
+      key,
+    });
+  }
+
+  dispatchInternalEvent(event, 'whenDownloadsBegin', {
+    context: contextKey,
+    keys,
+  });
+}
+
+function handleSearchWorkerDownloadProgressMessage(message) {
+  const {event} = wikiSearchInfo;
+  const {context: contextKey, key, progress} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey);
+
+  context[key] = progress;
+
+  dispatchInternalEvent(event, 'whenDownloadProgresses', {
+    context: contextKey,
+    key,
+    progress,
+  });
+}
+
+function handleSearchWorkerDownloadCompleteMessage(message) {
+  const {event} = wikiSearchInfo;
+  const {context: contextKey, key} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey);
+
+  context[key] = 1.00;
+
+  dispatchInternalEvent(event, 'whenDownloadEnds', {
+    context: contextKey,
+    key,
+  });
+}
+
+function getSearchWorkerDownloadContext(context, initialize = false) {
+  const {state} = wikiSearchInfo;
+
+  if (context in state.downloads) {
+    return state.downloads[context];
+  }
+
+  if (!initialize) {
+    return null;
+  }
+
+  return state.downloads[context] = Object.create(null);
+}
+
+async function postSearchWorkerAction(action, options) {
+  const {state} = wikiSearchInfo;
+
+  const worker = await initializeSearchWorker();
+  const id = ++state.workerActionCounter;
+
+  const {promise, resolve, reject} = promiseWithResolvers();
+
+  state.workerActionPromiseResolverMap.set(id, {resolve, reject});
+
+  worker.postMessage({
+    kind: 'action',
+    action: action,
+    id,
+    options,
+  });
+
+  return await promise;
+}
+
+async function searchAll(query, options = {}) {
+  return await postSearchWorkerAction('search', {
+    query,
+    options,
+  });
+}
+
+// Sidebar search box -------------------------------------
+
+const sidebarSearchInfo = initInfo('sidebarSearchInfo', {
+  pageContainer: null,
+
+  searchSidebarColumn: null,
+  searchBox: null,
+  searchInput: null,
+
+  progressRule: null,
+  progressContainer: null,
+  progressLabel: null,
+  progressBar: null,
+
+  failedRule: null,
+  failedContainer: null,
+
+  resultsRule: null,
+  resultsContainer: null,
+  results: null,
+
+  endSearchRule: null,
+  endSearchLine: null,
+  endSearchLink: null,
+
+  preparingString: null,
+  loadingDataString: null,
+  searchingString: null,
+  failedString: null,
+
+  noResultsString: null,
+  currentResultString: null,
+  endSearchString: null,
+
+  albumResultKindString: null,
+  artistResultKindString: null,
+  groupResultKindString: null,
+  tagResultKindString: null,
+
+  state: {
+    sidebarColumnShownForSearch: null,
+
+    tidiedSidebar: null,
+    collapsedDetailsForTidiness: null,
+
+    workerStatus: null,
+    searchStage: null,
+
+    stoppedTypingTimeout: null,
+    stoppedScrollingTimeout: null,
+
+    indexDownloadStatuses: Object.create(null),
+  },
+
+  session: {
+    activeQuery: {
+      type: 'string',
+    },
+
+    activeQueryResults: {
+      type: 'json',
+      maxLength: settings => settings.maxActiveResultsStorage,
+    },
+
+    repeatQueryOnReload: {
+      type: 'boolean',
+      default: false,
+    },
+
+    resultsScrollOffset: {
+      type: 'number',
+    },
+  },
+
+  settings: {
+    stoppedTypingDelay: 800,
+    stoppedScrollingDelay: 200,
+
+    maxActiveResultsStorage: 100000,
+  },
+});
+
+function getSidebarSearchReferences() {
+  const info = sidebarSearchInfo;
+
+  info.pageContainer =
+    document.getElementById('page-container');
+
+  info.searchBox =
+    document.querySelector('.wiki-search-sidebar-box');
+
+  if (!info.searchBox) {
+    return;
+  }
+
+  info.searchInput =
+    info.searchBox.querySelector('.wiki-search-input');
+
+  info.searchSidebarColumn =
+    info.searchBox.closest('.sidebar-column');
+
+  const findString = classPart =>
+    info.searchBox.querySelector(`.wiki-search-${classPart}-string`);
+
+  info.preparingString =
+    findString('preparing');
+
+  info.loadingDataString =
+    findString('loading-data');
+
+  info.searchingString =
+    findString('searching');
+
+  info.failedString =
+    findString('failed');
+
+  info.noResultsString =
+    findString('no-results');
+
+  info.currentResultString =
+    findString('current-result');
+
+  info.endSearchString =
+    findString('end-search');
+
+  info.albumResultKindString =
+    findString('album-result-kind');
+
+  info.artistResultKindString =
+    findString('artist-result-kind');
+
+  info.groupResultKindString =
+    findString('group-result-kind');
+
+  info.tagResultKindString =
+    findString('tag-result-kind');
+}
+
+function addSidebarSearchInternalListeners() {
+  const info = sidebarSearchInfo;
+
+  if (!info.searchBox) return;
+
+  wikiSearchInfo.event.whenWorkerAlive.push(
+    trackSidebarSearchWorkerAlive,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerReady.push(
+    trackSidebarSearchWorkerReady,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerFailsToInitialize.push(
+    trackSidebarSearchWorkerFailsToInitialize,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerHasRuntimeError.push(
+    trackSidebarSearchWorkerHasRuntimeError,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadsBegin.push(
+    trackSidebarSearchDownloadsBegin,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadProgresses.push(
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadEnds.push(
+    trackSidebarSearchDownloadEnds,
+    updateSidebarSearchStatus);
+}
+
+function mutateSidebarSearchContent() {
+  const info = sidebarSearchInfo;
+
+  if (!info.searchBox) return;
+
+  // Progress section
+
+  info.progressRule =
+    document.createElement('hr');
+
+  info.progressContainer =
+    document.createElement('div');
+
+  info.progressContainer.classList.add('wiki-search-progress-container');
+
+  cssProp(info.progressRule, 'display', 'none');
+  cssProp(info.progressContainer, 'display', 'none');
+
+  info.progressLabel =
+    document.createElement('label');
+
+  info.progressLabel.classList.add('wiki-search-progress-label');
+  info.progressLabel.htmlFor = 'wiki-search-progress-bar';
+
+  info.progressBar =
+    document.createElement('progress');
+
+  info.progressBar.classList.add('wiki-search-progress-bar');
+  info.progressBar.id = 'wiki-search-progress-bar';
+
+  info.progressContainer.appendChild(info.progressLabel);
+  info.progressContainer.appendChild(info.progressBar);
+
+  info.searchBox.appendChild(info.progressRule);
+  info.searchBox.appendChild(info.progressContainer);
+
+  // Search failed section
+
+  info.failedRule =
+    document.createElement('hr');
+
+  info.failedContainer =
+    document.createElement('div');
+
+  info.failedContainer.classList.add('wiki-search-failed-container');
+
+  {
+    const p = document.createElement('p');
+    p.appendChild(templateContent(info.failedString));
+    info.failedContainer.appendChild(p);
+  }
+
+  cssProp(info.failedRule, 'display', 'none');
+  cssProp(info.failedContainer, 'display', 'none');
+
+  info.searchBox.appendChild(info.failedRule);
+  info.searchBox.appendChild(info.failedContainer);
+
+  // Results section
+
+  info.resultsRule =
+    document.createElement('hr');
+
+  info.resultsContainer =
+    document.createElement('div');
+
+  info.resultsContainer.classList.add('wiki-search-results-container');
+
+  cssProp(info.resultsRule, 'display', 'none');
+  cssProp(info.resultsContainer, 'display', 'none');
+
+  info.results =
+    document.createElement('div');
+
+  info.results.classList.add('wiki-search-results');
+
+  info.resultsContainer.appendChild(info.results);
+
+  info.searchBox.appendChild(info.resultsRule);
+  info.searchBox.appendChild(info.resultsContainer);
+
+  // End search section
+
+  info.endSearchRule =
+    document.createElement('hr');
+
+  info.endSearchLine =
+    document.createElement('p');
+
+  info.endSearchLink =
+    document.createElement('a');
+
+  {
+    const p = info.endSearchLine;
+    const a = info.endSearchLink;
+    p.classList.add('wiki-search-end-search-line');
+    a.setAttribute('href', '#');
+    a.appendChild(templateContent(info.endSearchString));
+    p.appendChild(a);
+  }
+
+  cssProp(info.endSearchRule, 'display', 'none');
+  cssProp(info.endSearchLine, 'display', 'none');
+
+  info.searchBox.appendChild(info.endSearchRule);
+  info.searchBox.appendChild(info.endSearchLine);
+}
+
+function addSidebarSearchListeners() {
+  const info = sidebarSearchInfo;
+
+  if (!info.searchInput) return;
+
+  info.searchInput.addEventListener('change', domEvent => {
+    if (info.searchInput.value) {
+      activateSidebarSearch(info.searchInput.value);
+    }
+  });
+
+  info.searchInput.addEventListener('input', domEvent => {
+    const {settings, state} = info;
+
+    if (!info.searchInput.value) {
+      clearSidebarSearch();
+      return;
+    }
+
+    if (state.stoppedTypingTimeout) {
+      clearTimeout(state.stoppedTypingTimeout);
+    }
+
+    state.stoppedTypingTimeout =
+      setTimeout(() => {
+        activateSidebarSearch(info.searchInput.value);
+      }, settings.stoppedTypingDelay);
+  });
+
+  info.endSearchLink.addEventListener('click', domEvent => {
+    domEvent.preventDefault();
+    clearSidebarSearch();
+    possiblyHideSearchSidebarColumn();
+    restoreSidebarSearchColumn();
+  });
+
+  info.resultsContainer.addEventListener('scroll', () => {
+    const {settings, state} = info;
+
+    if (state.stoppedScrollingTimeout) {
+      clearTimeout(state.stoppedScrollingTimeout);
+    }
+
+    state.stoppedScrollingTimeout =
+      setTimeout(() => {
+        saveSidebarSearchResultsScrollOffset();
+      }, settings.stoppedScrollingDelay);
+  });
+}
+
+function initializeSidebarSearchState() {
+  const info = sidebarSearchInfo;
+  const {session} = info;
+
+  if (!info.searchInput) return;
+
+  if (session.activeQuery) {
+    info.searchInput.value = session.activeQuery;
+    if (session.repeatQueryOnReload) {
+      activateSidebarSearch(session.activeQuery);
+    } else if (session.activeQueryResults) {
+      showSidebarSearchResults(session.activeQueryResults);
+    }
+  }
+}
+
+function trackSidebarSearchWorkerAlive() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'alive';
+}
+
+function trackSidebarSearchWorkerReady() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'ready';
+  state.searchStage = 'searching';
+}
+
+function trackSidebarSearchWorkerFailsToInitialize() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'failed';
+  state.searchStage = 'failed';
+}
+
+function trackSidebarSearchWorkerHasRuntimeError() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'failed';
+  state.searchStage = 'failed';
+}
+
+function trackSidebarSearchDownloadsBegin(event) {
+  const {state} = sidebarSearchInfo;
+
+  if (event.context === 'search-indexes') {
+    for (const key of event.keys) {
+      state.indexDownloadStatuses[key] = 'active';
+    }
+  }
+}
+
+function trackSidebarSearchDownloadEnds(event) {
+  const {state} = sidebarSearchInfo;
+
+  if (event.context === 'search-indexes') {
+    state.indexDownloadStatuses[event.key] = 'complete';
+
+    const statuses = Object.values(state.indexDownloadStatuses);
+    if (statuses.every(status => status === 'complete')) {
+      for (const key of Object.keys(state.indexDownloadStatuses)) {
+        delete state.indexDownloadStatuses[key];
+      }
+    }
+  }
+}
+
+async function activateSidebarSearch(query) {
+  const {session, settings, state} = sidebarSearchInfo;
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+
+  state.searchStage =
+    (state.workerStatus === 'ready'
+      ? 'searching'
+      : 'preparing');
+  updateSidebarSearchStatus();
+
+  let results;
+  try {
+    results = await searchAll(query, {enrich: true});
+  } catch (error) {
+    console.error(`There was an error performing a sidebar search:`);
+    console.error(error);
+    showSidebarSearchFailed();
+    return;
+  }
+
+  state.searchStage = 'complete';
+  updateSidebarSearchStatus();
+
+  session.activeQuery = query;
+  session.activeQueryResults = results;
+  session.resultsScrollOffset = 0;
+
+  showSidebarSearchResults(results);
+}
+
+function clearSidebarSearch() {
+  const info = sidebarSearchInfo;
+  const {session, state} = info;
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+
+  info.searchBox.classList.remove('showing-results');
+  info.searchSidebarColumn.classList.remove('search-showing-results');
+
+  info.searchInput.value = '';
+
+  state.searchStage = null;
+
+  session.activeQuery = null;
+  session.activeQueryResults = null;
+  session.resultsScrollOffset = null;
+
+  hideSidebarSearchResults();
+}
+
+function updateSidebarSearchStatus() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  if (state.searchStage === 'failed') {
+    hideSidebarSearchResults();
+    showSidebarSearchFailed();
+
+    return;
+  }
+
+  const searchIndexDownloads =
+    getSearchWorkerDownloadContext('search-indexes');
+
+  const downloadProgressValues =
+    Object.values(searchIndexDownloads ?? {});
+
+  if (downloadProgressValues.some(v => v < 1.00)) {
+    const total = Object.keys(state.indexDownloadStatuses).length;
+    const sum = accumulateSum(downloadProgressValues);
+    showSidebarSearchProgress(
+      sum / total,
+      templateContent(info.loadingDataString));
+
+    return;
+  }
+
+  if (state.searchStage === 'preparing') {
+    showSidebarSearchProgress(
+      null,
+      templateContent(info.preparingString));
+
+    return;
+  }
+
+  if (state.searchStage === 'searching') {
+    showSidebarSearchProgress(
+      null,
+      templateContent(info.searchingString));
+
+    return;
+  }
+
+  hideSidebarSearchProgress();
+}
+
+function showSidebarSearchProgress(progress, label) {
+  const info = sidebarSearchInfo;
+
+  cssProp(info.progressRule, 'display', null);
+  cssProp(info.progressContainer, 'display', null);
+
+  if (progress === null) {
+    info.progressBar.removeAttribute('value');
+  } else {
+    info.progressBar.value = progress;
+  }
+
+  while (info.progressLabel.firstChild) {
+    info.progressLabel.firstChild.remove();
+  }
+
+  info.progressLabel.appendChild(label);
+}
+
+function hideSidebarSearchProgress() {
+  const info = sidebarSearchInfo;
+
+  cssProp(info.progressRule, 'display', 'none');
+  cssProp(info.progressContainer, 'display', 'none');
+}
+
+function showSidebarSearchFailed() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  hideSidebarSearchProgress();
+  hideSidebarSearchResults();
+
+  cssProp(info.failedRule, 'display', null);
+  cssProp(info.failedContainer, 'display', null);
+
+  info.searchInput.disabled = true;
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+}
+
+function showSidebarSearchResults(results) {
+  const info = sidebarSearchInfo;
+
+  console.debug(`Showing search results:`, results);
+
+  showSearchSidebarColumn();
+
+  const flatResults =
+    Object.entries(results)
+      .filter(([index]) => index === 'generic')
+      .flatMap(([index, results]) => results
+        .flatMap(({doc, id}) => ({
+          index,
+          reference: id ?? null,
+          referenceType: (id ? id.split(':')[0] : null),
+          directory: (id ? id.split(':')[1] : null),
+          data: doc,
+        })));
+
+  info.searchBox.classList.add('showing-results');
+  info.searchSidebarColumn.classList.add('search-showing-results');
+
+  while (info.results.firstChild) {
+    info.results.firstChild.remove();
+  }
+
+  cssProp(info.resultsRule, 'display', 'block');
+  cssProp(info.resultsContainer, 'display', 'block');
+
+  if (empty(flatResults)) {
+    const p = document.createElement('p');
+    p.classList.add('wiki-search-no-results');
+    p.appendChild(templateContent(info.noResultsString));
+    info.results.appendChild(p);
+  }
+
+  for (const result of flatResults) {
+    const el = generateSidebarSearchResult(result);
+    if (!el) continue;
+
+    info.results.appendChild(el);
+  }
+
+  if (!empty(flatResults)) {
+    cssProp(info.endSearchRule, 'display', 'block');
+    cssProp(info.endSearchLine, 'display', 'block');
+
+    tidySidebarSearchColumn();
+  }
+
+  restoreSidebarSearchResultsScrollOffset();
+}
+
+function generateSidebarSearchResult(result) {
+  const info = sidebarSearchInfo;
+
+  const preparedSlots = {
+    color:
+      result.data.color ?? null,
+
+    name:
+      result.data.name ?? result.data.primaryName ?? null,
+
+    imageSource:
+      getSearchResultImageSource(result),
+  };
+
+  switch (result.referenceType) {
+    case 'album': {
+      preparedSlots.href =
+        openAlbum(result.directory);
+
+      preparedSlots.kindString =
+        info.albumResultKindString;
+
+      break;
+    }
+
+    case 'artist': {
+      preparedSlots.href =
+        openArtist(result.directory);
+
+      preparedSlots.kindString =
+        info.artistResultKindString;
+
+      break;
+    }
+
+    case 'group': {
+      preparedSlots.href =
+        openGroup(result.directory);
+
+      preparedSlots.kindString =
+        info.groupResultKindString;
+
+      break;
+    }
+
+    case 'flash': {
+      preparedSlots.href =
+        openFlash(result.directory);
+
+      break;
+    }
+
+    case 'tag': {
+      preparedSlots.href =
+        openArtTag(result.directory);
+
+      preparedSlots.kindString =
+        info.tagResultKindString;
+
+      break;
+    }
+
+    case 'track': {
+      preparedSlots.href =
+        openTrack(result.directory);
+
+      break;
+    }
+
+    default:
+      return null;
+  }
+
+  return generateSidebarSearchResultTemplate(preparedSlots);
+}
+
+function getSearchResultImageSource(result) {
+  const {artwork} = result.data;
+  if (!artwork) return null;
+
+  return (
+    rebase(
+      artwork.replace('<>', result.directory),
+      'rebaseThumb'));
+}
+
+function generateSidebarSearchResultTemplate(slots) {
+  const info = sidebarSearchInfo;
+
+  const link = document.createElement('a');
+  link.classList.add('wiki-search-result');
+
+  if (slots.href) {
+    link.setAttribute('href', slots.href);
+  }
+
+  if (slots.color) {
+    cssProp(link, '--primary-color', slots.color);
+
+    try {
+      const colors = getColors(slots.color, {chroma});
+      cssProp(link, '--light-ghost-color', colors.lightGhost);
+      cssProp(link, '--deep-color', colors.deep);
+    } catch (error) {
+      console.warn(error);
+    }
+  }
+
+  const imgContainer = document.createElement('span');
+  imgContainer.classList.add('wiki-search-result-image-container');
+
+  if (slots.imageSource) {
+    const img = document.createElement('img');
+    img.classList.add('wiki-search-result-image');
+    img.setAttribute('src', slots.imageSource);
+    imgContainer.appendChild(img);
+    if (slots.imageSource.endsWith('.mini.jpg')) {
+      img.classList.add('has-warning');
+    }
+  } else {
+    const placeholder = document.createElement('span');
+    placeholder.classList.add('wiki-search-result-image-placeholder');
+    imgContainer.appendChild(placeholder);
+  }
+
+  link.appendChild(imgContainer);
+
+  const text = document.createElement('span');
+  text.classList.add('wiki-search-result-text-area');
+
+  if (slots.name) {
+    const span = document.createElement('span');
+    span.classList.add('wiki-search-result-name');
+    span.appendChild(document.createTextNode(slots.name));
+    text.appendChild(span);
+  }
+
+  let accentSpan = null;
+
+  if (link.href) {
+    const here = location.href.replace(/\/$/, '');
+    const there = link.href.replace(/\/$/, '');
+    if (here === there) {
+      link.classList.add('current-result');
+      accentSpan = document.createElement('span');
+      accentSpan.classList.add('wiki-search-current-result-text');
+      accentSpan.appendChild(templateContent(info.currentResultString));
+    }
+  }
+
+  if (!accentSpan && slots.kindString) {
+    accentSpan = document.createElement('span');
+    accentSpan.classList.add('wiki-search-result-kind');
+    accentSpan.appendChild(templateContent(slots.kindString));
+  }
+
+  if (accentSpan) {
+    text.appendChild(document.createTextNode(' '));
+    text.appendChild(accentSpan);
+  }
+
+  link.appendChild(text);
+
+  link.addEventListener('click', () => {
+    saveSidebarSearchResultsScrollOffset();
+  });
+
+  return link;
+}
+
+function hideSidebarSearchResults() {
+  const info = sidebarSearchInfo;
+
+  cssProp(info.resultsRule, 'display', 'none');
+  cssProp(info.resultsContainer, 'display', 'none');
+
+  while (info.results.firstChild) {
+    info.results.firstChild.remove();
+  }
+
+  cssProp(info.endSearchRule, 'display', 'none');
+  cssProp(info.endSearchLine, 'display', 'none');
+}
+
+function saveSidebarSearchResultsScrollOffset() {
+  const info = sidebarSearchInfo;
+  const {session} = info;
+
+  session.resultsScrollOffset = info.resultsContainer.scrollTop;
+}
+
+function restoreSidebarSearchResultsScrollOffset() {
+  const info = sidebarSearchInfo;
+  const {session} = info;
+
+  if (session.resultsScrollOffset) {
+    info.resultsContainer.scrollTop = session.resultsScrollOffset;
+  }
+}
+
+function showSearchSidebarColumn() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  if (!info.searchSidebarColumn) {
+    return;
+  }
+
+  if (!info.searchSidebarColumn.classList.contains('initially-hidden')) {
+    return;
+  }
+
+  info.searchSidebarColumn.classList.remove('initially-hidden');
+
+  if (info.searchSidebarColumn.id === 'sidebar-left') {
+    info.pageContainer.classList.add('showing-sidebar-left');
+  } else if (info.searchSidebarColumn.id === 'sidebar-right') {
+    info.pageContainer.classList.add('showing-sidebar-right');
+  }
+
+  state.sidebarColumnShownForSearch = true;
+}
+
+function possiblyHideSearchSidebarColumn() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  if (!info.searchSidebarColumn) {
+    return;
+  }
+
+  if (!state.sidebarColumnShownForSearch) {
+    return;
+  }
+
+  info.searchSidebarColumn.classList.add('initially-hidden');
+
+  if (info.searchSidebarColumn.id === 'sidebar-left') {
+    info.pageContainer.classList.remove('showing-sidebar-left');
+  } else if (info.searchSidebarColumn.id === 'sidebar-right') {
+    info.pageContainer.classList.remove('showing-sidebar-right');
+  }
+
+  state.sidebarColumnShownForSearch = null;
+}
+
+// This should be called after results are shown, since it checks the
+// elements added to understand the current search state.
+function tidySidebarSearchColumn() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  // Don't *re-tidy* the sidebar if we've already tidied it to display
+  // some results. This flag will get cleared if the search is dismissed
+  // altogether (and the pre-tidy state is restored).
+  if (state.tidiedSidebar) {
+    return;
+  }
+
+  const here = location.href.replace(/\/$/, '');
+  const currentPageIsResult =
+    Array.from(info.results.querySelectorAll('a'))
+      .some(link => {
+        const there = link.href.replace(/\/$/, '');
+        return here === there;
+      });
+
+  // Don't tidy the sidebar if you've navigated to some other page than
+  // what's in the current result list.
+  if (!currentPageIsResult) {
+    return;
+  }
+
+  state.tidiedSidebar = true;
+  state.collapsedDetailsForTidiness = [];
+
+  for (const box of info.searchSidebarColumn.querySelectorAll('.sidebar')) {
+    if (box === info.searchBox) {
+      continue;
+    }
+
+    for (const details of box.getElementsByTagName('details')) {
+      if (details.open) {
+        details.removeAttribute('open');
+        state.collapsedDetailsForTidiness.push(details);
+      }
+    }
+  }
+}
+
+function restoreSidebarSearchColumn() {
+  const {state} = sidebarSearchInfo;
+
+  if (!state.tidiedSidebar) {
+    return;
+  }
+
+  for (const details of state.collapsedDetailsForTidiness) {
+    details.setAttribute('open', '');
+  }
+
+  state.collapsedDetailsForTidiness = [];
+  state.tidiedSidebar = null;
+}
+
+clientSteps.getPageReferences.push(getSidebarSearchReferences);
+clientSteps.addInternalListeners.push(addSidebarSearchInternalListeners);
+clientSteps.mutatePageContent.push(mutateSidebarSearchContent);
+clientSteps.addPageListeners.push(addSidebarSearchListeners);
+clientSteps.initializeState.push(initializeSidebarSearchState);
+
 // Sticky commentary sidebar ------------------------------
 
 const albumCommentarySidebarInfo = initInfo('albumCommentarySidebarInfo', {
@@ -3476,8 +4882,8 @@ for (const [key, steps] of Object.entries(clientSteps)) {
     try {
       step();
     } catch (error) {
-      console.warn(`During ${key}, failed to run ${step.name}`);
-      console.debug(error);
+      console.error(`During ${key}, failed to run ${step.name}`);
+      console.error(error);
     }
   }
 }
diff --git a/src/static/lazy-loading.js b/src/static/js/lazy-loading.js
index 1df56f08..1df56f08 100644
--- a/src/static/lazy-loading.js
+++ b/src/static/js/lazy-loading.js
diff --git a/src/static/js/module-import-shims.js b/src/static/js/module-import-shims.js
new file mode 100644
index 00000000..e7e1e0cc
--- /dev/null
+++ b/src/static/js/module-import-shims.js
@@ -0,0 +1,27 @@
+export const loadDependency = {
+  async fromWindow(modulePath) {
+    globalThis.window = {};
+
+    await import(modulePath);
+
+    const exports = globalThis.window;
+
+    delete globalThis.window;
+
+    return exports;
+  },
+
+  async fromModuleExports(modulePath) {
+    globalThis.exports = {};
+    globalThis.module = {exports: globalThis.exports};
+
+    await import(modulePath);
+
+    const exports = globalThis.exports;
+
+    delete globalThis.module;
+    delete globalThis.exports;
+
+    return exports;
+  },
+};
diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js
new file mode 100644
index 00000000..8e83fe02
--- /dev/null
+++ b/src/static/js/search-worker.js
@@ -0,0 +1,620 @@
+/* eslint-env worker */
+
+import FlexSearch from '../lib/flexsearch/flexsearch.bundle.module.min.js';
+
+import {makeSearchIndex, searchSpec} from '../shared-util/search-spec.js';
+
+import {
+  empty,
+  groupArray,
+  promiseWithResolvers,
+  stitchArrays,
+  unique,
+  withEntries,
+} from '../shared-util/sugar.js';
+
+import {loadDependency} from './module-import-shims.js';
+import {fetchWithProgress} from './xhr-util.js';
+
+// Will be loaded from dependencies.
+let decompress;
+let unpack;
+
+let idb;
+
+let status = null;
+let indexes = null;
+
+onmessage = handleWindowMessage;
+onerror = handleRuntimeError;
+onunhandledrejection = handleRuntimeError;
+postStatus('alive');
+
+Promise.all([
+  loadDependencies(),
+  loadDatabase(),
+]).then(main)
+  .then(
+    () => {
+      postStatus('ready');
+    },
+    error => {
+      console.error(`Search worker setup error:`, error);
+      postStatus('setup-error');
+    });
+
+async function loadDependencies() {
+  const {compressJSON} =
+    await loadDependency.fromWindow('../lib/compress-json/bundle.min.js');
+
+  const msgpackr =
+    await loadDependency.fromModuleExports('../lib/msgpackr/index.js');
+
+  ({decompress} = compressJSON);
+  ({unpack} = msgpackr);
+}
+
+async function promisifyIDBRequest(request) {
+  const {promise, resolve, reject} = promiseWithResolvers();
+
+  request.addEventListener('success', () => resolve(request.result));
+  request.addEventListener('error', () => reject(request.error));
+
+  return promise;
+}
+
+async function* iterateIDBObjectStore(store, query) {
+  const request =
+    store.openCursor(query);
+
+  let promise, resolve, reject;
+  let cursor;
+
+  request.onsuccess = () => {
+    cursor = request.result;
+    if (cursor) {
+      resolve({done: false, value: [cursor.key, cursor.value]});
+    } else {
+      resolve({done: true});
+    }
+  };
+
+  request.onerror = () => {
+    reject(request.error);
+  };
+
+  do {
+    ({promise, resolve, reject} = promiseWithResolvers());
+
+    const result = await promise;
+
+    if (result.done) {
+      return;
+    }
+
+    yield result.value;
+
+    cursor.continue();
+  } while (true);
+}
+
+async function loadCachedIndexFromIDB() {
+  if (!idb) return null;
+
+  const transaction =
+    idb.transaction(['indexes'], 'readwrite');
+
+  const store =
+    transaction.objectStore('indexes');
+
+  const result = {};
+
+  for await (const [key, object] of iterateIDBObjectStore(store)) {
+    result[key] = object;
+  }
+
+  return result;
+}
+
+async function loadDatabase() {
+  const request =
+    globalThis.indexedDB.open('hsmusicSearchDatabase', 4);
+
+  request.addEventListener('upgradeneeded', () => {
+    const idb = request.result;
+
+    idb.createObjectStore('indexes', {
+      keyPath: 'key',
+    });
+  });
+
+  try {
+    idb = await promisifyIDBRequest(request);
+  } catch (error) {
+    console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`);
+    console.warn(request.error);
+    idb = null;
+  }
+}
+
+function rebase(path) {
+  return `/search-data/` + path;
+}
+
+async function prepareIndexData() {
+  return Promise.all([
+    fetch(rebase('index.json'))
+      .then(resp => resp.json()),
+
+    loadCachedIndexFromIDB(),
+  ]).then(
+      ([indexData, idbIndexData]) =>
+        ({indexData, idbIndexData}));
+}
+
+function fetchIndexes(keysNeedingFetch) {
+  if (!empty(keysNeedingFetch)) {
+    postMessage({
+      kind: 'download-begun',
+      context: 'search-indexes',
+      keys: keysNeedingFetch,
+    });
+  }
+
+  return (
+    keysNeedingFetch.map(key =>
+      fetchWithProgress(
+        rebase(key + '.json.msgpack'),
+        progress => {
+          postMessage({
+            kind: 'download-progress',
+            context: 'search-indexes',
+            progress: progress / 1.00,
+            key,
+          });
+        }).then(response => {
+            postMessage({
+              kind: 'download-complete',
+              context: 'search-indexes',
+              key,
+            });
+
+            return response;
+          })));
+}
+
+async function main() {
+  const prepareIndexDataPromise = prepareIndexData();
+
+  indexes =
+    withEntries(searchSpec, entries => entries
+      .map(([key, descriptor]) => [
+        key,
+        makeSearchIndex(descriptor, {FlexSearch}),
+      ]));
+
+  const {indexData, idbIndexData} = await prepareIndexDataPromise;
+
+  const keysNeedingFetch =
+    (idbIndexData
+      ? Object.keys(indexData)
+          .filter(key =>
+            indexData[key].md5 !==
+            idbIndexData[key]?.md5)
+      : Object.keys(indexData));
+
+  const keysFromCache =
+    Object.keys(indexData)
+      .filter(key => !keysNeedingFetch.includes(key))
+
+  const cacheArrayBufferPromises =
+    keysFromCache
+      .map(key => idbIndexData[key])
+      .map(({cachedBinarySource}) =>
+        cachedBinarySource.arrayBuffer());
+
+  const fetchPromises =
+    fetchIndexes(keysNeedingFetch);
+
+  const fetchBlobPromises =
+    fetchPromises
+      .map(promise => promise
+        .then(response => response.blob()));
+
+  const fetchArrayBufferPromises =
+    fetchBlobPromises
+      .map(promise => promise
+        .then(blob => blob.arrayBuffer()));
+
+  function arrayBufferToJSON(data) {
+    data = new Uint8Array(data);
+    data = unpack(data);
+    data = decompress(data);
+    return data;
+  }
+
+  function importIndexes(keys, jsons) {
+    stitchArrays({key: keys, json: jsons})
+      .forEach(({key, json}) => {
+        importIndex(key, json);
+      });
+  }
+
+  if (idb) {
+    console.debug(`Reusing indexes from search cache:`, keysFromCache);
+    console.debug(`Fetching indexes anew:`, keysNeedingFetch);
+  }
+
+  await Promise.all([
+    async () => {
+      const cacheArrayBuffers =
+        await Promise.all(cacheArrayBufferPromises);
+
+      const cacheJSONs =
+        cacheArrayBuffers
+          .map(arrayBufferToJSON);
+
+      importIndexes(keysFromCache, cacheJSONs);
+    },
+
+    async () => {
+      const fetchArrayBuffers =
+        await Promise.all(fetchArrayBufferPromises);
+
+      const fetchJSONs =
+        fetchArrayBuffers
+          .map(arrayBufferToJSON);
+
+      importIndexes(keysNeedingFetch, fetchJSONs);
+    },
+
+    async () => {
+      if (!idb) return;
+
+      const fetchBlobs =
+        await Promise.all(fetchBlobPromises);
+
+      const transaction =
+        idb.transaction(['indexes'], 'readwrite');
+
+      const store =
+        transaction.objectStore('indexes');
+
+      for (const {key, blob} of stitchArrays({
+        key: keysNeedingFetch,
+        blob: fetchBlobs,
+      })) {
+        const value = {
+          key,
+          md5: indexData[key].md5,
+          cachedBinarySource: blob,
+        };
+
+        try {
+          await promisifyIDBRequest(store.put(value));
+        } catch (error) {
+          console.warn(`Error saving ${key} to internal search cache:`, value);
+          console.warn(error);
+          continue;
+        }
+      }
+    },
+  ].map(fn => fn()));
+}
+
+function importIndex(indexKey, indexData) {
+  // If this fails, it's because an outdated index was cached.
+  // TODO: If this fails, try again once with a cache busting url.
+  for (const [key, value] of Object.entries(indexData)) {
+    indexes[indexKey].import(key, JSON.stringify(value));
+  }
+}
+
+function handleRuntimeError() {
+  postStatus('runtime-error');
+}
+
+function handleWindowMessage(message) {
+  switch (message.data.kind) {
+    case 'action':
+      handleWindowActionMessage(message);
+      break;
+
+    default:
+      console.warn(`Unknown message kind -> to search worker:`, message.data);
+      break;
+  }
+}
+
+async function handleWindowActionMessage(message) {
+  const {id} = message.data;
+
+  if (!id) {
+    console.warn(`Action without id -> to search worker:`, message.data);
+    return;
+  }
+
+  if (status !== 'ready') {
+    return postActionResult(id, 'reject', 'not ready');
+  }
+
+  let value;
+
+  switch (message.data.action) {
+    case 'search':
+      value = await performSearchAction(message.data.options);
+      break;
+
+    default:
+      console.warn(`Unknown action "${message.data.action}" -> to search worker:`, message.data);
+      return postActionResult(id, 'reject', 'unknown action');
+  }
+
+  await postActionResult(id, 'resolve', value);
+}
+
+function postStatus(newStatus) {
+  status = newStatus;
+  postMessage({
+    kind: 'status',
+    status: newStatus,
+  });
+}
+
+function postActionResult(id, status, value) {
+  postMessage({
+    kind: 'result',
+    id,
+    status,
+    value,
+  });
+}
+
+function performSearchAction({query, options}) {
+  const {generic, ...otherIndexes} = indexes;
+
+  const genericResults =
+    queryGenericIndex(generic, query, options);
+
+  const otherResults =
+    withEntries(otherIndexes, entries => entries
+      .map(([indexName, index]) => [
+        indexName,
+        index.search(query, options),
+      ]));
+
+  return {
+    generic: genericResults,
+    ...otherResults,
+  };
+}
+
+function queryGenericIndex(index, query, options) {
+  const interestingFieldCombinations = [
+    ['primaryName', 'parentName', 'groups'],
+    ['primaryName', 'parentName'],
+    ['primaryName', 'groups', 'contributors'],
+    ['primaryName', 'groups', 'artTags'],
+    ['primaryName', 'groups'],
+    ['primaryName', 'contributors'],
+    ['primaryName', 'artTags'],
+    ['parentName', 'groups', 'artTags'],
+    ['parentName', 'artTags'],
+    ['groups', 'contributors'],
+    ['groups', 'artTags'],
+
+    // This prevents just matching *everything* tagged "john" if you
+    // only search "john", but it actually supports matching more than
+    // *two* tags at once: "john rose lowas" works! This is thanks to
+    // flexsearch matching multiple field values in a single query.
+    ['artTags', 'artTags'],
+
+    ['contributors', 'groups'],
+    ['primaryName', 'contributors'],
+    ['primaryName'],
+  ];
+
+  const interestingFields =
+    unique(interestingFieldCombinations.flat());
+
+  const {genericTerms, queriedKind} =
+    processTerms(query);
+
+  const particles =
+    particulate(genericTerms);
+
+  const groupedParticles =
+    groupArray(particles, ({length}) => length);
+
+  const queriesBy = keys =>
+    (groupedParticles.get(keys.length) ?? [])
+      .flatMap(permutations)
+      .map(values => values.map(({terms}) => terms.join(' ')))
+      .map(values =>
+        stitchArrays({
+          field: keys,
+          query: values,
+        }));
+
+  const boilerplate = queryBoilerplate(index);
+
+  const particleResults =
+    Object.fromEntries(
+      interestingFields.map(field => [
+        field,
+        Object.fromEntries(
+          particles.flat()
+            .map(({terms}) => terms.join(' '))
+            .map(query => [
+              query,
+              new Set(
+                boilerplate
+                  .query(query, {
+                    ...options,
+                    field,
+                    limit: Infinity,
+                  })
+                  .fieldResults[field]),
+            ])),
+      ]));
+
+  const results = new Set();
+
+  for (const interestingFieldCombination of interestingFieldCombinations) {
+    for (const query of queriesBy(interestingFieldCombination)) {
+      const idToMatchingFieldsMap = new Map();
+      for (const {field, query: fieldQuery} of query) {
+        for (const id of particleResults[field][fieldQuery]) {
+          if (idToMatchingFieldsMap.has(id)) {
+            idToMatchingFieldsMap.get(id).push(field);
+          } else {
+            idToMatchingFieldsMap.set(id, [field]);
+          }
+        }
+      }
+
+      const commonAcrossFields =
+        Array.from(idToMatchingFieldsMap.entries())
+          .filter(([id, matchingFields]) =>
+            matchingFields.length === interestingFieldCombination.length)
+          .map(([id]) => id);
+
+      for (const result of commonAcrossFields) {
+        results.add(result);
+      }
+    }
+  }
+
+  const constituted =
+    boilerplate.constitute(results);
+
+  const constitutedAndFiltered =
+    constituted
+      .filter(({id}) =>
+        (queriedKind
+          ? id.split(':')[0] === queriedKind
+          : true));
+
+  return constitutedAndFiltered;
+}
+
+function processTerms(query) {
+  const kindTermSpec = [
+    {kind: 'album', terms: ['album']},
+    {kind: 'artist', terms: ['artist']},
+    {kind: 'flash', terms: ['flash']},
+    {kind: 'group', terms: ['group']},
+    {kind: 'tag', terms: ['art tag', 'tag']},
+    {kind: 'track', terms: ['track']},
+  ];
+
+  const genericTerms = [];
+  let queriedKind = null;
+
+  const termRegexp =
+    new RegExp(
+      String.raw`(?<kind>${kindTermSpec.flatMap(spec => spec.terms).join('|')})` +
+      String.raw`|\S+`,
+      'gi');
+
+  for (const match of query.matchAll(termRegexp)) {
+    const {groups} = match;
+
+    if (groups.kind && !queriedKind) {
+      queriedKind =
+        kindTermSpec
+          .find(({terms}) => terms.includes(groups.kind.toLowerCase()))
+          .kind;
+
+      continue;
+    }
+
+    genericTerms.push(match[0]);
+  }
+
+  return {genericTerms, queriedKind};
+}
+
+function particulate(terms) {
+  if (empty(terms)) return [];
+
+  const results = [];
+
+  for (let slice = 1; slice <= 2; slice++) {
+    if (slice === terms.length) {
+      break;
+    }
+
+    const front = terms.slice(0, slice);
+    const back = terms.slice(slice);
+
+    results.push(...
+      particulate(back)
+        .map(result => [
+          {terms: front},
+          ...result
+        ]));
+  }
+
+  results.push([{terms}]);
+
+  return results;
+}
+
+// This function doesn't even come close to "performant",
+// but it only operates on small data here.
+function permutations(array) {
+  switch (array.length) {
+    case 0:
+      return [];
+
+    case 1:
+      return [array];
+
+    default:
+      return array.flatMap((item, index) => {
+        const behind = array.slice(0, index);
+        const ahead = array.slice(index + 1);
+        return (
+          permutations([...behind, ...ahead])
+            .map(rest => [item, ...rest]));
+      });
+  }
+}
+
+function queryBoilerplate(index) {
+  const idToDoc = {};
+
+  return {
+    idToDoc,
+
+    constitute: (ids) =>
+      Array.from(ids)
+        .map(id => ({id, doc: idToDoc[id]})),
+
+    query: (query, options) => {
+      const rawResults =
+        index.search(query, options);
+
+      const fieldResults =
+        Object.fromEntries(
+          rawResults
+            .map(({field, result}) => [
+              field,
+              result.map(result =>
+                (typeof result === 'string'
+                  ? result
+                  : result.id)),
+            ]));
+
+      Object.assign(
+        idToDoc,
+        Object.fromEntries(
+          rawResults
+            .flatMap(({result}) => result)
+            .map(({id, doc}) => [id, doc])));
+
+      return {rawResults, fieldResults};
+    },
+  };
+}
diff --git a/src/static/js/xhr-util.js b/src/static/js/xhr-util.js
new file mode 100644
index 00000000..8a43072c
--- /dev/null
+++ b/src/static/js/xhr-util.js
@@ -0,0 +1,64 @@
+/* eslint-env browser */
+
+/**
+ * This fetch function is adapted from a `loadImage` function
+ * credited to Parziphal, Feb 13, 2017.
+ * https://stackoverflow.com/a/42196770
+ *
+ * The callback is generally run with the loading progress as a decimal 0-1.
+ * However, if it's not possible to compute the progress ration (which might
+ * only become apparent after a progress amount *has* been sent!),
+ * the callback will be run with the value -1.
+ *
+ * The return promise resolves to a manually instantiated Response object
+ * which generally behaves the same as a normal fetch response; access headers,
+ * text, blob, arrayBuffer as usual. Accordingly, non-200 responses do *not*
+ * reject the prmoise, so be sure to check the response status yourself.
+ */
+export function fetchWithProgress(url, progressCallback) {
+  return new Promise(resolve => {
+    const xhr = new XMLHttpRequest();
+    let notifiedNotComputable = false;
+
+    xhr.open('GET', url, true);
+    xhr.responseType = 'arraybuffer';
+
+    xhr.onprogress = event => {
+      if (notifiedNotComputable) {
+        return;
+      }
+
+      if (!event.lengthComputable) {
+        notifiedNotComputable = true;
+        progressCallback(-1);
+        return;
+      }
+
+      progressCallback(event.loaded / event.total);
+    };
+
+    xhr.onloadend = () => {
+      const body = xhr.response;
+
+      const options = {
+        status: xhr.status,
+        headers:
+          parseResponseHeaders(xhr.getAllResponseHeaders()),
+      };
+
+      resolve(new Response(body, options));
+    };
+
+    xhr.send();
+  });
+
+  function parseResponseHeaders(headers) {
+    return (
+      Object.fromEntries(
+        headers
+          .trim()
+          .split(/[\r\n]+/)
+          .map(line => line.match(/(.+?):\s*(.+)/))
+          .map(match => [match[1], match[2]])));
+  }
+}
diff --git a/src/static/icons.svg b/src/static/misc/icons.svg
index 8c9a80a9..8c9a80a9 100644
--- a/src/static/icons.svg
+++ b/src/static/misc/icons.svg
diff --git a/src/static/warning.svg b/src/static/misc/warning.svg
index 92e55778..92e55778 100644
--- a/src/static/warning.svg
+++ b/src/static/misc/warning.svg
diff --git a/src/static/shared-util/README.md b/src/static/shared-util/README.md
new file mode 100644
index 00000000..d21c0e6b
--- /dev/null
+++ b/src/static/shared-util/README.md
@@ -0,0 +1,11 @@
+# `src/static/shared-util`
+
+Module imports under `src/static/js` may appear to be pointing to files that aren't at quite the right place. For example, the import:
+
+    import {empty} from '../shared-util/sugar.js';
+
+...is reading a file that doesn't exist here, under `shared-util`. This isn't an error!
+
+This folder (`src/shared-util`) does not actually exist in a build of the website; instead, the folder `src/util` is symlinked in its place. So, all files under `src/util` are actually available at (e.g.) `/static/shared-util/` online.
+
+The above import would actually import from the bindings in `src/util/sugar.js`.
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 4b38b60d..9c429d46 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -273,17 +273,41 @@ releaseInfo:
       _: "{TRACK} ({ALBUM})"
       withYear: "({YEAR}) {TRACK} ({ALBUM})"
 
-  tracksReferenced: "Tracks that {TRACK} references:"
-  tracksThatReference: "Tracks that reference {TRACK}:"
-  tracksSampled: "Tracks that {TRACK} samples:"
-  tracksThatSample: "Tracks that sample {TRACK}:"
+  tracksReferenced:
+    _: "Tracks that {TRACK} references:"
+    sticky: "Tracks that this one references:"
+
+  tracksSampled:
+    _: "Tracks that {TRACK} samples:"
+    sticky: "Tracks that this one samples:"
+
+  tracksThatReference:
+    _: "Tracks that reference {TRACK}:"
+
+    sticky:
+      _: "Tracks that reference this one:"
+      fromGroup: "Tracks from {GROUP} that reference this one:"
+      fromOther: "Tracks from somewhere else that reference this one:"
+
+  tracksThatSample:
+    _: "Tracks that sample {TRACK}:"
+
+    sticky:
+      _: "Tracks that sample this one:"
+      fromGroup: "Tracks from {GROUP} that sample this one:"
+      fromOther: "Tracks from somewhere else that sample this one:"
 
   flashesThatFeature:
     _: "Flashes & games that feature {TRACK}:"
+    sticky: "Flashes & games that feature this track:"
+
     item:
       _: "{FLASH}"
       asDifferentRelease: "{FLASH} (as {TRACK})"
 
+  # Note that there's no sticky variant here,
+  # such as "Tracks that this flash features",
+  # because not all flashes are *called* flashes!
   tracksFeatured: "Tracks that {FLASH} features:"
 
   # Actions
@@ -321,7 +345,7 @@ releaseInfo:
     link: "artist commentary"
 
   additionalFiles:
-    heading: "View or download {ADDITIONAL_FILES}:"
+    heading: "View or download additional files:"
 
     entry:
       _: "{TITLE}"
@@ -334,8 +358,8 @@ releaseInfo:
       withSize: "{FILE} ({SIZE})"
 
     shortcut:
-      _: "View {ANCHOR_LINK}: {TITLES}"
-      anchorLink: "additional files"
+      _: "View {LINK}."
+      link: "additional files"
 
   sheetMusicFiles:
     heading: "Print or download sheet music files:"
@@ -364,10 +388,10 @@ trackList:
   section:
     _: "{SECTION}:"
     withDuration: "{SECTION}: ({DURATION})"
+    sticky: "{SECTION}:"
 
-  group:
-    _: "From {GROUP}:"
-    fromOther: "From somewhere else:"
+  fromGroup: "From {GROUP}:"
+  fromOther: "From somewhere else:"
 
   item:
     _: "{TRACK}"
@@ -430,12 +454,15 @@ misc:
     entry:
       title:
         _: "{ARTISTS}:"
+
         noArtists: "Unknown artist"
+
         withAccent: "{ARTISTS}: {ACCENT}"
+
         accent:
           withAnnotation: "({ANNOTATION})"
-          withDate: ({DATE})"
-          withAnnotation.withDate: "({ANNOTATION}, {DATE})"
+
+        date: "{DATE}"
 
       seeOriginalRelease: "See {ORIGINAL}!"
 
@@ -490,6 +517,8 @@ misc:
       coverArt: "{INDEX} cover art by {ARTIST}"
       flash: "{INDEX} flash/game by {ARTIST}"
       track: "{INDEX} track by {ARTIST}"
+      trackArt: "{INDEX} track art by {ARTIST}"
+      onlyIndex: "Only"
 
   # external:
   #   Links which will generally bring you somewhere off of the wiki.
@@ -514,6 +543,7 @@ misc:
       _: "{LINK} ({ANNOTATION})"
       annotation: "invalid URL"
 
+    amazonMusic: "Amazon Music"
     appleMusic: "Apple Music"
     artstation: "ArtStation"
     bandcamp: "Bandcamp"
@@ -613,6 +643,35 @@ misc:
     _: "{TITLE}"
     withWikiName: "{TITLE} | {WIKI_NAME}"
 
+  # search:
+  #   Strings to do with the search bar!
+
+  search:
+    placeholder: "Search for anything"
+
+    preparing: "Preparing..."
+    loadingData: "Loading data..."
+    searching: "Searching..."
+
+    failed: >-
+      There was an internal error,
+      and your search couldn't be processed.
+      Reloading this page and trying again may help.
+      Sorry for the trouble!
+
+    noResults: >-
+      No results for this query, sorry!
+      Check spelling and use complete words.
+
+    currentResult: "(you are here)"
+    endSearch: "(OK, I'm done searching now.)"
+
+    resultKind:
+      album: "(album)"
+      artTag: "(art tag)"
+      artist: "(artist)"
+      group: "(group)"
+
   # skippers:
   #
   #   These are navigational links that only show up when you're
@@ -872,6 +931,7 @@ albumCommentaryPage:
     title:
       albumCommentary:
         _: "{ALBUM}"
+        sticky: "{ALBUM} (album commentary)"
         accent: "Listen on: {LISTENING_LINKS}"
 
       trackCommentary:
@@ -1840,6 +1900,12 @@ trackPage:
       _: "{TRACK}"
       withNumber: "{NUMBER}. {TRACK}"
 
+    chronology:
+      scope:
+        title: "Chronology links {SCOPE}"
+        wiki: "across this wiki"
+        album: "within this album"
+
   socialEmbed:
     heading: "{ALBUM}"
     title: "{TRACK}"
diff --git a/src/upd8.js b/src/upd8.js
index 6bd52da9..9e4ef4fb 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -31,38 +31,26 @@
 // Oh yeah, like. Just run this through some relatively recent version of
 // node.js and you'll 8e fine. ...Within the project root. O8viously.
 
+import '#import-heck';
+
 import {execSync} from 'node:child_process';
-import {readdir, readFile} from 'node:fs/promises';
+import {readdir, readFile, stat} from 'node:fs/promises';
 import * as path from 'node:path';
 import {fileURLToPath} from 'node:url';
 
 import wrap from 'word-wrap';
 
-// Due to import time shenanigans, these imports have to come in the specified
-// order. This obviously needs fixing up.
-
-/* precede #find */
-import {
-  filterReferenceErrors,
-  reportDuplicateDirectories,
-  reportContentTextErrors,
-} from '#data-checks';
-
-import {bindFind, getAllFindSpecs} from '#find';
-
-// End of import time shenanigans (hopefully)
-
-import {showAggregate} from '#aggregate';
+import {mapAggregate, showAggregate} from '#aggregate';
 import CacheableObject from '#cacheable-object';
 import {displayCompositeCacheAnalysis} from '#composite';
+import {bindFind, getAllFindSpecs} from '#find';
 import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile}
   from '#language';
 import {isMain, traverse} from '#node-utils';
+import {writeSearchData} from '#search';
 import {sortByName} from '#sort';
-import {empty, withEntries} from '#sugar';
 import {generateURLs, urlSpec} from '#urls';
-import {linkWikiDataArrays, loadAndProcessDataDocuments, sortWikiDataArrays}
-  from '#yaml';
+import {identifyAllWebRoutes} from '#web-routes';
 
 import {
   colors,
@@ -73,8 +61,22 @@ import {
   logError,
   parseOptions,
   progressCallAll,
+  showHelpForOptions as unboundShowHelpForOptions,
 } from '#cli';
 
+import {
+  filterReferenceErrors,
+  reportDirectoryErrors,
+  reportContentTextErrors,
+} from '#data-checks';
+
+import {
+  bindOpts,
+  empty,
+  indentWrap as unboundIndentWrap,
+  withEntries,
+} from '#sugar';
+
 import genThumbs, {
   CACHE_FILE as thumbsCacheFile,
   defaultMagickThreads,
@@ -84,14 +86,21 @@ import genThumbs, {
   verifyImagePaths,
 } from '#thumbs';
 
+import {
+  getAllDataSteps,
+  linkWikiDataArrays,
+  loadYAMLDocumentsFromDataSteps,
+  processThingsFromDataSteps,
+  saveThingsFromDataSteps,
+  sortWikiDataArrays,
+} from '#yaml';
+
 import FileSizePreloader from './file-size-preloader.js';
 import {listingSpec, listingTargetSpec} from './listing-spec.js';
 import * as buildModes from './write/build-modes/index.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-const CACHEBUST = 23;
-
 let COMMIT;
 try {
   COMMIT = execSync('git log --format="%h %B" -n 1 HEAD', {cwd: __dirname}).toString().trim();
@@ -118,66 +127,109 @@ let showStepStatusSummary = false;
 async function main() {
   Error.stackTraceLimit = Infinity;
 
+  let paragraph = true;
+
   stepStatusSummary = {
     determineMediaCachePath:
-      {...defaultStepStatus, name: `determine media cache path`},
+      {...defaultStepStatus, name: `determine media cache path`,
+        for: ['thumbs', 'build']},
 
     migrateThumbnails:
-      {...defaultStepStatus, name: `migrate thumbnails`},
+      {...defaultStepStatus, name: `migrate thumbnails`,
+        for: ['thumbs']},
 
     loadThumbnailCache:
-      {...defaultStepStatus, name: `load thumbnail cache file`},
+      {...defaultStepStatus, name: `load thumbnail cache file`,
+        for: ['thumbs', 'build']},
 
     generateThumbnails:
-      {...defaultStepStatus, name: `generate thumbnails`},
+      {...defaultStepStatus, name: `generate thumbnails`,
+        for: ['thumbs']},
 
     loadDataFiles:
-      {...defaultStepStatus, name: `load and process data files`},
+      {...defaultStepStatus, name: `load and process data files`,
+        for: ['build']},
 
     linkWikiDataArrays:
-      {...defaultStepStatus, name: `link wiki data arrays`},
+      {...defaultStepStatus, name: `link wiki data arrays`,
+        for: ['build']},
 
     precacheCommonData:
-      {...defaultStepStatus, name: `precache common data`},
+      {...defaultStepStatus, name: `precache common data`,
+        for: ['build']},
 
-    reportDuplicateDirectories:
-      {...defaultStepStatus, name: `report duplicate directories`},
+    reportDirectoryErrors:
+      {...defaultStepStatus, name: `report directory errors`,
+        for: ['verify']},
 
     filterReferenceErrors:
-      {...defaultStepStatus, name: `filter reference errors`},
+      {...defaultStepStatus, name: `filter reference errors`,
+        for: ['verify']},
 
     reportContentTextErrors:
-      {...defaultStepStatus, name: `report content text errors`},
+      {...defaultStepStatus, name: `report content text errors`,
+        for: ['verify']},
 
     sortWikiDataArrays:
-      {...defaultStepStatus, name: `sort wiki data arrays`},
+      {...defaultStepStatus, name: `sort wiki data arrays`,
+        for: ['build']},
 
     precacheAllData:
-      {...defaultStepStatus, name: `precache nearly all data`},
+      {...defaultStepStatus, name: `precache nearly all data`,
+        for: ['build']},
 
     // TODO: This should be split into load/watch steps.
     loadInternalDefaultLanguage:
-      {...defaultStepStatus, name: `load internal default language`},
+      {...defaultStepStatus, name: `load internal default language`,
+        for: ['build']},
 
     loadLanguageFiles:
-      {...defaultStepStatus, name: `statically load custom language files`},
+      {...defaultStepStatus, name: `statically load custom language files`,
+        for: ['build']},
 
     watchLanguageFiles:
-      {...defaultStepStatus, name: `watch custom language files`},
+      {...defaultStepStatus, name: `watch custom language files`,
+        for: ['build']},
 
     initializeDefaultLanguage:
-      {...defaultStepStatus, name: `initialize default language`},
+      {...defaultStepStatus, name: `initialize default language`,
+        for: ['build']},
 
     verifyImagePaths:
-      {...defaultStepStatus, name: `verify missing/misplaced image paths`},
+      {...defaultStepStatus, name: `verify missing/misplaced image paths`,
+        for: ['verify']},
 
     preloadFileSizes:
-      {...defaultStepStatus, name: `preload file sizes`},
+      {...defaultStepStatus, name: `preload file sizes`,
+        for: ['build']},
+
+    buildSearchIndex:
+      {...defaultStepStatus, name: `generate search index`,
+        for: ['build', 'search']},
+
+    identifyWebRoutes:
+      {...defaultStepStatus, name: `identify web routes`,
+        for: ['build']},
 
     performBuild:
-      {...defaultStepStatus, name: `perform selected build mode`},
+      {...defaultStepStatus, name: `perform selected build mode`,
+        for: ['build']},
   };
 
+  const stepsWhich = condition =>
+    Object.entries(stepStatusSummary)
+      .filter(([_key, value]) => condition(value))
+      .map(([key]) => key);
+
+  /* eslint-disable-next-line no-unused-vars */
+  const stepsFor = (...which) =>
+    stepsWhich(step =>
+      which.some(w => step.for?.includes(w)));
+
+  const stepsNotFor = (...which) =>
+    stepsWhich(step =>
+      which.every(w => !step.for?.includes(w)));
+
   const defaultQueueSize = 500;
 
   const buildModeFlagOptions = (
@@ -194,21 +246,24 @@ async function main() {
     }));
 
   let selectedBuildModeFlag;
-  let usingDefaultBuildMode;
 
   if (empty(selectedBuildModeFlags)) {
-    selectedBuildModeFlag = 'static-build';
-    usingDefaultBuildMode = true;
+    // No build mode selected. This is not a valid state for building the wiki,
+    // but we want to let access to --help, so we'll show a message about what
+    // to do later.
+    selectedBuildModeFlag = null;
   } else if (selectedBuildModeFlags.length > 1) {
     logError`Building multiple modes (${selectedBuildModeFlags.join(', ')}) at once not supported.`;
-    logError`Please specify a maximum of one build mode.`;
+    logError`Please specify one build mode.`;
     return false;
   } else {
     selectedBuildModeFlag = selectedBuildModeFlags[0];
-    usingDefaultBuildMode = false;
   }
 
-  const selectedBuildMode = buildModes[selectedBuildModeFlag];
+  const selectedBuildMode =
+    (selectedBuildModeFlag
+      ? buildModes[selectedBuildModeFlag]
+      : null);
 
   // This is about to get a whole lot more stuff put in it.
   const wikiData = {
@@ -216,7 +271,10 @@ async function main() {
     listingTargetSpec,
   };
 
-  const buildOptions = selectedBuildMode.getCLIOptions();
+  const buildOptions =
+    (selectedBuildMode
+      ? selectedBuildMode.getCLIOptions()
+      : {});
 
   const commonOptions = {
     'help': {
@@ -228,7 +286,7 @@ async function main() {
     // and like a jillion other things too. Pretty much everything which
     // makes an individual wiki what it is goes here!
     'data-path': {
-      help: `Specify path to data directory, including YAML files that cover all info about wiki content, layout, and structure\n\nAlways required for wiki building, but may be provided via the HSMUSIC_DATA environment variable instead`,
+      help: `Specify path to data directory, including YAML files that cover all info about wiki content, layout, and structure\n\nAlways required for wiki building; may be provided via the HSMUSIC_DATA environment variable`,
       type: 'value',
     },
 
@@ -236,12 +294,17 @@ async function main() {
     // categorized; check out MEDIA_ALBUM_ART_DIRECTORY and other constants
     // near the top of this file (upd8.js).
     'media-path': {
-      help: `Specify path to media directory, including album artwork and additional files, as well as custom site layout media and other media files for reference or linking in wiki content\n\nAlways required for wiki building, but may be provided via the HSMUSIC_MEDIA environment variable instead`,
+      help: `Specify path to media directory, including album artwork and additional files, as well as custom site layout media and other media files for reference or linking in wiki content\n\nAlways required for wiki building; may be provided via the HSMUSIC_MEDIA environment variable`,
       type: 'value',
     },
 
     'media-cache-path': {
-      help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred by adding "-cache" to the end of the media directory`,
+      help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred either by loading "media-cache" from --cache-path, or by adding "-cache" to the end of the media directory\n\nMay be provided via the HSMUSIC_MEDIA_CACHE environment variable`,
+      type: 'value',
+    },
+
+    'cache-path': {
+      help: `Specify path to general cache directory, usually containing generated thumbnails and assorted files reused between builds\n\nAlways required for wiki building; may be provided via the HSMUSIC_CACHE environment varaible`,
       type: 'value',
     },
 
@@ -285,6 +348,11 @@ async function main() {
       type: 'flag',
     },
 
+    'new-thumbs': {
+      help: `Repair a media cache that's completely missing its index file by starting clean and not reusing any existing thumbnails`,
+      type: 'flag',
+    },
+
     'skip-file-sizes': {
       help: `Skips preloading file sizes for images and additional files, which will be left blank in the build`,
       type: 'flag',
@@ -295,6 +363,16 @@ async function main() {
       type: 'flag',
     },
 
+    'refresh-search': {
+      help: `Generate the text search index this build, instead of waiting for the automatic delay`,
+      type: 'flag',
+    },
+
+    'skip-search': {
+      help: `Skip creation of the text search index no matter what, even if it'd normally be scheduled for now`,
+      type: 'flag',
+    },
+
     // Just working on data entries and not interested in actually
     // generating site HTML yet? This flag will cut execution off right
     // 8efore any site 8uilding actually happens.
@@ -374,6 +452,18 @@ async function main() {
     },
   };
 
+  const indentWrap =
+    bindOpts(unboundIndentWrap, {
+      wrap,
+    });
+
+  const showHelpForOptions =
+    bindOpts(unboundShowHelpForOptions, {
+      [bindOpts.bindIndex]: 0,
+      indentWrap,
+      sort: sortByName,
+    });
+
   const cliOptions = await parseOptions(process.argv.slice(2), {
     // We don't want to error when we receive these options, so specify them
     // here, even though we won't be doing anything with them later.
@@ -384,89 +474,71 @@ async function main() {
     ...buildOptions,
   });
 
-  if (cliOptions['help']) {
-    const indentWrap = (spaces, str) => wrap(str, {width: 60 - spaces, indent: ' '.repeat(spaces)});
-
-    const showOptions = (msg, options) => {
-      console.log(colors.bright(msg));
-
-      const entries = Object.entries(options);
-      const sortedOptions = sortByName(entries
-        .map(([name, descriptor]) => ({name, descriptor})));
-
-      if (!sortedOptions.length) {
-        console.log(`(No options available)`)
-      }
-
-      let justInsertedPaddingLine = false;
-
-      for (const {name, descriptor} of sortedOptions) {
-        if (descriptor.alias) {
-          continue;
-        }
-
-        const aliases = entries
-          .filter(([_name, {alias}]) => alias === name)
-          .map(([name]) => name);
-
-        let wrappedHelp, wrappedHelpLines = 0;
-        if (descriptor.help) {
-          wrappedHelp = indentWrap(4, descriptor.help);
-          wrappedHelpLines = wrappedHelp.split('\n').length;
-        }
-
-        if (wrappedHelpLines > 0 && !justInsertedPaddingLine) {
-          console.log('');
-        }
-
-        console.log(colors.bright(` --` + name) +
-          (aliases.length
-            ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})`
-            : '') +
-          (descriptor.help
-            ? ''
-            : colors.dim('  (no help provided)')));
-
-        if (wrappedHelp) {
-          console.log(wrappedHelp);
-        }
-
-        if (wrappedHelpLines > 1) {
-          console.log('');
-          justInsertedPaddingLine = true;
-        } else {
-          justInsertedPaddingLine = false;
-        }
-      }
-
-      if (!justInsertedPaddingLine) {
-        console.log(``);
-      }
-    };
+  showStepStatusSummary = cliOptions['show-step-summary'] ?? false;
 
+  if (cliOptions['help']) {
     console.log(
-      colors.bright(`hsmusic (aka. Homestuck Music Wiki)\n`) +
+      colors.bright(`hsmusic (aka. Homestuck Music Wiki, HSMusic Wiki)\n`) +
       `static wiki software cataloguing collaborative creation\n`);
 
-    console.log(indentWrap(0,
-      `The \`hsmusic\` command provides basic control over all parts of generating user-visible HTML pages and website content/structure from provided data, media, and language directories.\n` +
+    console.log(indentWrap(
+      `The \`hsmusic\` command provides basic control over ` +
+      `all parts of generating user-visible HTML pages ` +
+      `and website content/structure ` +
+      `from provided data, media, and language directories.\n` +
       `\n` +
       `CLI options are divided into three groups:\n`));
-    console.log(` 1) ` + indentWrap(4,
-      `Common options: These are shared by all build modes and always have the same essential behavior`).trim());
-    console.log(` 2) ` + indentWrap(4,
-      `Build mode selection: One build mode may be selected (or else the default, --static-build, is used), and it decides which entire set of behavior to use for providing site content to the user`).trim());
-    console.log(` 3) ` + indentWrap(4,
-      `Build options: Each build mode has a set of unique options which customize behavior for that build mode`).trim());
+
+    console.log(` 1) ` + indentWrap(
+      `Common options: ` +
+      `These are shared by all build modes ` +
+      `and always have the same essential behavior`,
+      {spaces: 4, bullet: true}));
+
+    console.log(` 2) ` + indentWrap(
+      `Build mode selection: ` +
+      `One build mode should be selected, ` +
+      `and it decides the main set of behavior to use ` +
+      `for presenting or interacting with site content`,
+      {spaces: 4, bullet: true}));
+
+    console.log(` 3) ` + indentWrap(
+      `Build options: ` +
+      `Each build mode has a set of unique options ` +
+      `which customize behavior for that build mode`,
+      {spaces: 4, bullet: true}));
+
     console.log(``);
 
-    showOptions(`Common options`, commonOptions);
-    showOptions(`Build mode selection`, buildModeFlagOptions);
+    showHelpForOptions({
+      heading: `Common options`,
+      options: commonOptions,
+      wrap,
+    });
+
+    showHelpForOptions({
+      heading: `Build mode selection`,
+      options: buildModeFlagOptions,
+      wrap,
+    });
+
+    if (selectedBuildMode) {
+      showHelpForOptions({
+        heading: `Build options for --${selectedBuildModeFlag}`,
+        options: buildOptions,
+        wrap,
+      });
+    } else {
+      console.log(
+        `Specify a build mode and run with ${colors.bright('--help')} again for info\n` +
+        `about the options for that build mode.`);
+    }
 
-    if (buildOptions) {
-      showOptions(`Build options for --${selectedBuildModeFlag} (${
-        usingDefaultBuildMode ? 'default' : 'selected'
-      })`, buildOptions);
+    for (const step of Object.values(stepStatusSummary)) {
+      Object.assign(step, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--help provided`,
+      });
     }
 
     return true;
@@ -474,13 +546,12 @@ async function main() {
 
   const dataPath = cliOptions['data-path'] || process.env.HSMUSIC_DATA;
   const mediaPath = cliOptions['media-path'] || process.env.HSMUSIC_MEDIA;
+  const wikiCachePath = cliOptions['cache-path'] || process.env.HSMUSIC_CACHE;
   const langPath = cliOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
 
   const thumbsOnly = cliOptions['thumbs-only'] ?? false;
   const noInput = cliOptions['no-input'] ?? false;
 
-  showStepStatusSummary = cliOptions['show-step-summary'] ?? false;
-
   const showAggregateTraces = cliOptions['show-traces'] ?? false;
 
   const precacheMode = cliOptions['precache-mode'] ?? 'common';
@@ -501,7 +572,11 @@ async function main() {
     logError`${`Expected --media-path option or HSMUSIC_MEDIA to be set`}`;
   }
 
-  if (!dataPath || !mediaPath) {
+  if (!wikiCachePath) {
+    logError`${`Expected --cache-path option or HSMUSIC_CACHE to be set`}`;
+  }
+
+  if (!dataPath || !mediaPath || !wikiCachePath) {
     return false;
   }
 
@@ -512,60 +587,92 @@ async function main() {
       status: STATUS_NOT_APPLICABLE,
       annotation: `--no-build provided`,
     });
-  } else {
-    if (usingDefaultBuildMode) {
-      logInfo`No build mode specified, will use default: ${selectedBuildModeFlag}`;
-    } else {
-      logInfo`Will use specified build mode: ${selectedBuildModeFlag}`;
-    }
   }
 
   // Finish setting up defaults by combining information from all options.
 
   const _fallbackStep = (stepKey, {
     default: defaultValue,
+    cli: cliArg,
+    buildConfig: buildConfigKey = null,
+  }) => {
+    const buildConfig = selectedBuildMode?.config?.[buildConfigKey];
+    const {[stepKey]: step} = stepStatusSummary;
+
+    const cliEntries =
+      (cliArg === null || cliArg === undefined
+        ? []
+     : Array.isArray(cliArg)
+        ? cliArg
+        : [cliArg]);
 
-    cli: {
+    for (const {
       flag: cliFlag = null,
       negate: cliFlagNegates = false,
       warn: cliFlagWarning = null,
-    } = {},
-
-    buildConfig: buildConfigKey,
-  }) => {
-    const {[buildConfigKey]: buildConfig} = selectedBuildMode.config;
-    const {[stepKey]: step} = stepStatusSummary;
+      disable: cliFlagDisablesSteps = [],
+    } of cliEntries) {
+      if (!cliOptions[cliFlag]) {
+        continue;
+      }
 
-    if (cliFlag && cliOptions[cliFlag]) {
       const cliPart = `--` + cliFlag;
       const modePart = `--` + selectedBuildModeFlag;
+
       if (buildConfig?.applicable === false) {
         if (cliFlagNegates) {
           logWarn`${cliPart} provided, but ${modePart} already skips this step`;
           logWarn`Redundant option ${cliPart}`;
+          continue;
         } else {
           logWarn`${cliPart} provided, but this step isn't applicable for ${modePart}`;
           logWarn`Ignoring option ${cliPart}`;
+          continue;
         }
-      } else if (buildConfig?.required === true) {
+      }
+
+      if (buildConfig?.required === true) {
         if (cliFlagNegates) {
           logWarn`${cliPart} provided, but ${modePart} requires this step`;
           logWarn`Ignoring option ${cliPart}`;
+          continue;
         } else {
           logWarn`${cliPart} provided, but ${modePart} already requires this step`;
           logWarn`Redundant option ${cliPart}`;
+          continue;
         }
-      } else {
-        if (cliFlagNegates) {
-          step.status = STATUS_NOT_APPLICABLE;
-          step.annotation = `--${cliFlag} provided`;
+      }
+
+      step.status =
+        (cliFlagNegates
+          ? STATUS_NOT_APPLICABLE
+          : STATUS_NOT_STARTED);
+
+      step.annotation = `--${cliFlag} provided`;
+
+      if (cliFlagWarning) {
+        for (const line of cliFlagWarning.split('\n')) {
+          logWarn(line);
         }
-        if (cliFlagWarning) {
-          for (const line of cliFlagWarning.split('\n')) {
-            logWarn(line);
-          }
+      }
+
+      for (const step of cliFlagDisablesSteps) {
+        const summary = stepStatusSummary[step];
+        if (summary.status === STATUS_NOT_APPLICABLE && summary.annotation) {
+          stepStatusSummary.performBuild.annotation += `; --${cliFlag} provided`;
+        } else {
+          summary.status = STATUS_NOT_APPLICABLE;
+          summary.annotation = `--${cliFlag} provided`;
         }
       }
+
+      return;
+    }
+
+    if (buildConfig?.required === true) {
+      step.status = STATUS_NOT_STARTED;
+      step.annotation = `required for --${selectedBuildModeFlag}`;
+      return;
     }
 
     if (buildConfig?.applicable === false) {
@@ -580,13 +687,29 @@ async function main() {
       return;
     }
 
+    if (buildConfig?.default === 'perform') {
+      step.status = STATUS_NOT_STARTED;
+      step.annotation = `default for --${selectedBuildModeFlag}`;
+      return;
+    }
+
     switch (defaultValue) {
-      case 'skip':
+      case 'skip': {
         step.status = STATUS_NOT_APPLICABLE;
-        if (cliFlag && !cliFlagNegates) {
-          step.annotation = `--${cliFlag} not provided`;
+
+        const enablingFlags =
+          cliEntries
+            .filter(({negate}) => !negate)
+            .map(({flag}) => flag);
+
+        if (!empty(enablingFlags)) {
+          step.annotation =
+            enablingFlags.map(flag => `--${flag}`).join(', ') +
+            ` not provided`;
         }
+
         break;
+      }
 
       case 'perform':
         break;
@@ -611,7 +734,6 @@ async function main() {
 
     fallbackStep('filterReferenceErrors', {
       default: 'perform',
-      buildConfig: null,
       cli: {
         flag: 'skip-reference-validation',
         negate: true,
@@ -624,17 +746,20 @@ async function main() {
     fallbackStep('generateThumbnails', {
       default: 'perform',
       buildConfig: 'thumbs',
-      cli: {
-        flag: 'skip-thumbs',
-        negate: true,
-      },
+      cli: [
+        {flag: 'thumbs-only', disable: stepsNotFor('thumbs')},
+        {flag: 'skip-thumbs', negate: true},
+      ],
     });
 
     fallbackStep('migrateThumbnails', {
       default: 'skip',
-      buildConfig: null,
       cli: {
         flag: 'migrate-thumbs',
+        disable: [
+          ...stepsNotFor('thumbs'),
+          'generateThumbnails',
+        ],
       },
     });
 
@@ -647,6 +772,99 @@ async function main() {
       },
     });
 
+    fallbackStep('identifyWebRoutes', {
+      default: 'perform',
+      buildConfig: 'webRoutes',
+    });
+
+    decideBuildSearchIndex: {
+      fallbackStep('buildSearchIndex', {
+        default: 'skip',
+        buildConfig: 'search',
+        cli: [
+          {flag: 'refresh-search'},
+          {flag: 'skip-search', negate: true},
+        ],
+      });
+
+      if (cliOptions['refresh-search'] || cliOptions['skip-search']) {
+        if (cliOptions['refresh-search']) {
+          logInfo`${'--refresh-search'} provided, will generate search fresh this build.`;
+        }
+
+        break decideBuildSearchIndex;
+      }
+
+      if (stepStatusSummary.buildSearchIndex.status !== STATUS_NOT_APPLICABLE) {
+        break decideBuildSearchIndex;
+      }
+
+      if (selectedBuildMode?.config?.search?.default === 'skip') {
+        break decideBuildSearchIndex;
+      }
+
+      // TODO: OK this is a little silly.
+      if (stepStatusSummary.buildSearchIndex.annotation?.startsWith('N/A')) {
+        break decideBuildSearchIndex;
+      }
+
+      const indexFile = path.join(wikiCachePath, 'search', 'index.json')
+      let stats;
+      try {
+        stats = await stat(indexFile);
+      } catch (error) {
+        if (error.code === 'ENOENT') {
+          Object.assign(stepStatusSummary.buildSearchIndex, {
+            status: STATUS_NOT_STARTED,
+            annotation: `search/index.json not present, will create`,
+          });
+
+          logInfo`Looks like the search cache doesn't exist.`;
+          logInfo`It'll be generated fresh, this build!`;
+        } else {
+          Object.assign(stepStatusSummary.buildSearchIndex, {
+            status: STATUS_NOT_APPLICABLE,
+            annotation: `error getting search index stats`,
+          });
+
+          if (!paragraph) console.log('');
+          console.error(error);
+
+          logWarn`There was an error checking the search index file, located at:`;
+          logWarn`${indexFile}`;
+          logWarn`You may want to toss out the "search" folder; it'll be generated`;
+          logWarn`anew, if you do, and may fix this error.`;
+        }
+
+        paragraph = false;
+        break decideBuildSearchIndex;
+      }
+
+      const delta = Date.now() - stats.mtimeMs;
+      const minute = 60 * 1000;
+      const delay = 45 * minute;
+
+      const whenst = duration => `~${Math.ceil(duration / minute)} min`;
+
+      if (delta < delay) {
+        logInfo`Search index was generated recently, skipping for this build.`;
+        logInfo`Next scheduled is in ${whenst(delay - delta)}, or by using ${'--refresh-search'}.`;
+        Object.assign(stepStatusSummary.buildSearchIndex, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `earlier than scheduled based on file mtime`,
+        });
+      } else {
+        logInfo`Search index hasn't been generated for a little while.`;
+        logInfo`It'll be generated this build, then again in ${whenst(delay)}.`;
+        Object.assign(stepStatusSummary.buildSearchIndex, {
+          status: STATUS_NOT_STARTED,
+          annotation: `past when shceduled based on file mtime`,
+        });
+      }
+
+      paragraph = false;
+    }
+
     fallbackStep('verifyImagePaths', {
       default: 'perform',
       buildConfig: 'mediaValidation',
@@ -687,33 +905,43 @@ async function main() {
     });
   }
 
+  // TODO: These should error if the option was actually provided but
+  // the relevant steps were already disabled for some other reason.
   switch (precacheMode) {
     case 'common':
-      Object.assign(stepStatusSummary.precacheAllData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is common, not all`,
-      });
+      if (stepStatusSummary.precacheAllData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheAllData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is common, not all`,
+        });
+      }
 
       break;
 
     case 'all':
-      Object.assign(stepStatusSummary.precacheCommonData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is all, not common`,
-      });
+      if (stepStatusSummary.precacheCommonData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheCommonData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is all, not common`,
+        });
+      }
 
       break;
 
     case 'none':
-      Object.assign(stepStatusSummary.precacheCommonData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is none`,
-      });
+      if (stepStatusSummary.precacheCommonData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheCommonData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is none`,
+        });
+      }
 
-      Object.assign(stepStatusSummary.precacheAllData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is none`,
-      });
+      if (stepStatusSummary.precacheAllData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheAllData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is none`,
+        });
+      }
 
       break;
   }
@@ -735,39 +963,162 @@ async function main() {
     return false;
   }
 
+  // If we're going to require a build mode and none is specified,
+  // exit and show what to do. This must not precede anything that might
+  // disable the build (e.g. changing its status to STATUS_NOT_APPLICABLE).
+
+  if (stepStatusSummary.performBuild.status === STATUS_NOT_STARTED) {
+    if (selectedBuildMode) {
+      logInfo`Will use specified build mode: ${selectedBuildModeFlag}`;
+    } else {
+      showHelpForOptions({
+        heading: `Please specify a build mode:`,
+        options: buildModeFlagOptions,
+      });
+
+      console.log(
+        `(Use ${colors.bright('--help')} for general info and all options, or specify\n` +
+        ` a build mode alongside ${colors.bright('--help')} for that mode's options!`);
+
+      for (const step of Object.values(stepStatusSummary)) {
+        Object.assign(step, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `no build mode provided`,
+        });
+      }
+
+      return false;
+    }
+  } else if (selectedBuildMode) {
+    if (stepStatusSummary.performBuild.annotation) {
+      logError`You've specified a build mode, ${selectedBuildModeFlag}, but it won't be used,`;
+      logError`according to the message: ${`"${stepStatusSummary.performBuild.annotation}"`}`;
+    } else {
+      logError`You've specified a build mode, ${selectedBuildModeFlag}, but it won't be used,`;
+      logError`probably because of another option you've provided.`;
+    }
+    logError`Please remove ${'--' + selectedBuildModeFlag} or the conflicting option.`;
+    return false;
+  }
+
   Object.assign(stepStatusSummary.determineMediaCachePath, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
   });
 
+  const regenerateMissingThumbnailCache =
+    cliOptions['new-thumbs'] ?? false;
+
   const {mediaCachePath, annotation: mediaCachePathAnnotation} =
     await determineMediaCachePath({
       mediaPath,
+      wikiCachePath,
+
       providedMediaCachePath:
         cliOptions['media-cache-path'] || process.env.HSMUSIC_MEDIA_CACHE,
+
+      regenerateMissingThumbnailCache,
+
       disallowDoubling:
         stepStatusSummary.migrateThumbnails.status === STATUS_NOT_STARTED,
     });
 
+  if (regenerateMissingThumbnailCache) {
+    if (
+      mediaCachePathAnnotation !== `contained path will regenerate missing cache` &&
+      mediaCachePathAnnotation !== `adjacent path will regenerate missing cache`
+    ) {
+      if (mediaCachePath) {
+        logError`Determined a media cache path. (${mediaCachePathAnnotation})`;
+        console.error('');
+        logWarn`By using ${'--new-thumbs'}, you requested to generate completely`;
+        logWarn`new thumbnails, but there's already a ${'thumbnail-cache.json'}`;
+        logWarn`file where it's expected, within this media cache:`;
+        logWarn`${path.resolve(mediaCachePath)}`;
+        console.error('');
+        logWarn`If you really do want to completely regenerate all thumbnails`;
+        logWarn`and not reuse any existing ones, move aside ${'thumbnail-cache.json'}`;
+        logWarn`and run with ${'--new-thumbs'} again.`;
+
+        Object.assign(stepStatusSummary.determineMediaCachePath, {
+          status: STATUS_FATAL_ERROR,
+          annotation: `--new-thumbs provided but regeneration not needed`,
+          timeEnd: Date.now(),
+        });
+
+        return false;
+      } else {
+        logError`Couldn't determine a media cache path. (${mediaCachePathAnnotation})`;
+        console.error('');
+        logWarn`You requested to generate completely new thumbnails, but`;
+        logWarn`the media cache wasn't readable or just couldn't be found.`;
+        logWarn`Run again without ${'--new-thumbs'} - you should investigate`;
+        logWarn`what's going on before continuing.`;
+
+        Object.assign(stepStatusSummary.determineMediaCachePath, {
+          status: STATUS_FATAL_ERROR,
+          annotation: mediaCachePathAnnotation,
+          timeEnd: Date.now(),
+        });
+
+        return false;
+      }
+    }
+  }
+
   if (!mediaCachePath) {
     logError`Couldn't determine a media cache path. (${mediaCachePathAnnotation})`;
 
     switch (mediaCachePathAnnotation) {
-      case 'inferred path does not have cache':
-        logError`If you're certain this is the right path, you can provide it via`;
-        logError`${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}, and it should work.`;
+      case `contained path does not have cache`:
+        console.error('');
+        logError`You've provided a ${'--cache-path'} or ${'HSMUSIC_CACHE_PATH'},`;
+        logError`${path.resolve(wikiCachePath)}`;
+        console.error('');
+        logError`It contains a ${'media-cache'} folder, but this folder is`;
+        logError`missing its ${'thumbnail-cache.json'} file. This means there's`;
+        logError`no information available to reuse. If you use this cache,`;
+        logError`hsmusic will generate any existing thumbnails over again.`;
+        console.error('');
+        logError`* Try to see if you can recover or locate a copy of your`;
+        logError`  ${'thumbnail-cache.json'} file and put it back in place;`;
+        logError`* Or, generate all-new thumbnails with ${'--new-thumbs'}.`;
+        break;
+
+      case 'adjacent path does not have cache':
+        console.error('');
+        logError`You have an existing ${'media-cache'} folder next to your media path,`;
+        logError`${path.resolve(mediaPath)}`;
+        console.error('');
+        logError`The ${'media-cache'} folder is missing its ${'thumbnail-cache.json'}`;
+        logError`file. This means there's no information available to reuse,`;
+        logError`and if you use this cache, hsmusic will generate any existing`;
+        logError`thumbnails over again.`;
+        console.error('');
+        logError`* Try to see if you can recover or locate a copy of your`;
+        logError`  ${'thumbnail-cache.json'} file and put it back in place;`;
+        logError`* Or, generate all-new thumbnails with ${'--new-thumbs'}.`;
         break;
 
-      case 'inferred path not readable':
+      case `contained path not readable`:
+      case `adjacent path not readable`:
+        console.error('');
         logError`The folder couldn't be read, which usually indicates`;
         logError`a permissions error. Try to resolve this, or provide`;
         logError`a new path with ${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}.`;
         break;
 
-      case 'media path not provided': /* unreachable */
+      case `media path not provided`: /* unreachable */
+        console.error('');
         logError`It seems a ${'--media-path'} (or ${'HSMUSIC_MEDIA'}) wasn't provided.`;
         logError`Make sure one of these is actually pointing to a path that exists.`;
         break;
+
+      case `cache path not provided`: /* unreachable */
+        console.error('');
+        logError`It seems a ${'--cache-path'} (or ${'HSMUSIC_CACHE'}) wasn't provided.`;
+        logError`Make sure one of these is actually pointing to a path that exists.`;
+        break;
     }
 
     Object.assign(stepStatusSummary.determineMediaCachePath, {
@@ -941,27 +1292,95 @@ async function main() {
     timeStart: Date.now(),
   });
 
-  let processDataAggregate, wikiDataResult;
+  let yamlDataSteps;
+  let yamlDocumentProcessingAggregate;
 
-  try {
-    ({aggregate: processDataAggregate, result: wikiDataResult} =
-        await loadAndProcessDataDocuments({dataPath}));
-  } catch (error) {
-    console.error(error);
+  {
+    const whoops = (error, stage) => {
+      if (!paragraph) console.log('');
 
-    logError`There was a JavaScript error loading data files.`;
-    fileIssue();
+      console.error(error);
+      niceShowAggregate(error);
 
-    Object.assign(stepStatusSummary.loadDataFiles, {
-      status: STATUS_FATAL_ERROR,
-      annotation: `javascript error - view log for details`,
-      timeEnd: Date.now(),
-    });
+      logError`There was a JavaScript error ${stage}.`;
+      fileIssue();
 
-    return false;
-  }
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `javascript error - view log for details`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
+    };
 
-  Object.assign(wikiData, wikiDataResult);
+    let loadAggregate, loadResult;
+    let processAggregate, processResult;
+    let saveAggregate, saveResult;
+
+    const dataSteps = getAllDataSteps();
+
+    try {
+      ({aggregate: loadAggregate, result: loadResult} =
+          await loadYAMLDocumentsFromDataSteps(
+            dataSteps,
+            {dataPath}));
+    } catch (error) {
+      return whoops(error, `loading data files`);
+    }
+
+    try {
+      loadAggregate.close();
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+
+      logError`The above errors were detected while loading data files.`;
+      logError`Since this indicates some files weren't able to load at all,`;
+      logError`there would probably be pretty bad reference errors if the`;
+      logError`build were to continue. Please resolve these errors and`;
+      logError`then give it another go.`;
+
+      paragraph = true;
+      console.log('');
+
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `error loading data files`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
+    }
+
+    try {
+      ({aggregate: processAggregate, result: processResult} =
+          await processThingsFromDataSteps(
+            loadResult.documentLists,
+            loadResult.fileLists,
+            dataSteps,
+            {dataPath}));
+    } catch (error) {
+      return whoops(error, `processing data files`);
+    }
+
+    try {
+      ({aggregate: saveAggregate, result: saveResult} =
+          saveThingsFromDataSteps(
+            processResult,
+            dataSteps));
+
+      saveAggregate.close();
+      saveAggregate = undefined;
+    } catch (error) {
+      return whoops(error, `finalizing data files`);
+    }
+
+    yamlDataSteps = dataSteps;
+    yamlDocumentProcessingAggregate = processAggregate;
+
+    Object.assign(wikiData, saveResult);
+  }
 
   {
     const logThings = (prop, label) => {
@@ -974,10 +1393,16 @@ async function main() {
     }
 
     try {
+      if (!paragraph) console.log('');
+
       logInfo`Loaded data and processed objects:`;
       logThings('albumData', 'albums');
       logThings('trackData', 'tracks');
-      logThings(wikiData.artistData.filter(artist => !artist.isAlias), 'artists');
+      logThings(
+        (wikiData.artistData
+          ? wikiData.artistData.filter(artist => !artist.isAlias)
+          : null),
+        'artists');
       if (wikiData.flashData) {
         logThings('flashData', 'flashes');
         logThings('flashActData', 'flash acts');
@@ -998,21 +1423,28 @@ async function main() {
       if (wikiData.wikiInfo) {
         logInfo` - ${1} wiki config file`;
       }
+
+      console.log('');
+      paragraph = true;
     } catch (error) {
       console.error(`Error showing data summary:`, error);
+      paragraph = false;
     }
 
     let errorless = true;
     try {
-      processDataAggregate.close();
+      yamlDocumentProcessingAggregate.close();
     } catch (error) {
+      if (!paragraph) console.log('');
       niceShowAggregate(error);
+
       logWarn`The above errors were detected while processing data files.`;
+
       errorless = false;
     }
 
     if (!wikiData.wikiInfo) {
-      logError`Can't proceed without wiki info file successfully loading`;
+      logError`Can't proceed without wiki info file successfully loading.`;
 
       Object.assign(stepStatusSummary.loadDataFiles, {
         status: STATUS_FATAL_ERROR,
@@ -1025,15 +1457,20 @@ async function main() {
 
     if (errorless) {
       logInfo`All data files processed without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.loadDataFiles, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
       });
     } else {
-      logWarn`If the remaining valid data is complete enough, the wiki will`;
-      logWarn`still build - but all errored data will be skipped.`;
-      logWarn`(Resolve errors for more complete output!)`;
+      logWarn`This might indicate some fields in the YAML data weren't formatted`;
+      logWarn`correctly, for example. The build should still work, but invalid`;
+      logWarn`fields will be skipped. Take a look at the report above to see`;
+      logWarn`what needs fixing up, for a more complete build!`;
+
+      console.log('');
+      paragraph = true;
 
       Object.assign(stepStatusSummary.loadDataFiles, {
         status: STATUS_HAS_WARNINGS,
@@ -1106,14 +1543,31 @@ async function main() {
       ]),
     };
 
-    for (const [wikiDataKey, properties] of Object.entries(commonDataMap)) {
-      const thingData = wikiData[wikiDataKey];
-      const allProperties = new Set(['name', 'directory', ...properties]);
-      for (const thing of thingData) {
-        for (const property of allProperties) {
-          void thing[property];
+    try {
+      for (const [wikiDataKey, properties] of Object.entries(commonDataMap)) {
+        const thingData = wikiData[wikiDataKey];
+        const allProperties = new Set(['name', 'directory', ...properties]);
+        for (const thing of thingData) {
+          for (const property of allProperties) {
+            void thing[property];
+          }
         }
       }
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+      console.log('');
+
+      logError`There was an error precaching internal data objects.`;
+      fileIssue();
+
+      Object.assign(stepStatusSummary.precacheCommonData, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `see log for details`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
     }
 
     Object.assign(stepStatusSummary.precacheCommonData, {
@@ -1122,23 +1576,27 @@ async function main() {
     });
   }
 
+  const urls = generateURLs(urlSpec);
+
   // Filter out any things with duplicate directories throughout the data,
   // warning about them too.
 
-  Object.assign(stepStatusSummary.reportDuplicateDirectories, {
+  Object.assign(stepStatusSummary.reportDirectoryErrors, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
   });
 
   try {
-    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
+    reportDirectoryErrors(wikiData, {getAllFindSpecs});
     logInfo`No duplicate directories found - nice!`;
+    paragraph = false;
 
-    Object.assign(stepStatusSummary.reportDuplicateDirectories, {
+    Object.assign(stepStatusSummary.reportDirectoryErrors, {
       status: STATUS_DONE_CLEAN,
       timeEnd: Date.now(),
     });
   } catch (aggregate) {
+    if (!paragraph) console.log('');
     niceShowAggregate(aggregate);
 
     logWarn`The above duplicate directories were detected while reviewing data files.`;
@@ -1146,7 +1604,10 @@ async function main() {
     logWarn`correct, the build can't continue. Specify unique 'Directory' fields in`;
     logWarn`some or all of these data entries to resolve the errors.`;
 
-    Object.assign(stepStatusSummary.reportDuplicateDirectories, {
+    console.log('');
+    paragraph = true;
+
+    Object.assign(stepStatusSummary.reportDirectoryErrors, {
       status: STATUS_FATAL_ERROR,
       annotation: `duplicate directories found`,
       timeEnd: Date.now(),
@@ -1171,17 +1632,23 @@ async function main() {
       filterReferenceErrorsAggregate.close();
 
       logInfo`All references validated without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.filterReferenceErrors, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
       });
     } catch (error) {
+      if (!paragraph) console.log('');
       niceShowAggregate(error);
 
       logWarn`The above errors were detected while validating references in data files.`;
-      logWarn`The wiki will still build, but these connections between data objects`;
-      logWarn`will be completely skipped. Resolve the errors for more complete output.`;
+      logWarn`The wiki should still build, but these connections between data objects`;
+      logWarn`will be skipped, which might have unexpected consequences. Take a look at`;
+      logWarn`the report above to see what needs fixing up, for a more complete build!`;
+
+      console.log('');
+      paragraph = true;
 
       Object.assign(stepStatusSummary.filterReferenceErrors, {
         status: STATUS_HAS_WARNINGS,
@@ -1199,19 +1666,25 @@ async function main() {
 
     try {
       reportContentTextErrors(wikiData, {bindFind});
+
       logInfo`All content text validated without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.reportContentTextErrors, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
       });
     } catch (error) {
+      if (!paragraph) console.log('');
       niceShowAggregate(error);
 
       logWarn`The above errors were detected while processing content text in data files.`;
       logWarn`The wiki will still build, but placeholders will be displayed in these spots.`;
       logWarn`Resolve the errors for more complete output.`;
 
+      console.log('');
+      paragraph = true;
+
       Object.assign(stepStatusSummary.reportContentTextErrors, {
         status: STATUS_HAS_WARNINGS,
         annotation: `view log for details`,
@@ -1228,7 +1701,7 @@ async function main() {
     timeStart: Date.now(),
   });
 
-  sortWikiDataArrays(wikiData);
+  sortWikiDataArrays(yamlDataSteps, wikiData);
 
   Object.assign(stepStatusSummary.sortWikiDataArrays, {
     status: STATUS_DONE_CLEAN,
@@ -1523,6 +1996,7 @@ async function main() {
     }
 
     logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
+    paragraph = false;
 
     finalDefaultLanguage = customDefaultLanguage;
     finalDefaultLanguageAnnotation = `using wiki-specified custom default language`;
@@ -1603,6 +2077,7 @@ async function main() {
   }
 
   logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
+  paragraph = false;
 
   Object.assign(stepStatusSummary.initializeDefaultLanguage, {
     status: STATUS_DONE_CLEAN,
@@ -1610,8 +2085,6 @@ async function main() {
     timeEnd: Date.now(),
   });
 
-  const urls = generateURLs(urlSpec);
-
   let missingImagePaths;
 
   if (stepStatusSummary.verifyImagePaths.status === STATUS_NOT_APPLICABLE) {
@@ -1735,6 +2208,7 @@ async function main() {
     await fileSizePreloader.waitUntilDoneLoading();
 
     logInfo`Preloading filesizes for ${imageFilePaths.length} full-resolution images...`;
+    paragraph = false;
 
     fileSizePreloader.loadPaths(...imageFilePaths.map((path) => path.device));
     await fileSizePreloader.waitUntilDoneLoading();
@@ -1751,6 +2225,7 @@ async function main() {
       });
     } else {
       logInfo`Done preloading filesizes without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.preloadFileSizes, {
         status: STATUS_DONE_CLEAN,
@@ -1759,6 +2234,110 @@ async function main() {
     }
   }
 
+  if (stepStatusSummary.buildSearchIndex.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.buildSearchIndex, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    try {
+      await writeSearchData({
+        thumbsCache,
+        urls,
+        wikiCachePath,
+        wikiData,
+      });
+
+      logInfo`Search data successfully written - nice!`;
+      paragraph = false;
+
+      Object.assign(stepStatusSummary.buildSearchIndex, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+
+      logError`There was an error preparing or writing search data.`;
+      fileIssue();
+      logWarn`Any existing search data will be reused, and search may be`;
+      logWarn`generally dysfunctional. The site should work otherwise, though!`;
+
+      console.log('');
+      paragraph = true;
+
+      Object.assign(stepStatusSummary.buildSearchIndex, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `see log for details`,
+        timeEnd: Date.now(),
+      });
+    }
+  }
+
+  let webRouteSources = null;
+  let preparedWebRoutes = null;
+
+  if (stepStatusSummary.identifyWebRoutes.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.identifyWebRoutes, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const fromRoot = urls.from('shared.root');
+
+    try {
+      webRouteSources = await identifyAllWebRoutes({
+        mediaCachePath,
+        mediaPath,
+        wikiCachePath,
+      });
+
+      const {aggregate, result} =
+        mapAggregate(
+          webRouteSources,
+          ({to, ...rest}) => ({
+            ...rest,
+            to: fromRoot.to(...to),
+          }),
+          {message: `Errors computing effective web route paths`},);
+
+      aggregate.close();
+      preparedWebRoutes = result;
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+
+      logError`There was an issue identifying web routes!`;
+      fileIssue();
+
+      console.log('');
+      paragraph = true;
+
+      Object.assign(stepStatusSummary.identifyWebRoutes, {
+        status: STATUS_FATAL_ERROR,
+        message: `JavaScript error - view log for details`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
+    }
+
+    logInfo`Successfully determined web routes - nice!`;
+    paragraph = false;
+
+    Object.assign(stepStatusSummary.identifyWebRoutes, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
+  }
+
+  wikiData.wikiInfo.searchDataAvailable =
+    (webRouteSources
+      ? webRouteSources
+          .some(({to}) => to[0].startsWith('searchData'))
+      : null);
+
   if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
     return true;
   }
@@ -1800,12 +2379,16 @@ async function main() {
 
   let buildModeResult;
 
+  logInfo`Passing control over to build mode: ${selectedBuildModeFlag}`;
+  console.log('');
+
   try {
     buildModeResult = await selectedBuildMode.go({
       cliOptions,
       dataPath,
       mediaPath,
       mediaCachePath,
+      wikiCachePath,
       queueSize,
       srcRootPath: __dirname,
 
@@ -1815,9 +2398,9 @@ async function main() {
       thumbsCache,
       urls,
       urlSpec,
+      webRoutes: preparedWebRoutes,
       wikiData,
 
-      cachebust: '?' + CACHEBUST,
       closeLanguageWatchers,
       developersComment,
       getSizeOfAdditionalFile,
diff --git a/src/url-spec.js b/src/url-spec.js
index ea5337a2..abb93053 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -1,12 +1,22 @@
 import {withEntries} from '#sugar';
 
+// Static files are all grouped under a `static-${STATIC_VERSION}` folder as
+// part of a build. This is so that multiple builds of a wiki can coexist
+// served from the same server / file system root: older builds' HTML files
+// refer to earlier values of STATIC_VERSION, avoiding name collisions.
+const STATIC_VERSION = '3r1';
+
+const genericPaths = {
+  root: '',
+  path: '<>',
+};
+
 const urlSpec = {
   data: {
     prefix: 'data/',
 
     paths: {
-      root: '',
-      path: '<>',
+      ...genericPaths,
 
       album: 'album/<>',
       artist: 'artist/<>',
@@ -19,8 +29,7 @@ const urlSpec = {
     // prefix: '_languageCode',
 
     paths: {
-      root: '',
-      path: '<>',
+      ...genericPaths,
       page: '<>/',
 
       home: '',
@@ -60,26 +69,42 @@ const urlSpec = {
   },
 
   shared: {
-    paths: {
-      root: '',
-      path: '<>',
+    paths: genericPaths,
+  },
 
-      utilityRoot: 'util',
-      staticRoot: 'static',
+  staticCSS: {
+    prefix: `static-${STATIC_VERSION}/css/`,
+    paths: genericPaths,
+  },
+
+  staticJS: {
+    prefix: `static-${STATIC_VERSION}/js/`,
+    paths: genericPaths,
+  },
 
-      utilityFile: 'util/<>',
-      staticFile: 'static/<>?<>',
+  staticLib: {
+    prefix: `static-${STATIC_VERSION}/lib/`,
+    paths: genericPaths,
+  },
 
-      staticIcon: 'static/icons.svg#icon-<>',
+  staticMisc: {
+    prefix: `static-${STATIC_VERSION}/misc/`,
+    paths: {
+      ...genericPaths,
+      icon: 'icons.svg#icon-<>',
     },
   },
 
+  staticSharedUtil: {
+    prefix: `static-${STATIC_VERSION}/shared-util/`,
+    paths: genericPaths,
+  },
+
   media: {
     prefix: 'media/',
 
     paths: {
-      root: '',
-      path: '<>',
+      ...genericPaths,
 
       albumAdditionalFile: 'album-additional/<>/<>',
       albumBanner: 'album-art/<>/banner.<>',
@@ -96,11 +121,12 @@ const urlSpec = {
 
   thumb: {
     prefix: 'thumb/',
+    paths: genericPaths,
+  },
 
-    paths: {
-      root: '',
-      path: '<>',
-    },
+  searchData: {
+    prefix: 'search-data/',
+    paths: genericPaths,
   },
 };
 
diff --git a/src/util/aggregate.js b/src/util/aggregate.js
index f0023359..e8f45f3b 100644
--- a/src/util/aggregate.js
+++ b/src/util/aggregate.js
@@ -91,6 +91,46 @@ export function openAggregate({
     return aggregate.callAsync(() => withAggregateAsync(...args));
   };
 
+  aggregate.receive = (results) => {
+    if (!Array.isArray(results)) {
+      if (typeof results === 'object' && results.aggregate) {
+        const {aggregate, result} = results;
+
+        try {
+          aggregate.close();
+        } catch (error) {
+          errors.push(error);
+        }
+
+        return result;
+      }
+
+      throw new Error(`Expected an array or {aggregate, result} object`);
+    }
+
+    return results.map(({aggregate, result}) => {
+      if (!aggregate) {
+        console.log('nope:', results);
+        throw new Error(`Expected an array of {aggregate, result} objects`);
+      }
+
+      try {
+        aggregate.close();
+      } catch (error) {
+        errors.push(error);
+      }
+
+      return result;
+    });
+  };
+
+  aggregate.contain = (results) => {
+    return {
+      aggregate,
+      result: aggregate.receive(results),
+    };
+  };
+
   aggregate.map = (...args) => {
     const parent = aggregate;
     const {result, aggregate: child} = mapAggregate(...args);
@@ -136,18 +176,33 @@ export function aggregateThrows(errorClass) {
   return {[openAggregate.errorClassSymbol]: errorClass};
 }
 
-// Helper function for allowing both (fn, aggregateOpts) and (aggregateOpts, fn)
-// in aggregate utilities.
-function _reorganizeAggregateArguments(arg1, arg2) {
-  if (typeof arg1 === 'function') {
-    return {fn: arg1, opts: arg2 ?? {}};
-  } else if (typeof arg2 === 'function') {
-    return {fn: arg2, opts: arg1 ?? {}};
+// Helper function for allowing both (fn, opts) and (opts, fn) in aggregate
+// utilities (or other shapes besides functions).
+function _reorganizeAggregateArguments(arg1, arg2, desire = v => typeof v === 'function') {
+  if (desire(arg1)) {
+    return [arg1, arg2 ?? {}];
+  } else if (desire(arg2)) {
+    return [arg2, arg1];
   } else {
-    throw new Error(`Expected a function`);
+    return [undefined, undefined];
   }
 }
 
+// Takes a list of {aggregate, result} objects, puts all the aggregates into
+// a new aggregate, and puts all the results into an array, returning both on
+// a new {aggregate, result} object. This is essentailly the generalized
+// composable version of functions like mapAggregate or filterAggregate.
+export function receiveAggregate(arg1, arg2) {
+  const [array, opts] = _reorganizeAggregateArguments(arg1, arg2, Array.isArray);
+  if (!array) {
+    throw new Error(`Expected an array`);
+  }
+
+  const aggregate = openAggregate(opts);
+  const result = aggregate.receive(array);
+  return {aggregate, result};
+}
+
 // Performs an ordinary array map with the given function, collating into a
 // results array (with errored inputs filtered out) and an error aggregate.
 //
@@ -158,12 +213,20 @@ function _reorganizeAggregateArguments(arg1, arg2) {
 // use aggregate.close() to throw the error. (This aggregate may be passed to a
 // parent aggregate: `parent.call(aggregate.close)`!)
 export function mapAggregate(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _mapAggregate('sync', null, array, fn, opts);
 }
 
 export function mapAggregateAsync(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
   return _mapAggregate('async', promiseAll, array, fn, remainingOpts);
 }
@@ -200,12 +263,20 @@ export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 //
 // As with mapAggregate, the returned aggregate property is not yet closed.
 export function filterAggregate(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _filterAggregate('sync', null, array, fn, opts);
 }
 
 export async function filterAggregateAsync(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
   return _filterAggregate('async', promiseAll, array, fn, remainingOpts);
 }
@@ -268,12 +339,20 @@ function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 // function with it, then closing the function and returning the result (if
 // there's no throw).
 export function withAggregate(arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _withAggregate('sync', opts, fn);
 }
 
 export function withAggregateAsync(arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _withAggregate('async', opts, fn);
 }
 
@@ -294,7 +373,10 @@ export function _withAggregate(mode, aggregateOpts, fn) {
 
 export const unhelpfulTraceLines = [
   /sugar/,
+  /sort/,
   /aggregate/,
+  /composite/,
+  /cacheable-object/,
   /node:/,
   /<anonymous>/,
 ];
diff --git a/src/util/cli.js b/src/util/cli.js
index ce513f08..72979d3f 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -201,6 +201,79 @@ export async function parseOptions(options, optionDescriptorMap) {
   return result;
 }
 
+// Takes precisely the same sort of structure as `parseOptions` above,
+// and displays associated help messages. Radical!
+//
+// 'indentWrap' should be the function from '#sugar', with its wrap option
+//   already bound.
+//
+// 'sort' should take care of sorting a list of {name, descriptor} entries.
+export function showHelpForOptions({
+  heading,
+  options,
+  indentWrap,
+  sort = entries => entries,
+}) {
+  if (heading) {
+    console.log(colors.bright(heading));
+  }
+
+  const sortedOptions =
+    sort(
+      Object.entries(options)
+        .map(([name, descriptor]) => ({name, descriptor})));
+
+  if (!sortedOptions.length) {
+    console.log(`(No options available)`)
+  }
+
+  let justInsertedPaddingLine = false;
+
+  for (const {name, descriptor} of sortedOptions) {
+    if (descriptor.alias) {
+      continue;
+    }
+
+    const aliases =
+      Object.entries(options)
+        .filter(([_name, {alias}]) => alias === name)
+        .map(([name]) => name);
+
+    let wrappedHelp, wrappedHelpLines = 0;
+    if (descriptor.help) {
+      wrappedHelp = indentWrap(descriptor.help, {spaces: 4});
+      wrappedHelpLines = wrappedHelp.split('\n').length;
+    }
+
+    if (wrappedHelpLines > 0 && !justInsertedPaddingLine) {
+      console.log('');
+    }
+
+    console.log(colors.bright(` --` + name) +
+      (aliases.length
+        ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})`
+        : '') +
+      (descriptor.help
+        ? ''
+        : colors.dim('  (no help provided)')));
+
+    if (wrappedHelp) {
+      console.log(wrappedHelp);
+    }
+
+    if (wrappedHelpLines > 1) {
+      console.log('');
+      justInsertedPaddingLine = true;
+    } else {
+      justInsertedPaddingLine = false;
+    }
+  }
+
+  if (!justInsertedPaddingLine) {
+    console.log(``);
+  }
+}
+
 export const handleDashless = Symbol();
 export const handleUnknown = Symbol();
 
diff --git a/src/util/colors.js b/src/util/colors.js
index 50339cd3..7298c46a 100644
--- a/src/util/colors.js
+++ b/src/util/colors.js
@@ -15,6 +15,7 @@ export function getColors(themeColor, {
   const deep = primary.saturate(1.2).luminance(0.035);
   const deepGhost = deep.alpha(0.8);
   const light = chroma.average(['#ffffff', primary], 'rgb', [4, 1]);
+  const lightGhost = primary.luminance(0.8).saturate(4).alpha(0.08);
 
   const bg = primary.luminance(0.008).desaturate(3.5).alpha(0.8);
   const bgBlack = primary.saturate(1).luminance(0.0025).alpha(0.8);
@@ -31,6 +32,7 @@ export function getColors(themeColor, {
     deep: deep.hex(),
     deepGhost: deepGhost.hex(),
     light: light.hex(),
+    lightGhost: lightGhost.hex(),
 
     bg: bg.hex(),
     bgBlack: bgBlack.hex(),
diff --git a/src/util/external-links.js b/src/util/external-links.js
index 3b779afc..a616efb3 100644
--- a/src/util/external-links.js
+++ b/src/util/external-links.js
@@ -211,6 +211,18 @@ export const externalLinkSpec = [
   // Generic domains, sorted alphabetically (by string)
 
   {
+    match: {
+      domains: [
+        'music.amazon.co.jp',
+        'music.amazon.com',
+      ],
+    },
+
+    platform: 'amazonMusic',
+    icon: 'globe',
+  },
+
+  {
     match: {domain: 'music.apple.com'},
     platform: 'appleMusic',
     icon: 'appleMusic',
diff --git a/src/util/html.js b/src/util/html.js
index d1d509e2..6e892031 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -53,11 +53,18 @@ export const attributeSpec = {
   },
 };
 
-// Pass to tag() as an attributes key to make tag() return a 8lank string if the
+// Pass to tag() as an attributes key to make tag() return a 8lank tag if the
 // provided content is empty. Useful for when you'll only 8e showing an element
 // according to the presence of content that would 8elong there.
 export const onlyIfContent = Symbol();
 
+// Pass to tag() as an attributes key to make tag() return a blank tag if
+// this tag doesn't get shown beside any siblings! (I.e, siblings who don't
+// also have the [html.onlyIfSiblings] attribute.) Since they'd just be blank,
+// tags with [html.onlyIfSiblings] never make the difference in counting as
+// content for [html.onlyIfContent]. Useful for <summary> and such.
+export const onlyIfSiblings = Symbol();
+
 // Pass to tag() as an attributes key to make children be joined together by the
 // provided string. This is handy, for example, for joining lines by <br> tags,
 // or putting some other divider between each child. Note this will only have an
@@ -124,13 +131,18 @@ function isBlankArrayHelper(content) {
   // content of tags marked onlyIfContent) into one array,
   // and templates into another. And if there's anything
   // else, that's a non-blank condition we'll detect now.
+  // We'll flat-out skip items marked onlyIfSiblings,
+  // since they could never count as content alone
+  // (some other item will have to count).
 
   const arrayContent = [];
   const templateContent = [];
 
   for (const item of nonStringContent) {
     if (item instanceof Tag) {
-      if (item.onlyIfContent || item.contentOnly) {
+      if (item.onlyIfSiblings) {
+        continue;
+      } else if (item.onlyIfContent || item.contentOnly) {
         arrayContent.push(item.content);
       } else {
         return false;
@@ -416,6 +428,10 @@ export class Tag {
   }
 
   get blank() {
+    // Tags don't have a reference to their parent, so this only evinces
+    // something about this tag's own content or attributes. It does *not*
+    // account for [html.onlyIfSiblings]!
+
     if (this.onlyIfContent && isBlank(this.content)) {
       return true;
     }
@@ -477,6 +493,14 @@ export class Tag {
     return this.#getAttributeFlag(onlyIfContent);
   }
 
+  set onlyIfSiblings(value) {
+    this.#setAttributeFlag(onlyIfSiblings, value);
+  }
+
+  get onlyIfSiblings() {
+    return this.#getAttributeFlag(onlyIfSiblings);
+  }
+
   set joinChildren(value) {
     this.#setAttributeString(joinChildren, value);
   }
@@ -593,6 +617,8 @@ export class Tag {
     let content = '';
     let blockwrapClosers = '';
 
+    let seenSiblingIndependentContent = false;
+
     const chunkwrapSplitter =
       (this.chunkwrap
         ? this.#getAttributeString('split')
@@ -647,6 +673,10 @@ export class Tag {
         continue;
       }
 
+      if (!(item instanceof Tag && item.onlyIfSiblings)) {
+        seenSiblingIndependentContent = true;
+      }
+
       const chunkwrapChunks =
         (typeof item === 'string' && chunkwrapSplitter
           ? itemContent.split(chunkwrapSplitter)
@@ -658,28 +688,25 @@ export class Tag {
           : null);
 
       if (content) {
-        if (itemIncludesChunkwrapSplit) {
-          if (!seenChunkwrapSplitter) {
-            // The first time we see a chunkwrap splitter, backtrack and wrap
-            // the content *so far* in a chunk.
-            content = `<span class="chunkwrap">` + content;
-          }
-
-          // Close the existing chunk. We'll add the new chunks after the
-          // (normal) joiner.
-          content += `</span>`;
+        if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) {
+          // The first time we see a chunkwrap splitter, backtrack and wrap
+          // the content *so far* in a chunk. This will be treated just like
+          // any other open chunkwrap, and closed after the first chunk of
+          // this item! (That means the existing content is part of the same
+          // chunk as the first chunk included in this content, which makes
+          // sense, because that first chink is really just more text that
+          // precedes the first split.)
+          content = `<span class="chunkwrap">` + content;
         }
 
         content += joiner;
-      } else {
+      } else if (itemIncludesChunkwrapSplit) {
         // We've encountered a chunkwrap split before any other content.
         // This means there's no content to wrap, no existing chunkwrap
         // to close, and no reason to add a joiner, but we *do* need to
         // enter a chunkwrap wrapper *now*, so the first chunk of this
         // item will be properly wrapped.
-        if (itemIncludesChunkwrapSplit) {
-          content = `<span class="chunkwrap">`;
-        }
+        content = `<span class="chunkwrap">`;
       }
 
       if (itemIncludesChunkwrapSplit) {
@@ -700,6 +727,10 @@ export class Tag {
         if (itemIncludesChunkwrapSplit) {
           for (const [index, chunk] of chunkwrapChunks.entries()) {
             if (index === 0) {
+              // The first chunk isn't actually a chunk all on its own, it's
+              // text that should be appended to the previous chunk. We will
+              // close this chunk as the first appended content as we process
+              // the next chunk.
               content += chunk;
             } else {
               const whitespace = chunk.match(/^\s+/) ?? '';
@@ -718,6 +749,12 @@ export class Tag {
       }
     }
 
+    // If we've only seen sibling-dependent content (or just no content),
+    // then the content in total is blank.
+    if (!seenSiblingIndependentContent) {
+      return '';
+    }
+
     if (chunkwrapSplitter) {
       if (seenChunkwrapSplitter) {
         content += '</span>';
@@ -1101,8 +1138,17 @@ export class Attributes {
     return this.#attributes[attribute];
   }
 
-  has(attribute) {
-    return attribute in this.#attributes;
+  has(attribute, pattern) {
+    if (typeof pattern === 'undefined') {
+      return attribute in this.#attributes;
+    } else if (this.has(attribute)) {
+      const value = this.get(attribute);
+      if (Array.isArray(value)) {
+        return value.includes(pattern);
+      } else {
+        return value === pattern;
+      }
+    }
   }
 
   remove(attribute) {
@@ -1338,6 +1384,22 @@ export function smush(smushee) {
   return smush(Tag.normalize(smushee));
 }
 
+// Much gentler version of smush - this only flattens nested html.tags(), and
+// guarantees the result is itself an html.tags(). It doesn't manipulate text
+// content, and it doesn't resolve templates.
+export function smooth(smoothie) {
+  // Helper function to avoid intermediate html.tags() calls.
+  function helper(tag) {
+    if (tag instanceof Tag && tag.contentOnly) {
+      return tag.content.flatMap(helper);
+    } else {
+      return tag;
+    }
+  }
+
+  return tags(helper(smoothie));
+}
+
 export function template(description) {
   return new Template(description);
 }
diff --git a/src/util/search-spec.js b/src/util/search-spec.js
new file mode 100644
index 00000000..79e8ee95
--- /dev/null
+++ b/src/util/search-spec.js
@@ -0,0 +1,256 @@
+// Index structures shared by client and server, and relevant interfaces.
+
+function getArtworkPath(thing) {
+  switch (thing.constructor[Symbol.for('Thing.referenceType')]) {
+    case 'album': {
+      return [
+        'media.albumCover',
+        thing.directory,
+        thing.coverArtFileExtension,
+      ];
+    }
+
+    case 'flash': {
+      return [
+        'media.flashArt',
+        thing.directory,
+        thing.coverArtFileExtension,
+      ];
+    }
+
+    case 'track': {
+      if (thing.hasUniqueCoverArt) {
+        return [
+          'media.trackCover',
+          thing.album.directory,
+          thing.directory,
+          thing.coverArtFileExtension,
+        ];
+      } else if (thing.album.hasCoverArt) {
+        return [
+          'media.albumCover',
+          thing.album.directory,
+          thing.album.coverArtFileExtension,
+        ];
+      } else {
+        return null;
+      }
+    }
+
+    default:
+      return null;
+  }
+}
+
+function prepareArtwork(thing, {
+  checkIfImagePathHasCachedThumbnails,
+  getThumbnailEqualOrSmaller,
+  urls,
+}) {
+  const hasWarnings =
+    thing.artTags?.some(artTag => artTag.isContentWarning);
+
+  const artworkPath =
+    getArtworkPath(thing);
+
+  if (!artworkPath) {
+    return undefined;
+  }
+
+  const mediaSrc =
+    urls
+      .from('media.root')
+      .to(...artworkPath);
+
+  if (!checkIfImagePathHasCachedThumbnails(mediaSrc)) {
+    return undefined;
+  }
+
+  const selectedSize =
+    getThumbnailEqualOrSmaller(
+      (hasWarnings ? 'mini' : 'adorb'),
+      mediaSrc);
+
+  const mediaSrcJpeg =
+    mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`);
+
+  const displaySrc =
+    urls
+      .from('thumb.root')
+      .to('thumb.path', mediaSrcJpeg);
+
+  const serializeSrc =
+    displaySrc.replace(thing.directory, '<>');
+
+  return serializeSrc;
+}
+
+export const searchSpec = {
+  generic: {
+    query: ({
+      albumData,
+      artTagData,
+      artistData,
+      flashData,
+      groupData,
+      trackData,
+    }) => [
+      albumData,
+
+      artTagData,
+
+      artistData
+        .filter(artist => !artist.isAlias),
+
+      flashData,
+
+      groupData,
+
+      trackData
+        // Exclude rereleases - there's no reasonable way to differentiate
+        // them from the main release as part of this query.
+        .filter(track => !track.originalReleaseTrack),
+    ].flat(),
+
+    process(thing, opts) {
+      const fields = {};
+
+      fields.primaryName =
+        thing.name;
+
+      fields.parentName =
+        (fields.kind === 'track'
+          ? thing.album.name
+       : fields.kind === 'group'
+          ? thing.category.name
+       : fields.kind === 'flash'
+          ? thing.act.name
+          : null);
+
+      fields.color =
+        thing.color;
+
+      fields.artTags =
+        (Object.hasOwn(thing, 'artTags')
+          ? thing.artTags.map(artTag => artTag.nameShort)
+          : []);
+
+      fields.additionalNames =
+        (Object.hasOwn(thing, 'additionalNames')
+          ? thing.additionalNames.map(entry => entry.name)
+       : Object.hasOwn(thing, 'aliasNames')
+          ? thing.aliasNames
+          : []);
+
+      const contribKeys = [
+        'artistContribs',
+        'bannerArtistContribs',
+        'contributorContribs',
+        'coverArtistContribs',
+        'wallpaperArtistContribs',
+      ];
+
+      const contributions =
+        contribKeys
+          .filter(key => Object.hasOwn(thing, key))
+          .flatMap(key => thing[key]);
+
+      fields.contributors =
+        contributions
+          .flatMap(({artist}) => [
+            artist.name,
+            ...artist.aliasNames,
+          ]);
+
+      const groups =
+         (Object.hasOwn(thing, 'groups')
+           ? thing.groups
+        : Object.hasOwn(thing, 'album')
+           ? thing.album.groups
+           : []);
+
+      const mainContributorNames =
+        contributions
+          .map(({artist}) => artist.name);
+
+      fields.groups =
+        groups
+          .filter(group => !mainContributorNames.includes(group.name))
+          .map(group => group.name);
+
+      fields.artwork =
+        prepareArtwork(thing, opts);
+
+      return fields;
+    },
+
+    index: [
+      'primaryName',
+      'parentName',
+      'artTags',
+      'additionalNames',
+      'contributors',
+      'groups',
+    ],
+
+    store: [
+      'primaryName',
+      'artwork',
+      'color',
+    ],
+  },
+};
+
+export function makeSearchIndex(descriptor, {FlexSearch}) {
+  return new FlexSearch.Document({
+    id: 'reference',
+    index: descriptor.index,
+    store: descriptor.store,
+  });
+}
+
+// TODO: This function basically mirrors bind-utilities.js, which isn't
+// exactly robust, but... binding might need some more thought across the
+// codebase in *general.*
+function bindSearchUtilities({
+  checkIfImagePathHasCachedThumbnails,
+  getThumbnailEqualOrSmaller,
+  thumbsCache,
+  urls,
+}) {
+  const bound = {
+    urls,
+  };
+
+  bound.checkIfImagePathHasCachedThumbnails =
+    (imagePath) =>
+      checkIfImagePathHasCachedThumbnails(imagePath, thumbsCache);
+
+  bound.getThumbnailEqualOrSmaller =
+    (preferred, imagePath) =>
+      getThumbnailEqualOrSmaller(preferred, imagePath, thumbsCache);
+
+  return bound;
+}
+
+export function populateSearchIndex(index, descriptor, opts) {
+  const {wikiData} = opts;
+  const bound = bindSearchUtilities(opts);
+
+  const collection = descriptor.query(wikiData);
+
+  for (const thing of collection) {
+    const reference = thing.constructor.getReference(thing);
+
+    let processed;
+    try {
+      processed = descriptor.process(thing, bound);
+    } catch (caughtError) {
+      throw new Error(
+        `Failed to process searchable thing ${reference}`,
+        {cause: caughtError});
+    }
+
+    index.add({reference, ...processed});
+  }
+}
diff --git a/src/util/serialize.js b/src/util/serialize.js
index 4992e2bf..eb18a759 100644
--- a/src/util/serialize.js
+++ b/src/util/serialize.js
@@ -14,10 +14,10 @@ export function serializeLink(thing) {
 }
 
 export function serializeContribs(contribs) {
-  return contribs.map(({who, what}) => {
+  return contribs.map(({artist, annotation}) => {
     const ret = {};
-    ret.artist = serializeLink(who);
-    if (what) ret.contribution = what;
+    ret.artist = serializeLink(artist);
+    if (annotation) ret.contribution = annotation;
     return ret;
   });
 }
diff --git a/src/util/sort.js b/src/util/sort.js
index b3a90812..9e9de641 100644
--- a/src/util/sort.js
+++ b/src/util/sort.js
@@ -388,7 +388,8 @@ export function sortFlashesChronologically(data, {
   getDate,
 } = {}) {
   // Group flashes by act...
-  sortByDirectory(data, {
+  sortAlphabetically(data, {
+    getName: flash => flash.act.name,
     getDirectory: flash => flash.act.directory,
   });
 
diff --git a/src/util/sugar.js b/src/util/sugar.js
index e060f458..3fa3fb46 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -136,6 +136,23 @@ export function stitchArrays(keyToArray) {
   return results;
 }
 
+// Like Map.groupBy! Collects the items of an unsorted array into buckets
+// according to a per-item computed value.
+export function groupArray(items, fn) {
+  const buckets = new Map();
+
+  for (const [index, item] of Array.prototype.entries.call(items)) {
+    const key = fn(item, index);
+    if (buckets.has(key)) {
+      buckets.get(key).push(item);
+    } else {
+      buckets.set(key, [item]);
+    }
+  }
+
+  return buckets;
+}
+
 // Turns this:
 //
 //   [
@@ -183,8 +200,14 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
     : arr1.every((x) => arr2.includes(x)));
 
 // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
-export const withEntries = (obj, fn) =>
-  Object.fromEntries(fn(Object.entries(obj)));
+export const withEntries = (obj, fn) => {
+  const result = fn(Object.entries(obj));
+  if (result instanceof Promise) {
+    return result.then(entries => Object.fromEntries(entries));
+  } else {
+    return Object.fromEntries(result);
+  }
+}
 
 export function setIntersection(set1, set2) {
   const intersection = new Set();
@@ -260,6 +283,16 @@ export function delay(ms) {
   return new Promise((res) => setTimeout(res, ms));
 }
 
+export function promiseWithResolvers() {
+  let obj = {};
+
+  obj.promise =
+    new Promise((...opts) =>
+      ([obj.resolve, obj.reject] = opts));
+
+  return obj;
+}
+
 // Stolen from here: https://stackoverflow.com/a/3561711
 //
 // There's a proposal for a native JS function like this, 8ut it's not even
@@ -315,6 +348,27 @@ export function cutStart(text, length = 40) {
   }
 }
 
+// Wrapper function around wrap(), ha, ha - this requires the Node module
+// 'node-wrap'.
+export function indentWrap(str, {
+  wrap,
+  spaces = 0,
+  width = 60,
+  bullet = false,
+}) {
+  const wrapped =
+    wrap(str, {
+      width: width - spaces,
+      indent: ' '.repeat(spaces),
+    });
+
+  if (bullet) {
+    return wrapped.trimStart();
+  } else {
+    return wrapped;
+  }
+}
+
 // Annotates {index, length} results from another iterator with contextual
 // details, including:
 //
diff --git a/src/web-routes.js b/src/web-routes.js
new file mode 100644
index 00000000..7e08d06f
--- /dev/null
+++ b/src/web-routes.js
@@ -0,0 +1,120 @@
+import {readdir} from 'node:fs/promises';
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const codeSrcPath = __dirname;
+const codeRootPath = path.resolve(codeSrcPath, '..');
+
+function getNodeDependencyRootPath(dependencyName) {
+  return (
+    path.dirname(
+      fileURLToPath(
+        import.meta.resolve(dependencyName))));
+}
+
+export const stationaryCodeRoutes = [
+  {
+    from: path.join(codeSrcPath, 'static', 'css'),
+    to: ['staticCSS.root'],
+  },
+
+  {
+    from: path.join(codeSrcPath, 'static', 'js'),
+    to: ['staticJS.root'],
+  },
+
+  {
+    from: path.join(codeSrcPath, 'static', 'misc'),
+    to: ['staticMisc.root'],
+  },
+
+  {
+    from: path.join(codeSrcPath, 'util'),
+    to: ['staticSharedUtil.root'],
+  },
+];
+
+function quickNodeDependency({
+  name,
+  path: subpath = '',
+}) {
+  const root = getNodeDependencyRootPath(name);
+
+  return [
+    {
+      from:
+        (subpath
+          ? path.join(root, subpath)
+          : root),
+
+      to: ['staticLib.path', name],
+    },
+  ];
+}
+
+export const dependencyRoutes = [
+  quickNodeDependency({
+    name: 'chroma-js',
+  }),
+
+  quickNodeDependency({
+    name: 'compress-json',
+    path: '..', // exit dist, access bundle.js
+  }),
+
+  quickNodeDependency({
+    name: 'flexsearch',
+  }),
+
+  quickNodeDependency({
+    name: 'msgpackr',
+    path: 'dist',
+  }),
+].flat();
+
+export const allStaticWebRoutes = [
+  ...stationaryCodeRoutes,
+  ...dependencyRoutes,
+];
+
+export async function identifyDynamicWebRoutes({
+  mediaPath,
+  mediaCachePath,
+  wikiCachePath,
+}) {
+  const routeFunctions = [
+    () => Promise.resolve([
+      {from: path.resolve(mediaPath), to: ['media.root']},
+      {from: path.resolve(mediaCachePath), to: ['thumb.root']},
+    ]),
+
+    () => {
+      if (!wikiCachePath) return [];
+
+      const from =
+        path.resolve(path.join(wikiCachePath, 'search'));
+
+      return (
+        readdir(from).then(
+          () => [{from, to: ['searchData.root']}],
+          () => []));
+    },
+  ];
+
+  const routeCheckPromises =
+    routeFunctions.map(fn => fn());
+
+  const routeCheckResults =
+    await Promise.all(routeCheckPromises);
+
+  return routeCheckResults.flat();
+}
+
+export async function identifyAllWebRoutes(opts) {
+  return [
+    ...allStaticWebRoutes,
+    ...await identifyDynamicWebRoutes(opts),
+  ];
+}
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index 3d4ecc7a..8dd08dba 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -19,7 +19,6 @@ import {
 
 export function bindUtilities({
   absoluteTo,
-  cachebust,
   defaultLanguage,
   getSizeOfAdditionalFile,
   getSizeOfImagePath,
@@ -36,7 +35,6 @@ export function bindUtilities({
 
   Object.assign(bound, {
     absoluteTo,
-    cachebust,
     defaultLanguage,
     getSizeOfAdditionalFile,
     getSizeOfImagePath,
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index 24e18320..b018bc1c 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -1,10 +1,11 @@
 import {spawn} from 'node:child_process';
 import * as http from 'node:http';
-import {readFile, stat} from 'node:fs/promises';
+import {open, stat} from 'node:fs/promises';
 import * as path from 'node:path';
+import {pipeline} from 'node:stream/promises';
 import {inspect as nodeInspect} from 'node:util';
 
-import {ENABLE_COLOR, logInfo, logWarn, progressCallAll} from '#cli';
+import {ENABLE_COLOR, colors, logInfo, logWarn, progressCallAll} from '#cli';
 import {watchContentDependencies} from '#content-dependencies';
 import {quickEvaluate} from '#content-function';
 import * as html from '#html';
@@ -23,23 +24,27 @@ import {generateRandomLinkDataJSON, generateRedirectHTML} from '../common-templa
 const defaultHost = '0.0.0.0';
 const defaultPort = 8002;
 
-export const description = `Hosts a local HTTP server which generates page content as it is requested, instead of all at once; reacts to changes in data files, so new reloads will be up-to-date with on-disk YAML data (<- not implemented yet, check back soon!)\n\nIntended for local development ONLY; this custom HTTP server is NOT rigorously tested and almost certainly has security flaws`;
+export const description = `Hosts a local HTTP server which generates page content as it is requested, instead of all at once\n\nIntended for local development ONLY; this custom HTTP server is NOT rigorously tested and almost certainly has security flaws`;
 
 export const config = {
   fileSizes: {
-    default: true,
+    default: 'perform',
   },
 
   languageReloading: {
-    default: true,
+    default: 'perform',
   },
 
   mediaValidation: {
-    default: true,
+    default: 'perform',
   },
 
   thumbs: {
-    default: true,
+    default: 'perform',
+  },
+
+  webRoutes: {
+    required: true,
   },
 };
 
@@ -88,9 +93,6 @@ export function getCLIOptions() {
 
 export async function go({
   cliOptions,
-  _dataPath,
-  mediaPath,
-  mediaCachePath,
 
   defaultLanguage,
   languages,
@@ -98,9 +100,9 @@ export async function go({
   srcRootPath,
   thumbsCache,
   urls,
+  webRoutes,
   wikiData,
 
-  cachebust,
   developersComment: _developersComment,
   getSizeOfAdditionalFile,
   getSizeOfImagePath,
@@ -211,7 +213,7 @@ export async function go({
 
         response.writeHead(200, contentTypeJSON);
         response.end(json);
-        if (loudResponses) console.log(`${requestHead} [200] ${pathname}`);
+        if (loudResponses) console.log(`${requestHead} [200] ${pathname} (${colors.yellow(`special`)})`);
       } catch (error) {
         response.writeHead(500, contentTypeJSON);
         response.end(`Internal error serializing wiki JSON`);
@@ -221,30 +223,27 @@ export async function go({
       return;
     }
 
-    const {
-      area: localFileArea,
-      path: localFilePath
-    } = pathname.match(/^\/(?<area>static|util|media|thumb)\/(?<path>.*)/)?.groups ?? {};
+    const matchedWebRoute =
+      webRoutes
+        .find(({to}) => pathname.startsWith('/' + to));
+
+    if (matchedWebRoute) {
+      const localFilePath = pathname.slice(1 + matchedWebRoute.to.length);
 
-    if (localFileArea) {
       // Not security tested, man, this is a dev server!!
-      const safePath = path.posix.resolve('/', localFilePath).replace(/^\//, '');
-
-      let localDirectory;
-      if (localFileArea === 'static' || localFileArea === 'util') {
-        localDirectory = path.join(srcRootPath, localFileArea);
-      } else if (localFileArea === 'media') {
-        localDirectory = mediaPath;
-      } else if (localFileArea === 'thumb') {
-        localDirectory = mediaCachePath;
-      }
+      const safePath =
+        path.posix
+          .resolve('/', localFilePath)
+          .replace(/^\//, '');
+
+      const localDirectory = matchedWebRoute.from;
 
       let filePath;
       try {
         filePath = path.resolve(localDirectory, decodeURI(safePath.split('/').join(path.sep)));
       } catch (error) {
         response.writeHead(404, contentTypePlain);
-        response.end(`No ${localFileArea} file found for: ${safePath}`);
+        response.end(`File not found for: ${safePath}`);
         console.log(`${requestHead} [404] ${pathname}`);
         console.log(`Failed to decode request pathname`);
       }
@@ -254,12 +253,12 @@ export async function go({
       } catch (error) {
         if (error.code === 'ENOENT') {
           response.writeHead(404, contentTypePlain);
-          response.end(`No ${localFileArea} file found for: ${safePath}`);
+          response.end(`File not found for: ${safePath}`);
           console.log(`${requestHead} [404] ${pathname}`);
           console.log(`ENOENT for stat: ${filePath}`);
         } else {
           response.writeHead(500, contentTypePlain);
-          response.end(`Internal error accessing ${localFileArea} file for: ${safePath}`);
+          response.end(`Internal error accessing file for: ${safePath}`);
           console.error(`${requestHead} [500] ${pathname}`);
           showError(error);
         }
@@ -300,21 +299,33 @@ export async function go({
         'zip': 'application/zip',
       }[extname];
 
+      let fd, size;
       try {
-        const {size} = await stat(filePath);
-        const buffer = await readFile(filePath)
-        response.writeHead(200, contentType ? {
-          'Content-Type': contentType,
-          'Content-Length': size,
-        } : {});
-        response.end(buffer);
-        if (loudResponses) console.log(`${requestHead} [200] ${pathname}`);
+        ({size} = await stat(filePath));
+        fd = await open(filePath);
       } catch (error) {
-        response.writeHead(500, contentTypePlain);
-        response.end(`Failed during file-to-response pipeline`);
-        console.error(`${requestHead} [500] ${pathname}`);
-        showError(error);
+        if (error.code === 'EISDIR') {
+          response.writeHead(404, contentTypePlain);
+          response.end(`File not found for: ${safePath}`);
+          console.error(`${requestHead} [404] ${pathname} (is directory)`);
+        } else {
+          response.writeHead(500, contentTypePlain);
+          response.end(`Failed during file-to-response pipeline`);
+          console.error(`${requestHead} [500] ${pathname}`);
+          showError(error);
+        }
+        return;
       }
+
+      response.writeHead(200, {
+        ...contentType ? {'Content-Type': contentType} : {},
+        'Content-Length': size,
+      });
+
+      await pipeline(fd.createReadStream(), response);
+
+      if (loudResponses) console.log(`${requestHead} [200] ${pathname} (${colors.magenta(`web route`)})`);
+
       return;
     }
 
@@ -326,7 +337,7 @@ export async function go({
     if (!Object.hasOwn(urlToPageMap, pathnameKey)) {
       response.writeHead(404, contentTypePlain);
       response.end(`No page found for: ${pathnameKey}\n`);
-      if (loudResponses) console.log(`${requestHead} [404] ${pathname}`);
+      if (loudResponses) console.log(`${requestHead} [404] ${pathname} (no page)`);
       return;
     }
 
@@ -387,7 +398,6 @@ export async function go({
 
       const bound = bindUtilities({
         absoluteTo,
-        cachebust,
         defaultLanguage,
         getSizeOfAdditionalFile,
         getSizeOfImagePath,
@@ -421,9 +431,9 @@ export async function go({
             ? `${(timeDelta / 1000).toFixed(2)}s`
             : `${timeDelta}ms`);
 
-        console.log(`${requestHead} [200, ${timeString}] ${pathname}`);
+        console.log(`${requestHead} [200, ${timeString}] ${pathname} (${colors.blue(`page`)})`);
       } else if (loudResponses) {
-        console.log(`${requestHead} [200] ${pathname}`);
+        console.log(`${requestHead} [200] ${pathname} (${colors.blue(`page`)})`);
       }
 
       response.writeHead(200, contentTypeHTML);
diff --git a/src/write/build-modes/repl.js b/src/write/build-modes/repl.js
index 20985595..faba8a34 100644
--- a/src/write/build-modes/repl.js
+++ b/src/write/build-modes/repl.js
@@ -6,13 +6,17 @@ export const config = {
   },
 
   languageReloading: {
-    default: true,
+    default: 'perform',
   },
 
   mediaValidation: {
     default: 'skip',
   },
 
+  search: {
+    default: 'skip',
+  },
+
   thumbs: {
     applicable: false,
   },
@@ -51,6 +55,7 @@ export async function getContextAssignments({
   missingImagePaths,
   thumbsCache,
   urls,
+  webRoutes,
   wikiData,
 
   getSizeOfAdditionalFile,
@@ -78,6 +83,7 @@ export async function getContextAssignments({
     missingImagePaths,
     thumbsCache,
     urls,
+    webRoutes,
 
     wikiData,
     ...wikiData,
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index a355a002..1ab0604e 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -38,7 +38,7 @@ export const description = `Generates all page content in one build (according t
 
 export const config = {
   fileSizes: {
-    default: true,
+    default: 'perform',
   },
 
   languageReloading: {
@@ -46,11 +46,19 @@ export const config = {
   },
 
   mediaValidation: {
-    default: true,
+    default: 'perform',
+  },
+
+  search: {
+    default: 'perform',
   },
 
   thumbs: {
-    default: true,
+    default: 'perform',
+  },
+
+  webRoutes: {
+    required: true,
   },
 };
 
@@ -99,9 +107,7 @@ export function getCLIOptions() {
 
 export async function go({
   cliOptions,
-  _dataPath,
   mediaPath,
-  mediaCachePath,
   queueSize,
 
   defaultLanguage,
@@ -110,9 +116,9 @@ export async function go({
   srcRootPath,
   thumbsCache,
   urls,
+  webRoutes,
   wikiData,
 
-  cachebust,
   developersComment: _developersComment,
   getSizeOfAdditionalFile,
   getSizeOfImagePath,
@@ -148,12 +154,9 @@ export async function go({
 
   await mkdir(outputPath, {recursive: true});
 
-  await writeSymlinks({
-    srcRootPath,
-    mediaPath,
-    mediaCachePath,
+  await writeWebRouteSymlinks({
     outputPath,
-    urls,
+    webRoutes,
   });
 
   if (writeAll) {
@@ -306,7 +309,6 @@ export async function go({
 
         const bound = bindUtilities({
           absoluteTo,
-          cachebust,
           defaultLanguage,
           getSizeOfAdditionalFile,
           getSizeOfImagePath,
@@ -432,42 +434,41 @@ async function writePage({
   ].filter(Boolean));
 }
 
-function writeSymlinks({
-  srcRootPath,
-  mediaPath,
-  mediaCachePath,
+function writeWebRouteSymlinks({
   outputPath,
-  urls,
+  webRoutes,
 }) {
-  return progressPromiseAll('Writing site symlinks.', [
-    link(path.join(srcRootPath, 'util'), 'shared.utilityRoot'),
-    link(path.join(srcRootPath, 'static'), 'shared.staticRoot'),
-    link(mediaPath, 'media.root'),
-    link(mediaCachePath, 'thumb.root'),
-  ]);
-
-  async function link(directory, urlKey) {
-    const pathname = urls.from('shared.root').toDevice(urlKey);
-    const file = path.join(outputPath, pathname);
-
-    try {
-      await unlink(file);
-    } catch (error) {
-      if (error.code !== 'ENOENT') {
-        throw error;
+  const promises =
+    webRoutes.map(async route => {
+      const parts = route.to.split('/');
+      const parentDirectoryParts = parts.slice(0, -1);
+      const symlinkNamePart = parts.at(-1);
+
+      const parentDirectory = path.join(outputPath, ...parentDirectoryParts);
+      const symlinkPath = path.join(parentDirectory, symlinkNamePart);
+
+      try {
+        await unlink(symlinkPath);
+      } catch (error) {
+        if (error.code !== 'ENOENT') {
+          throw error;
+        }
       }
-    }
 
-    try {
-      await symlink(path.resolve(directory), file);
-    } catch (error) {
-      if (error.code === 'EPERM') {
-        await symlink(path.resolve(directory), file, 'junction');
-      } else {
-        throw error;
+      await mkdir(parentDirectory, {recursive: true});
+
+      try {
+        await symlink(route.from, symlinkPath);
+      } catch (error) {
+        if (error.code === 'EPERM') {
+          await symlink(route.from, symlinkPath, 'junction');
+        } else {
+          throw error;
+        }
       }
-    }
-  }
+    });
+
+  return progressPromiseAll(`Writing web route symlinks.`, promises);
 }
 
 async function writeFavicon({
diff --git a/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs b/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs
deleted file mode 100644
index e166140a..00000000
--- a/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs
+++ /dev/null
@@ -1,14 +0,0 @@
-/* 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/generateAlbumCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs
index 71d9c55d..47df3e20 100644
--- a/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs
@@ -15,7 +15,7 @@ exports[`test/snapshot/generateAlbumCoverArtwork.js > TAP > generateAlbumCoverAr
      { name: 'creepy crawlies', isContentWarning: true }
    ]
  ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'medium', reveal: true, link: true, square: true }]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'medium', reveal: true, link: true, dimensions: [ 400, 300 ] }]
 <ul class="image-details">
     <li><a href="tag/damara/">Damara</a></li>
     <li><a href="tag/cronus/">Cronus</a></li>
@@ -33,5 +33,5 @@ exports[`test/snapshot/generateAlbumCoverArtwork.js > TAP > generateAlbumCoverAr
      { name: 'creepy crawlies', isContentWarning: true }
    ]
  ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'small', reveal: false, link: false, square: true }]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'small', reveal: false, link: false, dimensions: [ 400, 300 ] }]
 `
diff --git a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
index f9fc025b..4a7f35c3 100644
--- a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
@@ -8,9 +8,9 @@
 exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > basic behavior 1`] = `
 <p>
     By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (music probably)</span> and <span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/tensei/">Tensei</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tenseimusic.bandcamp.com/">
-                        <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
+                        <svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg>
                         <span class="icon-text">tenseimusic</span>
-                    </a></span></span></span> (hot jams)</span>.
+                    </a><span class="icon-platform">Bandcamp</span></span></span></span> (hot jams)</span>.
     <br>
     Cover art by <a href="artist/hb/">Hanni Brosh</a>.
     <br>
@@ -24,7 +24,7 @@ exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseI
     <br>
     Duration: ~10:25.
 </p>
-<p>Listen on <a href="https://homestuck.bandcamp.com/album/alterniabound-with-alternia" class="nowrap">Bandcamp</a>, <a href="https://www.youtube.com/playlist?list=PLnVpmehyaOFZWO9QOZmD6A3TIK0wZ6xE2" class="nowrap">YouTube (playlist)</a>, or <a href="https://www.youtube.com/watch?v=HO5V2uogkYc" class="nowrap">YouTube (full album)</a>.</p>
+<p>Listen on <a class="external-link" href="https://homestuck.bandcamp.com/album/alterniabound-with-alternia">Bandcamp</a>, <a class="external-link" href="https://www.youtube.com/playlist?list=PLnVpmehyaOFZWO9QOZmD6A3TIK0wZ6xE2">YouTube (playlist)</a>, or <a class="external-link" href="https://www.youtube.com/watch?v=HO5V2uogkYc">YouTube (full album)</a>.</p>
 `
 
 exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > equal cover art date 1`] = `
@@ -36,5 +36,5 @@ exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseI
 `
 
 exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > URLs only 1`] = `
-<p>Listen on <a href="https://homestuck.bandcamp.com/foo" class="nowrap">Bandcamp</a> or <a href="https://soundcloud.com/bar" class="nowrap">SoundCloud</a>.</p>
+<p>Listen on <a class="external-link" href="https://homestuck.bandcamp.com/foo">Bandcamp</a> or <a class="external-link" href="https://soundcloud.com/bar">SoundCloud</a>.</p>
 `
diff --git a/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs
index f2b51cb5..de35048c 100644
--- a/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs
@@ -7,11 +7,11 @@
 'use strict'
 exports[`test/snapshot/generateAlbumSecondaryNav.js > TAP > generateAlbumSecondaryNav (snapshot) > basic behavior, mode: album 1`] = `
 <nav id="secondary-nav" class="nav-links-groups">
-    <span style="--primary-color: #abcdef">
+    <span class="nav-link" style="--primary-color: #abcdef">
         <a href="group/vcg/">VCG</a>
         (<a title="First" href="album/first/">Previous</a>, <a title="Last" href="album/last/">Next</a>)
     </span>
-    <span style="--primary-color: #123456">
+    <span class="nav-link" style="--primary-color: #123456">
         <a href="group/bepis/">Bepis</a>
         (<a title="Second" href="album/second/">Next</a>)
     </span>
@@ -20,14 +20,14 @@ exports[`test/snapshot/generateAlbumSecondaryNav.js > TAP > generateAlbumSeconda
 
 exports[`test/snapshot/generateAlbumSecondaryNav.js > TAP > generateAlbumSecondaryNav (snapshot) > basic behavior, mode: track 1`] = `
 <nav id="secondary-nav" class="nav-links-groups">
-    <a style="--primary-color: #abcdef" href="group/vcg/">VCG</a>
-    <a style="--primary-color: #123456" href="group/bepis/">Bepis</a>
+    <span class="nav-link" style="--primary-color: #abcdef"><a href="group/vcg/">VCG</a></span>
+    <span class="nav-link" style="--primary-color: #123456"><a href="group/bepis/">Bepis</a></span>
 </nav>
 `
 
 exports[`test/snapshot/generateAlbumSecondaryNav.js > TAP > generateAlbumSecondaryNav (snapshot) > dateless album in mixed group 1`] = `
 <nav id="secondary-nav" class="nav-links-groups">
-    <a style="--primary-color: #abcdef" href="group/vcg/">VCG</a>
-    <a style="--primary-color: #123456" href="group/bepis/">Bepis</a>
+    <span class="nav-link" style="--primary-color: #abcdef"><a href="group/vcg/">VCG</a></span>
+    <span class="nav-link" style="--primary-color: #123456"><a href="group/bepis/">Bepis</a></span>
 </nav>
 `
diff --git a/tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs
index 0b7a0f77..6502f719 100644
--- a/tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs
@@ -6,7 +6,7 @@
  */
 'use strict'
 exports[`test/snapshot/generateAlbumSidebarGroupBox.js > TAP > generateAlbumSidebarGroupBox (snapshot) > basic behavior, mode: album 1`] = `
-<div class="sidebar individual-group-sidebar-box">
+<div class="sidebar collapsible individual-group-sidebar-box">
     <h1><a href="group/vcg/">VCG</a></h1>
     Very cool group.
     <p>Visit on <a class="external-link" href="https://vcg.bandcamp.com/">Bandcamp</a> or <a class="external-link" href="https://youtube.com/@vcg">YouTube</a>.</p>
@@ -16,14 +16,14 @@ exports[`test/snapshot/generateAlbumSidebarGroupBox.js > TAP > generateAlbumSide
 `
 
 exports[`test/snapshot/generateAlbumSidebarGroupBox.js > TAP > generateAlbumSidebarGroupBox (snapshot) > basic behavior, mode: track 1`] = `
-<div class="sidebar individual-group-sidebar-box">
+<div class="sidebar collapsible individual-group-sidebar-box">
     <h1><a href="group/vcg/">VCG</a></h1>
     <p>Visit on <a class="external-link" href="https://vcg.bandcamp.com/">Bandcamp</a> or <a class="external-link" href="https://youtube.com/@vcg">YouTube</a>.</p>
 </div>
 `
 
 exports[`test/snapshot/generateAlbumSidebarGroupBox.js > TAP > generateAlbumSidebarGroupBox (snapshot) > dateless album in mixed group 1`] = `
-<div class="sidebar individual-group-sidebar-box">
+<div class="sidebar collapsible individual-group-sidebar-box">
     <h1><a href="group/vcg/">VCG</a></h1>
     Very cool group.
     <p>Visit on <a class="external-link" href="https://vcg.bandcamp.com/">Bandcamp</a> or <a class="external-link" href="https://youtube.com/@vcg">YouTube</a>.</p>
diff --git a/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
index 3b6676f8..b338f293 100644
--- a/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
@@ -10,7 +10,7 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
 </ul>
 `
 
@@ -25,7 +25,7 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
         </ul>
     </dd>
     <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
-    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li></ul></dd>
 </dl>
 `
 
@@ -40,17 +40,17 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
         </ul>
     </dd>
     <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
-    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
 </ul>
 <ul>
     <li><a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
 </ul>
 `
 
@@ -65,17 +65,17 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
         </ul>
     </dd>
     <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
-    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
 </ul>
 <ul>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
 </ul>
 `
 
@@ -90,17 +90,17 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
         </ul>
     </dd>
     <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
-    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
 </ul>
 <ul>
     <li><a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
 </ul>
 `
 
@@ -115,16 +115,16 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
         </ul>
     </dd>
     <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
-    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li><a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
 </ul>
 <ul>
     <li><a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <a href="artist/apricot/">Apricot</a>,</span> <span class="chunkwrap"><a href="artist/peach/">Peach</a>,</span> <span class="chunkwrap">and <a href="artist/cerise/">Cerise</a></span></span></li>
 </ul>
 `
diff --git a/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs
index 1d21e47d..29399c70 100644
--- a/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs
@@ -15,7 +15,7 @@ exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverAr
      { name: 'creepy crawlies', isContentWarning: true }
    ]
  ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'medium', reveal: true, link: true, square: true }]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'medium', reveal: true, link: true, dimensions: [ 400, 300 ] }]
 <ul class="image-details">
     <li><a href="tag/damara/">Damara</a></li>
     <li><a href="tag/cronus/">Cronus</a></li>
@@ -40,7 +40,7 @@ exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverAr
      { name: 'creepy crawlies', isContentWarning: true }
    ]
  ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'small', reveal: false, link: false, square: true }]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'small', reveal: false, link: false, dimensions: [ 400, 300 ] }]
 `
 
 exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverArtwork (snapshot) > display: thumbnail - unique art 1`] = `
diff --git a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
index 3d988dce..e35f9358 100644
--- a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
@@ -13,7 +13,7 @@ exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseI
     <br>
     Duration: 0:58.
 </p>
-<p>Listen on <a href="https://soundcloud.com/foo" class="nowrap">SoundCloud</a> or <a href="https://youtube.com/watch?v=bar" class="nowrap">YouTube</a>.</p>
+<p>Listen on <a class="external-link" href="https://soundcloud.com/foo">SoundCloud</a> or <a class="external-link" href="https://youtube.com/watch?v=bar">YouTube</a>.</p>
 `
 
 exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > cover artist contribs, non-unique 1`] = `
diff --git a/tap-snapshots/test/snapshot/image.js.test.cjs b/tap-snapshots/test/snapshot/image.js.test.cjs
index ddfb3e6c..283f4352 100644
--- a/tap-snapshots/test/snapshot/image.js.test.cjs
+++ b/tap-snapshots/test/snapshot/image.js.test.cjs
@@ -12,7 +12,7 @@ exports[`test/snapshot/image.js > TAP > image (snapshot) > content warnings via
             <img class="image" src="media/album-art/beyond-canon/cover.png">
             <span class="reveal-text-container">
                 <span class="reveal-text">
-                    <img class="reveal-symbol" src="static/warning.svg?413">
+                    <img class="reveal-symbol" src="static/misc/warning.svg">
                     <br>
                     <span class="reveal-warnings">too cool for school</span>
                     <br>
@@ -24,6 +24,14 @@ exports[`test/snapshot/image.js > TAP > image (snapshot) > content warnings via
 </div>
 `
 
+exports[`test/snapshot/image.js > TAP > image (snapshot) > dimensions 1`] = `
+<div class="image-container"><div class="image-outer-area"><div class="image-inner-area"><img class="image" width="600" height="400" src="foobar"></div></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > dimensions with square 1`] = `
+<div class="image-container square"><div class="image-outer-area square-content"><div class="image-inner-area"><img class="image" width="600" height="400" src="foobar"></div></div></div>
+`
+
 exports[`test/snapshot/image.js > TAP > image (snapshot) > lazy with square 1`] = `
 <noscript><div class="image-container square"><div class="image-outer-area square-content"><div class="image-inner-area"><img class="image" src="foobar"></div></div></div></noscript>
 <div class="image-container square js-hide"><div class="image-outer-area square-content"><div class="image-inner-area"><img class="image lazy" data-original="foobar"></div></div></div>
@@ -64,7 +72,3 @@ exports[`test/snapshot/image.js > TAP > image (snapshot) > thumb requested but s
 exports[`test/snapshot/image.js > TAP > image (snapshot) > thumbnail details 1`] = `
 <div class="image-container"><div class="image-outer-area"><div class="image-inner-area"><img class="image" data-original-length="1200" data-thumbs="voluminous:1200 middling:900 petite:20" src="thumb/album-art/beyond-canon/cover.voluminous.jpg"></div></div></div>
 `
-
-exports[`test/snapshot/image.js > TAP > image (snapshot) > width & height 1`] = `
-<div class="image-container"><div class="image-outer-area"><div class="image-inner-area"><img class="image" width="600" height="400" src="foobar"></div></div></div>
-`
diff --git a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
index 20f5adcb..92d697e7 100644
--- a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
+++ b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
@@ -8,53 +8,53 @@
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (inline) 1`] = `
 <span class="contribution nowrap"><a href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a> (<span class="icons icons-inline"><a class="icon" href="https://loremipsum.io">
             <svg>
-                <title>External (loremipsum.io)</title>
-                <use href="static/icons.svg#icon-globe"></use>
+                <title>loremipsum.io</title>
+                <use href="static/misc/icons.svg#icon-globe"></use>
             </svg>
         </a>, <a class="icon" href="https://loremipsum.io/generator/">
             <svg>
-                <title>External (loremipsum.io)</title>
-                <use href="static/icons.svg#icon-globe"></use>
+                <title>loremipsum.io</title>
+                <use href="static/misc/icons.svg#icon-globe"></use>
             </svg>
         </a>, <a class="icon" href="https://loremipsum.io/#meaning">
             <svg>
-                <title>External (loremipsum.io)</title>
-                <use href="static/icons.svg#icon-globe"></use>
+                <title>loremipsum.io</title>
+                <use href="static/misc/icons.svg#icon-globe"></use>
             </svg>
         </a>, <a class="icon" href="https://loremipsum.io/#usage-and-examples">
             <svg>
-                <title>External (loremipsum.io)</title>
-                <use href="static/icons.svg#icon-globe"></use>
+                <title>loremipsum.io</title>
+                <use href="static/misc/icons.svg#icon-globe"></use>
             </svg>
         </a></span>)</span>
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (tooltip) 1`] = `
 <span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://loremipsum.io">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
                     <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/generator/">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/generator/">
+                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
                     <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#meaning">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#meaning">
+                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
                     <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#usage-and-examples">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#usage-and-examples">
+                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
                     <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#controversy">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#controversy">
+                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
                     <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#when-to-use-lorem-ipsum">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#when-to-use-lorem-ipsum">
+                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
                     <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#lorem-ipsum-all-the-things">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#lorem-ipsum-all-the-things">
+                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
                     <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#original-source">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                </a><span class="icon-platform">Other</span><a class="icon has-text" href="https://loremipsum.io/#original-source">
+                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
                     <span class="icon-text">loremipsum.io</span>
-                </a></span></span></span></span>
+                </a><span class="icon-platform">Other</span></span></span></span></span>
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no accents 1`] = `
@@ -67,19 +67,19 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) >
 <span class="contribution"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
             <svg>
                 <title>SoundCloud</title>
-                <use href="static/icons.svg#icon-soundcloud"></use>
+                <use href="static/misc/icons.svg#icon-soundcloud"></use>
             </svg>
         </a></span>)</span>
 <span class="contribution"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
 <span class="contribution"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
             <svg>
                 <title>Bandcamp</title>
-                <use href="static/icons.svg#icon-bandcamp"></use>
+                <use href="static/misc/icons.svg#icon-bandcamp"></use>
             </svg>
         </a>, <a class="icon" href="https://toby.fox/">
             <svg>
-                <title>External (toby.fox)</title>
-                <use href="static/icons.svg#icon-globe"></use>
+                <title>toby.fox</title>
+                <use href="static/misc/icons.svg#icon-globe"></use>
             </svg>
         </a></span>)</span>
 `
@@ -94,70 +94,70 @@ exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) >
 <span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
             <svg>
                 <title>SoundCloud</title>
-                <use href="static/icons.svg#icon-soundcloud"></use>
+                <use href="static/misc/icons.svg#icon-soundcloud"></use>
             </svg>
         </a></span>)</span>
 <a href="artist/the-big-baddies/">Grounder &amp; Scratch</a>
 <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
             <svg>
                 <title>Bandcamp</title>
-                <use href="static/icons.svg#icon-bandcamp"></use>
+                <use href="static/misc/icons.svg#icon-bandcamp"></use>
             </svg>
         </a>, <a class="icon" href="https://toby.fox/">
             <svg>
-                <title>External (toby.fox)</title>
-                <use href="static/icons.svg#icon-globe"></use>
+                <title>toby.fox</title>
+                <use href="static/misc/icons.svg#icon-globe"></use>
             </svg>
         </a></span>)</span>
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (tooltip) 1`] = `
 <span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://soundcloud.com/plazmataz">
-                    <svg><use href="static/icons.svg#icon-soundcloud"></use></svg>
+                    <svg><use href="static/misc/icons.svg#icon-soundcloud"></use></svg>
                     <span class="icon-text">plazmataz</span>
-                </a></span></span></span></span>
+                </a><span class="icon-platform">SoundCloud</span></span></span></span></span>
 <span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
 <span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tobyfox.bandcamp.com/">
-                    <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
+                    <svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg>
                     <span class="icon-text">tobyfox</span>
-                </a><a class="icon has-text" href="https://toby.fox/">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                </a><span class="icon-platform">Bandcamp</span><a class="icon has-text" href="https://toby.fox/">
+                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
                     <span class="icon-text">toby.fox</span>
-                </a></span></span></span> (Arrangement)</span>
+                </a><span class="icon-platform">Other</span></span></span></span> (Arrangement)</span>
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (inline) 1`] = `
 <span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
             <svg>
                 <title>SoundCloud</title>
-                <use href="static/icons.svg#icon-soundcloud"></use>
+                <use href="static/misc/icons.svg#icon-soundcloud"></use>
             </svg>
         </a></span>)</span>
 <span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
 <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
             <svg>
                 <title>Bandcamp</title>
-                <use href="static/icons.svg#icon-bandcamp"></use>
+                <use href="static/misc/icons.svg#icon-bandcamp"></use>
             </svg>
         </a>, <a class="icon" href="https://toby.fox/">
             <svg>
-                <title>External (toby.fox)</title>
-                <use href="static/icons.svg#icon-globe"></use>
+                <title>toby.fox</title>
+                <use href="static/misc/icons.svg#icon-globe"></use>
             </svg>
         </a></span>)</span>
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (tooltip) 1`] = `
 <span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://soundcloud.com/plazmataz">
-                    <svg><use href="static/icons.svg#icon-soundcloud"></use></svg>
+                    <svg><use href="static/misc/icons.svg#icon-soundcloud"></use></svg>
                     <span class="icon-text">plazmataz</span>
-                </a></span></span></span></span>
+                </a><span class="icon-platform">SoundCloud</span></span></span></span></span>
 <span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
 <span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tobyfox.bandcamp.com/">
-                    <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
+                    <svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg>
                     <span class="icon-text">tobyfox</span>
-                </a><a class="icon has-text" href="https://toby.fox/">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                </a><span class="icon-platform">Bandcamp</span><a class="icon has-text" href="https://toby.fox/">
+                    <svg><use href="static/misc/icons.svg#icon-globe"></use></svg>
                     <span class="icon-text">toby.fox</span>
-                </a></span></span></span> (Arrangement)</span>
+                </a><span class="icon-platform">Other</span></span></span></span> (Arrangement)</span>
 `
diff --git a/test/lib/content-function.js b/test/lib/content-function.js
index 7bc62139..a46d18c9 100644
--- a/test/lib/content-function.js
+++ b/test/lib/content-function.js
@@ -17,8 +17,23 @@ import mock from './generic-mock.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
+function cleanURLSpec(reference) {
+  const prepared = structuredClone(reference);
+
+  for (const spec of Object.values(prepared)) {
+    if (spec.prefix) {
+      // Strip out STATIC_VERSION. This updates fairly regularly and we
+      // don't want it to affect snapshot tests.
+      spec.prefix = spec.prefix
+        .replace(/static-\d+[a-z]\d+/i, 'static');
+    }
+  }
+
+  return prepared;
+}
+
 export function testContentFunctions(t, message, fn) {
-  const urls = generateURLs(urlSpec);
+  const urls = generateURLs(cleanURLSpec(urlSpec));
 
   t.test(message, async t => {
     let loadedContentDependencies;
@@ -52,7 +67,6 @@ export function testContentFunctions(t, message, fn) {
             to,
             urls,
 
-            cachebust: 413,
             pagePath: ['home'],
             appendIndexHTML: false,
             getColors: c => getColors(c, {chroma}),
diff --git a/test/lib/wiki-data.js b/test/lib/wiki-data.js
index d2d860ce..c373aadd 100644
--- a/test/lib/wiki-data.js
+++ b/test/lib/wiki-data.js
@@ -12,23 +12,6 @@ export function linkAndBindWikiData(wikiData, {
         ? withEntries(wikiData, entries => entries
             .map(([key, value]) => [key, value.slice()]))
         : wikiData));
-
-    // If albumData is present, automatically set albums' ownTrackData values
-    // by resolving track sections' references against the full array. This is
-    // just a nicety for working with albums throughout tests.
-    if (inferAlbumsOwnTrackData && wikiData.albumData && wikiData.trackData) {
-      for (const album of wikiData.albumData) {
-        const trackSections =
-          CacheableObject.getUpdateValue(album, 'trackSections');
-
-        const trackRefs =
-          trackSections.flatMap(section => section.tracks);
-
-        album.ownTrackData =
-          trackRefs.map(ref =>
-            find.track(ref, wikiData.trackData, {mode: 'error'}));
-      }
-    }
   }
 
   customLinkWikiDataArrays(wikiData);
diff --git a/test/snapshot/generateAdditionalFilesShortcut.js b/test/snapshot/generateAdditionalFilesShortcut.js
deleted file mode 100644
index 9825efa1..00000000
--- a/test/snapshot/generateAdditionalFilesShortcut.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import t from 'tap';
-import {testContentFunctions} from '#test-lib';
-
-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/generateAlbumCoverArtwork.js b/test/snapshot/generateAlbumCoverArtwork.js
index 9244c034..939c6e19 100644
--- a/test/snapshot/generateAlbumCoverArtwork.js
+++ b/test/snapshot/generateAlbumCoverArtwork.js
@@ -13,6 +13,7 @@ testContentFunctions(t, 'generateAlbumCoverArtwork (snapshot)', async (t, evalua
   const album = {
     directory: 'bee-forus-seatbelt-safebee',
     coverArtFileExtension: 'png',
+    coverArtDimensions: [400, 300],
     color: '#f28514',
     artTags: [
       {name: 'Damara', directory: 'damara', isContentWarning: false},
diff --git a/test/snapshot/generateAlbumReleaseInfo.js b/test/snapshot/generateAlbumReleaseInfo.js
index 3dea1196..a109912f 100644
--- a/test/snapshot/generateAlbumReleaseInfo.js
+++ b/test/snapshot/generateAlbumReleaseInfo.js
@@ -8,22 +8,22 @@ testContentFunctions(t, 'generateAlbumReleaseInfo (snapshot)', async (t, evaluat
     name: 'generateAlbumReleaseInfo',
     args: [{
       artistContribs: [
-        {who: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, what: 'music probably'},
-        {who: {name: 'Tensei', directory: 'tensei', urls: ['https://tenseimusic.bandcamp.com/']}, what: 'hot jams'},
+        {artist: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, annotation: 'music probably'},
+        {artist: {name: 'Tensei', directory: 'tensei', urls: ['https://tenseimusic.bandcamp.com/']}, annotation: 'hot jams'},
       ],
 
       coverArtistContribs: [
-        {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: null}, annotation: null},
       ],
 
       wallpaperArtistContribs: [
-        {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null},
-        {who: {name: 'Niklink', directory: 'niklink', urls: null}, what: 'edits'},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: null}, annotation: null},
+        {artist: {name: 'Niklink', directory: 'niklink', urls: null}, annotation: 'edits'},
       ],
 
       bannerArtistContribs: [
-        {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null},
-        {who: {name: 'Niklink', directory: 'niklink', urls: null}, what: 'edits'},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: null}, annotation: null},
+        {artist: {name: 'Niklink', directory: 'niklink', urls: null}, annotation: 'edits'},
       ],
 
       name: 'AlterniaBound',
diff --git a/test/snapshot/generateAlbumSecondaryNav.js b/test/snapshot/generateAlbumSecondaryNav.js
index 709b062e..57618f2f 100644
--- a/test/snapshot/generateAlbumSecondaryNav.js
+++ b/test/snapshot/generateAlbumSecondaryNav.js
@@ -10,6 +10,8 @@ testContentFunctions(t, 'generateAlbumSecondaryNav (snapshot)', async (t, evalua
   group2 = {name: 'Bepis', directory: 'bepis', color: '#123456'};
 
   album = {
+    name: 'Album',
+    directory: 'album',
     date: new Date('2010-04-13'),
     groups: [group1, group2],
   };
diff --git a/test/snapshot/generateAlbumTrackList.js b/test/snapshot/generateAlbumTrackList.js
index 181cc1d2..08b31902 100644
--- a/test/snapshot/generateAlbumTrackList.js
+++ b/test/snapshot/generateAlbumTrackList.js
@@ -10,12 +10,13 @@ testContentFunctions(t, 'generateAlbumTrackList (snapshot)', async (t, evaluate)
   });
 
   const contribs1 = [
-    {who: {name: 'Apricot', directory: 'apricot', urls: null}},
+    {artist: {name: 'Apricot', directory: 'apricot', urls: null}},
   ];
 
   const contribs2 = [
-    {who: {name: 'Apricot', directory: 'apricot', urls: null}},
-    {who: {name: 'Peach', directory: 'peach', urls: ['https://peach.bandcamp.com/']}},
+    {artist: {name: 'Apricot', directory: 'apricot', urls: null}},
+    {artist: {name: 'Peach', directory: 'peach', urls: ['https://peach.bandcamp.com/']}},
+    {artist: {name: 'Cerise', directory: 'cerise', urls: null}},
   ];
 
   const color1 = '#fb07ff';
diff --git a/test/snapshot/generateTrackCoverArtwork.js b/test/snapshot/generateTrackCoverArtwork.js
index 1e651eb1..4d952119 100644
--- a/test/snapshot/generateTrackCoverArtwork.js
+++ b/test/snapshot/generateTrackCoverArtwork.js
@@ -11,6 +11,7 @@ testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evalua
   const album = {
     directory: 'bee-forus-seatbelt-safebee',
     coverArtFileExtension: 'png',
+    coverArtDimensions: [400, 300],
     artTags: [
       {name: 'Damara', directory: 'damara', isContentWarning: false},
       {name: 'Cronus', directory: 'cronus', isContentWarning: false},
@@ -23,6 +24,7 @@ testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evalua
     directory: 'beesmp3',
     hasUniqueCoverArt: true,
     coverArtFileExtension: 'jpg',
+    coverArtDimensions: null,
     color: '#f28514',
     artTags: [{name: 'Bees', directory: 'bees', isContentWarning: false}],
     album,
diff --git a/test/snapshot/generateTrackReleaseInfo.js b/test/snapshot/generateTrackReleaseInfo.js
index c72344b9..78f0fee7 100644
--- a/test/snapshot/generateTrackReleaseInfo.js
+++ b/test/snapshot/generateTrackReleaseInfo.js
@@ -4,8 +4,8 @@ import {testContentFunctions} from '#test-lib';
 testContentFunctions(t, 'generateTrackReleaseInfo (snapshot)', async (t, evaluate) => {
   await evaluate.load();
 
-  const artistContribs = [{who: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, what: null}];
-  const coverArtistContribs = [{who: {name: 'Alpaca', directory: 'alpaca', urls: null}, what: '🔥'}];
+  const artistContribs = [{artist: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, annotation: null}];
+  const coverArtistContribs = [{artist: {name: 'Alpaca', directory: 'alpaca', urls: null}, annotation: '🔥'}];
 
   evaluate.snapshot('basic behavior', {
     name: 'generateTrackReleaseInfo',
diff --git a/test/snapshot/image.js b/test/snapshot/image.js
index 447e7fa7..1985211f 100644
--- a/test/snapshot/image.js
+++ b/test/snapshot/image.js
@@ -38,11 +38,10 @@ testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => {
     },
   });
 
-  quickSnapshot('width & height', {
+  quickSnapshot('dimensions', {
     slots: {
       src: 'foobar',
-      width: 600,
-      height: 400,
+      dimensions: [600, 400],
     },
   });
 
@@ -53,6 +52,14 @@ testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => {
     },
   });
 
+  quickSnapshot('dimensions with square', {
+    slots: {
+      src: 'foobar',
+      dimensions: [600, 400],
+      square: true,
+    },
+  });
+
   quickSnapshot('lazy with square', {
     slots: {
       src: 'foobar',
diff --git a/test/snapshot/linkContribution.js b/test/snapshot/linkContribution.js
index ebd3be58..1043ddc6 100644
--- a/test/snapshot/linkContribution.js
+++ b/test/snapshot/linkContribution.js
@@ -9,25 +9,25 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
       name: 'linkContribution',
       multiple: [
         {args: [
-          {who: {
+          {artist: {
             name: 'Clark Powell',
             directory: 'clark-powell',
             urls: ['https://soundcloud.com/plazmataz'],
-          }, what: null},
+          }, annotation: null},
         ]},
         {args: [
-          {who: {
+          {artist: {
             name: 'Grounder & Scratch',
             directory: 'the-big-baddies',
             urls: [],
-          }, what: 'Snooping'},
+          }, annotation: 'Snooping'},
         ]},
         {args: [
-          {who: {
+          {artist: {
             name: 'Toby Fox',
             directory: 'toby-fox',
             urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'],
-          }, what: 'Arrangement'},
+          }, annotation: 'Arrangement'},
         ]},
       ],
       slots,
@@ -65,7 +65,7 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
   evaluate.snapshot('loads of links (inline)', {
     name: 'linkContribution',
     args: [
-      {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
+      {artist: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
         'https://loremipsum.io',
         'https://loremipsum.io/generator/',
         'https://loremipsum.io/#meaning',
@@ -74,7 +74,7 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
         'https://loremipsum.io/#when-to-use-lorem-ipsum',
         'https://loremipsum.io/#lorem-ipsum-all-the-things',
         'https://loremipsum.io/#original-source',
-      ]}, what: null},
+      ]}, annotation: null},
     ],
     slots: {showIcons: true},
   });
@@ -82,7 +82,7 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
   evaluate.snapshot('loads of links (tooltip)', {
     name: 'linkContribution',
     args: [
-      {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
+      {artist: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
         'https://loremipsum.io',
         'https://loremipsum.io/generator/',
         'https://loremipsum.io/#meaning',
@@ -91,7 +91,7 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
         'https://loremipsum.io/#when-to-use-lorem-ipsum',
         'https://loremipsum.io/#lorem-ipsum-all-the-things',
         'https://loremipsum.io/#original-source',
-      ]}, what: null},
+      ]}, annotation: null},
     ],
     slots: {showIcons: true, iconMode: 'tooltip'},
   });
diff --git a/test/unit/content/dependencies/linkContribution.js b/test/unit/content/dependencies/linkContribution.js
index 94908901..ab45b03a 100644
--- a/test/unit/content/dependencies/linkContribution.js
+++ b/test/unit/content/dependencies/linkContribution.js
@@ -2,27 +2,27 @@ import t from 'tap';
 import {testContentFunctions} from '#test-lib';
 
 t.test('generateContributionLinks (unit)', async t => {
-  const who1 = {
+  const artist1 = {
     name: 'Clark Powell',
     directory: 'clark-powell',
     urls: ['https://soundcloud.com/plazmataz'],
   };
 
-  const who2 = {
+  const artist2 = {
     name: 'Grounder & Scratch',
     directory: 'the-big-baddies',
     urls: [],
   };
 
-  const who3 = {
+  const artist3 = {
     name: 'Toby Fox',
     directory: 'toby-fox',
     urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'],
   };
 
-  const what1 = null;
-  const what2 = 'Snooping';
-  const what3 = 'Arrangement';
+  const annotation1 = null;
+  const annotation2 = 'Snooping';
+  const annotation3 = 'Arrangement';
 
   await testContentFunctions(t, 'generateContributionLinks (unit 1)', async (t, evaluate) => {
     const slots = {
@@ -34,14 +34,14 @@ t.test('generateContributionLinks (unit)', async t => {
       mock: evaluate.mock(mock => ({
         linkArtist: {
           relations: mock.function('linkArtist.relations', () => ({}))
-            .args([undefined, who1]).next()
-            .args([undefined, who2]).next()
-            .args([undefined, who3]),
+            .args([undefined, artist1]).next()
+            .args([undefined, artist2]).next()
+            .args([undefined, artist3]),
 
           data: mock.function('linkArtist.data', () => ({}))
-            .args([who1]).next()
-            .args([who2]).next()
-            .args([who3]),
+            .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.
@@ -51,9 +51,9 @@ t.test('generateContributionLinks (unit)', async t => {
 
         linkExternalAsIcon: {
           data: mock.function('linkExternalAsIcon.data', () => ({}))
-            .args([who1.urls[0]]).next()
-            .args([who3.urls[0]]).next()
-            .args([who3.urls[1]]),
+            .args([artist1.urls[0]]).next()
+            .args([artist3.urls[0]]).next()
+            .args([artist3.urls[1]]),
 
           generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
             .repeat(3),
@@ -64,9 +64,9 @@ t.test('generateContributionLinks (unit)', async t => {
     evaluate({
       name: 'linkContribution',
       multiple: [
-        {args: [{who: who1, what: what1}]},
-        {args: [{who: who2, what: what2}]},
-        {args: [{who: who3, what: what3}]},
+        {args: [{artist: artist1, annotation: annotation1}]},
+        {args: [{artist: artist2, annotation: annotation2}]},
+        {args: [{artist: artist3, annotation: annotation3}]},
       ],
       slots,
     });
@@ -82,14 +82,14 @@ t.test('generateContributionLinks (unit)', async t => {
       mock: evaluate.mock(mock => ({
         linkArtist: {
           relations: mock.function('linkArtist.relations', () => ({}))
-            .args([undefined, who1]).next()
-            .args([undefined, who2]).next()
-            .args([undefined, who3]),
+            .args([undefined, artist1]).next()
+            .args([undefined, artist2]).next()
+            .args([undefined, artist3]),
 
           data: mock.function('linkArtist.data', () => ({}))
-            .args([who1]).next()
-            .args([who2]).next()
-            .args([who3]),
+            .args([artist1]).next()
+            .args([artist2]).next()
+            .args([artist3]),
 
           generate: mock.function(() => 'artist link')
             .repeat(3),
@@ -112,9 +112,9 @@ t.test('generateContributionLinks (unit)', async t => {
     evaluate({
       name: 'linkContribution',
       multiple: [
-        {args: [{who: who1, what: what1}]},
-        {args: [{who: who2, what: what2}]},
-        {args: [{who: who3, what: what3}]},
+        {args: [{artist: artist1, annotation: annotation1}]},
+        {args: [{artist: artist2, annotation: annotation2}]},
+        {args: [{artist: artist3, annotation: annotation3}]},
       ],
       slots,
     });
diff --git a/test/unit/data/cacheable-object.js b/test/unit/data/cacheable-object.js
index 8c31a5bc..4b927248 100644
--- a/test/unit/data/cacheable-object.js
+++ b/test/unit/data/cacheable-object.js
@@ -4,7 +4,7 @@ import CacheableObject from '#cacheable-object';
 
 function newCacheableObject(PD) {
   return new (class extends CacheableObject {
-    static propertyDescriptors = PD;
+    static [CacheableObject.propertyDescriptors] = PD;
   });
 }
 
diff --git a/test/unit/data/composite/things/track/withAlbum.js b/test/unit/data/composite/things/track/withAlbum.js
index d822f318..6f50776b 100644
--- a/test/unit/data/composite/things/track/withAlbum.js
+++ b/test/unit/data/composite/things/track/withAlbum.js
@@ -1,5 +1,9 @@
 import t from 'tap';
 
+import '#import-heck';
+
+import Thing from '#thing';
+
 import {compositeFrom, input} from '#composite';
 import {exposeConstant, exposeDependency} from '#composite/control-flow';
 import {withAlbum} from '#composite/things/track';
@@ -21,9 +25,21 @@ t.test(`withAlbum: basic behavior`, t => {
     },
   });
 
-  const fakeTrack1 = {directory: 'foo'};
-  const fakeTrack2 = {directory: 'bar'};
-  const fakeAlbum = {directory: 'baz', tracks: [fakeTrack1]};
+  const fakeTrack1 = {
+    [Thing.isThing]: true,
+    directory: 'foo',
+  };
+
+  const fakeTrack2 = {
+    [Thing.isThing]: true,
+    directory: 'bar',
+  };
+
+  const fakeAlbum = {
+    [Thing.isThing]: true,
+    directory: 'baz',
+    tracks: [fakeTrack1],
+  };
 
   t.equal(
     composite.expose.compute({
@@ -53,9 +69,21 @@ t.test(`withAlbum: early exit conditions`, t => {
     ],
   });
 
-  const fakeTrack1 = {directory: 'foo'};
-  const fakeTrack2 = {directory: 'bar'};
-  const fakeAlbum = {directory: 'baz', tracks: [fakeTrack1]};
+  const fakeTrack1 = {
+    [Thing.isThing]: true,
+    directory: 'foo',
+  };
+
+  const fakeTrack2 = {
+    [Thing.isThing]: true,
+    directory: 'bar',
+  };
+
+  const fakeAlbum = {
+    [Thing.isThing]: true,
+    directory: 'baz',
+    tracks: [fakeTrack1],
+  };
 
   t.equal(
     composite.expose.compute({
diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js
index 46ea83b0..06265b09 100644
--- a/test/unit/data/things/album.js
+++ b/test/unit/data/things/album.js
@@ -8,6 +8,7 @@ const {
   ArtTag,
   Artist,
   Track,
+  TrackSection,
 } = thingConstructors;
 
 function stubArtTag(tagName = `Test Art Tag`) {
@@ -21,8 +22,8 @@ function stubArtistAndContribs() {
   const artist = new Artist();
   artist.name = `Test Artist`;
 
-  const contribs = [{who: `Test Artist`, what: null}];
-  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
+  const contribs = [{artist: `Test Artist`, annotation: null}];
+  const badContribs = [{artist: `Figment of Your Imagination`, annotation: null}];
 
   return {artist, contribs, badContribs};
 }
@@ -34,6 +35,15 @@ function stubTrack(directory = 'foo') {
   return track;
 }
 
+function stubTrackSection(album, tracks, directory = 'baz') {
+  const trackSection = new TrackSection();
+  trackSection.unqualifiedDirectory = directory;
+  trackSection.tracks = tracks.map(t => Thing.getReference(t));
+  trackSection.ownTrackData = tracks;
+  trackSection.ownAlbumData = [album];
+  return trackSection;
+}
+
 t.test(`Album.artTags`, t => {
   t.plan(3);
 
@@ -237,78 +247,133 @@ t.test(`Album.tracks`, t => {
   t.plan(5);
 
   const album = new Album();
+  album.directory = 'foo';
+
   const track1 = stubTrack('track1');
   const track2 = stubTrack('track2');
   const track3 = stubTrack('track3');
   const tracks = [track1, track2, track3];
 
-  album.ownTrackData = tracks;
+  const section1 = stubTrackSection(album, [], 'section1');
+  const section2 = stubTrackSection(album, [], 'section2');
+  const section3 = stubTrackSection(album, [], 'section3');
+  const section4 = stubTrackSection(album, [], 'section4');
+  const section5 = stubTrackSection(album, [], 'section5');
+  const section6 = stubTrackSection(album, [], 'section6');
+  const sections = [section1, section2, section3, section4, section5, section6];
+
+  const section1_ref = `unqualified-track-section:section1`;
+  const section2_ref = `unqualified-track-section:section2`;
+  const section3_ref = `unqualified-track-section:section3`;
+  const section4_ref = `unqualified-track-section:section4`;
+  const section5_ref = `unqualified-track-section:section5`;
+  const section6_ref = `unqualified-track-section:section6`;
 
   for (const track of tracks) {
     track.albumData = [album];
   }
 
+  for (const section of sections) {
+    section.ownAlbumData = [album];
+  }
+
   t.same(album.tracks, [],
     `Album.tracks #1: defaults to empty array`);
 
-  album.trackSections = [
-    {tracks: ['track:track1', 'track:track2', 'track:track3']},
-  ];
+  section1.tracks = ['track:track1', 'track:track2', 'track:track3'];
+  section1.ownTrackData = [track1, track2, track3];
+
+  album.trackSections = [section1_ref];
+  album.ownTrackSectionData = [section1];
 
   t.same(album.tracks, [track1, track2, track3],
     `Album.tracks #2: pulls tracks from one track section`);
 
-  album.trackSections = [
-    {tracks: ['track:track1']},
-    {tracks: ['track:track2', 'track:track3']},
-  ];
+  section1.tracks = ['track:track1'];
+  section2.tracks = ['track:track2', 'track:track3'];
+
+  section1.ownTrackData = [track1];
+  section2.ownTrackData = [track2, track3];
+
+  album.trackSections = [section1_ref, section2_ref];
+  album.ownTrackSectionData = [section1, section2];
 
   t.same(album.tracks, [track1, track2, track3],
     `Album.tracks #3: pulls tracks from multiple track sections`);
 
-  album.trackSections = [
-    {tracks: ['track:track1', 'track:does-not-exist']},
-    {tracks: ['track:this-one-neither', 'track:track2']},
-    {tracks: ['track:effectively-empty-section']},
-    {tracks: ['track:track3']},
-  ];
+  section1.tracks = ['track:track1', 'track:does-not-exist'];
+  section2.tracks = ['track:this-one-neither', 'track:track2'];
+  section3.tracks = ['track:effectively-empty-section'];
+  section4.tracks = ['track:track3'];
+
+  section1.ownTrackData = [track1];
+  section2.ownTrackData = [track2];
+  section3.ownTrackData = [];
+  section4.ownTrackData = [track3];
+
+  album.trackSections = [section1_ref, section2_ref, section3_ref, section4_ref];
+  album.ownTrackSectionData = [section1, section2, section3, section4];
 
   t.same(album.tracks, [track1, track2, track3],
     `Album.tracks #4: filters out references without matches`);
 
-  album.trackSections = [
-    {tracks: ['track:track1']},
-    {},
-    {tracks: ['track:track2']},
-    {},
-    {},
-    {tracks: ['track:track3']},
-  ];
+  section1.tracks = ['track:track1'];
+  section2.tracks = [];
+  section3.tracks = ['track:track2'];
+  section4.tracks = [];
+  section5.tracks = [];
+  section6.tracks = ['track:track3'];
+
+  section1.ownTrackData = [track1];
+  section2.ownTrackData = [];
+  section3.ownTrackData = [track2];
+  section4.ownTrackData = [];
+  section5.ownTrackData = [];
+  section6.ownTrackData = [track3];
+
+  album.trackSections = [section1_ref, section2_ref, section3_ref, section4_ref, section5_ref, section6_ref];
+  album.ownTrackSectionData = [section1, section2, section3, section4, section5, section6];
 
   t.same(album.tracks, [track1, track2, track3],
-    `Album.tracks #5: skips missing tracks property`);
+    `Album.tracks #5: skips empty track sections`);
 });
 
 t.test(`Album.trackSections`, t => {
   t.plan(7);
 
   const album = new Album();
+
   const track1 = stubTrack('track1');
   const track2 = stubTrack('track2');
   const track3 = stubTrack('track3');
   const track4 = stubTrack('track4');
   const tracks = [track1, track2, track3, track4];
 
-  album.ownTrackData = tracks;
+  const section1 = stubTrackSection(album, [], 'section1');
+  const section2 = stubTrackSection(album, [], 'section2');
+  const section3 = stubTrackSection(album, [], 'section3');
+  const section4 = stubTrackSection(album, [], 'section4');
+  const section5 = stubTrackSection(album, [], 'section5');
+  const sections = [section1, section2, section3, section4, section5];
+
+  const section1_ref = `unqualified-track-section:section1`;
+  const section2_ref = `unqualified-track-section:section2`;
+  const section3_ref = `unqualified-track-section:section3`;
+  const section4_ref = `unqualified-track-section:section4`;
+  const section5_ref = `unqualified-track-section:section5`;
 
   for (const track of tracks) {
     track.albumData = [album];
   }
 
-  album.trackSections = [
-    {tracks: ['track:track1', 'track:track2']},
-    {tracks: ['track:track3', 'track:track4']},
-  ];
+  section1.tracks = ['track:track1', 'track:track2'];
+  section2.tracks = ['track:track3', 'track:track4'];
+
+  section1.ownTrackData = [track1, track2];
+  section2.ownTrackData = [track3, track4];
+
+  album.trackSections = [section1_ref, section2_ref];
+  album.ownTrackSectionData = [section1, section2];
 
   t.match(album.trackSections, [
     {tracks: [track1, track2]},
@@ -320,11 +385,19 @@ t.test(`Album.trackSections`, t => {
     {tracks: [track3, track4], startIndex: 2},
   ], `Album.trackSections #2: exposes startIndex`);
 
-  album.trackSections = [
-    {name: 'First section', tracks: ['track:track1']},
-    {name: 'Second section', tracks: ['track:track2']},
-    {tracks: ['track:track3']},
-  ];
+  section1.tracks = ['track:track1'];
+  section2.tracks = ['track:track2'];
+  section3.tracks = ['track:track3'];
+
+  section1.ownTrackData = [track1];
+  section2.ownTrackData = [track2];
+  section3.ownTrackData = [track3];
+
+  section1.name = 'First section';
+  section2.name = 'Second section';
+
+  album.trackSections = [section1_ref, section2_ref, section3_ref];
+  album.ownTrackSectionData = [section1, section2, section3];
 
   t.match(album.trackSections, [
     {name: 'First section', tracks: [track1]},
@@ -334,11 +407,11 @@ t.test(`Album.trackSections`, t => {
 
   album.color = '#123456';
 
-  album.trackSections = [
-    {tracks: ['track:track1'], color: null},
-    {tracks: ['track:track2'], color: '#abcdef'},
-    {tracks: ['track:track3'], color: null},
-  ];
+  section2.color = '#abcdef';
+
+  // XXX_decacheWikiData
+  album.trackSections = [];
+  album.trackSections = [section1_ref, section2_ref, section3_ref];
 
   t.match(album.trackSections, [
     {tracks: [track1], color: '#123456'},
@@ -346,11 +419,11 @@ t.test(`Album.trackSections`, t => {
     {tracks: [track3], color: '#123456'},
   ], `Album.trackSections #4: exposes color, inherited from album`);
 
-  album.trackSections = [
-    {tracks: ['track:track1'], dateOriginallyReleased: null},
-    {tracks: ['track:track2'], dateOriginallyReleased: new Date('2009-04-11')},
-    {tracks: ['track:track3'], dateOriginallyReleased: null},
-  ];
+  section2.dateOriginallyReleased = new Date('2009-04-11');
+
+  // XXX_decacheWikiData
+  album.trackSections = [];
+  album.trackSections = [section1_ref, section2_ref, section3_ref];
 
   t.match(album.trackSections, [
     {tracks: [track1], dateOriginallyReleased: null},
@@ -358,11 +431,12 @@ t.test(`Album.trackSections`, t => {
     {tracks: [track3], dateOriginallyReleased: null},
   ], `Album.trackSections #5: exposes dateOriginallyReleased, if present`);
 
-  album.trackSections = [
-    {tracks: ['track:track1'], isDefaultTrackSection: true},
-    {tracks: ['track:track2'], isDefaultTrackSection: false},
-    {tracks: ['track:track3'], isDefaultTrackSection: null},
-  ];
+  section1.isDefaultTrackSection = true;
+  section2.isDefaultTrackSection = false;
+
+  // XXX_decacheWikiData
+  album.trackSections = [];
+  album.trackSections = [section1_ref, section2_ref, section3_ref];
 
   t.match(album.trackSections, [
     {tracks: [track1], isDefaultTrackSection: true},
@@ -370,19 +444,34 @@ t.test(`Album.trackSections`, t => {
     {tracks: [track3], isDefaultTrackSection: false},
   ], `Album.trackSections #6: exposes isDefaultTrackSection, defaults to false`);
 
-  album.trackSections = [
-    {tracks: ['track:track1', 'track:track2', 'track:snooping'], color: '#112233'},
-    {tracks: ['track:track3', 'track:as-usual'],                 color: '#334455'},
-    {tracks: [],                                                 color: '#bbbbba'},
-    {tracks: ['track:icy', 'track:chilly', 'track:frigid'],      color: '#556677'},
-    {tracks: ['track:track4'],                                   color: '#778899'},
-  ];
+  section1.tracks = ['track:track1', 'track:track2', 'track:snooping'];
+  section2.tracks = ['track:track3', 'track:as-usual'];
+  section3.tracks = [];
+  section4.tracks = ['track:icy', 'track:chilly', 'track:frigid'];
+  section5.tracks = ['track:track4'];
+
+  section1.ownTrackData = [track1, track2];
+  section2.ownTrackData = [track3];
+  section3.ownTrackData = [];
+  section4.ownTrackData = [];
+  section5.ownTrackData = [track4];
+
+  section1.color = '#112233';
+  section2.color = '#334455';
+  section3.color = '#bbbbba';
+  section4.color = '#556677';
+  section5.color = '#778899';
+
+  album.trackSections = [section1_ref, section2_ref, section3_ref, section4_ref, section5_ref];
+  album.ownTrackSectionData = [section1, section2, section3, section4, section5];
 
   t.match(album.trackSections, [
     {tracks: [track1, track2], color: '#112233'},
     {tracks: [track3],         color: '#334455'},
+    {tracks: [],               color: '#bbbbba'},
+    {tracks: [],               color: '#556677'},
     {tracks: [track4],         color: '#778899'},
-  ], `Album.trackSections #7: filters out references without matches & empty sections`);
+  ], `Album.trackSections #7: filters out references without matches, keeps empty sections`);
 });
 
 t.test(`Album.wallpaperFileExtension`, t => {
diff --git a/test/unit/data/things/art-tag.js b/test/unit/data/things/art-tag.js
index 561c93ef..cf2135c6 100644
--- a/test/unit/data/things/art-tag.js
+++ b/test/unit/data/things/art-tag.js
@@ -8,18 +8,29 @@ const {
   Artist,
   ArtTag,
   Track,
+  trackSection,
 } = thingConstructors;
 
 function stubAlbum(tracks, directory = 'bar') {
   const album = new Album();
   album.directory = directory;
 
-  const trackRefs = tracks.map(t => Thing.getReference(t));
-  album.trackSections = [{tracks: trackRefs}];
+  const trackSection = stubTrackSection(album, tracks);
+  album.trackSections = [`unqualified-track-section:${trackSection.unqualifiedDirectory}`];
+  album.ownTrackSectionData = [trackSection];
 
   return album;
 }
 
+function stubTrackSection(album, tracks, directory = 'baz') {
+  const trackSection = new TrackSection();
+  trackSection.unqualifiedDirectory = directory;
+  trackSection.tracks = tracks.map(t => Thing.getReference(t));
+  trackSection.ownTrackData = tracks;
+  trackSection.ownAlbumData = [album];
+  return trackSection;
+}
+
 function stubTrack(directory = 'foo') {
   const track = new Track();
   track.directory = directory;
@@ -43,8 +54,8 @@ function stubArtist(artistName = `Test Artist`) {
 
 function stubArtistAndContribs(artistName = `Test Artist`) {
   const artist = stubArtist(artistName);
-  const contribs = [{who: artistName, what: null}];
-  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
+  const contribs = [{artist: artistName, annotation: null}];
+  const badContribs = [{artist: `Figment of Your Imagination`, annotation: null}];
 
   return {artist, contribs, badContribs};
 }
diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js
index b1c1611e..c6695b6f 100644
--- a/test/unit/data/things/track.js
+++ b/test/unit/data/things/track.js
@@ -11,18 +11,29 @@ const {
   FlashAct,
   Thing,
   Track,
+  TrackSection,
 } = thingConstructors;
 
 function stubAlbum(tracks, directory = 'bar') {
   const album = new Album();
   album.directory = directory;
 
-  const trackRefs = tracks.map(t => Thing.getReference(t));
-  album.trackSections = [{tracks: trackRefs}];
+  const trackSection = stubTrackSection(album, tracks);
+  album.trackSections = [`unqualified-track-section:${trackSection.unqualifiedDirectory}`];
+  album.ownTrackSectionData = [trackSection];
 
   return album;
 }
 
+function stubTrackSection(album, tracks, directory = 'baz') {
+  const trackSection = new TrackSection();
+  trackSection.unqualifiedDirectory = directory;
+  trackSection.tracks = tracks.map(t => Thing.getReference(t));
+  trackSection.ownTrackData = tracks;
+  trackSection.ownAlbumData = [album];
+  return trackSection;
+}
+
 function stubTrack(directory = 'foo') {
   const track = new Track();
   track.directory = directory;
@@ -46,8 +57,8 @@ function stubArtist(artistName = `Test Artist`) {
 
 function stubArtistAndContribs(artistName = `Test Artist`) {
   const artist = stubArtist(artistName);
-  const contribs = [{who: artistName, what: null}];
-  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
+  const contribs = [{artist: artistName, annotation: null}];
+  const badContribs = [{artist: `Figment of Your Imagination`, annotation: null}];
 
   return {artist, contribs, badContribs};
 }
@@ -81,16 +92,28 @@ t.test(`Track.album`, t => {
   const track2 = stubTrack('track2');
   const album1 = new Album();
   const album2 = new Album();
+  const section1 = new TrackSection();
+  const section2 = new TrackSection();
+  section1.unqualifiedDirectory = 'section1';
+  section2.unqualifiedDirectory = 'section2';
+  const section1_ref = `unqualified-track-section:section1`;
+  const section2_ref = `unqualified-track-section:section2`;
 
   t.equal(track1.album, null,
     `album #1: defaults to null`);
 
   track1.albumData = [album1, album2];
   track2.albumData = [album1, album2];
-  album1.ownTrackData = [track1, track2];
-  album2.ownTrackData = [track1, track2];
-  album1.trackSections = [{tracks: ['track:track1']}];
-  album2.trackSections = [{tracks: ['track:track2']}];
+  section1.ownTrackData = [track1];
+  section2.ownTrackData = [track2];
+  section1.ownAlbumData = [album1];
+  section2.ownAlbumData = [album2];
+  section1.tracks = ['track:track1'];
+  section2.tracks = ['track:track2'];
+  album1.trackSections = [section1_ref];
+  album2.trackSections = [section2_ref];
+  album1.ownTrackSectionData = [section1];
+  album2.ownTrackSectionData = [section2];
 
   t.equal(track1.album, album1,
     `album #2: is album when album's trackSections matches track`);
@@ -105,21 +128,83 @@ t.test(`Track.album`, t => {
   t.equal(track1.album, null,
     `album #4: is null when track missing albumData`);
 
-  album1.ownTrackData = [];
-  track1.albumData = [album1, album2];
+  section1.ownTrackData = [];
+
+  // XXX_decacheWikiData
+  album1.trackSections = [];
+  album1.trackSections = [section1_ref];
+  track1.albumData = [];
+  track1.albumData = [album2, album1];
 
   t.equal(track1.album, null,
-    `album #5: is null when album missing ownTrackData`);
+    `album #5: is null when album track section missing ownTrackData`);
 
-  album1.ownTrackData = [track1, track2];
-  album1.trackSections = [{tracks: ['track:track2']}];
+  section1.ownTrackData = [track2];
+  section1.tracks = ['track:track2'];
 
   // XXX_decacheWikiData
+  album1.trackSections = [];
+  album1.trackSections = [section1_ref];
   track1.albumData = [];
-  track1.albumData = [album1, album2];
+  track1.albumData = [album2, album1];
 
   t.equal(track1.album, null,
-    `album #6: is null when album's trackSections don't match track`);
+    `album #6: is null when album track section doesn't match track`);
+});
+
+t.test(`Track.alwaysReferenceByDirectory`, t => {
+  t.plan(7);
+
+  const {track: originalTrack, album: originalAlbum} =
+    stubTrackAndAlbum('original-track', 'original-album');
+
+  const {track: rereleaseTrack, album: rereleaseAlbum} =
+    stubTrackAndAlbum('rerelease-track', 'rerelease-album');
+
+  originalTrack.name = 'Cowabunga';
+  rereleaseTrack.name = 'Cowabunga';
+
+  originalTrack.dataSourceAlbum = 'album:original-album';
+  rereleaseTrack.dataSourceAlbum = 'album:rerelease-album';
+
+  rereleaseTrack.originalReleaseTrack = 'track:original-track';
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [originalAlbum, rereleaseAlbum],
+    trackData: [originalTrack, rereleaseTrack],
+  });
+
+  t.equal(originalTrack.alwaysReferenceByDirectory, false,
+    `alwaysReferenceByDirectory #1: defaults to false`);
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, true,
+    `alwaysReferenceByDirectory #2: is true if rerelease name matches original`);
+
+  rereleaseTrack.name = 'Foo Dog!';
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, false,
+    `alwaysReferenceByDirectory #3: is false if rerelease name doesn't match original`);
+
+  rereleaseTrack.name = `COWabunga`;
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, false,
+    `alwaysReferenceByDirectory #4: is false if rerelease name doesn't match original exactly`);
+
+  rereleaseAlbum.alwaysReferenceTracksByDirectory = true;
+  XXX_decacheWikiData();
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, true,
+    `alwaysReferenceByDirectory #5: is true if album's alwaysReferenceTracksByDirectory is true`);
+
+  rereleaseTrack.alwaysReferenceByDirectory = false;
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, false,
+    `alwaysReferenceByDirectory #6: doesn't inherit from album if set to false`);
+
+  rereleaseTrack.name = 'Cowabunga';
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, false,
+    `alwaysReferenceByDirectory #7: doesn't compare original release name if set to false`);
 });
 
 t.test(`Track.artTags`, t => {
@@ -189,31 +274,31 @@ t.test(`Track.artistContribs`, t => {
     `artistContribs #1: defaults to empty array`);
 
   album.artistContribs = [
-    {who: `Artist 1`, what: `composition`},
-    {who: `Artist 2`, what: null},
+    {artist: `Artist 1`, annotation: `composition`},
+    {artist: `Artist 2`, annotation: null},
   ];
 
   XXX_decacheWikiData();
 
   t.same(track.artistContribs,
-    [{who: artist1, what: `composition`}, {who: artist2, what: null}],
+    [{artist: artist1, annotation: `composition`}, {artist: artist2, annotation: null}],
     `artistContribs #2: inherits album artistContribs`);
 
   track.artistContribs = [
-    {who: `Artist 1`, what: `arrangement`},
+    {artist: `Artist 1`, annotation: `arrangement`},
   ];
 
-  t.same(track.artistContribs, [{who: artist1, what: `arrangement`}],
+  t.same(track.artistContribs, [{artist: artist1, annotation: `arrangement`}],
     `artistContribs #3: resolves from own value`);
 
   track.artistContribs = [
-    {who: `Artist 1`, what: `snooping`},
-    {who: `Artist 413`, what: `as`},
-    {who: `Artist 2`, what: `usual`},
+    {artist: `Artist 1`, annotation: `snooping`},
+    {artist: `Artist 413`, annotation: `as`},
+    {artist: `Artist 2`, annotation: `usual`},
   ];
 
   t.same(track.artistContribs,
-    [{who: artist1, what: `snooping`}, {who: artist2, what: `usual`}],
+    [{artist: artist1, annotation: `snooping`}, {artist: artist2, annotation: `usual`}],
     `artistContribs #4: filters out names without matches`);
 });
 
@@ -230,11 +315,14 @@ t.test(`Track.color`, t => {
   t.equal(track.color, null,
     `color #1: defaults to null`);
 
+  const section = stubTrackSection(album, [track], 'section');
+
   album.color = '#abcdef';
-  album.trackSections = [{
-    color: '#beeeef',
-    tracks: [Thing.getReference(track)],
-  }];
+  section.color = '#beeeef';
+
+  album.trackSections = [`unqualified-track-section:section`];
+  album.ownTrackSectionData = [section];
+
   XXX_decacheWikiData();
 
   t.equal(track.color, '#beeeef',
@@ -248,6 +336,7 @@ t.test(`Track.color`, t => {
   track.albumData = [
     {
       constructor: {[Thing.referenceType]: 'album'},
+      [Thing.isThing]: true,
       color: '#abcdef',
       tracks: [track],
       trackSections: [
@@ -303,7 +392,7 @@ t.test(`Track.commentatorArtists`, t => {
     `Track.commentatorArtists #2: works with two commentators`);
 
   track.commentary = commentary +=
-    `<i>Icy|<b>Icy What You Did There</b>:</i>\n` +
+    `<i>Icy|<b>Icy annotation You Did There</b>:</i>\n` +
     `Incredible.\n`;
 
   t.same(track.commentatorArtists, [artist1, artist2, artist3],
@@ -362,31 +451,31 @@ t.test(`Track.coverArtistContribs`, t => {
     `coverArtistContribs #1: defaults to empty array`);
 
   album.trackCoverArtistContribs = [
-    {who: `Artist 1`, what: `lines`},
-    {who: `Artist 2`, what: null},
+    {artist: `Artist 1`, annotation: `lines`},
+    {artist: `Artist 2`, annotation: null},
   ];
 
   XXX_decacheWikiData();
 
   t.same(track.coverArtistContribs,
-    [{who: artist1, what: `lines`}, {who: artist2, what: null}],
+    [{artist: artist1, annotation: `lines`}, {artist: artist2, annotation: null}],
     `coverArtistContribs #2: inherits album trackCoverArtistContribs`);
 
   track.coverArtistContribs = [
-    {who: `Artist 1`, what: `collage`},
+    {artist: `Artist 1`, annotation: `collage`},
   ];
 
-  t.same(track.coverArtistContribs, [{who: artist1, what: `collage`}],
+  t.same(track.coverArtistContribs, [{artist: artist1, annotation: `collage`}],
     `coverArtistContribs #3: resolves from own value`);
 
   track.coverArtistContribs = [
-    {who: `Artist 1`, what: `snooping`},
-    {who: `Artist 413`, what: `as`},
-    {who: `Artist 2`, what: `usual`},
+    {artist: `Artist 1`, annotation: `snooping`},
+    {artist: `Artist 413`, annotation: `as`},
+    {artist: `Artist 2`, annotation: `usual`},
   ];
 
   t.same(track.coverArtistContribs,
-    [{who: artist1, what: `snooping`}, {who: artist2, what: `usual`}],
+    [{artist: artist1, annotation: `snooping`}, {artist: artist2, annotation: `usual`}],
     `coverArtistContribs #4: filters out names without matches`);
 
   track.disableUniqueCoverArt = true;
diff --git a/test/unit/data/things/validators.js b/test/unit/data/things/validators.js
index 11134a90..3a217d6f 100644
--- a/test/unit/data/things/validators.js
+++ b/test/unit/data/things/validators.js
@@ -1,5 +1,5 @@
 import t from 'tap';
-import {showAggregate} from '#sugar';
+import {showAggregate} from '#aggregate';
 
 import {
   // Basic types
@@ -280,17 +280,17 @@ t.test('isContentString', t => {
 
 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/});
+  t.ok(isContribution({artist: 'artist:toby-fox', annotation: 'Music'}));
+  t.ok(isContribution({artist: 'Toby Fox'}));
+  t.throws(() => isContribution(({artist: 'group:umspaf', annotation: 'Organizing'})),
+    {errors: /artist/});
+  t.throws(() => isContribution(({artist: 'artist:toby-fox', annotation: 123})),
+    {errors: /annotation/});
 });
 
 t.test('isContributionList', t => {
   t.plan(4);
-  t.ok(isContributionList([{who: 'Beavis'}, {who: 'Butthead', what: 'Wrangling'}]));
+  t.ok(isContributionList([{artist: 'Beavis'}, {artist: 'Butthead', annotation: 'Wrangling'}]));
   t.ok(isContributionList([]));
   t.throws(() => isContributionList(2));
   t.throws(() => isContributionList(['Charlie', 'Woodstock']));