« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/content/dependencies/generateAdditionalFilesShortcut.js27
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js13
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js8
-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/generatePageLayout.js104
-rw-r--r--src/content/dependencies/generatePageSidebar.js19
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js57
-rw-r--r--src/content/dependencies/generateTrackChronologyLinks.js166
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js86
-rw-r--r--src/content/dependencies/image.js15
-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/listTracksByDate.js3
-rw-r--r--src/content/util/getChronologyRelations.js14
-rw-r--r--src/data/cacheable-object.js6
-rw-r--r--src/data/checks.js35
-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/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-properties/directory.js48
-rw-r--r--src/data/composite/wiki-properties/referenceList.js20
-rw-r--r--src/data/thing.js27
-rw-r--r--src/data/things/album.js284
-rw-r--r--src/data/things/wiki-info.js21
-rw-r--r--src/data/yaml.js64
-rw-r--r--src/gen-thumbs.js88
-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)361
-rw-r--r--src/static/js/client.js (renamed from src/static/client3.js)1545
-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.js577
-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.yaml49
-rwxr-xr-xsrc/upd8.js646
-rw-r--r--src/url-spec.js47
-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.js29
-rw-r--r--src/util/search-spec.js260
-rw-r--r--src/util/sort.js3
-rw-r--r--src/util/sugar.js58
-rw-r--r--src/web-routes.js74
-rw-r--r--src/write/bind-utilities.js2
-rw-r--r--src/write/build-modes/live-dev-server.js44
-rw-r--r--src/write/build-modes/repl.js6
-rw-r--r--src/write/build-modes/static-build.js6
62 files changed, 4780 insertions, 923 deletions
diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js
deleted file mode 100644
index 9e119bc..0000000
--- 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/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index e0f23bd..739a666 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', {
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index 1725d4b..ef81739 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/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js
index 8ec6ee0..7f24ded 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 0000000..23c4426
--- /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 069d85d..5270dbe 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 522a028..7994040 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)),
+        [
+          data.date &&
+            html.tag('time',
+              language.$(titlePrefix, 'date', {
+                date:
+                  language.formatDate(data.date),
+              })),
+
+          language.$(...titleParts, titleOptions)
+        ]),
 
       html.tag('blockquote', {class: 'commentary-entry-body'},
         style,
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 0bf3454..fd5df91 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,
@@ -374,30 +388,66 @@ 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');
-    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right');
+    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left', willShowSearch);
+    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right', false);
+
+    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));
 
+    showingSidebarLeft ??= hasSidebarLeft;
+    showingSidebarRight ??= hasSidebarRight;
+
     const processSkippers = skipperList =>
       skipperList
         .filter(({condition, id}) =>
           (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))));
@@ -528,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')},
 
         [
@@ -598,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', [
@@ -608,7 +660,18 @@ 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'),
             }),
           ]),
 
@@ -628,6 +691,12 @@ export default {
                 hasSidebarRight &&
                   {class: 'has-sidebar-right'},
 
+                showingSidebarLeft &&
+                  {class: 'showing-sidebar-left'},
+
+                showingSidebarRight &&
+                  {class: 'showing-sidebar-right'},
+
                 [
                   skippersHTML,
                   layoutHTML,
@@ -635,11 +704,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 43015aa..d3b5558 100644
--- a/src/content/dependencies/generatePageSidebar.js
+++ b/src/content/dependencies/generatePageSidebar.js
@@ -19,14 +19,13 @@ 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',
     },
 
@@ -37,6 +36,16 @@ 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(slots, {html}) {
@@ -68,7 +77,11 @@ export default {
       attributes.add('class', 'all-boxes-collapsible');
     }
 
-    if (html.isBlank(slots.boxes)) {
+    if (slots.initiallyHidden) {
+      attributes.add('class', 'initially-hidden');
+    }
+
+    if (html.isBlank(slots.boxes) && !slots.initiallyHidden) {
       return html.blank();
     } else {
       return html.tag('div', attributes, slots.boxes);
diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js
new file mode 100644
index 0000000..6607c78
--- /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/generateTrackChronologyLinks.js b/src/content/dependencies/generateTrackChronologyLinks.js
new file mode 100644
index 0000000..5f6b077
--- /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 a3ff07b..ae4ca62 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,19 +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',
@@ -21,13 +16,13 @@ export default {
     'generatePageLayout',
     'generateRelativeDatetimestamp',
     'generateTrackAdditionalNamesBox',
+    'generateTrackChronologyLinks',
     'generateTrackCoverArtwork',
     'generateTrackList',
     'generateTrackListDividedByGroups',
     'generateTrackReleaseInfo',
     'generateTrackSocialEmbed',
     'linkAlbum',
-    'linkArtist',
     'linkFlash',
     'linkTrack',
     'transformContent',
@@ -56,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);
 
@@ -110,8 +60,8 @@ export default {
     relations.albumNavAccent =
       relation('generateAlbumNavAccent', track.album, track);
 
-    relations.chronologyLinks =
-      relation('generateChronologyLinks');
+    relations.trackChronologyLinks =
+      relation('generateTrackChronologyLinks', track);
 
     relations.secondaryNav =
       relation('generateAlbumSecondaryNav', track.album);
@@ -138,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)) {
@@ -375,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', {
@@ -586,18 +531,7 @@ 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
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 822efe3..b1f0281 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -3,7 +3,6 @@ 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,
@@ -133,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'},
@@ -152,7 +143,7 @@ export default {
         {width: slots.dimensions[0]},
 
       slots.dimensions?.[1] &&
-        {width: slots.dimensions[1]},
+        {height: slots.dimensions[1]},
     ]);
 
     const isPlaceholder =
@@ -172,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/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
index 6f37529..e2ce4b3 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 f677d82..66fab8b 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 30884d2..f221fe8 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/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
index 01ce4e2..0a2bfd6 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/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js
index c4a62da..c601a99 100644
--- a/src/content/util/getChronologyRelations.js
+++ b/src/content/util/getChronologyRelations.js
@@ -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,
+      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/data/cacheable-object.js b/src/data/cacheable-object.js
index 5e26c5f..71dc5bd 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -132,7 +132,11 @@ export default class CacheableObject {
         return;
       }
 
-      if (update?.default) {
+      if (
+        typeof update === 'object' &&
+        update !== null &&
+        'default' in update
+      ) {
         this[property] = update?.default;
       } else {
         this[property] = null;
diff --git a/src/data/checks.js b/src/data/checks.js
index fe4528e..f3741a1 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -24,13 +24,21 @@ 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;
@@ -52,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);
@@ -61,8 +74,6 @@ export function reportDuplicateDirectories(wikiData, {
       }
     }
 
-    if (empty(duplicateDirectories)) continue;
-
     const sortedDuplicateDirectories =
       Array.from(duplicateDirectories)
         .sort((a, b) => {
@@ -77,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.
@@ -109,12 +118,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')));
+    }
   });
 }
 
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
index 8139f10..0ef91b8 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 0a1ebeb..a56bda3 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 fff3d5a..c8d27c4 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 0000000..3202ed4
--- /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 0000000..608cc0c
--- /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/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index b4cf6d1..15ebaff 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 0000000..b08b615
--- /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 0000000..034464e
--- /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-properties/directory.js b/src/data/composite/wiki-properties/directory.js
index 0b2181c..41ce4b2 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 af634a6..ebd5947 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/thing.js b/src/data/thing.js
index 9a8cec9..29f50d2 100644
--- a/src/data/thing.js
+++ b/src/data/thing.js
@@ -33,17 +33,36 @@ export default class Thing extends CacheableObject {
     },
   };
 
+  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 f835496..e9f55b2 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'),
@@ -111,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(),
@@ -155,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
@@ -345,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}) {
@@ -416,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: {
@@ -437,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/wiki-info.js b/src/data/things/wiki-info.js
index 316bd3b..2a2c998 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/yaml.js b/src/data/yaml.js
index bd0b55d..7e47053 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -23,7 +23,7 @@ import {
 import {
   filterReferenceErrors,
   reportContentTextErrors,
-  reportDuplicateDirectories,
+  reportDirectoryErrors,
 } from '#data-checks';
 
 import {
@@ -370,34 +370,42 @@ 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;
+  }
+
+  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 contributionStrings.map(item => {
+  return entries.map(mapFn);
+}
+
+export function parseContributors(entries) {
+  return parseArrayEntries(entries, item => {
     if (typeof item === 'object' && item['Who'])
       return {
         artist: item['Who'],
@@ -422,12 +430,20 @@ export function parseContributors(contributionStrings) {
   });
 }
 
-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};
 
@@ -1188,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);
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index c5c5ee4..d08726c 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,
@@ -630,6 +637,13 @@ export async function determineMediaCachePath({
     };
   }
 
+  if (!wikiCachePath) {
+    return {
+      annotation: 'wiki cache path not provided',
+      mediaCachePath: null,
+    };
+  }
+
   let mediaIncludesThumbnailCache;
 
   try {
@@ -648,24 +662,33 @@ export async function determineMediaCachePath({
 
   // Two inferred paths are possible - "adjacent" and "contained".
   // "Contained" is the preferred format and we'll create it if
-  // wikiCachePath is provided, but if it *isn't* we won't know
-  // where to create it. Since "adjacent" isn't preferred we don't
-  // ever generate it, and we'd prefer not to *newly* generate
-  // thumbs in-place with mediaPath, so give up - we've already
-  // determined mediaPath doesn't include in-place thumbs.
-
-  const adjacentInferredPath =
-    path.join(
-      path.dirname(mediaPath),
-      path.basename(mediaPath) + '-cache');
+  // 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);
 
-  let adjacentIncludesThumbnailCache;
+  const adjacentInferredPath =
+    path.join(
+      path.dirname(mediaPath),
+      path.basename(mediaPath) + '-cache');
+
   let containedIncludesThumbnailCache;
+  let adjacentIncludesThumbnailCache;
+
+  try {
+    const files = await readdir(containedInferredPath);
+    containedIncludesThumbnailCache = files.includes(CACHE_FILE);
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      containedIncludesThumbnailCache = null;
+    } else {
+      containedIncludesThumbnailCache = undefined;
+    }
+  }
 
   try {
     const files = await readdir(adjacentInferredPath);
@@ -678,19 +701,6 @@ export async function determineMediaCachePath({
     }
   }
 
-  if (wikiCachePath) {
-    try {
-      const files = await readdir(containedInferredPath);
-      containedIncludesThumbnailCache = files.includes(CACHE_FILE);
-    } catch (error) {
-      if (error.code === 'ENOENT') {
-        containedIncludesThumbnailCache = null;
-      } else {
-        containedIncludesThumbnailCache = undefined;
-      }
-    }
-  }
-
   // Go ahead with the contained path if it exists and contains a cache -
   // no other conditions matter.
   if (containedIncludesThumbnailCache === true) {
@@ -712,7 +722,7 @@ export async function determineMediaCachePath({
   // 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 (wikiCachePath && containedIncludesThumbnailCache === undefined) {
+  if (containedIncludesThumbnailCache === undefined) {
     return {
       annotation: `contained path not readable`,
       mediaCachePath: null,
@@ -764,28 +774,12 @@ export async function determineMediaCachePath({
     }
   }
 
-  // If wikiCachePath was provided and the contained cache just doesn't
-  // exist yet, we'll create it during this run.
-  if (wikiCachePath && containedIncludesThumbnailCache === null) {
-    return {
-      annotation: `contained path will be created`,
-      mediaCachePath: containedInferredPath,
-    };
-  }
-
-  // If the adjacent cache doesn't exist, too dang bad!
-  // We aren't interested in newly creating it, so
-  // don't count it as an option.
-
-  // Similarly, we've already established mediaPath isn't
-  // currently doubling as the thumbnail cache, and we won't
-  // newly start generating thumbnails here either.
-
-  // All options aside struck out, there's no way to continue.
-
+  // 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: `missing wiki cache to create media cache inside`,
-    mediaCachePath: null,
+    annotation: `contained path will be created`,
+    mediaCachePath: containedInferredPath,
   };
 }
 
diff --git a/src/listing-spec.js b/src/listing-spec.js
index 73fbee6..bfea397 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 0000000..a2dae9e
--- /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 586f37b..586f37b 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 66518d3..eb1edb2 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 {
@@ -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;
 }
@@ -813,6 +1108,10 @@ ul.image-details li {
   font-style: oblique;
 }
 
+.commentary-entry-heading time {
+  float: right;
+}
+
 .commentary-art {
   float: right;
   width: 30%;
@@ -995,6 +1294,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;
 }
@@ -1346,7 +1656,6 @@ img.pixelate, .pixelate img {
 
   font-size: 1.6em;
   opacity: 0.8;
-  background-image: url("warning.svg");
 }
 
 .reveal-interaction {
@@ -2030,40 +2339,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 +2408,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;
@@ -2331,6 +2641,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
   }
 
   .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 64f5b37..733e458 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);
 
@@ -24,7 +35,7 @@ function initInfo(infoKey, description) {
   for (const obj of [
     object,
     object.state,
-    object.setting,
+    object.settings,
     object.event,
   ]) {
     if (!obj) continue;
@@ -32,35 +43,117 @@ function initInfo(infoKey, description) {
   }
 
   if (object.session) {
-    const sessionDefaults = object.session;
+    const sessionSpecs = object.session;
 
     object.session = {};
 
-    for (const [key, defaultValue] of Object.entries(sessionDefaults)) {
+    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 {
-            return sessionStorage.getItem(storageKey) ?? defaultValue;
+            value = sessionStorage.getItem(storageKey) ?? defaultValue;
           } catch (error) {
             if (error instanceof DOMException) {
-              return fallbackValue;
+              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 {
-            sessionStorage.setItem(storageKey, value);
+            operation();
           } catch (error) {
-            if (error instanceof DOMException) {
-              fallbackValue = value;
-            } else {
+            if (!(error instanceof DOMException)) {
               throw error;
             }
           }
@@ -143,6 +236,14 @@ function cssProp(el, ...args) {
   }
 }
 
+function templateContent(el) {
+  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) => {
@@ -175,9 +276,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.
 
@@ -209,8 +313,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);
     }
   }
@@ -1044,6 +1148,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', {
@@ -2653,7 +2805,7 @@ function addImageOverlayClickHandlers() {
   }
 }
 
-function handleImageLinkClicked(evt) {
+async function handleImageLinkClicked(evt) {
   if (evt.metaKey || evt.shiftKey || evt.altKey) {
     return;
   }
@@ -2720,26 +2872,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');
   }
 }
 
@@ -2839,67 +3011,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', {
@@ -2990,6 +3101,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.
@@ -3300,6 +3519,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('art-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', {
@@ -3516,8 +4877,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 1df56f0..1df56f0 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 0000000..e7e1e0c
--- /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 0000000..6d4ed52
--- /dev/null
+++ b/src/static/js/search-worker.js
@@ -0,0 +1,577 @@
+/* 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 main() {
+  let background;
+
+  background =
+    Promise.all([
+      fetch(rebase('index.json'))
+        .then(resp => resp.json()),
+
+      loadCachedIndexFromIDB(),
+    ]);
+
+  indexes =
+    withEntries(searchSpec, entries => entries
+      .map(([key, descriptor]) => [
+        key,
+        makeSearchIndex(descriptor, {FlexSearch}),
+      ]));
+
+  const [indexData, idbIndexData] = await background;
+
+  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))
+
+  if (!empty(keysNeedingFetch)) {
+    postMessage({
+      kind: 'download-begun',
+      context: 'search-indexes',
+      keys: keysNeedingFetch,
+    });
+  }
+
+  const fetchPromises =
+    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;
+          }));
+
+  const fetchBlobPromises =
+    fetchPromises
+      .map(promise => promise
+        .then(response => response.blob()));
+
+  const fetchArrayBufferPromises =
+    fetchBlobPromises
+      .map(promise => promise
+        .then(blob => blob.arrayBuffer()));
+
+  const cacheArrayBufferPromises =
+    keysFromCache
+      .map(key => idbIndexData[key])
+      .map(({cachedBinarySource}) =>
+        cachedBinarySource.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 = [
+    ['kind', 'primaryName', 'parentName', 'groups'],
+    ['kind', 'primaryName', 'parentName'],
+    ['kind', 'primaryName', 'groups', 'contributors'],
+    ['kind', 'primaryName', 'groups', 'artTags'],
+    ['kind', 'primaryName', 'groups'],
+    ['kind', 'primaryName', 'contributors'],
+    ['kind', 'primaryName', 'artTags'],
+    ['kind', 'parentName', 'groups', 'artTags'],
+    ['kind', 'parentName', 'artTags'],
+    ['kind', 'groups', 'contributors'],
+    ['kind', 'groups', 'artTags'],
+    ['kind', 'groups'],
+    ['kind', 'contributors'],
+
+    ['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 terms = query.split(' ');
+
+  const particles = particulate(terms);
+
+  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);
+      }
+    }
+  }
+
+  return boilerplate.constitute(results);
+}
+
+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 0000000..8a43072
--- /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 8c9a80a..8c9a80a 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 92e5577..92e5577 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 0000000..d21c0e6
--- /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 4b38b60..3fc1d2e 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -334,8 +334,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:"
@@ -430,12 +430,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 +493,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 +519,7 @@ misc:
       _: "{LINK} ({ANNOTATION})"
       annotation: "invalid URL"
 
+    amazonMusic: "Amazon Music"
     appleMusic: "Apple Music"
     artstation: "ArtStation"
     bandcamp: "Bandcamp"
@@ -613,6 +619,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
@@ -1840,6 +1875,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 600cc25..82ba8a6 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -34,7 +34,7 @@
 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';
 
@@ -47,8 +47,8 @@ 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 {identifyAllWebRoutes} from '#web-routes';
 
@@ -61,14 +61,22 @@ import {
   logError,
   parseOptions,
   progressCallAll,
+  showHelpForOptions as unboundShowHelpForOptions,
 } from '#cli';
 
 import {
   filterReferenceErrors,
-  reportDuplicateDirectories,
+  reportDirectoryErrors,
   reportContentTextErrors,
 } from '#data-checks';
 
+import {
+  bindOpts,
+  empty,
+  indentWrap as unboundIndentWrap,
+  withEntries,
+} from '#sugar';
+
 import genThumbs, {
   CACHE_FILE as thumbsCacheFile,
   defaultMagickThreads,
@@ -93,8 +101,6 @@ 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();
@@ -121,69 +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`},
+      {...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 = (
@@ -200,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 = {
@@ -222,7 +271,10 @@ async function main() {
     listingTargetSpec,
   };
 
-  const buildOptions = selectedBuildMode.getCLIOptions();
+  const buildOptions =
+    (selectedBuildMode
+      ? selectedBuildMode.getCLIOptions()
+      : {});
 
   const commonOptions = {
     'help': {
@@ -234,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',
     },
 
@@ -242,17 +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 either by loading "media-cache" from --cache-path, or by adding "-cache" to the end of the media directory\n\nAlso may be provided via the HSMUSIC_MEDIA_CACHE environment variable`,
+      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\nRequired for some features and may always be required if you're starting a new workspace\n\nAlso may be provided via the HSMUSIC_CACHE environment varaible`,
+      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',
     },
 
@@ -311,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.
@@ -390,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.
@@ -400,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 (buildOptions) {
-      showOptions(`Build options for --${selectedBuildModeFlag} (${
-        usingDefaultBuildMode ? 'default' : 'selected'
-      })`, buildOptions);
+    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.`);
+    }
+
+    for (const step of Object.values(stepStatusSummary)) {
+      Object.assign(step, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--help provided`,
+      });
     }
 
     return true;
@@ -496,8 +552,6 @@ async function main() {
   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';
@@ -519,10 +573,10 @@ async function main() {
   }
 
   if (!wikiCachePath) {
-    logWarn`No --cache-path option nor HSMUSIC_CACHE set; provide for more features`;
+    logError`${`Expected --cache-path option or HSMUSIC_CACHE to be set`}`;
   }
 
-  if (!dataPath || !mediaPath) {
+  if (!dataPath || !mediaPath || !wikiCachePath) {
     return false;
   }
 
@@ -533,65 +587,86 @@ 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 {
-        step.status =
-          (cliFlagNegates
-            ? STATUS_NOT_APPLICABLE
-            : STATUS_NOT_STARTED);
+      }
 
-        step.annotation = `--${cliFlag} provided`;
+      step.status =
+        (cliFlagNegates
+          ? STATUS_NOT_APPLICABLE
+          : STATUS_NOT_STARTED);
 
-        if (cliFlagWarning) {
-          for (const line of cliFlagWarning.split('\n')) {
-            logWarn(line);
-          }
+      step.annotation = `--${cliFlag} provided`;
+
+      if (cliFlagWarning) {
+        for (const line of cliFlagWarning.split('\n')) {
+          logWarn(line);
         }
+      }
 
-        return;
+      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) {
@@ -619,12 +694,22 @@ async function main() {
     }
 
     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;
@@ -649,7 +734,6 @@ async function main() {
 
     fallbackStep('filterReferenceErrors', {
       default: 'perform',
-      buildConfig: null,
       cli: {
         flag: 'skip-reference-validation',
         negate: true,
@@ -662,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',
+        ],
       },
     });
 
@@ -686,10 +773,98 @@ async function main() {
     });
 
     fallbackStep('identifyWebRoutes', {
-      default: 'skip',
+      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',
@@ -730,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;
   }
@@ -778,6 +963,44 @@ 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(),
@@ -885,17 +1108,15 @@ async function main() {
         logError`a new path with ${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}.`;
         break;
 
-      case `missing wiki cache to create media cache inside`:
+      case `media path not provided`: /* unreachable */
         console.error('');
-        logError`It looks like you're starting totally fresh, so please`;
-        logError`create a ${'cache'} folder and provide it with ${'--cache-path'}`;
-        logError`or ${'HSMUSIC_CACHE'}. The media cache will automatically be`;
-        logError`generated inside of this folder!`;
+        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 `media path not provided`: /* unreachable */
+      case `cache path not provided`: /* unreachable */
         console.error('');
-        logError`It seems a ${'--media-path'} (or ${'HSMUSIC_MEDIA'}) wasn't provided.`;
+        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;
     }
@@ -1066,8 +1287,6 @@ async function main() {
     CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
   }
 
-  let paragraph = false;
-
   Object.assign(stepStatusSummary.loadDataFiles, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
@@ -1340,20 +1559,22 @@ 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(),
     });
@@ -1369,7 +1590,7 @@ async function main() {
     console.log('');
     paragraph = true;
 
-    Object.assign(stepStatusSummary.reportDuplicateDirectories, {
+    Object.assign(stepStatusSummary.reportDirectoryErrors, {
       status: STATUS_FATAL_ERROR,
       annotation: `duplicate directories found`,
       timeEnd: Date.now(),
@@ -1847,8 +2068,6 @@ async function main() {
     timeEnd: Date.now(),
   });
 
-  const urls = generateURLs(urlSpec);
-
   let missingImagePaths;
 
   if (stepStatusSummary.verifyImagePaths.status === STATUS_NOT_APPLICABLE) {
@@ -1998,7 +2217,49 @@ async function main() {
     }
   }
 
-  let webRoutes = null;
+  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, {
@@ -2009,7 +2270,7 @@ async function main() {
     const fromRoot = urls.from('shared.root');
 
     try {
-      const webRouteSources = await identifyAllWebRoutes({
+      webRouteSources = await identifyAllWebRoutes({
         mediaCachePath,
         mediaPath,
         wikiCachePath,
@@ -2025,7 +2286,7 @@ async function main() {
           {message: `Errors computing effective web route paths`},);
 
       aggregate.close();
-      webRoutes = result;
+      preparedWebRoutes = result;
     } catch (error) {
       if (!paragraph) console.log('');
       niceShowAggregate(error);
@@ -2045,7 +2306,7 @@ async function main() {
       return false;
     }
 
-    logInfo`Successfully determined web routes.`;
+    logInfo`Successfully determined web routes - nice!`;
     paragraph = false;
 
     Object.assign(stepStatusSummary.identifyWebRoutes, {
@@ -2054,6 +2315,12 @@ async function main() {
     });
   }
 
+  wikiData.wikiInfo.searchDataAvailable =
+    (webRouteSources
+      ? webRouteSources
+          .some(({to}) => to[0].startsWith('searchData'))
+      : null);
+
   if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
     return true;
   }
@@ -2095,12 +2362,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,
 
@@ -2110,10 +2381,9 @@ async function main() {
       thumbsCache,
       urls,
       urlSpec,
-      webRoutes,
+      webRoutes: preparedWebRoutes,
       wikiData,
 
-      cachebust: '?' + CACHEBUST,
       closeLanguageWatchers,
       developersComment,
       getSizeOfAdditionalFile,
diff --git a/src/url-spec.js b/src/url-spec.js
index ec971c0..dc4673e 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -1,5 +1,11 @@
 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 = '2p2';
+
 const genericPaths = {
   root: '',
   path: '<>',
@@ -63,19 +69,37 @@ const urlSpec = {
   },
 
   shared: {
-    paths: {
-      ...genericPaths,
+    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/',
 
@@ -100,13 +124,8 @@ const urlSpec = {
     paths: genericPaths,
   },
 
-  static: {
-    prefix: 'static/',
-    paths: genericPaths,
-  },
-
-  util: {
-    prefix: 'util/',
+  searchData: {
+    prefix: 'search-data/',
     paths: genericPaths,
   },
 };
diff --git a/src/util/cli.js b/src/util/cli.js
index ce513f0..72979d3 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 50339cd..7298c46 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 3b779af..a616efb 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 9e07f9b..bd9f4eb 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -658,28 +658,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 +697,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+/) ?? '';
diff --git a/src/util/search-spec.js b/src/util/search-spec.js
new file mode 100644
index 0000000..22ce71a
--- /dev/null
+++ b/src/util/search-spec.js
@@ -0,0 +1,260 @@
+// 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.kind =
+        thing.constructor[Symbol.for('Thing.referenceType')];
+
+      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: [
+      'kind',
+      '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/sort.js b/src/util/sort.js
index b3a9081..9e9de64 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 e060f45..3fa3fb4 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
index efd86ca..7e08d06 100644
--- a/src/web-routes.js
+++ b/src/web-routes.js
@@ -8,25 +8,71 @@ const codeSrcPath = __dirname;
 const codeRootPath = path.resolve(codeSrcPath, '..');
 
 function getNodeDependencyRootPath(dependencyName) {
-  const packageJSON =
-    import.meta.resolve(dependencyName + '/package.json');
-
-  return path.dirname(fileURLToPath(packageJSON));
+  return (
+    path.dirname(
+      fileURLToPath(
+        import.meta.resolve(dependencyName))));
 }
 
 export const stationaryCodeRoutes = [
   {
-    from: path.join(codeSrcPath, 'static'),
-    to: ['static.root'],
+    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: ['util.root'],
+    to: ['staticSharedUtil.root'],
   },
 ];
 
-export const dependencyRoutes = [];
+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,
@@ -43,6 +89,18 @@ export async function identifyDynamicWebRoutes({
       {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 =
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index 3d4ecc7..8dd08db 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 91ed4ee..b018bc1 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,7 +24,7 @@ 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: {
@@ -102,7 +103,6 @@ export async function go({
   webRoutes,
   wikiData,
 
-  cachebust,
   developersComment: _developersComment,
   getSizeOfAdditionalFile,
   getSizeOfImagePath,
@@ -213,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`);
@@ -299,26 +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) {
         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);
         }
-        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;
     }
 
@@ -330,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;
     }
 
@@ -391,7 +398,6 @@ export async function go({
 
       const bound = bindUtilities({
         absoluteTo,
-        cachebust,
         defaultLanguage,
         getSizeOfAdditionalFile,
         getSizeOfImagePath,
@@ -425,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 b300e8e..faba8a3 100644
--- a/src/write/build-modes/repl.js
+++ b/src/write/build-modes/repl.js
@@ -13,6 +13,10 @@ export const config = {
     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 68cf094..1ab0604 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -49,6 +49,10 @@ export const config = {
     default: 'perform',
   },
 
+  search: {
+    default: 'perform',
+  },
+
   thumbs: {
     default: 'perform',
   },
@@ -115,7 +119,6 @@ export async function go({
   webRoutes,
   wikiData,
 
-  cachebust,
   developersComment: _developersComment,
   getSizeOfAdditionalFile,
   getSizeOfImagePath,
@@ -306,7 +309,6 @@ export async function go({
 
         const bound = bindUtilities({
           absoluteTo,
-          cachebust,
           defaultLanguage,
           getSizeOfAdditionalFile,
           getSizeOfImagePath,