« get me outta code hell

Merge branch 'preview' into listing-tweaks - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-11-09 14:42:24 -0400
committer(quasar) nebula <qznebula@protonmail.com>2023-11-09 14:42:24 -0400
commitaa30c888ea2307931c555db474d709f520c551a8 (patch)
treeb23042b5b575862d83f401b5fa21f8b45f7988ff /src
parente71230340181a3b7b38ff05ba23504b264f5b26c (diff)
parentb62622d3cd8ffe1ed517ceb873d9352943c4a601 (diff)
Merge branch 'preview' into listing-tweaks
Diffstat (limited to 'src')
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js2
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js10
-rw-r--r--src/content/dependencies/generateListingPage.js6
-rw-r--r--src/content/dependencies/generatePageLayout.js3
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js2
-rw-r--r--src/content/dependencies/image.js7
-rw-r--r--src/content/dependencies/linkExternal.js25
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js8
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js51
-rw-r--r--src/data/composite/wiki-properties/wikiData.js34
-rw-r--r--src/data/language.js199
-rw-r--r--src/data/things/album.js19
-rw-r--r--src/data/things/art-tag.js11
-rw-r--r--src/data/things/artist.js19
-rw-r--r--src/data/things/composite.js14
-rw-r--r--src/data/things/flash.js18
-rw-r--r--src/data/things/group.js16
-rw-r--r--src/data/things/homepage-layout.js16
-rw-r--r--src/data/things/track.js24
-rw-r--r--src/data/things/validators.js40
-rw-r--r--src/data/things/wiki-info.js4
-rw-r--r--src/data/yaml.js193
-rw-r--r--src/find.js29
-rw-r--r--src/gen-thumbs.js348
-rw-r--r--src/listing-spec.js1
-rw-r--r--src/repl.js4
-rw-r--r--src/static/client2.js16
-rw-r--r--src/strings-default.json528
-rw-r--r--src/strings-default.yaml1690
-rwxr-xr-xsrc/upd8.js953
-rw-r--r--src/url-spec.js13
-rw-r--r--src/util/cli.js31
-rw-r--r--src/util/html.js4
-rw-r--r--src/util/sugar.js157
-rw-r--r--src/write/build-modes/live-dev-server.js21
-rw-r--r--src/write/build-modes/static-build.js6
36 files changed, 3503 insertions, 1019 deletions
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index c04bfb68..e28b54cb 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -23,7 +23,7 @@ export default {
     const things = tag.taggedInThings.slice();
 
     sortAlbumsTracksChronologically(things, {
-      getDate: thing => thing.coverArtDate,
+      getDate: thing => thing.coverArtDate ?? thing.date,
       latestFirst: true,
     });
 
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
index aa6efe5e..a3bcf687 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -30,7 +30,7 @@ export default {
         entry: {
           type: 'albumCover',
           album: album,
-          date: album.coverArtDate,
+          date: album.coverArtDate ?? album.date,
           contribs: album.coverArtistContribs,
         },
       })),
@@ -40,7 +40,7 @@ export default {
         entry: {
           type: 'albumWallpaper',
           album: album,
-          date: album.coverArtDate,
+          date: album.coverArtDate ?? album.date,
           contribs: album.wallpaperArtistContribs,
         },
       })),
@@ -50,7 +50,7 @@ export default {
         entry: {
           type: 'albumBanner',
           album: album,
-          date: album.coverArtDate,
+          date: album.coverArtDate ?? album.date,
           contribs: album.bannerArtistContribs,
         },
       })),
@@ -60,7 +60,7 @@ export default {
         entry: {
           type: 'trackCover',
           album: track.album,
-          date: track.coverArtDate,
+          date: track.coverArtDate ?? track.date,
           track: track,
           contribs: track.coverArtistContribs,
         },
@@ -69,7 +69,7 @@ export default {
 
     sortEntryThingPairs(entries,
       things => sortAlbumsTracksChronologically(things, {
-        getDate: thing => thing.coverArtDate,
+        getDate: thing => thing.coverArtDate ?? thing.date,
       }));
 
     const chunks =
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index f527f16f..45b7dc1b 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -156,8 +156,10 @@ export default {
                           .slots({
                             hash: id,
                             content:
-                              formatListingString('chunk.title', title)
-                                .replace(/:$/, ''),
+                              html.normalize(
+                                formatListingString('chunk.title', title)
+                                  .toString()
+                                  .replace(/:$/, '')),
                           }))))),
             ],
 
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index cd831ba7..72dfbae5 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -449,7 +449,8 @@ export default {
             {[html.onlyIfContent]: true, class: 'skipper-list'},
             processSkippers([
               {id: 'tracks', string: 'tracks'},
-              {id: 'art', string: 'flashes'},
+              {id: 'art', string: 'artworks'},
+              {id: 'flashes', string: 'flashes'},
               {id: 'contributors', string: 'contributors'},
               {id: 'references', string: 'references'},
               {id: 'referenced-by', string: 'referencedBy'},
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 1083d863..93334948 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -82,7 +82,7 @@ export default {
             ...artist.albumsAsCoverArtist,
             ...artist.tracksAsCoverArtist,
           ], {
-            getDate: albumOrTrack => albumOrTrack.coverArtDate,
+            getDate: thing => thing.coverArtDate ?? thing.date,
           }),
       }),
 
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 6c0aeecd..8aa9753b 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -77,6 +77,11 @@ export default {
       originalSrc = '';
     }
 
+    // TODO: This feels janky. It's necessary to deal with static content that
+    // includes strings like <img src="media/misc/foo.png">, but processing the
+    // src string directly when a parts-formed path *is* available seems wrong.
+    // It should be possible to do urls.from(slots.path[0]).to(...slots.path),
+    // for example, but will require reworking the control flow here a little.
     let mediaSrc = null;
     if (originalSrc.startsWith(to('media.root'))) {
       mediaSrc =
@@ -160,7 +165,7 @@ export default {
       // which is the HTML output-appropriate path including `../../` or
       // another alternate base path.
       const selectedSize = getThumbnailEqualOrSmaller(slots.thumb, mediaSrc);
-      thumbSrc = originalSrc.replace(/\.(jpg|png)$/, `.${selectedSize}.jpg`);
+      thumbSrc = to('thumb.path', mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`));
 
       const dimensions = getDimensionsOfImagePath(mediaSrc);
       availableThumbs = getThumbnailsAvailableForDimensions(dimensions);
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index 73c656e3..5de612e2 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -3,10 +3,20 @@ const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
 const MASTODON_DOMAINS = ['types.pl'];
 
 export default {
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['html', 'language', 'wikiData'],
 
-  data(url) {
-    return {url};
+  sprawl: ({wikiInfo}) => ({wikiInfo}),
+
+  data(sprawl, url) {
+    const data = {url};
+
+    const {canonicalBase} = sprawl.wikiInfo;
+    if (canonicalBase) {
+      const {hostname: canonicalDomain} = new URL(canonicalBase);
+      Object.assign(data, {canonicalDomain});
+    }
+
+    return data;
   },
 
   slots: {
@@ -20,6 +30,7 @@ export default {
     let isLocal;
     let domain;
     let pathname;
+
     try {
       const url = new URL(data.url);
       domain = url.hostname;
@@ -28,6 +39,14 @@ export default {
       // No support for relative local URLs yet, sorry! (I.e, local URLs must
       // be absolute relative to the domain name in order to work.)
       isLocal = true;
+      domain = null;
+      pathname = null;
+    }
+
+    // isLocal also applies for URLs which match the 'Canonical Base' under
+    // wiki-info.yaml, if present.
+    if (data.canonicalDomain && domain === data.canonicalDomain) {
+      isLocal = true;
     }
 
     const link = html.tag('a',
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index edb02e0d..45f8390f 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -97,14 +97,14 @@ export default {
         ...getArtists(album, 'bannerArtistContribs'),
       ])) {
         // Might combine later with 'track' of the same album and date.
-        considerDate(artist, album.coverArtDate, album, 'artwork');
+        considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork');
       }
     }
 
     for (const track of tracksLatestFirst) {
       for (const artist of getArtists(track, 'coverArtistContribs')) {
         // No special effect if artist already has 'artwork' for the same album and date.
-        considerDate(artist, track.coverArtDate, track.album, 'artwork');
+        considerDate(artist, track.coverArtDate ?? track.date, track.album, 'artwork');
       }
 
       for (const artist of new Set([
@@ -199,10 +199,6 @@ export default {
         }
       });
 
-    // Last off, turn the flat sorted list into a proper chunked list, now that
-    // entries going in the same chunk are sorted correctly next to each other.
-    // Then extract the parts that are useful for displaying on the listing!
-
     const chunks =
       chunkMultipleArrays(artistThings, artistDates, artistContributions, artists,
         (thing, lastThing, date, lastDate) =>
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
index d27f7b23..fac8e213 100644
--- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -2,22 +2,15 @@
 // just to the track's name, which means you don't have to always reference
 // some *other* (much more commonly referenced) track by directory instead
 // of more naturally by name.
-//
-// See the implementation for an important caveat about matching the original
-// track against other tracks, which uses a custom implementation pulling (and
-// duplicating) details from #find instead of using withOriginalRelease and the
-// usual withResolvedReference / find.track() utilities.
-//
 
 import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
 import {isBoolean} from '#validators';
 
 import {exitWithoutDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
-
-// TODO: Kludge. (The usage of this, not so much the import.)
-import CacheableObject from '../../../things/cacheable-object.js';
+import {withResolvedReference} from '#composite/wiki-data';
 
 export default templateCompositeFrom({
   annotation: `withAlwaysReferenceByDirectory`,
@@ -44,29 +37,23 @@ export default templateCompositeFrom({
       value: input.value(false),
     }),
 
-    // "Slow" / uncached, manual search from trackData (with this track
-    // excluded). Otherwise there end up being pretty bad recursion issues
-    // (track1.alwaysReferencedByDirectory depends on searching through data
-    // including track2, which depends on evaluating track2.alwaysReferenced-
-    // ByDirectory, which depends on searcing through data including track1...)
-    // That said, this is 100% a kludge, since it involves duplicating find
-    // logic on a completely unrelated context.
-    {
-      dependencies: [input.myself(), 'trackData', 'originalReleaseTrack'],
-      compute: (continuation, {
-        [input.myself()]: thisTrack,
-        ['trackData']: trackData,
-        ['originalReleaseTrack']: ref,
-      }) => continuation({
-        ['#originalRelease']:
-          (ref.startsWith('track:')
-            ? trackData.find(track => track.directory === ref.slice('track:'.length))
-            : trackData.find(track =>
-                track !== thisTrack &&
-                !CacheableObject.getUpdateValue(track, 'originalReleaseTrack') &&
-                track.name.toLowerCase() === ref.toLowerCase())),
-      })
-    },
+    // It's necessary to use the custom trackOriginalReleasesOnly find function
+    // here, so as to avoid recursion issues - the find.track() function depends
+    // on accessing each track's alwaysReferenceByDirectory, which means it'll
+    // hit *this track* - and thus this step - and end up recursing infinitely.
+    // By definition, find.trackOriginalReleasesOnly excludes tracks which have
+    // an originalReleaseTrack update value set, which means even though it does
+    // still access each of tracks' `alwaysReferenceByDirectory` property, it
+    // won't access that of *this* track - it will never proceed past the
+    // `exitWithoutDependency` step directly above, so there's no opportunity
+    // for recursion.
+    withResolvedReference({
+      ref: 'originalReleaseTrack',
+      data: 'trackData',
+      find: input.value(find.trackOriginalReleasesOnly),
+    }).outputs({
+      '#resolvedReference': '#originalRelease',
+    }),
 
     exitWithoutDependency({
       dependency: '#originalRelease',
diff --git a/src/data/composite/wiki-properties/wikiData.js b/src/data/composite/wiki-properties/wikiData.js
index 4ea47785..5cea49a0 100644
--- a/src/data/composite/wiki-properties/wikiData.js
+++ b/src/data/composite/wiki-properties/wikiData.js
@@ -1,17 +1,29 @@
 // General purpose wiki data constructor, for properties like artistData,
 // trackData, etc.
 
-import {validateArrayItems, validateInstanceOf} from '#validators';
+import {input, templateCompositeFrom} from '#composite';
+import {validateWikiData} from '#validators';
 
-// TODO: Not templateCompositeFrom.
+import {inputThingClass} from '#composite/wiki-data';
 
-// TODO: This should validate with validateWikiData.
+// TODO: Kludge.
+import Thing from '../../things/thing.js';
 
-export default function(thingClass) {
-  return {
-    flags: {update: true},
-    update: {
-      validate: validateArrayItems(validateInstanceOf(thingClass)),
-    },
-  };
-}
+export default templateCompositeFrom({
+  annotation: `wikiData`,
+
+  compose: false,
+
+  inputs: {
+    class: inputThingClass(),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => {
+    const referenceType = thingClass[Thing.referenceType];
+    return {validate: validateWikiData({referenceType})};
+  },
+
+  steps: () => [],
+});
diff --git a/src/data/language.js b/src/data/language.js
index 09466907..6ffc31e0 100644
--- a/src/data/language.js
+++ b/src/data/language.js
@@ -1,39 +1,190 @@
+import EventEmitter from 'node:events';
 import {readFile} from 'node:fs/promises';
+import path from 'node:path';
 
-// It stands for "HTML Entities", apparently. Cursed.
-import he from 'he';
+import chokidar from 'chokidar';
+import he from 'he'; // It stands for "HTML Entities", apparently. Cursed.
+import yaml from 'js-yaml';
 
 import T from '#things';
+import {colors, logWarn} from '#cli';
 
-export async function processLanguageFile(file) {
-  const contents = await readFile(file, 'utf-8');
-  const json = JSON.parse(contents);
+import {
+  annotateError,
+  annotateErrorWithFile,
+  showAggregate,
+  withAggregate,
+} from '#sugar';
+
+const {Language} = T;
+
+export function processLanguageSpec(spec, {existingCode = null} = {}) {
+  const {
+    'meta.languageCode': code,
+    'meta.languageName': name,
+
+    'meta.languageIntlCode': intlCode = null,
+    'meta.hidden': hidden = false,
+
+    ...strings
+  } = spec;
+
+  withAggregate({message: `Errors validating language spec`}, ({push}) => {
+    if (!code) {
+      push(new Error(`Missing language code`));
+    }
+
+    if (!name) {
+      push(new Error(`Missing language name`));
+    }
+
+    if (code && existingCode && code !== existingCode) {
+      push(new Error(`Language code (${code}) doesn't match previous value\n(You'll have to reload hsmusic to load this)`));
+    }
+  });
+
+  return {code, intlCode, name, hidden, strings};
+}
+
+function flattenLanguageSpec(spec) {
+  const recursive = (keyPath, value) =>
+    (typeof value === 'object'
+      ? Object.assign({}, ...
+          Object.entries(value)
+            .map(([key, value]) =>
+              (key === '_'
+                ? {[keyPath]: value}
+                : recursive(
+                    (keyPath ? `${keyPath}.${key}` : key),
+                    value))))
+      : {[keyPath]: value});
 
-  const code = json['meta.languageCode'];
-  if (!code) {
-    throw new Error(`Missing language code (file: ${file})`);
+  return recursive('', spec);
+}
+
+async function processLanguageSpecFromFile(file, processLanguageSpecOpts) {
+  let contents;
+
+  try {
+    contents = await readFile(file, 'utf-8');
+  } catch (caughtError) {
+    throw annotateError(
+      new Error(`Failed to read language file`, {cause: caughtError}),
+      error => annotateErrorWithFile(error, file));
   }
-  delete json['meta.languageCode'];
 
-  const intlCode = json['meta.languageIntlCode'] ?? null;
-  delete json['meta.languageIntlCode'];
+  let rawSpec;
+  let parseLanguage;
 
-  const name = json['meta.languageName'];
-  if (!name) {
-    throw new Error(`Missing language name (${code})`);
+  try {
+    if (path.extname(file) === '.yaml') {
+      parseLanguage = 'YAML';
+      rawSpec = yaml.load(contents);
+    } else {
+      parseLanguage = 'JSON';
+      rawSpec = JSON.parse(contents);
+    }
+  } catch (caughtError) {
+    throw annotateError(
+      new Error(`Failed to parse language file as valid ${parseLanguage}`, {cause: caughtError}),
+      error => annotateErrorWithFile(error, file));
   }
-  delete json['meta.languageName'];
 
-  const hidden = json['meta.hidden'] ?? false;
-  delete json['meta.hidden'];
+  const flattenedSpec = flattenLanguageSpec(rawSpec);
+
+  try {
+    return processLanguageSpec(flattenedSpec, processLanguageSpecOpts);
+  } catch (caughtError) {
+    throw annotateErrorWithFile(caughtError, file);
+  }
+}
 
-  const language = new T.Language();
-  language.code = code;
-  language.intlCode = intlCode;
-  language.name = name;
-  language.hidden = hidden;
-  language.escapeHTML = (string) =>
+export function initializeLanguageObject() {
+  const language = new Language();
+
+  language.escapeHTML = string =>
     he.encode(string, {useNamedReferences: true});
-  language.strings = json;
+
   return language;
 }
+
+export async function processLanguageFile(file) {
+  const language = initializeLanguageObject();
+  const properties = await processLanguageSpecFromFile(file);
+  return Object.assign(language, properties);
+}
+
+export function watchLanguageFile(file, {
+  logging = true,
+} = {}) {
+  const basename = path.basename(file);
+
+  const events = new EventEmitter();
+  const language = initializeLanguageObject();
+
+  let emittedReady = false;
+  let successfullyAppliedLanguage = false;
+
+  Object.assign(events, {language, close});
+
+  const watcher = chokidar.watch(file);
+  watcher.on('change', () => handleFileUpdated());
+
+  setImmediate(handleFileUpdated);
+
+  return events;
+
+  async function close() {
+    return watcher.close();
+  }
+
+  function checkReadyConditions() {
+    if (emittedReady) return;
+    if (!successfullyAppliedLanguage) return;
+
+    events.emit('ready');
+    emittedReady = true;
+  }
+
+  async function handleFileUpdated() {
+    let properties;
+
+    try {
+      properties = await processLanguageSpecFromFile(file, {
+        existingCode:
+          (successfullyAppliedLanguage
+            ? language.code
+            : null),
+      });
+    } catch (error) {
+      events.emit('error', error);
+
+      if (logging) {
+        const label =
+          (successfullyAppliedLanguage
+            ? `${language.name} (${language.code})`
+            : basename);
+
+        if (successfullyAppliedLanguage) {
+          logWarn`Failed to load language ${label} - using existing version`;
+        } else {
+          logWarn`Failed to load language ${label} - no prior version loaded`;
+        }
+        showAggregate(error, {showTraces: false});
+      }
+
+      return;
+    }
+
+    Object.assign(language, properties);
+    successfullyAppliedLanguage = true;
+
+    if (logging && emittedReady) {
+      const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'});
+      console.log(colors.green(`[${timestamp}] Updated language ${language.name} (${language.code})`));
+    }
+
+    events.emit('update');
+    checkReadyConditions();
+  }
+}
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 546fda3b..af3eb042 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -121,10 +121,21 @@ export class Album extends Thing {
 
     // Update only
 
-    artistData: wikiData(Artist),
-    artTagData: wikiData(ArtTag),
-    groupData: wikiData(Group),
-    trackData: wikiData(Track),
+    artistData: wikiData({
+      class: input.value(Artist),
+    }),
+
+    artTagData: wikiData({
+      class: input.value(ArtTag),
+    }),
+
+    groupData: wikiData({
+      class: input.value(Group),
+    }),
+
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
 
     // Expose only
 
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index 6503beec..f9e5f0f3 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -40,8 +40,13 @@ export class ArtTag extends Thing {
 
     // Update only
 
-    albumData: wikiData(Album),
-    trackData: wikiData(Track),
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
 
     // Expose only
 
@@ -54,7 +59,7 @@ export class ArtTag extends Thing {
           sortAlbumsTracksChronologically(
             [...albumData, ...trackData]
               .filter(({artTags}) => artTags.includes(artTag)),
-            {getDate: o => o.coverArtDate}),
+            {getDate: thing => thing.coverArtDate ?? thing.date}),
       },
     },
   });
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 1b313db6..a51723c4 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -45,10 +45,21 @@ export class Artist extends Thing {
 
     // Update only
 
-    albumData: wikiData(Album),
-    artistData: wikiData(Artist),
-    flashData: wikiData(Flash),
-    trackData: wikiData(Track),
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    artistData: wikiData({
+      class: input.value(Artist),
+    }),
+
+    flashData: wikiData({
+      class: input.value(Flash),
+    }),
+
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
 
     // Expose only
 
diff --git a/src/data/things/composite.js b/src/data/things/composite.js
index 51525bc1..113f0a4f 100644
--- a/src/data/things/composite.js
+++ b/src/data/things/composite.js
@@ -637,6 +637,10 @@ export function compositeFrom(description) {
 
   const compositionNests = description.compose ?? true;
 
+  if (compositionNests && empty(steps)) {
+    aggregate.push(new TypeError(`Expected at least one step`));
+  }
+
   // Steps default to exposing if using a shorthand syntax where flags aren't
   // specified at all.
   const stepsExpose =
@@ -802,8 +806,8 @@ export function compositeFrom(description) {
     });
   }
 
-  if (!compositionNests && !anyStepsCompute && !anyStepsTransform) {
-    aggregate.push(new TypeError(`Expected at least one step to compute or transform`));
+  if (!compositionNests && !compositionUpdates && !anyStepsCompute) {
+    aggregate.push(new TypeError(`Expected at least one step to compute`));
   }
 
   aggregate.close();
@@ -1241,8 +1245,10 @@ export function compositeFrom(description) {
         expose.cache = base.cacheComposition;
       }
     } else if (compositionUpdates) {
-      expose.transform = (value, dependencies) =>
-        _wrapper(value, null, dependencies);
+      if (!empty(steps)) {
+        expose.transform = (value, dependencies) =>
+          _wrapper(value, null, dependencies);
+      }
     } else {
       expose.compute = (dependencies) =>
         _wrapper(noTransformSymbol, null, dependencies);
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index e2afcef4..1bdda6c8 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -95,9 +95,17 @@ export class Flash extends Thing {
 
     // Update only
 
-    artistData: wikiData(Artist),
-    trackData: wikiData(Track),
-    flashActData: wikiData(FlashAct),
+    artistData: wikiData({
+      class: input.value(Artist),
+    }),
+
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
+
+    flashActData: wikiData({
+      class: input.value(FlashAct),
+    }),
 
     // Expose only
 
@@ -159,6 +167,8 @@ export class FlashAct extends Thing {
 
     // Update only
 
-    flashData: wikiData(Flash),
+    flashData: wikiData({
+      class: input.value(Flash),
+    }),
   })
 }
diff --git a/src/data/things/group.js b/src/data/things/group.js
index 8764a9db..75469bbd 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -34,8 +34,13 @@ export class Group extends Thing {
 
     // Update only
 
-    albumData: wikiData(Album),
-    groupCategoryData: wikiData(GroupCategory),
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    groupCategoryData: wikiData({
+      class: input.value(GroupCategory),
+    }),
 
     // Expose only
 
@@ -83,12 +88,15 @@ export class Group extends Thing {
 }
 
 export class GroupCategory extends Thing {
+  static [Thing.referenceType] = 'group-category';
   static [Thing.friendlyName] = `Group Category`;
 
   static [Thing.getPropertyDescriptors] = ({Group}) => ({
     // Update & expose
 
     name: name('Unnamed Group Category'),
+    directory: directory(),
+
     color: color(),
 
     groups: referenceList({
@@ -99,6 +107,8 @@ export class GroupCategory extends Thing {
 
     // Update only
 
-    groupData: wikiData(Group),
+    groupData: wikiData({
+      class: input.value(Group),
+    }),
   });
 }
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index bfa971ca..59c069bd 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -70,11 +70,17 @@ export class HomepageLayoutRow extends Thing {
 
     // Update only
 
-    // These aren't necessarily used by every HomepageLayoutRow subclass, but
-    // for convenience of providing this data, every row accepts all wiki data
-    // arrays depended upon by any subclass's behavior.
-    albumData: wikiData(Album),
-    groupData: wikiData(Group),
+    // These wiki data arrays aren't necessarily used by every subclass, but
+    // to the convenience of providing these, the superclass accepts all wiki
+    // data arrays depended upon by any subclass.
+
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    groupData: wikiData({
+      class: input.value(Group),
+    }),
   });
 }
 
diff --git a/src/data/things/track.js b/src/data/things/track.js
index db325a17..8d310611 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -256,11 +256,25 @@ export class Track extends Thing {
 
     // Update only
 
-    albumData: wikiData(Album),
-    artistData: wikiData(Artist),
-    artTagData: wikiData(ArtTag),
-    flashData: wikiData(Flash),
-    trackData: wikiData(Track),
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    artistData: wikiData({
+      class: input.value(Artist),
+    }),
+
+    artTagData: wikiData({
+      class: input.value(ArtTag),
+    }),
+
+    flashData: wikiData({
+      class: input.value(Flash),
+    }),
+
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
 
     // Expose only
 
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
index ee301f15..f60c363c 100644
--- a/src/data/things/validators.js
+++ b/src/data/things/validators.js
@@ -433,18 +433,38 @@ export function validateWikiData({
         OK = true; return true;
       }
 
-      const allRefTypes =
-        new Set(array.map(object =>
-          object.constructor[Symbol.for('Thing.referenceType')]));
+      const allRefTypes = new Set();
 
-      if (allRefTypes.has(undefined)) {
-        if (allRefTypes.size === 1) {
-          throw new TypeError(`Expected array of wiki data objects, got array of other objects`);
+      let foundThing = false;
+      let foundOtherObject = false;
+
+      for (const object of array) {
+        const {[Symbol.for('Thing.referenceType')]: referenceType} = object.constructor;
+
+        if (referenceType === undefined) {
+          foundOtherObject = true;
+
+          // Early-exit if a Thing has been found - nothing more can be learned.
+          if (foundThing) {
+            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
+          }
         } else {
-          throw new TypeError(`Expected array of wiki data objects, got mixed items`);
+          foundThing = true;
+
+          // Early-exit if a non-Thing object has been found - nothing more can
+          // be learned.
+          if (foundOtherObject) {
+            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
+          }
+
+          allRefTypes.add(referenceType);
         }
       }
 
+      if (foundOtherObject && !foundThing) {
+        throw new TypeError(`Expected array of wiki data objects, got array of other objects`);
+      }
+
       if (allRefTypes.size > 1) {
         if (allowMixedTypes) {
           OK = true; return true;
@@ -464,8 +484,10 @@ export function validateWikiData({
         throw new TypeError(`Expected array of unmixed reference types, got multiple: ${types()}`);
       }
 
-      if (referenceType && !allRefTypes.has(referenceType)) {
-        throw new TypeError(`Expected array of ${referenceType}, got array of ${allRefTypes[0]}`)
+      const onlyRefType = Array.from(allRefTypes)[0];
+
+      if (referenceType && onlyRefType !== referenceType) {
+        throw new TypeError(`Expected array of ${referenceType}, got array of ${onlyRefType}`)
       }
 
       OK = true; return true;
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 6286a267..89053d62 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -64,6 +64,8 @@ export class WikiInfo extends Thing {
 
     // Update only
 
-    groupData: wikiData(Group),
+    groupData: wikiData({
+      class: input.value(Group),
+    }),
   });
 }
diff --git a/src/data/yaml.js b/src/data/yaml.js
index f7856cb7..1d35bae8 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -18,11 +18,12 @@ import T, {
 } from '#things';
 
 import {
+  annotateErrorWithFile,
   conditionallySuppressError,
   decorateErrorWithIndex,
+  decorateErrorWithAnnotation,
   empty,
   filterProperties,
-  mapAggregate,
   openAggregate,
   showAggregate,
   withAggregate,
@@ -1120,22 +1121,25 @@ export async function loadAndProcessDataDocuments({dataPath}) {
   const wikiDataResult = {};
 
   function decorateErrorWithFile(fn) {
-    return (x, index, array) => {
-      try {
-        return fn(x, index, array);
-      } catch (error) {
-        error.message +=
-          (error.message.includes('\n') ? '\n' : ' ') +
-          `(file: ${colors.bright(colors.blue(path.relative(dataPath, x.file)))})`;
-        throw error;
-      }
-    };
+    return decorateErrorWithAnnotation(fn,
+      (caughtError, firstArg) =>
+        annotateErrorWithFile(
+          caughtError,
+          path.relative(
+            dataPath,
+            (typeof firstArg === 'object'
+              ? firstArg.file
+              : firstArg))));
+  }
+
+  function asyncDecorateErrorWithFile(fn) {
+    return decorateErrorWithFile(fn).async;
   }
 
   for (const dataStep of dataSteps) {
     await processDataAggregate.nestAsync(
       {message: `Errors during data step: ${colors.bright(dataStep.title)}`},
-      async ({call, callAsync, map, mapAsync, push, nest}) => {
+      async ({call, callAsync, map, mapAsync, push}) => {
         const {documentMode} = dataStep;
 
         if (!Object.values(documentModes).includes(documentMode)) {
@@ -1323,8 +1327,8 @@ export async function loadAndProcessDataDocuments({dataPath}) {
           throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
         }
 
-        let files = (
-          typeof dataStep.files === 'function'
+        const filesFromDataStep =
+          (typeof dataStep.files === 'function'
             ? await callAsync(() =>
                 dataStep.files(dataPath).then(
                   files => files,
@@ -1335,101 +1339,110 @@ export async function loadAndProcessDataDocuments({dataPath}) {
                       throw error;
                     }
                   }))
-            : dataStep.files
-        );
+            : dataStep.files);
 
-        if (!files) {
-          return;
-        }
+        const filesUnderDataPath =
+          filesFromDataStep
+            .map(file => path.join(dataPath, file));
 
-        files = files.map((file) => path.join(dataPath, file));
-
-        const readResults = await mapAsync(
-          files,
-          (file) => readFile(file, 'utf-8').then((contents) => ({file, contents})),
-          {message: `Errors reading data files`});
-
-        let yamlResults = map(
-          readResults,
-          decorateErrorWithFile(({file, contents}) => ({
-            file,
-            documents: yaml.loadAll(contents),
-          })),
-          {message: `Errors parsing data files as valid YAML`});
-
-        yamlResults = yamlResults.map(({file, documents}) => {
-          const {documents: filteredDocuments, aggregate} = filterBlankDocuments(documents);
-          call(decorateErrorWithFile(aggregate.close), {file});
-          return {file, documents: filteredDocuments};
-        });
+        const yamlResults = [];
+
+        await mapAsync(filesUnderDataPath, {message: `Errors loading data files`},
+          asyncDecorateErrorWithFile(async file => {
+            let contents;
+            try {
+              contents = await readFile(file, 'utf-8');
+            } catch (caughtError) {
+              throw new Error(`Failed to read data file`, {cause: caughtError});
+            }
+
+            let documents;
+            try {
+              documents = yaml.loadAll(contents);
+            } catch (caughtError) {
+              throw new Error(`Failed to parse valid YAML`, {cause: caughtError});
+            }
+
+            const {documents: filteredDocuments, aggregate: filterAggregate} =
+              filterBlankDocuments(documents);
+
+            try {
+              filterAggregate.close();
+            } catch (caughtError) {
+              // Blank documents aren't a critical error, they're just something
+              // that should be noted - the (filtered) documents still get pushed.
+              const pathToFile = path.relative(dataPath, file);
+              annotateErrorWithFile(caughtError, pathToFile);
+              push(caughtError);
+            }
+
+            yamlResults.push({file, documents: filteredDocuments});
+          }));
 
         const processResults = [];
 
         switch (documentMode) {
           case documentModes.headerAndEntries:
-            map(yamlResults, decorateErrorWithFile(({documents}) => {
-              const headerDocument = documents[0];
-              const entryDocuments = documents.slice(1).filter(Boolean);
-
-              if (!headerDocument)
-                throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
-
-              // This'll be decorated with the file, and groups together any
-              // errors from processing the header and entry documents.
-              const fileAggregate =
-                openAggregate({message: `Errors processing documents`});
-
-              const {thing: headerObject, aggregate: headerAggregate} =
-                dataStep.processHeaderDocument(headerDocument);
-
-              try {
-                headerAggregate.close()
-              } catch (caughtError) {
-                caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
-                fileAggregate.push(caughtError);
-              }
+            map(yamlResults, {message: `Errors processing documents in data files`},
+              decorateErrorWithFile(({documents}) => {
+                const headerDocument = documents[0];
+                const entryDocuments = documents.slice(1).filter(Boolean);
+
+                if (!headerDocument)
+                  throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
+
+                withAggregate({message: `Errors processing documents`}, ({push}) => {
+                  const {thing: headerObject, aggregate: headerAggregate} =
+                    dataStep.processHeaderDocument(headerDocument);
 
-              const entryObjects = [];
+                  try {
+                    headerAggregate.close();
+                  } catch (caughtError) {
+                    caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
+                    push(caughtError);
+                  }
 
-              for (let index = 0; index < entryDocuments.length; index++) {
-                const entryDocument = entryDocuments[index];
+                  const entryObjects = [];
 
-                const {thing: entryObject, aggregate: entryAggregate} =
-                  dataStep.processEntryDocument(entryDocument);
+                  for (let index = 0; index < entryDocuments.length; index++) {
+                    const entryDocument = entryDocuments[index];
 
-                entryObjects.push(entryObject);
+                    const {thing: entryObject, aggregate: entryAggregate} =
+                      dataStep.processEntryDocument(entryDocument);
 
-                try {
-                  entryAggregate.close();
-                } catch (caughtError) {
-                  caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
-                  fileAggregate.push(caughtError);
-                }
-              }
+                    entryObjects.push(entryObject);
 
-              processResults.push({
-                header: headerObject,
-                entries: entryObjects,
-              });
+                    try {
+                      entryAggregate.close();
+                    } catch (caughtError) {
+                      caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
+                      push(caughtError);
+                    }
+                  }
 
-              fileAggregate.close();
-            }), {message: `Errors processing documents in data files`});
+                  processResults.push({
+                    header: headerObject,
+                    entries: entryObjects,
+                  });
+                });
+              }));
             break;
 
           case documentModes.onePerFile:
-            map(yamlResults, decorateErrorWithFile(({documents}) => {
-              if (documents.length > 1)
-                throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
+            map(yamlResults, {message: `Errors processing data files as valid documents`},
+              decorateErrorWithFile(({documents}) => {
+                if (documents.length > 1)
+                  throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
 
-              if (empty(documents) || !documents[0])
-                throw new Error(`Expected a document, this file is empty`);
+                if (empty(documents) || !documents[0])
+                  throw new Error(`Expected a document, this file is empty`);
 
-              const {thing, aggregate} =
-                dataStep.processDocument(documents[0]);
+                const {thing, aggregate} =
+                  dataStep.processDocument(documents[0]);
 
-              processResults.push(thing);
-              aggregate.close();
-            }), {message: `Errors processing data files as valid documents`});
+                processResults.push(thing);
+                aggregate.close();
+              }));
             break;
         }
 
@@ -1662,7 +1675,7 @@ export function filterReferenceErrors(wikiData) {
           }
         }
 
-        nest({message: `Reference errors in ${inspect(thing)}`}, ({push, filter}) => {
+        nest({message: `Reference errors in ${inspect(thing)}`}, ({nest, push, filter}) => {
           for (const [property, findFnKey] of Object.entries(propSpec)) {
             const value = CacheableObject.getUpdateValue(thing, property);
 
diff --git a/src/find.js b/src/find.js
index 8c9413b7..dfcaa9aa 100644
--- a/src/find.js
+++ b/src/find.js
@@ -2,6 +2,7 @@ import {inspect} from 'node:util';
 
 import {colors, logWarn} from '#cli';
 import {typeAppearance} from '#sugar';
+import {CacheableObject} from '#things';
 
 function warnOrThrow(mode, message) {
   if (mode === 'error') {
@@ -16,6 +17,8 @@ function warnOrThrow(mode, message) {
 }
 
 export function processAllAvailableMatches(data, {
+  include = thing => true,
+
   getMatchableNames = thing =>
     (Object.hasOwn(thing, 'name')
       ? [thing.name]
@@ -26,6 +29,10 @@ export function processAllAvailableMatches(data, {
   const multipleNameMatches = Object.create(null);
 
   for (const thing of data) {
+    if (!include(thing)) continue;
+
+    byDirectory[thing.directory] = thing;
+
     for (const name of getMatchableNames(thing)) {
       if (typeof name !== 'string') {
         logWarn`Unexpected ${typeAppearance(name)} returned in names for ${inspect(thing)}`;
@@ -33,6 +40,7 @@ export function processAllAvailableMatches(data, {
       }
 
       const normalizedName = name.toLowerCase();
+
       if (normalizedName in byName) {
         const alreadyMatchesByName = byName[normalizedName];
         byName[normalizedName] = null;
@@ -45,8 +53,6 @@ export function processAllAvailableMatches(data, {
         byName[normalizedName] = thing;
       }
     }
-
-    byDirectory[thing.directory] = thing;
   }
 
   return {byName, byDirectory, multipleNameMatches};
@@ -55,6 +61,7 @@ export function processAllAvailableMatches(data, {
 function findHelper({
   referenceTypes,
 
+  include = undefined,
   getMatchableNames = undefined,
 }) {
   const keyRefRegex =
@@ -84,6 +91,7 @@ function findHelper({
     if (!subcache) {
       subcache =
         processAllAvailableMatches(data, {
+          include,
           getMatchableNames,
         });
 
@@ -178,6 +186,22 @@ const find = {
         ? []
         : [track.name]),
   }),
+
+  trackOriginalReleasesOnly: findHelper({
+    referenceTypes: ['track'],
+
+    include: track =>
+      !CacheableObject.getUpdateValue(track, 'originalReleaseTrack'),
+
+    // It's still necessary to check alwaysReferenceByDirectory here, since it
+    // may be set manually (with the `Always Reference By Directory` field), and
+    // these shouldn't be matched by name (as per usual). See the definition for
+    // that property for more information.
+    getMatchableNames: track =>
+      (track.alwaysReferenceByDirectory
+        ? []
+        : [track.name]),
+  }),
 };
 
 export default find;
@@ -200,6 +224,7 @@ export function bindFind(wikiData, opts1) {
       newsEntry: 'newsData',
       staticPage: 'staticPageData',
       track: 'trackData',
+      trackOriginalReleasesOnly: 'trackData',
     }).map(([key, value]) => {
       const findFn = find[key];
       const thingData = wikiData[value];
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 3d441bc9..1bbcb9c1 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -88,13 +88,22 @@ const thumbnailSpec = {
 import {spawn} from 'node:child_process';
 import {createHash} from 'node:crypto';
 import {createReadStream} from 'node:fs';
-import {readFile, stat, unlink, writeFile} from 'node:fs/promises';
 import * as path from 'node:path';
 
+import {
+  mkdir,
+  readdir,
+  readFile,
+  rename,
+  stat,
+  writeFile,
+} from 'node:fs/promises';
+
 import dimensionsOf from 'image-size';
 
-import {delay, empty, queue} from '#sugar';
+import {delay, empty, queue, unique} from '#sugar';
 import {CacheableObject} from '#things';
+import {sortByName} from '#wiki-data';
 
 import {
   colors,
@@ -102,6 +111,7 @@ import {
   logError,
   logInfo,
   logWarn,
+  logicalPathTo,
   parseOptions,
   progressPromiseAll,
 } from '#cli';
@@ -304,18 +314,30 @@ async function getSpawnMagick(tool) {
 // Note: This returns an array of no-argument functions, suitable for passing
 // to queue().
 function generateImageThumbnails({
+  mediaPath,
+  mediaCachePath,
   filePath,
   dimensions,
   spawnConvert,
 }) {
-  const dirname = path.dirname(filePath);
-  const extname = path.extname(filePath);
-  const basename = path.basename(filePath, extname);
-  const output = (name) => path.join(dirname, basename + name + '.jpg');
-
-  const convert = (name, {size, quality}) =>
-    spawnConvert([
-      filePath,
+  const filePathInMedia = path.join(mediaPath, filePath);
+
+  function getOutputPath(thumbtack) {
+    return path.join(
+      mediaCachePath,
+      path.dirname(filePath),
+      [
+        path.basename(filePath, path.extname(filePath)),
+        thumbtack,
+        'jpg'
+      ].join('.'));
+  }
+
+  function startConvertProcess(outputPathInCache, details) {
+    const {size, quality} = details;
+
+    return spawnConvert([
+      filePathInMedia,
       '-strip',
       '-resize',
       `${size}x${size}>`,
@@ -323,24 +345,115 @@ function generateImageThumbnails({
       'Plane',
       '-quality',
       `${quality}%`,
-      output(name),
+      outputPathInCache,
     ]);
+  }
 
   return (
     getThumbnailsAvailableForDimensions(dimensions)
-      .map(([name]) => [name, thumbnailSpec[name]])
-      .map(([name, details]) => () =>
-        promisifyProcess(convert('.' + name, details), false)));
+      .map(([thumbtack]) => [thumbtack, thumbnailSpec[thumbtack]])
+      .map(([thumbtack, details]) => async () => {
+        const outputPathInCache = getOutputPath(thumbtack);
+        await mkdir(path.dirname(outputPathInCache), {recursive: true});
+
+        const convertProcess = startConvertProcess(outputPathInCache, details);
+        await promisifyProcess(convertProcess, false);
+      }));
 }
 
-export async function clearThumbs(mediaPath, {
+export async function determineMediaCachePath({
+  mediaPath,
+  providedMediaCachePath,
+  disallowDoubling = false,
+}) {
+  if (!mediaPath) {
+    return {
+      annotation: 'media path not provided',
+      mediaCachePath: null,
+    };
+  }
+
+  if (providedMediaCachePath) {
+    return {
+      annotation: 'custom path provided',
+      mediaCachePath: providedMediaCachePath,
+    };
+  }
+
+  let mediaIncludesThumbnailCache;
+
+  try {
+    const files = await readdir(mediaPath);
+    mediaIncludesThumbnailCache = files.includes(CACHE_FILE);
+  } catch (error) {
+    mediaIncludesThumbnailCache = false;
+  }
+
+  if (mediaIncludesThumbnailCache === true && !disallowDoubling) {
+    return {
+      annotation: 'media path doubles as cache',
+      mediaCachePath: mediaPath,
+    };
+  }
+
+  const inferredPath =
+    path.join(
+      path.dirname(mediaPath),
+      path.basename(mediaPath) + '-cache');
+
+  let inferredIncludesThumbnailCache;
+
+  try {
+    const files = await readdir(inferredPath);
+    inferredIncludesThumbnailCache = files.includes(CACHE_FILE);
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      inferredIncludesThumbnailCache = null;
+    } else {
+      inferredIncludesThumbnailCache = undefined;
+    }
+  }
+
+  if (inferredIncludesThumbnailCache === true) {
+    return {
+      annotation: 'inferred path has cache',
+      mediaCachePath: inferredPath,
+    };
+  } else if (inferredIncludesThumbnailCache === false) {
+    return {
+      annotation: 'inferred path does not have cache',
+      mediaCachePath: null,
+    };
+  } else if (inferredIncludesThumbnailCache === null) {
+    return {
+      annotation: 'inferred path will be created',
+      mediaCachePath: inferredPath,
+    };
+  } else {
+    return {
+      annotation: 'inferred path not readable',
+      mediaCachePath: null,
+    };
+  }
+}
+
+export async function migrateThumbsIntoDedicatedCacheDirectory({
+  mediaPath,
+  mediaCachePath,
+
   queueSize = 0,
-} = {}) {
+}) {
   if (!mediaPath) {
-    throw new Error('Expected mediaPath to be passed');
+    throw new Error('Expected mediaPath');
   }
 
-  logInfo`Looking for thumbnails to clear out...`;
+  if (!mediaCachePath) {
+    throw new Error(`Expected mediaCachePath`);
+  }
+
+  logInfo`Migrating thumbnail files into dedicated directory.`;
+  logInfo`Moving thumbs from: ${mediaPath}`;
+  logInfo`Moving thumbs into: ${mediaCachePath}`;
 
   const thumbFiles = await traverse(mediaPath, {
     pathStyle: 'device',
@@ -349,8 +462,7 @@ export async function clearThumbs(mediaPath, {
   });
 
   if (thumbFiles.length) {
-    // Double-check files. Since we're unlinking (deleting) files,
-    // we're better off safe than sorry!
+    // Double-check files.
     const thumbtacks = Object.keys(thumbnailSpec);
     const unsafeFiles = thumbFiles.filter(file => {
       if (path.extname(file) !== '.jpg') return true;
@@ -369,14 +481,20 @@ export async function clearThumbs(mediaPath, {
       return {success: false};
     }
 
-    logInfo`Clearing out ${thumbFiles.length} thumbs.`;
+    logInfo`Moving ${thumbFiles.length} thumbs.`;
+
+    await mkdir(mediaCachePath, {recursive: true});
 
     const errored = [];
 
-    await progressPromiseAll(`Removing thumbnail files`, queue(
+    await progressPromiseAll(`Moving thumbnail files`, queue(
       thumbFiles.map(file => async () => {
         try {
-          await unlink(file);
+          const filePathInMedia = file;
+          const filePath = path.relative(mediaPath, filePathInMedia);
+          const filePathInCache = path.join(mediaCachePath, filePath);
+          await mkdir(path.dirname(filePathInCache), {recursive: true});
+          await rename(filePathInMedia, filePathInCache);
         } catch (error) {
           if (error.code !== 'ENOENT') {
             errored.push(file);
@@ -386,18 +504,18 @@ export async function clearThumbs(mediaPath, {
       queueSize));
 
     if (errored.length) {
-      logError`Couldn't remove these paths (${errored.length}):`;
+      logError`Couldn't move these paths (${errored.length}):`;
       for (const file of errored) {
         console.error(file);
       }
-      logError`Check for permission errors?`;
+      logError`It's possible there were permission errors. After you've`;
+      logError`investigated, running again should work to move these.`;
       return {success: false};
     } else {
-      logInfo`Successfully deleted all ${thumbFiles.length} thumbnail files!`;
+      logInfo`Successfully moved all ${thumbFiles.length} thumbnail files!`;
     }
   } else {
-    logInfo`Didn't find any thumbs in media directory.`;
-    logInfo`${mediaPath}`;
+    logInfo`Didn't find any thumbnails to move.`;
   }
 
   let cacheExists = false;
@@ -406,7 +524,7 @@ export async function clearThumbs(mediaPath, {
     cacheExists = true;
   } catch (error) {
     if (error.code === 'ENOENT') {
-      logInfo`Cache file already missing, nothing to remove there.`;
+      logInfo`No cache file present here. (${CACHE_FILE})`;
     } else {
       logWarn`Failed to access cache file. Check its permissions?`;
     }
@@ -414,21 +532,27 @@ export async function clearThumbs(mediaPath, {
 
   if (cacheExists) {
     try {
-      unlink(path.join(mediaPath, CACHE_FILE));
-      logInfo`Removed thumbnail cache file.`;
+      await rename(
+        path.join(mediaPath, CACHE_FILE),
+        path.join(mediaCachePath, CACHE_FILE));
+      logInfo`Moved thumbnail cache file.`;
     } catch (error) {
-      logWarn`Failed to remove cache file. Check its permissions?`;
+      logWarn`Failed to move cache file. (${CACHE_FILE})`;
+      logWarn`Check its permissions, or try copying/pasting.`;
     }
   }
 
   return {success: true};
 }
 
-export default async function genThumbs(mediaPath, {
+export default async function genThumbs({
+  mediaPath,
+  mediaCachePath,
+
   queueSize = 0,
   magickThreads = defaultMagickThreads,
   quiet = false,
-} = {}) {
+}) {
   if (!mediaPath) {
     throw new Error('Expected mediaPath to be passed');
   }
@@ -454,13 +578,13 @@ export default async function genThumbs(mediaPath, {
 
   quietInfo`Running up to ${magickThreads + ' magick threads'} simultaneously.`;
 
-  let cache,
-    firstRun = false;
+  let cache = null;
+  let firstRun = false;
+
   try {
-    cache = JSON.parse(await readFile(path.join(mediaPath, CACHE_FILE)));
+    cache = JSON.parse(await readFile(path.join(mediaCachePath, CACHE_FILE)));
     quietInfo`Cache file successfully read.`;
   } catch (error) {
-    cache = {};
     if (error.code === 'ENOENT') {
       firstRun = true;
     } else {
@@ -472,7 +596,20 @@ export default async function genThumbs(mediaPath, {
   }
 
   try {
-    await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache));
+    await mkdir(mediaCachePath, {recursive: true});
+  } catch (error) {
+    logError`Couldn't create the media cache directory: ${error.code}`;
+    logError`That's where the media files are going to go, so you'll`;
+    logError`have to investigate this - it's likely a permissions error.`;
+    return {success: false};
+  }
+
+  try {
+    await writeFile(
+      path.join(mediaCachePath, CACHE_FILE),
+      (firstRun
+        ? JSON.stringify({})
+        : JSON.stringify(cache)));
     quietInfo`Writing to cache file appears to be working.`;
   } catch (error) {
     logWarn`Test of cache file writing failed: ${error}`;
@@ -480,6 +617,7 @@ export default async function genThumbs(mediaPath, {
       logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`;
     } else if (firstRun) {
       logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`;
+      logWarn`You may also have to provide ${'--media-cache-path'} ${mediaCachePath} next run.`;
     } else {
       logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`;
     }
@@ -487,6 +625,10 @@ export default async function genThumbs(mediaPath, {
     await delay(WARNING_DELAY_TIME);
   }
 
+  if (firstRun) {
+    cache = {};
+  }
+
   const imagePaths = await traverseSourceImagePaths(mediaPath, {target: 'generate'});
 
   const imageToMD5Entries =
@@ -574,7 +716,9 @@ export default async function genThumbs(mediaPath, {
   const generateCalls =
     entriesToGenerate.flatMap(([filePath, md5]) =>
       generateImageThumbnails({
-        filePath: path.join(mediaPath, filePath),
+        mediaPath,
+        mediaCachePath,
+        filePath,
         dimensions: imageToDimensions[filePath],
         spawnConvert,
       }).map(call => async () => {
@@ -610,7 +754,7 @@ export default async function genThumbs(mediaPath, {
 
   try {
     await writeFile(
-      path.join(mediaPath, CACHE_FILE),
+      path.join(mediaCachePath, CACHE_FILE),
       JSON.stringify(updatedCache)
     );
     quietInfo`Updated cache file successfully written!`;
@@ -626,7 +770,7 @@ export default async function genThumbs(mediaPath, {
 export function getExpectedImagePaths(mediaPath, {urls, wikiData}) {
   const fromRoot = urls.from('media.root');
 
-  return [
+  const paths = [
     wikiData.albumData
       .flatMap(album => [
         album.hasCoverArt && fromRoot.to('media.albumCover', album.directory, album.coverArtFileExtension),
@@ -646,6 +790,10 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) {
     wikiData.flashData
       .map(flash => fromRoot.to('media.flashArt', flash.directory, flash.coverArtFileExtension)),
   ].flat();
+
+  sortByName(paths, {getName: path => path});
+
+  return paths;
 }
 
 export function checkMissingMisplacedMediaFiles(expectedImagePaths, extantImagePaths) {
@@ -674,28 +822,114 @@ export function checkMissingMisplacedMediaFiles(expectedImagePaths, extantImageP
 export async function verifyImagePaths(mediaPath, {urls, wikiData}) {
   const expectedPaths = getExpectedImagePaths(mediaPath, {urls, wikiData});
   const extantPaths = await traverseSourceImagePaths(mediaPath, {target: 'verify'});
-  const {missing, misplaced} = checkMissingMisplacedMediaFiles(expectedPaths, extantPaths);
 
-  if (empty(missing) && empty(misplaced)) {
+  const {missing: missingPaths, misplaced: misplacedPaths} =
+    checkMissingMisplacedMediaFiles(expectedPaths, extantPaths);
+
+  if (empty(missingPaths) && empty(misplacedPaths)) {
     logInfo`All image paths are good - nice! None are missing or misplaced.`;
-    return {missing, misplaced};
+    return {missing: [], misplaced: []};
+  }
+
+  const relativeMediaPath = await logicalPathTo(mediaPath);
+
+  const dirnamesOfExpectedPaths =
+    unique(expectedPaths.map(file => path.dirname(file)));
+
+  const dirnamesOfExtantPaths =
+    unique(extantPaths.map(file => path.dirname(file)));
+
+  const dirnamesOfMisplacedPaths =
+    unique(misplacedPaths.map(file => path.dirname(file)));
+
+  const completelyMisplacedDirnames =
+    dirnamesOfMisplacedPaths
+      .filter(dirname => !dirnamesOfExpectedPaths.includes(dirname));
+
+  const completelyMissingDirnames =
+    dirnamesOfExpectedPaths
+      .filter(dirname => !dirnamesOfExtantPaths.includes(dirname));
+
+  const individuallyMisplacedPaths =
+    misplacedPaths
+      .filter(file => !completelyMisplacedDirnames.includes(path.dirname(file)));
+
+  const individuallyMissingPaths =
+    missingPaths
+      .filter(file => !completelyMissingDirnames.includes(path.dirname(file)));
+
+  const wrongExtensionPaths =
+    misplacedPaths
+      .map(file => {
+        const stripExtension = file =>
+          path.join(
+            path.dirname(file),
+            path.basename(file, path.extname(file)));
+
+        const extantExtension = path.extname(file);
+        const basename = stripExtension(file);
+
+        const expectedPath =
+          missingPaths
+            .find(file => stripExtension(file) === basename);
+
+        if (!expectedPath) return null;
+
+        const expectedExtension = path.extname(expectedPath);
+        return {basename, extantExtension, expectedExtension};
+      })
+      .filter(Boolean);
+
+  if (!empty(missingPaths)) {
+    if (missingPaths.length === 1) {
+      logWarn`${1} expected image file is missing from ${relativeMediaPath}:`;
+    } else {
+      logWarn`${missingPaths.length} expected image files are missing:`;
+    }
+
+    for (const dirname of completelyMissingDirnames) {
+      console.log(` - (missing) All files under ${colors.bright(dirname)}`);
+    }
+
+    for (const file of individuallyMissingPaths) {
+      console.log(` - (missing) ${file}`);
+    }
   }
 
-  if (!empty(missing)) {
-    logWarn`** Some image files are missing! (${missing.length + ' files'}) **`;
-    for (const file of missing) {
-      console.warn(colors.yellow(` - `) + file);
+  if (!empty(misplacedPaths)) {
+    if (misplacedPaths.length === 1) {
+      logWarn`${1} image file, present in ${relativeMediaPath}, wasn't expected:`;
+    } else {
+      logWarn`${misplacedPaths.length} image files, present in ${relativeMediaPath}, weren't expected:`;
+    }
+
+    for (const dirname of completelyMisplacedDirnames) {
+      console.log(` - (misplaced) All files under ${colors.bright(dirname)}`);
+    }
+
+    for (const file of individuallyMisplacedPaths) {
+      console.log(` - (misplaced) ${file}`);
     }
   }
 
-  if (!empty(misplaced)) {
-    logWarn`** Some image files are misplaced! (${misplaced.length + ' files'}) **`;
-    for (const file of misplaced) {
-      console.warn(colors.yellow(` - `) + file);
+  if (!empty(wrongExtensionPaths)) {
+    if (wrongExtensionPaths.length === 1) {
+      logWarn`Of these, ${1} has an unexpected file extension:`;
+    } else {
+      logWarn`Of these, ${wrongExtensionPaths.length} have an unexpected file extension:`;
     }
+
+    for (const {basename, extantExtension, expectedExtension} of wrongExtensionPaths) {
+      console.log(` - (expected ${colors.green(expectedExtension)}) ${basename + colors.red(extantExtension)}`);
+    }
+
+    logWarn`To handle unexpected file extensions:`;
+    logWarn` * Source and ${`replace`} with the correct file, or`;
+    logWarn` * Add ${`"Cover Art File Extension"`} field (or similar)`;
+    logWarn`   to the respective document in YAML data files.`;
   }
 
-  return {missing, misplaced};
+  return {missing: missingPaths, misplaced: misplacedPaths};
 }
 
 // Recursively traverses the provided (extant) media path, filtering so only
@@ -725,7 +959,7 @@ export async function traverseSourceImagePaths(mediaPath, {target}) {
     throw new Error(`Expected target to be 'verify' or 'generate', got ${target}`);
   }
 
-  return await traverse(mediaPath, {
+  const paths = await traverse(mediaPath, {
     pathStyle: (target === 'verify' ? 'posix' : 'device'),
     prefixPath: '',
 
@@ -755,6 +989,10 @@ export async function traverseSourceImagePaths(mediaPath, {target}) {
       return true;
     },
   });
+
+  sortByName(paths, {getName: path => path});
+
+  return paths;
 }
 
 export function isThumb(file) {
diff --git a/src/listing-spec.js b/src/listing-spec.js
index f57762b0..9433ee68 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -66,6 +66,7 @@ listingSpec.push({
   contentFunction: 'listArtistsByDuration',
 });
 
+// TODO: hide if no groups...
 listingSpec.push({
   directory: 'artists/by-group',
   stringsKey: 'listArtists.byGroup',
diff --git a/src/repl.js b/src/repl.js
index ead01567..7a6f5c45 100644
--- a/src/repl.js
+++ b/src/repl.js
@@ -16,6 +16,8 @@ import * as serialize from '#serialize';
 import * as sugar from '#sugar';
 import * as wikiDataUtils from '#wiki-data';
 
+import {DEFAULT_STRINGS_FILE} from './upd8.js';
+
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 export async function getContextAssignments({
@@ -46,7 +48,7 @@ export async function getContextAssignments({
     language = await processLanguageFile(
       path.join(
         path.dirname(fileURLToPath(import.meta.url)),
-        'strings-default.json'));
+        DEFAULT_STRINGS_FILE));
   } catch (error) {
     console.error(error);
     logWarn`Failed to create Language object`;
diff --git a/src/static/client2.js b/src/static/client2.js
index 3a5f9c37..28882a88 100644
--- a/src/static/client2.js
+++ b/src/static/client2.js
@@ -993,10 +993,14 @@ function handleImageLinkClicked(evt) {
   const thumbImage = document.getElementById('image-overlay-image-thumb');
 
   const {href: originalSrc} = evt.target.closest('a');
-  const {dataset: {
-    originalSize: originalFileSize,
-    thumbs: availableThumbList,
-  }} = evt.target.closest('a').querySelector('img');
+
+  const {
+    src: embeddedSrc,
+    dataset: {
+      originalSize: originalFileSize,
+      thumbs: availableThumbList,
+    },
+  } = evt.target.closest('a').querySelector('img');
 
   updateFileSizeInformation(originalFileSize);
 
@@ -1006,8 +1010,8 @@ function handleImageLinkClicked(evt) {
   if (availableThumbList) {
     const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList);
     const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList);
-    mainSrc = originalSrc.replace(/\.(jpg|png)$/, `.${mainThumb}.jpg`);
-    thumbSrc = originalSrc.replace(/\.(jpg|png)$/, `.${smallThumb}.jpg`);
+    mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`);
+    thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`);
     // Show the thumbnail size on each <img> element's data attributes.
     // Y'know, just for debugging convenience.
     mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`;
diff --git a/src/strings-default.json b/src/strings-default.json
deleted file mode 100644
index b0b68a57..00000000
--- a/src/strings-default.json
+++ /dev/null
@@ -1,528 +0,0 @@
-{
-  "meta.languageCode": "en",
-  "meta.languageName": "English",
-  "count.tracks": "{TRACKS}",
-  "count.tracks.withUnit.zero": "",
-  "count.tracks.withUnit.one": "{TRACKS} track",
-  "count.tracks.withUnit.two": "",
-  "count.tracks.withUnit.few": "",
-  "count.tracks.withUnit.many": "",
-  "count.tracks.withUnit.other": "{TRACKS} tracks",
-  "count.additionalFiles": "{FILES}",
-  "count.additionalFiles.withUnit.zero": "",
-  "count.additionalFiles.withUnit.one": "{FILES} file",
-  "count.additionalFiles.withUnit.two": "",
-  "count.additionalFiles.withUnit.few": "",
-  "count.additionalFiles.withUnit.many": "",
-  "count.additionalFiles.withUnit.other": "{FILES} files",
-  "count.albums": "{ALBUMS}",
-  "count.albums.withUnit.zero": "",
-  "count.albums.withUnit.one": "{ALBUMS} album",
-  "count.albums.withUnit.two": "",
-  "count.albums.withUnit.few": "",
-  "count.albums.withUnit.many": "",
-  "count.albums.withUnit.other": "{ALBUMS} albums",
-  "count.artworks": "{ARTWORKS}",
-  "count.artworks.withUnit.zero": "",
-  "count.artworks.withUnit.one": "{ARTWORKS} artwork",
-  "count.artworks.withUnit.two": "",
-  "count.artworks.withUnit.few": "",
-  "count.artworks.withUnit.many": "",
-  "count.artworks.withUnit.other": "{ARTWORKS} artworks",
-  "count.commentaryEntries": "{ENTRIES}",
-  "count.commentaryEntries.withUnit.zero": "",
-  "count.commentaryEntries.withUnit.one": "{ENTRIES} entry",
-  "count.commentaryEntries.withUnit.two": "",
-  "count.commentaryEntries.withUnit.few": "",
-  "count.commentaryEntries.withUnit.many": "",
-  "count.commentaryEntries.withUnit.other": "{ENTRIES} entries",
-  "count.contributions": "{CONTRIBUTIONS}",
-  "count.contributions.withUnit.zero": "",
-  "count.contributions.withUnit.one": "{CONTRIBUTIONS} contribution",
-  "count.contributions.withUnit.two": "",
-  "count.contributions.withUnit.few": "",
-  "count.contributions.withUnit.many": "",
-  "count.contributions.withUnit.other": "{CONTRIBUTIONS} contributions",
-  "count.coverArts": "{COVER_ARTS}",
-  "count.coverArts.withUnit.zero": "",
-  "count.coverArts.withUnit.one": "{COVER_ARTS} cover art",
-  "count.coverArts.withUnit.two": "",
-  "count.coverArts.withUnit.few": "",
-  "count.coverArts.withUnit.many": "",
-  "count.coverArts.withUnit.other": "{COVER_ARTS} cover arts",
-  "count.flashes": "{FLASHES}",
-  "count.flashes.withUnit.zero": "",
-  "count.flashes.withUnit.one": "{FLASHES} flashes & games",
-  "count.flashes.withUnit.two": "",
-  "count.flashes.withUnit.few": "",
-  "count.flashes.withUnit.many": "",
-  "count.flashes.withUnit.other": "{FLASHES} flashes & games",
-  "count.timesReferenced": "{TIMES_REFERENCED}",
-  "count.timesReferenced.withUnit.zero": "",
-  "count.timesReferenced.withUnit.one": "{TIMES_REFERENCED} time referenced",
-  "count.timesReferenced.withUnit.two": "",
-  "count.timesReferenced.withUnit.few": "",
-  "count.timesReferenced.withUnit.many": "",
-  "count.timesReferenced.withUnit.other": "{TIMES_REFERENCED} times referenced",
-  "count.words": "{WORDS}",
-  "count.words.thousand": "{WORDS}k",
-  "count.words.withUnit.zero": "",
-  "count.words.withUnit.one": "{WORDS} word",
-  "count.words.withUnit.two": "",
-  "count.words.withUnit.few": "",
-  "count.words.withUnit.many": "",
-  "count.words.withUnit.other": "{WORDS} words",
-  "count.timesUsed": "{TIMES_USED}",
-  "count.timesUsed.withUnit.zero": "",
-  "count.timesUsed.withUnit.one": "used {TIMES_USED} time",
-  "count.timesUsed.withUnit.two": "",
-  "count.timesUsed.withUnit.few": "",
-  "count.timesUsed.withUnit.many": "",
-  "count.timesUsed.withUnit.other": "used {TIMES_USED} times",
-  "count.index.zero": "",
-  "count.index.one": "{INDEX}st",
-  "count.index.two": "{INDEX}nd",
-  "count.index.few": "{INDEX}rd",
-  "count.index.many": "",
-  "count.index.other": "{INDEX}th",
-  "count.duration.hours": "{HOURS}:{MINUTES}:{SECONDS}",
-  "count.duration.hours.withUnit": "{HOURS}:{MINUTES}:{SECONDS} hours",
-  "count.duration.minutes": "{MINUTES}:{SECONDS}",
-  "count.duration.minutes.withUnit": "{MINUTES}:{SECONDS} minutes",
-  "count.duration.approximate": "~{DURATION}",
-  "count.duration.missing": "_:__",
-  "count.fileSize.terabytes": "{TERABYTES} TB",
-  "count.fileSize.gigabytes": "{GIGABYTES} GB",
-  "count.fileSize.megabytes": "{MEGABYTES} MB",
-  "count.fileSize.kilobytes": "{KILOBYTES} kB",
-  "count.fileSize.bytes": "{BYTES} bytes",
-  "releaseInfo.by": "By {ARTISTS}.",
-  "releaseInfo.from": "From {ALBUM}.",
-  "releaseInfo.coverArtBy": "Cover art by {ARTISTS}.",
-  "releaseInfo.wallpaperArtBy": "Wallpaper art by {ARTISTS}.",
-  "releaseInfo.bannerArtBy": "Banner art by {ARTISTS}.",
-  "releaseInfo.released": "Released {DATE}.",
-  "releaseInfo.artReleased": "Art released {DATE}.",
-  "releaseInfo.addedToWiki": "Added to wiki {DATE}.",
-  "releaseInfo.duration": "Duration: {DURATION}.",
-  "releaseInfo.viewCommentary": "View {LINK}!",
-  "releaseInfo.viewCommentary.link": "commentary page",
-  "releaseInfo.viewGallery": "View {LINK}!",
-  "releaseInfo.viewGallery.link": "gallery page",
-  "releaseInfo.viewGalleryOrCommentary": "View {GALLERY} or {COMMENTARY}!",
-  "releaseInfo.viewGalleryOrCommentary.gallery": "gallery page",
-  "releaseInfo.viewGalleryOrCommentary.commentary": "commentary page",
-  "releaseInfo.viewOriginalFile": "View {LINK}.",
-  "releaseInfo.viewOriginalFile.withSize": "View {LINK} ({SIZE}).",
-  "releaseInfo.viewOriginalFile.link": "original file",
-  "releaseInfo.viewOriginalFile.sizeWarning": "(Heads up! If you're on a mobile plan, this is a large download.)",
-  "releaseInfo.listenOn": "Listen on {LINKS}.",
-  "releaseInfo.listenOn.noLinks": "This wiki doesn't have any listening links for {NAME}.",
-  "releaseInfo.visitOn": "Visit on {LINKS}.",
-  "releaseInfo.playOn": "Play on {LINKS}.",
-  "releaseInfo.readCommentary": "Read {LINK}.",
-  "releaseInfo.readCommentary.link": "artist commentary",
-  "releaseInfo.alsoReleasedAs": "Also released as:",
-  "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})",
-  "releaseInfo.contributors": "Contributors:",
-  "releaseInfo.tracksReferenced": "Tracks that {TRACK} references:",
-  "releaseInfo.tracksThatReference": "Tracks that reference {TRACK}:",
-  "releaseInfo.tracksSampled": "Tracks that {TRACK} samples:",
-  "releaseInfo.tracksThatSample": "Tracks that sample {TRACK}:",
-  "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:",
-  "releaseInfo.flashesThatFeature.item": "{FLASH}",
-  "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})",
-  "releaseInfo.tracksFeatured": "Tracks that {FLASH} features:",
-  "releaseInfo.lyrics": "Lyrics:",
-  "releaseInfo.artistCommentary": "Artist commentary:",
-  "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!",
-  "releaseInfo.artTags": "Tags:",
-  "releaseInfo.artTags.inline": "Tags: {TAGS}",
-  "releaseInfo.additionalFiles.shortcut": "View {ANCHOR_LINK}: {TITLES}",
-  "releaseInfo.additionalFiles.shortcut.anchorLink": "additional files",
-  "releaseInfo.additionalFiles.heading": "View or download {ADDITIONAL_FILES}:",
-  "releaseInfo.additionalFiles.entry": "{TITLE}",
-  "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}",
-  "releaseInfo.additionalFiles.file": "{FILE}",
-  "releaseInfo.additionalFiles.file.withSize": "{FILE} ({SIZE})",
-  "releaseInfo.sheetMusicFiles.shortcut": "Download {LINK}.",
-  "releaseInfo.sheetMusicFiles.shortcut.link": "sheet music files",
-  "releaseInfo.sheetMusicFiles.heading": "Print or download sheet music files:",
-  "releaseInfo.midiProjectFiles.shortcut": "Download {LINK}.",
-  "releaseInfo.midiProjectFiles.shortcut.link": "MIDI/project files",
-  "releaseInfo.midiProjectFiles.heading": "Download MIDI/project files:",
-  "releaseInfo.note": "Context notes:",
-  "trackList.section.withDuration": "{SECTION} ({DURATION}):",
-  "trackList.group": "From {GROUP}:",
-  "trackList.group.fromOther": "From somewhere else:",
-  "trackList.item.withDuration": "({DURATION}) {TRACK}",
-  "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}",
-  "trackList.item.withArtists": "{TRACK} {BY}",
-  "trackList.item.withArtists.by": "by {ARTISTS}",
-  "trackList.item.rerelease": "{TRACK} (re-release)",
-  "misc.alt.albumCover": "album cover",
-  "misc.alt.albumBanner": "album banner",
-  "misc.alt.trackCover": "track cover",
-  "misc.alt.artistAvatar": "artist avatar",
-  "misc.alt.flashArt": "flash art",
-  "misc.artistLink": "{ARTIST}",
-  "misc.artistLink.withContribution": "{ARTIST} ({CONTRIB})",
-  "misc.artistLink.withExternalLinks": "{ARTIST} ({LINKS})",
-  "misc.artistLink.withContribution.withExternalLinks": "{ARTIST} ({CONTRIB}) ({LINKS})",
-  "misc.chronology.seeArtistPages": "(See artist pages for chronology info!)",
-  "misc.chronology.heading.coverArt": "{INDEX} cover art by {ARTIST}",
-  "misc.chronology.heading.flash": "{INDEX} flash/game by {ARTIST}",
-  "misc.chronology.heading.track": "{INDEX} track by {ARTIST}",
-  "misc.chronology.withNavigation": "{HEADING} ({NAVIGATION})",
-  "misc.external.domain": "External ({DOMAIN})",
-  "misc.external.local": "Wiki Archive (local upload)",
-  "misc.external.bandcamp": "Bandcamp",
-  "misc.external.bandcamp.domain": "Bandcamp ({DOMAIN})",
-  "misc.external.deviantart": "DeviantArt",
-  "misc.external.instagram": "Instagram",
-  "misc.external.mastodon": "Mastodon",
-  "misc.external.mastodon.domain": "Mastodon ({DOMAIN})",
-  "misc.external.newgrounds": "Newgrounds",
-  "misc.external.patreon": "Patreon",
-  "misc.external.poetryFoundation": "Poetry Foundation",
-  "misc.external.soundcloud": "SoundCloud",
-  "misc.external.spotify": "Spotify",
-  "misc.external.tumblr": "Tumblr",
-  "misc.external.twitter": "Twitter",
-  "misc.external.wikipedia": "Wikipedia",
-  "misc.external.youtube": "YouTube",
-  "misc.external.youtube.playlist": "YouTube (playlist)",
-  "misc.external.youtube.fullAlbum": "YouTube (full album)",
-  "misc.external.flash.bgreco": "{LINK} (HQ Audio)",
-  "misc.external.flash.homestuck.page": "{LINK} (page {PAGE})",
-  "misc.external.flash.homestuck.secret": "{LINK} (secret page)",
-  "misc.external.flash.youtube": "{LINK} (on any device)",
-  "misc.missingImage": "(This image file is missing)",
-  "misc.missingLinkContent": "(Missing link content)",
-  "misc.nav.previous": "Previous",
-  "misc.nav.next": "Next",
-  "misc.nav.info": "Info",
-  "misc.nav.gallery": "Gallery",
-  "misc.pageTitle": "{TITLE}",
-  "misc.pageTitle.withWikiName": "{TITLE} | {WIKI_NAME}",
-  "misc.skippers.skipTo": "Skip to:",
-  "misc.skippers.content": "Content",
-  "misc.skippers.sidebar": "Sidebar",
-  "misc.skippers.sidebar.left": "Sidebar (left)",
-  "misc.skippers.sidebar.right": "Sidebar (right)",
-  "misc.skippers.header": "Header",
-  "misc.skippers.footer": "Footer",
-  "misc.skippers.tracks": "Tracks",
-  "misc.skippers.art": "Artworks",
-  "misc.skippers.flashes": "Flashes & Games",
-  "misc.skippers.contributors": "Contributors",
-  "misc.skippers.references": "References...",
-  "misc.skippers.referencedBy": "Referenced by...",
-  "misc.skippers.samples": "Samples...",
-  "misc.skippers.sampledBy": "Sampled by...",
-  "misc.skippers.features": "Features...",
-  "misc.skippers.featuredIn": "Featured in...",
-  "misc.skippers.lyrics": "Lyrics",
-  "misc.skippers.sheetMusicFiles": "Sheet music files",
-  "misc.skippers.midiProjectFiles": "MIDI/project files",
-  "misc.skippers.additionalFiles": "Additional files",
-  "misc.skippers.commentary": "Commentary",
-  "misc.skippers.artistCommentary": "Commentary",
-  "misc.socialEmbed.heading": "{WIKI_NAME} | {HEADING}",
-  "misc.jumpTo": "Jump to:",
-  "misc.jumpTo.withLinks": "Jump to: {LINKS}.",
-  "misc.contentWarnings": "cw: {WARNINGS}",
-  "misc.contentWarnings.reveal": "click to show",
-  "misc.albumGrid.details": "({TRACKS}, {TIME})",
-  "misc.albumGrid.details.coverArtists": "(Illust. {ARTISTS})",
-  "misc.albumGrid.details.otherCoverArtists": "(With {ARTISTS})",
-  "misc.albumGrid.noCoverArt": "{ALBUM}",
-  "misc.albumGalleryGrid.noCoverArt": "{NAME}",
-  "misc.uiLanguage": "UI Language: {LANGUAGES}",
-  "homepage.title": "{TITLE}",
-  "homepage.news.title": "News",
-  "homepage.news.entry.viewRest": "(View rest of entry!)",
-  "albumSidebar.trackList.fallbackSectionName": "Track list",
-  "albumSidebar.trackList.group": "{GROUP}",
-  "albumSidebar.trackList.group.withRange": "{GROUP} ({RANGE})",
-  "albumSidebar.trackList.item": "{TRACK}",
-  "albumSidebar.groupBox.title": "{GROUP}",
-  "albumSidebar.groupBox.next": "Next: {ALBUM}",
-  "albumSidebar.groupBox.previous": "Previous: {ALBUM}",
-  "albumPage.title": "{ALBUM}",
-  "albumPage.nav.album": "{ALBUM}",
-  "albumPage.nav.randomTrack": "Random Track",
-  "albumPage.nav.gallery": "Gallery",
-  "albumPage.nav.commentary": "Commentary",
-  "albumPage.socialEmbed.heading": "{GROUP}",
-  "albumPage.socialEmbed.title": "{ALBUM}",
-  "albumPage.socialEmbed.body.withDuration": "{DURATION}.",
-  "albumPage.socialEmbed.body.withTracks": "{TRACKS}.",
-  "albumPage.socialEmbed.body.withReleaseDate": "Released {DATE}.",
-  "albumPage.socialEmbed.body.withDuration.withTracks": "{DURATION}, {TRACKS}.",
-  "albumPage.socialEmbed.body.withDuration.withReleaseDate": "{DURATION}. Released {DATE}.",
-  "albumPage.socialEmbed.body.withTracks.withReleaseDate": "{TRACKS}. Released {DATE}.",
-  "albumPage.socialEmbed.body.withDuration.withTracks.withReleaseDate": "{DURATION}, {TRACKS}. Released {DATE}.",
-  "albumGalleryPage.title": "{ALBUM} - Gallery",
-  "albumGalleryPage.statsLine": "{TRACKS} totaling {DURATION}.",
-  "albumGalleryPage.statsLine.withDate": "{TRACKS} totaling {DURATION}. Released {DATE}.",
-  "albumGalleryPage.coverArtistsLine": "All track artwork by {ARTISTS}.",
-  "albumGalleryPage.noTrackArtworksLine": "This album doesn't have any track artwork.",
-  "albumCommentaryPage.title": "{ALBUM} - Commentary",
-  "albumCommentaryPage.infoLine": "{WORDS} across {ENTRIES}.",
-  "albumCommentaryPage.nav.album": "Album: {ALBUM}",
-  "albumCommentaryPage.entry.title.albumCommentary": "Album commentary",
-  "albumCommentaryPage.entry.title.trackCommentary": "{TRACK}",
-  "artistPage.title": "{ARTIST}",
-  "artistPage.creditList.album": "{ALBUM}",
-  "artistPage.creditList.album.withDate": "{ALBUM} ({DATE})",
-  "artistPage.creditList.album.withDuration": "{ALBUM} ({DURATION})",
-  "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})",
-  "artistPage.creditList.flashAct": "{ACT}",
-  "artistPage.creditList.flashAct.withDate": "{ACT} ({DATE})",
-  "artistPage.creditList.flashAct.withDateRange": "{ACT} ({DATE_RANGE})",
-  "artistPage.creditList.entry.track": "{TRACK}",
-  "artistPage.creditList.entry.track.withDuration": "({DURATION}) {TRACK}",
-  "artistPage.creditList.entry.album.coverArt": "(cover art)",
-  "artistPage.creditList.entry.album.wallpaperArt": "(wallpaper art)",
-  "artistPage.creditList.entry.album.bannerArt": "(banner art)",
-  "artistPage.creditList.entry.album.commentary": "(album commentary)",
-  "artistPage.creditList.entry.flash": "{FLASH}",
-  "artistPage.creditList.entry.rerelease": "{ENTRY} (re-release)",
-  "artistPage.creditList.entry.withContribution": "{ENTRY} ({CONTRIBUTION})",
-  "artistPage.creditList.entry.withArtists": "{ENTRY} (with {ARTISTS})",
-  "artistPage.creditList.entry.withArtists.withContribution": "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})",
-  "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.",
-  "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}",
-  "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}",
-  "artistPage.groupsLine.item.withCount": "{GROUP} ({COUNT})",
-  "artistPage.groupsLine.item.withDuration": "{GROUP} ({DURATION})",
-  "artistPage.groupContributions.title.music": "Contributed music to groups:",
-  "artistPage.groupContributions.title.artworks": "Contributed artworks to groups:",
-  "artistPage.groupContributions.title.withSortButton": "{TITLE} ({SORT})",
-  "artistPage.groupContributions.title.sorting.count": "Sorting by count.",
-  "artistPage.groupContributions.title.sorting.duration": "Sorting by duration.",
-  "artistPage.groupContributions.item.countAccent": "({COUNT})",
-  "artistPage.groupContributions.item.durationAccent": "({DURATION})",
-  "artistPage.groupContributions.item.countDurationAccent": "({COUNT} — {DURATION})",
-  "artistPage.groupContributions.item.durationCountAccent": "({DURATION} — {COUNT})",
-  "artistPage.trackList.title": "Tracks",
-  "artistPage.artList.title": "Artworks",
-  "artistPage.flashList.title": "Flashes & Games",
-  "artistPage.commentaryList.title": "Commentary",
-  "artistPage.viewArtGallery": "View {LINK}!",
-  "artistPage.viewArtGallery.orBrowseList": "View {LINK}! Or browse the list:",
-  "artistPage.viewArtGallery.link": "art gallery",
-  "artistPage.nav.artist": "Artist: {ARTIST}",
-  "artistGalleryPage.title": "{ARTIST} - Gallery",
-  "artistGalleryPage.infoLine": "Contributed to {COVER_ARTS}.",
-  "commentaryIndex.title": "Commentary",
-  "commentaryIndex.infoLine": "{WORDS} across {ENTRIES}, in all.",
-  "commentaryIndex.albumList.title": "Choose an album:",
-  "commentaryIndex.albumList.item": "{ALBUM} ({WORDS} across {ENTRIES})",
-  "flashIndex.title": "Flashes & Games",
-  "flashPage.title": "{FLASH}",
-  "flashPage.nav.flash": "{FLASH}",
-  "flashSidebar.flashList.flashesInThisAct": "Flashes in this act",
-  "flashSidebar.flashList.entriesInThisSection": "Entries in this section",
-  "groupSidebar.title": "Groups",
-  "groupSidebar.groupList.category": "{CATEGORY}",
-  "groupSidebar.groupList.item": "{GROUP}",
-  "groupPage.nav.group": "Group: {GROUP}",
-  "groupInfoPage.title": "{GROUP}",
-  "groupInfoPage.viewAlbumGallery": "View {LINK}! Or browse the list:",
-  "groupInfoPage.viewAlbumGallery.link": "album gallery",
-  "groupInfoPage.albumList.title": "Albums",
-  "groupInfoPage.albumList.item": "({YEAR}) {ALBUM}",
-  "groupInfoPage.albumList.item.withoutYear": "{ALBUM}",
-  "groupInfoPage.albumList.item.withAccent": "{ITEM} {ACCENT}",
-  "groupInfoPage.albumList.item.otherGroupAccent": "(from {GROUP})",
-  "groupGalleryPage.title": "{GROUP} - Gallery",
-  "groupGalleryPage.infoLine": "{TRACKS} across {ALBUMS}, totaling {TIME}.",
-  "listingIndex.title": "Listings",
-  "listingIndex.infoLine": "{WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.",
-  "listingIndex.exploreList": "Feel free to explore any of the listings linked below and in the sidebar!",
-  "listingPage.target.album": "Albums",
-  "listingPage.target.artist": "Artists",
-  "listingPage.target.group": "Groups",
-  "listingPage.target.track": "Tracks",
-  "listingPage.target.tag": "Tags",
-  "listingPage.target.other": "Other",
-  "listingPage.listingsFor": "Listings for {TARGET}: {LISTINGS}",
-  "listingPage.seeAlso": "Also check out: {LISTINGS}",
-  "listingPage.skipToSection": "Skip to a section:",
-  "listingPage.listAlbums.byName.title": "Albums - by Name",
-  "listingPage.listAlbums.byName.title.short": "...by Name",
-  "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})",
-  "listingPage.listAlbums.byTracks.title": "Albums - by Tracks",
-  "listingPage.listAlbums.byTracks.title.short": "...by Tracks",
-  "listingPage.listAlbums.byTracks.item": "{ALBUM} ({TRACKS})",
-  "listingPage.listAlbums.byDuration.title": "Albums - by Duration",
-  "listingPage.listAlbums.byDuration.title.short": "...by Duration",
-  "listingPage.listAlbums.byDuration.item": "{ALBUM} ({DURATION})",
-  "listingPage.listAlbums.byDate.title": "Albums - by Date",
-  "listingPage.listAlbums.byDate.title.short": "...by Date",
-  "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})",
-  "listingPage.listAlbums.byDateAdded.title.short": "...by Date Added to Wiki",
-  "listingPage.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki",
-  "listingPage.listAlbums.byDateAdded.chunk.title": "{DATE}",
-  "listingPage.listAlbums.byDateAdded.chunk.item": "{ALBUM}",
-  "listingPage.listArtists.byName.title": "Artists - by Name",
-  "listingPage.listArtists.byName.title.short": "...by Name",
-  "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})",
-  "listingPage.listArtists.byContribs.title": "Artists - by Contributions",
-  "listingPage.listArtists.byContribs.title.short": "...by Contributions",
-  "listingPage.listArtists.byContribs.chunk.title.trackContributors": "Contributed tracks:",
-  "listingPage.listArtists.byContribs.chunk.title.artContributors": "Contributed artworks:",
-  "listingPage.listArtists.byContribs.chunk.title.flashContributors": "Contributed to flashes & games:",
-  "listingPage.listArtists.byContribs.chunk.item": "{ARTIST} ({CONTRIBUTIONS})",
-  "listingPage.listArtists.byCommentary.title": "Artists - by Commentary Entries",
-  "listingPage.listArtists.byCommentary.title.short": "...by Commentary Entries",
-  "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})",
-  "listingPage.listArtists.byDuration.title": "Artists - by Duration",
-  "listingPage.listArtists.byDuration.title.short": "...by Duration",
-  "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})",
-  "listingPage.listArtists.byGroup.title": "Artists - by Group",
-  "listingPage.listArtists.byGroup.title.short": "...by Group",
-  "listingPage.listArtists.byGroup.item": "{ARTIST} ({CONTRIBUTIONS})",
-  "listingPage.listArtists.byGroup.chunk.title": "Contributed to {GROUP}:",
-  "listingPage.listArtists.byGroup.chunk.item": "{ARTIST} ({CONTRIBUTIONS})",
-  "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution",
-  "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution",
-  "listingPage.listArtists.byLatest.chunk.title.album": "{ALBUM} ({DATE})",
-  "listingPage.listArtists.byLatest.chunk.title.flash": "{FLASH} ({DATE})",
-  "listingPage.listArtists.byLatest.chunk.title.dateless": "These artists' contributions aren't dated:",
-  "listingPage.listArtists.byLatest.chunk.item": "{ARTIST}",
-  "listingPage.listArtists.byLatest.chunk.item.tracks": "{ARTIST} (tracks)",
-  "listingPage.listArtists.byLatest.chunk.item.tracksAndArt": "{ARTIST} (tracks, art)",
-  "listingPage.listArtists.byLatest.chunk.item.art": "{ARTIST} (art)",
-  "listingPage.listGroups.byName.title": "Groups - by Name",
-  "listingPage.listGroups.byName.title.short": "...by Name",
-  "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})",
-  "listingPage.listGroups.byName.item.gallery": "Gallery",
-  "listingPage.listGroups.byCategory.title": "Groups - by Category",
-  "listingPage.listGroups.byCategory.title.short": "...by Category",
-  "listingPage.listGroups.byCategory.chunk.title": "{CATEGORY}",
-  "listingPage.listGroups.byCategory.chunk.item": "{GROUP} ({GALLERY})",
-  "listingPage.listGroups.byCategory.chunk.item.gallery": "Gallery",
-  "listingPage.listGroups.byAlbums.title": "Groups - by Albums",
-  "listingPage.listGroups.byAlbums.title.short": "...by Albums",
-  "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})",
-  "listingPage.listGroups.byTracks.title": "Groups - by Tracks",
-  "listingPage.listGroups.byTracks.title.short": "...by Tracks",
-  "listingPage.listGroups.byTracks.item": "{GROUP} ({TRACKS})",
-  "listingPage.listGroups.byDuration.title": "Groups - by Duration",
-  "listingPage.listGroups.byDuration.title.short": "...by Duration",
-  "listingPage.listGroups.byDuration.item": "{GROUP} ({DURATION})",
-  "listingPage.listGroups.byLatest.title": "Groups - by Latest Album",
-  "listingPage.listGroups.byLatest.title.short": "...by Latest Album",
-  "listingPage.listGroups.byLatest.item": "{GROUP} ({DATE})",
-  "listingPage.listTracks.byName.title": "Tracks - by Name",
-  "listingPage.listTracks.byName.title.short": "...by Name",
-  "listingPage.listTracks.byName.item": "{TRACK}",
-  "listingPage.listTracks.byAlbum.title": "Tracks - by Album",
-  "listingPage.listTracks.byAlbum.title.short": "...by Album",
-  "listingPage.listTracks.byAlbum.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.byAlbum.chunk.item": "{TRACK}",
-  "listingPage.listTracks.byDate.title": "Tracks - by Date",
-  "listingPage.listTracks.byDate.title.short": "...by Date",
-  "listingPage.listTracks.byDate.chunk.title": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.byDate.chunk.item": "{TRACK}",
-  "listingPage.listTracks.byDate.chunk.item.rerelease": "{TRACK} (re-release)",
-  "listingPage.listTracks.byDuration.title": "Tracks - by Duration",
-  "listingPage.listTracks.byDuration.title.short": "...by Duration",
-  "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})",
-  "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)",
-  "listingPage.listTracks.byDurationInAlbum.title.short": "...by Duration (in Album)",
-  "listingPage.listTracks.byDurationInAlbum.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.byDurationInAlbum.chunk.item": "{TRACK} ({DURATION})",
-  "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced",
-  "listingPage.listTracks.byTimesReferenced.title.short": "...by Times Referenced",
-  "listingPage.listTracks.byTimesReferenced.item": "{TRACK} ({TIMES_REFERENCED})",
-  "listingPage.listTracks.inFlashes.byAlbum.title": "Tracks - in Flashes & Games (by Album)",
-  "listingPage.listTracks.inFlashes.byAlbum.title.short": "...in Flashes & Games (by Album)",
-  "listingPage.listTracks.inFlashes.byAlbum.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.inFlashes.byAlbum.chunk.item": "{TRACK} (in {FLASHES})",
-  "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)",
-  "listingPage.listTracks.inFlashes.byFlash.title.short": "...in Flashes & Games (by Flash)",
-  "listingPage.listTracks.inFlashes.byFlash.chunk.title": "{FLASH}",
-  "listingPage.listTracks.inFlashes.byFlash.chunk.item": "{TRACK} (from {ALBUM})",
-  "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics",
-  "listingPage.listTracks.withLyrics.title.short": "...with Lyrics",
-  "listingPage.listTracks.withLyrics.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.withLyrics.chunk.title.withDate": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.withLyrics.chunk.item": "{TRACK}",
-  "listingPage.listTracks.withSheetMusicFiles.title": "Tracks - with Sheet Music Files",
-  "listingPage.listTracks.withSheetMusicFiles.title.short": "...with Sheet Music Files",
-  "listingPage.listTracks.withSheetMusicFiles.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.withSheetMusicFiles.chunk.title.withDate": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.withSheetMusicFiles.chunk.item": "{TRACK}",
-  "listingPage.listTracks.withMidiProjectFiles.title": "Tracks - with MIDI & Project Files",
-  "listingPage.listTracks.withMidiProjectFiles.title.short": "...with MIDI & Project Files",
-  "listingPage.listTracks.withMidiProjectFiles.chunk.title": "{ALBUM}",
-  "listingPage.listTracks.withMidiProjectFiles.chunk.title.withDate": "{ALBUM} ({DATE})",
-  "listingPage.listTracks.withMidiProjectFiles.chunk.item": "{TRACK}",
-  "listingPage.listTags.byName.title": "Tags - by Name",
-  "listingPage.listTags.byName.title.short": "...by Name",
-  "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})",
-  "listingPage.listTags.byUses.title": "Tags - by Uses",
-  "listingPage.listTags.byUses.title.short": "...by Uses",
-  "listingPage.listTags.byUses.item": "{TAG} ({TIMES_USED})",
-  "listingPage.other.allSheetMusic.title": "All Sheet Music",
-  "listingPage.other.allSheetMusic.title.short": "All Sheet Music",
-  "listingPage.other.allSheetMusic.albumFiles": "Album sheet music:",
-  "listingPage.other.allSheetMusic.file": "{TITLE}",
-  "listingPage.other.allSheetMusic.file.withMultipleFiles": "{TITLE} ({FILES})",
-  "listingPage.other.allMidiProjectFiles.title": "All MIDI/Project Files",
-  "listingPage.other.allMidiProjectFiles.title.short": "All MIDI/Project Files",
-  "listingPage.other.allMidiProjectFiles.albumFiles": "Album MIDI/project files:",
-  "listingPage.other.allMidiProjectFiles.file": "{TITLE}",
-  "listingPage.other.allMidiProjectFiles.file.withMultipleFiles": "{TITLE} ({FILES})",
-  "listingPage.other.allAdditionalFiles.title": "All Additional Files",
-  "listingPage.other.allAdditionalFiles.title.short": "All Additional Files",
-  "listingPage.other.allAdditionalFiles.albumFiles": "Album additional files:",
-  "listingPage.other.allAdditionalFiles.file": "{TITLE}",
-  "listingPage.other.allAdditionalFiles.file.withMultipleFiles": "{TITLE} ({FILES})",
-  "listingPage.other.randomPages.title": "Random Pages",
-  "listingPage.other.randomPages.title.short": "Random Pages",
-  "listingPage.other.randomPages.chooseLinkLine": "{FROM_PART} {BROWSER_SUPPORT_PART}",
-  "listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups": "Choose a link to go to a random page in that group or album!",
-  "listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups": "Choose a link to go to a random page in that album!",
-  "listingPage.other.randomPages.chooseLinkLine.browserSupportPart": "If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.",
-  "listingPage.other.randomPages.dataLoadingLine": "(Data files are downloading in the background! Please wait for data to load.)",
-  "listingPage.other.randomPages.dataLoadedLine": "(Data files have finished being downloaded. The links should work!)",
-  "listingPage.other.randomPages.misc": "Miscellaneous:",
-  "listingPage.other.randomPages.misc.randomArtist": "Random Artist",
-  "listingPage.other.randomPages.misc.atLeastTwoContributions": "at least 2 contributions",
-  "listingPage.other.randomPages.misc.randomAlbumWholeSite": "Random Album (whole site)",
-  "listingPage.other.randomPages.misc.randomTrackWholeSite": "Random Track (whole site)",
-  "listingPage.other.randomPages.fromAlbum": "From an album:",
-  "listingPage.other.randomPages.fromGroup": "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})",
-  "listingPage.other.randomPages.fromGroup.randomAlbum": "Random Album",
-  "listingPage.other.randomPages.fromGroup.randomTrack": "Random Track",
-  "listingPage.other.randomPages.album": "{ALBUM}",
-  "listingPage.misc.trackContributors": "Track Contributors",
-  "listingPage.misc.artContributors": "Art Contributors",
-  "listingPage.misc.flashContributors": "Flash & Game Contributors",
-  "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors",
-  "newsIndex.title": "News",
-  "newsIndex.entry.viewRest": "(View rest of entry!)",
-  "newsEntryPage.title": "{ENTRY}",
-  "newsEntryPage.published": "(Published {DATE}.)",
-  "redirectPage.title": "Moved to {TITLE}",
-  "redirectPage.infoLine": "This page has been moved to {TARGET}.",
-  "tagPage.title": "{TAG}",
-  "tagPage.infoLine": "Appears in {COVER_ARTS}.",
-  "tagPage.nav.tag": "Tag: {TAG}",
-  "trackPage.title": "{TRACK}",
-  "trackPage.referenceList.fandom": "Fandom:",
-  "trackPage.referenceList.official": "Official:",
-  "trackPage.nav.track": "{TRACK}",
-  "trackPage.nav.track.withNumber": "{NUMBER}. {TRACK}",
-  "trackPage.nav.random": "Random",
-  "trackPage.socialEmbed.heading": "{ALBUM}",
-  "trackPage.socialEmbed.title": "{TRACK}",
-  "trackPage.socialEmbed.body.withArtists.withCoverArtists": "By {ARTISTS}; art by {COVER_ARTISTS}.",
-  "trackPage.socialEmbed.body.withArtists": "By {ARTISTS}.",
-  "trackPage.socialEmbed.body.withCoverArtists": "Art by {COVER_ARTISTS}."
-}
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
new file mode 100644
index 00000000..6e975de7
--- /dev/null
+++ b/src/strings-default.yaml
@@ -0,0 +1,1690 @@
+meta.languageCode: en
+meta.languageName: English
+
+#
+# count:
+#
+#   This covers pretty much any time that a specific number of things
+#   is represented! It's sectioned... like an alignment chart meme...
+#
+#   First counting specific wiki objects, then more abstract stuff,
+#   and finally numerical representations of kinds of quantities that
+#   aren't really "counting", per se.
+#
+#   These must be filled out according to the Unicode Common Locale
+#   Data Repository (Unicode CLDR). Check out info on their site:
+#   https://cldr.unicode.org
+#
+#   Specifically, you'll want to look into the Plural Rules for your
+#   language. Here's a summary on what those even are:
+#   https://cldr.unicode.org/index/cldr-spec/plural-rules
+#
+#   CLDR's charts are available online! This should bring you to the
+#   most recent table of plural rules:
+#   https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html
+#
+#   Counting is generally done with the "Type: cardinal" section on
+#   that chart - for example, if the chart lists "one", "many", and
+#   "other" under the cardinal plural rules for your language, then
+#   your job is to fill in the correct pluralizations of the specific
+#   term for each of those.
+#
+#   If you adore technical details or want to better understand the
+#   "Rules" column, you'll want to check out the syntax outline here:
+#   https://unicode.org/reports/tr35/tr35-numbers.html#Language_Plural_Rules
+#
+count:
+
+  # Count things and objects
+
+  additionalFiles:
+    _: "{FILES}"
+    withUnit:
+      zero: ""
+      one: "{FILES} file"
+      two: ""
+      few: ""
+      many: ""
+      other: "{FILES} files"
+
+  albums:
+    _: "{ALBUMS}"
+    withUnit:
+      zero: ""
+      one: "{ALBUMS} album"
+      two: ""
+      few: ""
+      many: ""
+      other: "{ALBUMS} albums"
+
+  artworks:
+    _: "{ARTWORKS}"
+    withUnit:
+      zero: ""
+      one: "{ARTWORKS} artwork"
+      two: ""
+      few: ""
+      many: ""
+      other: "{ARTWORKS} artworks"
+
+  commentaryEntries:
+    _: "{ENTRIES}"
+    withUnit:
+      zero: ""
+      one: "{ENTRIES} entry"
+      two: ""
+      few: ""
+      many: ""
+      other: "{ENTRIES} entries"
+
+  contributions:
+    _: "{CONTRIBUTIONS}"
+    withUnit:
+      zero: ""
+      one: "{CONTRIBUTIONS} contribution"
+      two: ""
+      few: ""
+      many: ""
+      other: "{CONTRIBUTIONS} contributions"
+
+  coverArts:
+    _: "{COVER_ARTS}"
+    withUnit:
+      zero: ""
+      one: "{COVER_ARTS} cover art"
+      two: ""
+      few: ""
+      many: ""
+      other: "{COVER_ARTS} cover arts"
+
+  flashes:
+    _: "{FLASHES}"
+    withUnit:
+      zero: ""
+      one: "{FLASHES} flashes & games"
+      two: ""
+      few: ""
+      many: ""
+      other: "{FLASHES} flashes & games"
+
+  tracks:
+    _: "{TRACKS}"
+    withUnit:
+      zero: ""
+      one: "{TRACKS} track"
+      two: ""
+      few: ""
+      many: ""
+      other: "{TRACKS} tracks"
+
+  # Count more abstract stuff
+
+  days:
+    _: "{DAYS}"
+    withUnit:
+      zero: ""
+      one: "{DAYS} day"
+      two: ""
+      few: ""
+      many: ""
+      other: "{DAYS} days"
+
+  timesReferenced:
+    _: "{TIMES_REFERENCED}"
+    withUnit:
+      zero: ""
+      one: "{TIMES_REFERENCED} time referenced"
+      two: ""
+      few: ""
+      many: ""
+      other: "{TIMES_REFERENCED} times referenced"
+
+  timesUsed:
+    _: "{TIMES_USED}"
+    withUnit:
+      zero: ""
+      one: "used {TIMES_USED} time"
+      two: ""
+      few: ""
+      many: ""
+      other: "used {TIMES_USED} times"
+
+  words:
+    _: "{WORDS}"
+    thousand: "{WORDS}k"
+    withUnit:
+      zero: ""
+      one: "{WORDS} word"
+      two: ""
+      few: ""
+      many: ""
+      other: "{WORDS} words"
+
+  # Numerical things that aren't exactly counting, per se
+
+  duration:
+    missing: "_:__"
+    approximate: "~{DURATION}"
+    hours:
+      _:        "{HOURS}:{MINUTES}:{SECONDS}"
+      withUnit: "{HOURS}:{MINUTES}:{SECONDS} hours"
+    minutes:
+      _:        "{MINUTES}:{SECONDS}"
+      withUnit: "{MINUTES}:{SECONDS} minutes"
+
+  fileSize:
+    terabytes: "{TERABYTES} TB"
+    gigabytes: "{GIGABYTES} GB"
+    megabytes: "{MEGABYTES} MB"
+    kilobytes: "{KILOBYTES} kB"
+    bytes: "{BYTES} bytes"
+
+  # Indexes in a list
+  # These use "Type: ordinal" on CLDR's chart of plural rules.
+
+  index:
+    zero: ""
+    one: "{INDEX}st"
+    two: "{INDEX}nd"
+    few: "{INDEX}rd"
+    many: ""
+    other: "{INDEX}th"
+
+#
+# releaseInfo:
+#
+#   This covers a lot of generic strings - they're used in a variety
+#   of contexts. They're sorted below with descriptions first, then
+#   actions further down.
+#
+releaseInfo:
+
+  # Descriptions
+
+  by: "By {ARTISTS}."
+  from: "From {ALBUM}."
+
+  coverArtBy: "Cover art by {ARTISTS}."
+  wallpaperArtBy: "Wallpaper art by {ARTISTS}."
+  bannerArtBy: "Banner art by {ARTISTS}."
+
+  released: "Released {DATE}."
+  artReleased: "Art released {DATE}."
+  addedToWiki: "Added to wiki {DATE}."
+
+  duration: "Duration: {DURATION}."
+
+  contributors: "Contributors:"
+  lyrics: "Lyrics:"
+  note: "Context notes:"
+
+  alsoReleasedAs:
+    _: "Also released as:"
+    item: "{TRACK} (on {ALBUM})"
+
+  tracksReferenced: "Tracks that {TRACK} references:"
+  tracksThatReference: "Tracks that reference {TRACK}:"
+  tracksSampled: "Tracks that {TRACK} samples:"
+  tracksThatSample: "Tracks that sample {TRACK}:"
+
+  flashesThatFeature:
+    _: "Flashes & games that feature {TRACK}:"
+    item:
+      _: "{FLASH}"
+      asDifferentRelease: "{FLASH} (as {TRACK})"
+
+  tracksFeatured: "Tracks that {FLASH} features:"
+
+  artTags:
+    _: "Tags:"
+    inline: "Tags: {TAGS}"
+
+  # Actions
+
+  viewCommentary:
+    _: "View {LINK}!"
+    link: "commentary page"
+
+  viewGallery:
+    _: "View {LINK}!"
+    link: "gallery page"
+
+  viewGalleryOrCommentary:
+    _: "View {GALLERY} or {COMMENTARY}!"
+    gallery: "gallery page"
+    commentary: "commentary page"
+
+  viewOriginalFile:
+    _: "View {LINK}."
+    withSize: "View {LINK} ({SIZE})."
+    link: "original file"
+    sizeWarning: >-
+      (Heads up! If you're on a mobile plan, this is a large download.)
+
+  listenOn:
+    _: "Listen on {LINKS}."
+    noLinks: >-
+      This wiki doesn't have any listening links for {NAME}.
+
+  visitOn: "Visit on {LINKS}."
+  playOn: "Play on {LINKS}."
+
+  readCommentary:
+    _: "Read {LINK}."
+    link: "artist commentary"
+
+  artistCommentary:
+    _: "Artist commentary:"
+    seeOriginalRelease: "See {ORIGINAL}!"
+
+  additionalFiles:
+    heading: "View or download {ADDITIONAL_FILES}:"
+
+    entry:
+      _: "{TITLE}"
+      withDescription: "{TITLE}: {DESCRIPTION}"
+
+    file:
+      _: "{FILE}"
+      withSize: "{FILE} ({SIZE})"
+
+    shortcut:
+      _: "View {ANCHOR_LINK}: {TITLES}"
+      anchorLink: "additional files"
+
+  sheetMusicFiles:
+    heading: "Print or download sheet music files:"
+
+    shortcut:
+      _: "Download {LINK}."
+      link: "sheet music files"
+
+  midiProjectFiles:
+    heading: "Download MIDI/project files:"
+
+    shortcut:
+      _: "Download {LINK}."
+      link: "MIDI/project files"
+
+#
+# trackList:
+#
+#   A list of tracks! These are used pretty much across the wiki.
+#   Track lists can be split into sections, groups, or not split at
+#   all. "Track sections" are divisions in the list which suit the
+#   album as a whole, like if it has multiple discs or bonus tracks.
+#   "Groups" are actual group objects (see ex. groupInfoPage).
+#
+trackList:
+  section:
+    withDuration: "{SECTION} ({DURATION}):"
+
+  group:
+    _: "From {GROUP}:"
+    fromOther: "From somewhere else:"
+
+  item:
+    withDuration: "({DURATION}) {TRACK}"
+    withDuration.withArtists: "({DURATION}) {TRACK} {BY}"
+    withArtists: "{TRACK} {BY}"
+    withArtists.by: "by {ARTISTS}"
+    rerelease: "{TRACK} (re-release)"
+
+#
+# misc:
+#
+#   These cover a whole host of general things across the wiki, and
+#   aren't specially organized. Sorry! See each entry for details.
+#
+misc:
+
+  # alt:
+  #   Fallback text for the alt text of images and artworks - these
+  #   are read aloud by screen readers.
+
+  alt:
+    albumCover: "album cover"
+    albumBanner: "album banner"
+    trackCover: "track cover"
+    artistAvatar: "artist avatar"
+    flashArt: "flash art"
+
+  # artistLink:
+  #   Artist links have special accents which are made conditionally
+  #   present in a variety of places across the wiki.
+
+  artistLink:
+    _: "{ARTIST}"
+
+    # Contribution to a track, artwork, or other thing.
+    withContribution: "{ARTIST} ({CONTRIB})"
+
+    # External links to visit the artist's own websites or profiles.
+    withExternalLinks: "{ARTIST} ({LINKS})"
+
+    # Combination of above.
+    withContribution.withExternalLinks: "{ARTIST} ({CONTRIB}) ({LINKS})"
+
+  # chronology:
+  #
+  #   "Chronology links" are a section that appear in the nav bar for
+  #   most things with individual contributors across the wiki! These
+  #   allow for quick navigation between older and newer releases of
+  #   a given artist, or seeing at a glance how many contributions an
+  #   artist made before the one you're currently viewing.
+  #
+  #   Chronology information is described for each artist and shows
+  #   the kind of thing which is being contributed to, since all of
+  #   the entries are displayed together in one list.
+  #
+
+  chronology:
+
+    # seeArtistPages:
+    #   If the thing you're viewing has a lot of contributors, their
+    #   chronology info will be exempt from the nav bar, which'll
+    #   show this message instead.
+
+    seeArtistPages: "(See artist pages for chronology info!)"
+
+    # withNavigation:
+    #   Navigation refers to previous/next links.
+
+    withNavigation: "{HEADING} ({NAVIGATION})"
+
+    heading:
+      coverArt: "{INDEX} cover art by {ARTIST}"
+      flash: "{INDEX} flash/game by {ARTIST}"
+      track: "{INDEX} track by {ARTIST}"
+
+  # external:
+  #   Links which will generally bring you somewhere off of the wiki.
+  #   The list of sites is hard-coded into the wiki software, so it
+  #   may be out of date or missing ones that are relevant to another
+  #   wiki - sorry!
+
+  external:
+
+    # domain:
+    #   General domain when one the URL doesn't match one of the
+    #   sites below.
+
+    domain: "External ({DOMAIN})"
+
+    # local:
+    #   Files which are locally available on the wiki (under its media
+    #   directory).
+
+    local: "Wiki Archive (local upload)"
+
+    deviantart: "DeviantArt"
+    instagram: "Instagram"
+    newgrounds: "Newgrounds"
+    patreon: "Patreon"
+    poetryFoundation: "Poetry Foundation"
+    soundcloud: "SoundCloud"
+    spotify: "Spotify"
+    tumblr: "Tumblr"
+    twitter: "Twitter"
+    wikipedia: "Wikipedia"
+
+    bandcamp:
+      _: "Bandcamp"
+      domain: "Bandcamp ({DOMAIN})"
+
+    mastodon:
+      _: "Mastodon"
+      domain: "Mastodon ({DOMAIN})"
+
+    youtube:
+      _: "YouTube"
+      playlist: "YouTube (playlist)"
+      fullAlbum: "YouTube (full album)"
+
+    flash:
+      bgreco: "{LINK} (HQ Audio)"
+      youtube: "{LINK} (on any device)"
+      homestuck:
+        page: "{LINK} (page {PAGE})"
+        secret: "{LINK} (secret page)"
+
+  # missingImage:
+  #   Fallback text displayed in an image when it's sourced to a file
+  #   that isn't available under the wiki's media directory. While it
+  #   shouldn't display on a correct build of the site, it may be
+  #   displayed when working on data locally (for example adding a
+  #   track before you've brought in its cover art).
+
+  missingImage: "(This image file is missing)"
+
+  # misingLinkContent:
+  #   Generic fallback when a link is completely missing its content.
+  #   This is only to make those links visible in the first place -
+  #   it should never appear on the website and is only intended for
+  #   debugging.
+
+  missingLinkContent: "(Missing link content)"
+
+  # nav:
+  #   Generic navigational elements. These usually only appear in the
+  #   wiki's nav bar, at the top of the page.
+
+  nav:
+    previous: "Previous"
+    next: "Next"
+    info: "Info"
+    gallery: "Gallery"
+
+  # pageTitle:
+  #   Title set under the page's <title> HTML element, which is
+  #   displayed in the browser tab bar, bookmarks list, etc.
+
+  pageTitle:
+    _: "{TITLE}"
+    withWikiName: "{TITLE} | {WIKI_NAME}"
+
+  # skippers:
+  #
+  #   These are navigational links that only show up when you're
+  #   navigating the wiki using the Tab key (or some other method of
+  #   "tabbing" between links and interactive elements). They move
+  #   the browser's nav focus to the selected element when pressed.
+  #
+  #   There are a lot of definitions here, and they're mostly shown
+  #   conditionally, based on the elements that are actually apparent
+  #   on the current page.
+  #
+
+  skippers:
+    skipTo: "Skip to:"
+
+    content: "Content"
+    header: "Header"
+    footer: "Footer"
+
+    sidebar:
+      _: "Sidebar"
+      left: "Sidebar (left)"
+      right: "Sidebar (right)"
+
+    # Displayed on artist info page.
+
+    tracks: "Tracks"
+    artworks: "Artworks"
+    flashes: "Flashes & Games"
+
+    # Displayed on track and flash info pages.
+
+    contributors: "Contributors"
+
+    # Displayed on track info page.
+
+    references: "References..."
+    referencedBy: "Referenced by..."
+    samples: "Samples..."
+    sampledBy: "Sampled by..."
+    features: "Features..."
+    featuredIn: "Featured in..."
+
+    lyrics: "Lyrics"
+
+    sheetMusicFiles: "Sheet music files"
+    midiProjectFiles: "MIDI/project files"
+
+    # Displayed on track and album info pages.
+
+    commentary: "Commentary"
+
+    artistCommentary: "Commentary"
+    additionalFiles: "Additional files"
+
+  # socialEmbed:
+  #   Social embeds describe how the page should be represented on
+  #   social platforms, chat messaging apps, and so on.
+
+  socialEmbed:
+    heading: "{WIKI_NAME} | {HEADING}"
+
+  # jumpTo:
+  #   Generic action displayed at the top of some longer pages, for
+  #   quickly scrolling down to a particular section.
+
+  jumpTo:
+    _: "Jump to:"
+    withLinks: "Jump to: {LINKS}."
+
+  # contentWarnings:
+  #   Displayed for some artworks, informing of possibly sensitive
+  #   content and giving the viewer a chance to consider before
+  #   clicking through.
+
+  contentWarnings:
+    _: "cw: {WARNINGS}"
+    reveal: "click to show"
+
+  # albumGrid:
+  #   Generic strings for various sorts of gallery grids, displayed
+  #   on the homepage, album galleries, artist artwork galleries, and
+  #   so on. These get the name of the thing being represented and,
+  #   often, a bit of text providing pertinent extra details about
+  #   that thing.
+
+  albumGrid:
+    noCoverArt: "{ALBUM}"
+
+    details:
+      _: "({TRACKS}, {TIME})"
+      coverArtists: "(Illust. {ARTISTS})"
+      otherCoverArtists: "(With {ARTISTS})"
+
+  albumGalleryGrid:
+    noCoverArt: "{NAME}"
+
+  # uiLanguage:
+  #   Displayed in the footer, for switching between languages.
+
+  uiLanguage: "UI Language: {LANGUAGES}"
+
+#
+# homepage:
+#   This is the main index and home for the whole wiki! There isn't
+#   much for strings here as the layout is very customizable and
+#   includes mostly wiki-provided content.
+#
+homepage:
+  title: "{TITLE}"
+
+  # news:
+  #   If the wiki has news entries enabled, then there's a box in the
+  #   homepage's sidebar (beneath custom sidebar content, if any)
+  #   which displays the bodies the latest few entries up to a split.
+
+  news:
+    title: "News"
+
+    entry:
+      viewRest: "(View rest of entry!)"
+
+#
+# albumSidebar:
+#   This sidebar is displayed on both the album and track info pages!
+#   It displays the groups that the album is from (each getting its
+#   own box on the album page, all conjoined in one box on the track
+#   page) and the list of tracks in the album, which can be sectioned
+#   similarly to normal track lists, but displays the range of tracks
+#   in each section rather than the section's duration.
+#
+albumSidebar:
+  trackList:
+    item: "{TRACK}"
+
+    # fallbackSectionName:
+    #   If an album's track list isn't sectioned, the track list here
+    #   will still have all the tracks grouped under a list that can
+    #   be toggled open and closed. This controls how that list gets
+    #   titled.
+
+    fallbackSectionName: "Track list"
+
+    # group:
+    #   "Group" is a misnomer - these are track sections. Some albums
+    #   don't use track numbers at all, and for these, the default
+    #   string will be used instead of group.withRange.
+
+    group:
+      _: "{GROUP}"
+      withRange: "{GROUP} ({RANGE})"
+
+  # groupBox:
+  #   This is the box for groups. Apart from the next and previous
+  #   links, it also gets "visit on" and the group's descripton
+  #   (up to a split).
+
+  groupBox:
+    title: "{GROUP}"
+    next: "Next: {ALBUM}"
+    previous: "Previous: {ALBUM}"
+
+#
+# albumPage:
+#
+#   Albums group together tracks and provide quick access to each of
+#   their pages, have release data (and sometimes credits) that are
+#   generally inherited by the album's tracks plus commentary and
+#   other goodies of their own, and are generally the main object on
+#   the wiki!
+#
+#   Most of the strings on the album info page are tracked under
+#   releaseInfo, so there isn't a lot here.
+#
+albumPage:
+  title: "{ALBUM}"
+
+  nav:
+    album: "{ALBUM}"
+    randomTrack: "Random Track"
+    gallery: "Gallery"
+    commentary: "Commentary"
+
+  socialEmbed:
+    heading: "{GROUP}"
+    title: "{ALBUM}"
+
+    # body:
+    #   These permutations are a bit awkward. "Tracks" is a counted
+    #   string, ex. "63 tracks".
+
+    body:
+      withDuration: "{DURATION}."
+      withTracks: "{TRACKS}."
+      withReleaseDate: Released {DATE}.
+      withDuration.withTracks: "{DURATION}, {TRACKS}."
+      withDuration.withReleaseDate: "{DURATION}. Released {DATE}."
+      withTracks.withReleaseDate: "{TRACKS}. Released {DATE}."
+      withDuration.withTracks.withReleaseDate: "{DURATION}, {TRACKS}. Released {DATE}."
+
+#
+# albumGalleryPage:
+#   Album galleries provide an alternative way to navigate the album,
+#   and put all its artwork - including for each track - into the
+#   spotlight. Apart from the main gallery grid (which usually lists
+#   each artwork's illustrators), this page also has a quick stats
+#   line about the album, and may display a message about all of the
+#   artworks if one applies.
+#
+albumGalleryPage:
+  title: "{ALBUM} - Gallery"
+
+  # statsLine:
+  #   Most albums have release dates, but not all. These strings
+  #   react accordingly.
+
+  statsLine: >-
+    {TRACKS} totaling {DURATION}.
+
+  statsLine.withDate: >-
+    {TRACKS} totaling {DURATION}. Released {DATE}.
+
+  # coverArtistsLine:
+  #   This is displayed if every track (which has artwork at all)
+  #   has the same illustration credits.
+
+  coverArtistsLine: >-
+    All track artwork by {ARTISTS}.
+
+  # noTrackArtworksLine:
+  #   This is displayed if none of the tracks on the album have any
+  #   artwork at all. Generally, this means the album gallery won't
+  #   be linked from the album's other pages, but it is possible to
+  #   end up on "stub galleries" using nav links on another gallery.
+
+  noTrackArtworksLine: >-
+    This album doesn't have any track artwork.
+
+#
+# albumCommentaryPage:
+#   The album commentary page is a more minimal layout that brings
+#   the commentary for the album, and each of its tracks, to the
+#   front. It's basically inspired by reading in a library, or by
+#   following along with an album's booklet or liner notes while
+#   playing it back on a treasured dinky CD player late at night.
+#
+albumCommentaryPage:
+  title: "{ALBUM} - Commentary"
+
+  nav:
+    album: "Album: {ALBUM}"
+
+  infoLine: >-
+    {WORDS} across {ENTRIES}.
+
+  entry:
+    title:
+      albumCommentary: "Album commentary"
+      trackCommentary: "{TRACK}"
+
+#
+# artistInfoPage:
+#   The artist info page is an artist's main home on the wiki, and
+#   automatically includes a full list of all the things they've
+#   contributed to and been credited on. It's split into a section
+#   for each of the kinds of things the artist is credited for,
+#   including tracks, artworks, flashes/games, and commentary.
+#
+artistPage:
+  title: "{ARTIST}"
+
+  nav:
+    artist: "Artist: {ARTIST}"
+
+  creditList:
+
+    # album:
+    #   Tracks are chunked by albums, as long as the tracks are all
+    #   of the same date (if applicable).
+
+    album:
+      _: "{ALBUM}"
+      withDate: "{ALBUM} ({DATE})"
+      withDuration: "{ALBUM} ({DURATION})"
+      withDate.withDuration: "{ALBUM} ({DATE}; {DURATION})"
+
+    # flashAct:
+    #   Flashes are chunked by flash act, though a single flash act
+    #   might be split into multiple chunks if it spans a long range
+    #   and the artist contributed to a flash from some other act
+    #   between. A date range will be shown if an act has at least
+    #   two differently dated flashes.
+
+    flashAct:
+      _: "{ACT}"
+      withDate: "{ACT} ({DATE})"
+      withDateRange: "{ACT} ({DATE_RANGE})"
+
+    # entry:
+    #   This section covers strings for all kinds of individual
+    #   things which an artist has contributed to, and refers to the
+    #   items in each of the chunks described above.
+
+    entry:
+
+      # withContribution:
+      #   The specific contribution that an artist made to a given
+      #   thing may be described with a word or two, and that's shown
+      #   in the list.
+
+      withContribution: "{ENTRY} ({CONTRIBUTION})"
+
+      # withArtists:
+      #   This lists co-artists or co-contributors, depending on how
+      #   the artist themselves was credited.
+
+      withArtists: "{ENTRY} (with {ARTISTS})"
+
+      withArtists.withContribution: "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})"
+
+      # rerelease:
+      #   Tracks which aren't the original release don't display co-
+      #   artists or contributors, and get dimmed a little compared
+      #   to original release track entries.
+
+      rerelease: "{ENTRY} (re-release)"
+
+      # track:
+      #   The string without duration is used in both the artist's
+      #   track credits list as well as their commentary list.
+
+      track:
+        _: "{TRACK}"
+        withDuration: "({DURATION}) {TRACK}"
+
+      # album:
+      #   The artist info page doesn't display if the artist is
+      #   musically credited outright for the album as a whole,
+      #   opting to show each of the tracks from that album instead.
+      #   But other parts belonging specifically to the album have
+      #   credits too, and those entreis get the strings below.
+
+      album:
+        coverArt: "(cover art)"
+        wallpaperArt: "(wallpaper art)"
+        bannerArt: "(banner art)"
+        commentary: "(album commentary)"
+
+      flash:
+        _: "{FLASH}"
+
+  # contributedDurationLine:
+  #   This is shown at the top of the artist's track list, provided
+  #   any of their tracks have durations at all.
+
+  contributedDurationLine: >-
+    {ARTIST} has contributed {DURATION} of music shared on this wiki.
+
+  # groupContributions:
+  #   This is a special "chunk" shown at the top of an artist's
+  #   track and artwork lists. It lists which groups an artist has
+  #   contributed the most (and least) to, and is interactive -
+  #   it can be sorted by count or, for tracks, by duration.
+
+  groupContributions:
+    title:
+      music: "Contributed music to groups:"
+      artworks: "Contributed artworks to groups:"
+      withSortButton: "{TITLE} ({SORT})"
+
+      sorting:
+        count: "Sorting by count."
+        duration: "Sorting by duration."
+
+    item:
+      countAccent: "({COUNT})"
+      durationAccent: "({DURATION})"
+      countDurationAccent: "({COUNT} — {DURATION})"
+      durationCountAccent: "({DURATION} — {COUNT})"
+
+  trackList:
+    title: "Tracks"
+
+  artList:
+    title: "Artworks"
+
+  flashList:
+    title: "Flashes & Games"
+
+  commentaryList:
+    title: "Commentary"
+
+  # viewArtGallery:
+  #   This is shown twice on the page - once at almost the very top
+  #   of the page, just beneath visiting links, and once above the
+  #   list of credited artworks, where it gets the longer
+  #   orBrowseList form.
+
+  viewArtGallery:
+    _: "View {LINK}!"
+    orBrowseList: "View {LINK}! Or browse the list:"
+    link: "art gallery"
+
+#
+# artistGalleryPage:
+#   The artist gallery page shows a neat grid of all of the album and
+#   track artworks an artist has contributed to! Co-illustrators are
+#   also displayed when applicable.
+#
+artistGalleryPage:
+  title: "{ARTIST} - Gallery"
+
+  infoLine: >-
+    Contributed to {COVER_ARTS}.
+
+#
+# commentaryIndex:
+#   The commentary index page shows a summary of all the commentary
+#   across the entire wiki, with a list linking to each album's
+#   dedicated commentary page.
+#
+commentaryIndex:
+  title: "Commentary"
+
+  infoLine: >-
+    {WORDS} across {ENTRIES}, in all.
+
+  albumList:
+    title: "Choose an album:"
+    item: "{ALBUM} ({WORDS} across {ENTRIES})"
+
+#
+# flashIndex:
+#   The flash index page shows a very long grid including every flash
+#   on the wiki, sectioned with big headings for each act. It's also
+#   got jump links at the top to skip to a specific overarching
+#   section ("side") of flash acts.
+#
+flashIndex:
+  title: "Flashes & Games"
+
+#
+# flashSidebar:
+#   The flash sidebar is used on both the flash info and flash act
+#   gallery pages, and has two boxes - one showing all the flashes in
+#   the current flash act, and one showing all the flash acts on the
+#   wiki, sectioned by "side".
+#
+flashSidebar:
+  flashList:
+
+    # These two strings are the default ones used when a flash act
+    # doesn't specify a custom phrasing.
+    flashesInThisAct: "Flashes in this act"
+    entriesInThisSection: "Entries in this section"
+
+#
+# flashPage:
+#   The flash info page shows release information, links to check the
+#   flash out, and lists of contributors and featured tracks. Most of
+#   those strings are under releaseInfo, so there aren't a lot of
+#   strings here.
+#
+flashPage:
+  title: "{FLASH}"
+
+  nav:
+    flash: "{FLASH}"
+
+#
+# groupSidebar:
+#   The group sidebar is used on both the group info and group
+#   gallery pages, and is formed of just one box, showing all the
+#   groups on the wiki, sectioned by "category".
+#
+groupSidebar:
+  title: "Groups"
+
+  groupList:
+    category: "{CATEGORY}"
+    item: "{GROUP}"
+
+#
+# groupPage:
+#   This section represents strings common to multiple group pages.
+#
+groupPage:
+  nav:
+    group: "Group: {GROUP}"
+
+#
+# groupInfoPage:
+#   The group info page shows visiting links, the group's full
+#   description, and a list of albums from the group.
+#
+groupInfoPage:
+  title: "{GROUP}"
+
+  viewAlbumGallery:
+    _: "View {LINK}! Or browse the list:"
+    link: "album gallery"
+
+  # albumList:
+  #   Many albums are present under multiple groups, and these get an
+  #   accent indicating what other group is highest on the album's
+  #   list of groups.
+
+  albumList:
+    title: "Albums"
+
+    item:
+      _: "({YEAR}) {ALBUM}"
+      withoutYear: "{ALBUM}"
+      withAccent: "{ITEM} {ACCENT}"
+      otherGroupAccent: "(from {GROUP})"
+
+#
+# groupGalleryPage:
+#   The group gallery page shows a grid of all the albums from that
+#   group, each including the number of tracks and duration, as well
+#   as a stats line for the group as a whole, and a neat carousel, if
+#   pre-configured!
+#
+groupGalleryPage:
+  title: "{GROUP} - Gallery"
+
+  infoLine: >-
+    {TRACKS} across {ALBUMS}, totaling {TIME}.
+
+#
+# listingIndex:
+#   The listing index page shows all available listings on the wiki,
+#   and a very exciting stats line for the wiki as a whole.
+#
+listingIndex:
+  title: "Listings"
+
+  infoLine: >-
+    {WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.
+
+  exploreList: >-
+    Feel free to explore any of the listings linked below and in the sidebar!
+
+#
+# listingPage:
+#
+#   There are a lot of listings! Each is automatically generated and
+#   sorts or organizes the data on the wiki in some way that provides
+#   useful or interesting information. Most listings work primarily
+#   with one kind of data and are sectioned accordingly, for example
+#   "listAlbums.byDuration" or "listTracks.byDate".
+#
+#   There are also some miscellaneous strings here, most of which are
+#   common to a variety of listings, and are often navigational in
+#   nature.
+#
+listingPage:
+
+  # target:
+  #   Just the names for each of the sections - each chunk on the
+  #   listing index (and in the sidebar) gets is titled with one of
+  #   these.
+
+  target:
+    album: "Albums"
+    artist: "Artists"
+    group: "Groups"
+    track: "Tracks"
+    tag: "Tags"
+    other: "Other"
+
+  # misc:
+  #   Common, generic terminology across multiple listings.
+
+  misc:
+    trackContributors: "Track Contributors"
+    artContributors: "Art Contributors"
+    flashContributors: "Flash & Game Contributors"
+    artAndFlashContributors: "Art & Flash Contributors"
+
+  # listingFor:
+  #   Displays quick links to navigate to other listings for the
+  #   current target.
+
+  listingsFor: "Listings for {TARGET}: {LISTINGS}"
+
+  # seeAlso:
+  #   Displays directly related listings, which might be from other
+  #   targets besides the current one.
+
+  seeAlso: "Also check out: {LISTINGS}"
+
+  # skipToSection:
+  #   Some listings which use a chunked-list layout also show links
+  #   to scroll down to each of these sections - this is the title
+  #   for the list of those links.
+
+  skipToSection: "Skip to a section:"
+
+  listAlbums:
+
+    # listAlbums.byName:
+    #   Lists albums alphabetically without sorting or chunking by
+    #   any other criteria. Also displays the number of tracks for
+    #   each album.
+
+    byName:
+      title: "Albums - by Name"
+      title.short: "...by Name"
+      item: "{ALBUM} ({TRACKS})"
+
+    # listAlbums.byTracks:
+    #   Lists albums by number of tracks, most to least, or by name
+    #   alphabetically, if two albums have the same track count.
+    #   Albums without any tracks are totally excluded.
+
+    byTracks:
+      title: "Albums - by Tracks"
+      title.short: "...by Tracks"
+      item: "{ALBUM} ({TRACKS})"
+
+    # listAlbums.byDuration:
+    #   Lists albums by total duration of all tracks, longest to
+    #   shortest, falling back to an alphabetical sort if two albums
+    #   are the same duration. Albums with zero duration are totally
+    #   excluded.
+
+    byDuration:
+      title: "Albums - by Duration"
+      title.short: "...by Duration"
+      item: "{ALBUM} ({DURATION})"
+
+    # listAlbums.byDate:
+    #   Lists albums by release date, oldest to newest, falling back
+    #   to an alphabetical sort if two albums were released on the
+    #   same date. Dateless albums are totally excluded.
+
+    byDate:
+      title: "Albums - by Date"
+      title.short: "...by Date"
+      item: "{ALBUM} ({DATE})"
+
+    # listAlbums.byDateAdded:
+    #   Lists albums by the date they were added to the wiki, oldest
+    #   to newest, and chunks these by date, since albums are usually
+    #   added in bunches at a time. The albums in each chunk are
+    #   sorted alphabetically, and albums which are missing the
+    #   "Date Added" field are totally excluded.
+
+    byDateAdded:
+      title: "Albums - by Date Added to Wiki"
+      title.short: "...by Date Added to Wiki"
+      chunk:
+        title: "{DATE}"
+        item: "{ALBUM}"
+
+  listArtists:
+
+    # listArtists.byName:
+    #   Lists artists alphabetically without sorting or chunking by
+    #   any other criteria. Also displays the number of contributions
+    #   from each artist.
+
+    byName:
+      title: "Artists - by Name"
+      title.short: "...by Name"
+      item: "{ARTIST} ({CONTRIBUTIONS})"
+
+    # listArtists.byContribs:
+    #   Lists artists by number of contributions, most to least,
+    #   with separate lists for contributions to tracks, artworks,
+    #   and flashes. Falls back alphabetically if two artists have
+    #   the same number of contributions. Artists who aren't credited
+    #   for any contributions to each of these categories are
+    #   excluded from the respective list.
+
+    byContribs:
+      title: "Artists - by Contributions"
+      title.short: "...by Contributions"
+      chunk:
+        item: "{ARTIST} ({CONTRIBUTIONS})"
+        title:
+          trackContributors: "Contributed tracks:"
+          artContributors: "Contributed artworks:"
+          flashContributors: "Contributed to flashes & games:"
+
+    # listArtists.byCommentary:
+    #   Lists artists by number of commentary entries, most to least,
+    #   falling back to an alphabetical sort if two artists have the
+    #   same count. Artists who don't have any commentary entries are
+    #   totally excluded.
+
+    byCommentary:
+      title: "Artists - by Commentary Entries"
+      title.short: "...by Commentary Entries"
+      item: "{ARTIST} ({ENTRIES})"
+
+    # listArtists.byDuration:
+    #   Lists artists by total duration of the tracks which they're
+    #   credited on (as either artist or contributor), longest sum to
+    #   shortest, falling back alphabetically if two artists have
+    #   the same duration. Artists who haven't contributed any music,
+    #   or whose tracks all lack durations, are totally excluded.
+
+    byDuration:
+      title: "Artists - by Duration"
+      title.short: "...by Duration"
+      item: "{ARTIST} ({DURATION})"
+
+    # listArtists.byGroup:
+    #   Lists artists who have contributed to each of the main groups
+    #   of a wiki (its "Divide Track Lists By Groups" field), sorted
+    #   alphabetically. Artists who aren't credited for contributions
+    #   under each of the groups are exlcuded from the respective
+    #   list.
+
+    byGroup:
+      title: "Artists - by Group"
+      title.short: "...by Group"
+      item: "{ARTIST} ({CONTRIBUTIONS})"
+      chunk:
+        title: "Contributed to {GROUP}:"
+        item: "{ARTIST} ({CONTRIBUTIONS})"
+
+    # listArtists.byLatest:
+    #   Lists artists by the date of their latest contribution
+    #   overall, and chunks artists together by the album or flash
+    #   which that contribution belongs to. Within albums, each
+    #   artist is accented with the kind of contribution they made -
+    #   tracks, artworks, or both - and sorted so those of the same
+    #   sort of contribution are bunched together, then by name.
+    #   Artists who aren't credited for any dated contributions are
+    #   included at the bottom under a separate chunk.
+
+    byLatest:
+      title: "Artists - by Latest Contribution"
+      title.short: "...by Latest Contribution"
+      chunk:
+        title:
+          album: "{ALBUM} ({DATE})"
+          flash: "{FLASH} ({DATE})"
+          dateless: "These artists' contributions aren't dated:"
+        item:
+          _: "{ARTIST}"
+          tracks: "{ARTIST} (tracks)"
+          tracksAndArt: "{ARTIST} (tracks, art)"
+          art: "{ARTIST} (art)"
+
+  listGroups:
+
+    # listGroups.byName:
+    #   Lists groups alphabetically without sorting or chunking by
+    #   any other criteria. Also displays a link to each group's
+    #   gallery page.
+
+    byName:
+      title: "Groups - by Name"
+      title.short: "...by Name"
+      item: "{GROUP} ({GALLERY})"
+      item.gallery: "Gallery"
+
+    # listGroups.byCategory:
+    #   Lists groups directly reflecting the way they're sorted in
+    #   the wiki's groups.yaml data file, with no automatic sorting,
+    #   chunked (as sectioned in groups.yaml) by category. Also shows
+    #   a link to each group's gallery page.
+
+    byCategory:
+      title: "Groups - by Category"
+      title.short: "...by Category"
+
+      chunk:
+        title: "{CATEGORY}"
+        item: "{GROUP} ({GALLERY})"
+        item.gallery: "Gallery"
+
+    # listGroups.byAlbums:
+    #   Lists groups by number of belonging albums, most to least,
+    #   falling back alphabetically if two groups have the same
+    #   number of albums. Groups without any albums are totally
+    #   excluded.
+
+    byAlbums:
+      title: "Groups - by Albums"
+      title.short: "...by Albums"
+      item: "{GROUP} ({ALBUMS})"
+
+    # listGroups.byTracks:
+    #   Lists groups by number of tracks under each group's albums,
+    #   most to least, falling back to an alphabetical sort if two
+    #   groups have the same track counts. Groups without any tracks
+    #   are totally excluded.
+
+    byTracks:
+      title: "Groups - by Tracks"
+      title.short: "...by Tracks"
+      item: "{GROUP} ({TRACKS})"
+
+    # listGroups.byDuration:
+    #   Lists groups by sum of durations of all the tracks under each
+    #   of the group's albums, longest to shortest, falling back to
+    #   an alphabetical sort if two groups have the same duration.
+    #   Groups whose total duration is zero are totally excluded.
+
+    byDuration:
+      title: "Groups - by Duration"
+      title.short: "...by Duration"
+      item: "{GROUP} ({DURATION})"
+
+    # listGroups.byLatest:
+    #   List groups by release date of each group's most recent
+    #   album, most recent to longest ago, falling back to sorting
+    #   alphabetically if two groups' latest albums were released
+    #   on the same date. Groups which don't have any albums, or
+    #   whose albums are all dateless, are totally excluded.
+
+    byLatest:
+      title: "Groups - by Latest Album"
+      title.short: "...by Latest Album"
+      item: "{GROUP} ({DATE})"
+
+  listTracks:
+
+    # listTracks.byName:
+    #   List tracks alphabetically without sorting or chunking by
+    #   any other criteria.
+
+    byName:
+      title: "Tracks - by Name"
+      title.short: "...by Name"
+      item: "{TRACK}"
+
+    # listTracks.byAlbum:
+    #   List tracks chunked by the album they're from, retaining the
+    #   position each track occupies in its album, and sorting albums
+    #   from oldest to newest (or alphabetically, if two albums were
+    #   released on the same date). Dateless albums are included at
+    #   the bottom of the list. Custom "Date First Released" fields
+    #   on individual tracks are totally ignored.
+
+    byAlbum:
+      title: "Tracks - by Album"
+      title.short: "...by Album"
+
+      chunk:
+        title: "{ALBUM}"
+        item: "{TRACK}"
+
+    # listTracks.byDate:
+    #   List tracks according to their own release dates, which may
+    #   differ from that of the album via the "Date First Released"
+    #   field, oldest to newest, and chunked by album when multiple
+    #   tracks from one album were released on the same date. Track
+    #   order within a given album is preserved where possible.
+    #   Dateless albums are excluded, except for contained tracks
+    #   which have custom "Date First Released" fields.
+
+    byDate:
+      title: "Tracks - by Date"
+      title.short: "...by Date"
+
+      chunk:
+        title: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+        item.rerelease: "{TRACK} (re-release)"
+
+    # listTracks.byDuration:
+    #   List tracks by duration, longest to shortest, falling back to
+    #   an alphabetical sort if two tracks have the same duration.
+    #   Tracks which don't have any duration are totally excluded.
+
+    byDuration:
+      title: "Tracks - by Duration"
+      title.short: "...by Duration"
+      item: "{TRACK} ({DURATION})"
+
+    # listTracks.byDurationInAlbum:
+    #   List tracks chunked by the album they're from, then sorted
+    #   by duration, longest to shortest; albums are sorted by date,
+    #   oldest to newest, and both sorts fall back alphabetically.
+    #   Dateless albums are included at the bottom of the list.
+
+    byDurationInAlbum:
+      title: "Tracks - by Duration (in Album)"
+      title.short: "...by Duration (in Album)"
+
+      chunk:
+        title: "{ALBUM}"
+        item: "{TRACK} ({DURATION})"
+
+    # listTracks.byTimesReferenced:
+    #   List tracks by how many other tracks' reference lists each
+    #   appears in, most times referenced to fewest, falling back
+    #   alphabetically if two tracks have been referenced the same
+    #   number of times. Tracks that aren't referenced by any other
+    #   tracks are totally excluded from the list.
+
+    byTimesReferenced:
+      title: "Tracks - by Times Referenced"
+      title.short: "...by Times Referenced"
+      item: "{TRACK} ({TIMES_REFERENCED})"
+
+    # listTracks.inFlashes.byAlbum:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   and display the list of flashes that eack track is featured
+    #   in. Tracks which aren't featured in any flashes are totally
+    #   excluded from the list.
+
+    inFlashes.byAlbum:
+      title: "Tracks - in Flashes & Games (by Album)"
+      title.short: "...in Flashes & Games (by Album)"
+
+      chunk:
+        title: "{ALBUM}"
+        item: "{TRACK} (in {FLASHES})"
+
+    # listTracks.inFlashes.byFlash:
+    #   List tracks, chunked by flash (which are sorted by date,
+    #   retaining their positions in a common act where applicable,
+    #   or else by the two acts' names) and sorted according to the
+    #   featured list of the flash, and display a link to the album
+    #   each track is contained in. Tracks which aren't featured in
+    #   any flashes are totally excluded from the list.
+
+    inFlashes.byFlash:
+      title: "Tracks - in Flashes & Games (by Flash)"
+      title.short: "...in Flashes & Games (by Flash)"
+
+      chunk:
+        title: "{FLASH}"
+        item: "{TRACK} (from {ALBUM})"
+
+    # listTracks.withLyrics:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   displaying only tracks which have lyrics. The chunk titles
+    #   also display the date each album was released, and tracks'
+    #   own custom "Date First Released" fields are totally ignored.
+
+    withLyrics:
+      title: "Tracks - with Lyrics"
+      title.short: "...with Lyrics"
+
+      chunk:
+        title: "{ALBUM}"
+        title.withDate: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+
+    # listTracks.withSheetMusicFiles:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   displaying only tracks which have sheet music files. The
+    #   chunk titles also display the date each album was released,
+    #   and tracks' own custom "Date First Released" fields are
+    #   totally ignored.
+
+    withSheetMusicFiles:
+      title: "Tracks - with Sheet Music Files"
+      title.short: "...with Sheet Music Files"
+
+      chunk:
+        title: "{ALBUM}"
+        title.withDate: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+
+    # listTracks.withMidiProjectFiles:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   displaying only tracks which have MIDI & project files. The
+    #   chunk titles also display the date each album was released,
+    #   and tracks' own custom "Date First Released" fields are
+    #   totally ignored.
+
+    withMidiProjectFiles:
+      title: "Tracks - with MIDI & Project Files"
+      title.short: "...with MIDI & Project Files"
+
+      chunk:
+        title: "{ALBUM}"
+        title.withDate: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+
+  listTags:
+
+    # listTags.byName:
+    #   List art tags alphabetically without sorting or chunking by
+    #   any other criteria. Also displays the number of times each
+    #   art tag has been featured.
+
+    byName:
+      title: "Tags - by Name"
+      title.short: "...by Name"
+      item: "{TAG} ({TIMES_USED})"
+
+    # listTags.byUses:
+    #   List art tags by number of times used, falling back to an
+    #   alphabetical sort if two art tags have been featured the same
+    #   number of times. Art tags which haven't haven't been featured
+    #   at all yet are totally excluded from the list.
+
+    byUses:
+      title: "Tags - by Uses"
+      title.short: "...by Uses"
+      item: "{TAG} ({TIMES_USED})"
+
+  other:
+
+    # other.allSheetMusic:
+    #   List all sheet music files, sectioned by album (which are
+    #   sorted by date, falling back alphabetically) and then by
+    #   track (which retain album ordering). If one "file" entry
+    #   contains multiple files, then it's displayed as an expandable
+    #   list, collapsed by default, accented with the number of
+    #   downloadable files.
+
+    allSheetMusic:
+      title: "All Sheet Music"
+      title.short: "All Sheet Music"
+      albumFiles: "Album sheet music:"
+
+      file:
+        _: "{TITLE}"
+        withMultipleFiles: "{TITLE} ({FILES})"
+
+    # other.midiProjectFiles:
+    #   Same as other.allSheetMusic, but for MIDI & project files.
+
+    allMidiProjectFiles:
+      title: "All MIDI/Project Files"
+      title.short: "All MIDI/Project Files"
+      albumFiles: "Album MIDI/project files:"
+
+      file:
+        _: "{TITLE}"
+        withMultipleFiles: "{TITLE} ({FILES})"
+
+    # other.additionalFiles:
+    #   Same as other.allSheetMusic, but for additional files.
+
+    allAdditionalFiles:
+      title: "All Additional Files"
+      title.short: "All Additional Files"
+      albumFiles: "Album additional files:"
+
+      file:
+        _: "{TITLE}"
+        withMultipleFiles: "{TITLE} ({FILES})"
+
+    # other.randomPages:
+    #   Special listing which shows a bunch of buttons that each
+    #   link to a random page on the wiki under a particular scope.
+
+    randomPages:
+      title: "Random Pages"
+      title.short: "Random Pages"
+
+      # chooseLinkLine:
+      #   Introductory line explaining the links on this listing.
+
+      chooseLinkLine:
+        _: "{FROM_PART} {BROWSER_SUPPORT_PART}"
+
+        fromPart:
+          dividedByGroups: >-
+            Choose a link to go to a random page in that group or album!
+          notDividedByGroups: >-
+            Choose a link to go to a random page in that album!
+
+        browserSupportPart: >-
+          If your browser doesn't support relatively modern JavaScript
+          or you've disabled it, these links won't work - sorry.
+
+      # dataLoadingLine, dataLoadedLine:
+      #   Since the links on this page depend on access to a fairly
+      #   large data file that is downloaded separately and in the
+      #   background, these messages indicate the status of that
+      #   download and whether or not the links will work yet.
+
+      dataLoadingLine: >-
+        (Data files are downloading in the background! Please wait for data to load.)
+
+      dataLoadedLine: >-
+        (Data files have finished being downloaded. The links should work!)
+
+      # misc:
+      #   The first chunk in the list includes general links which
+      #   bring you to some random page across the whole site!
+
+      misc:
+        _: "Miscellaneous:"
+        randomArtist: "Random Artist"
+        atLeastTwoContributions: "at least 2 contributions"
+        randomAlbumWholeSite: "Random Album (whole site)"
+        randomTrackWholeSite: "Random Track (whole site)"
+
+      # fromGroup:
+      #   Provided the wiki has "Divide Track Lists By Groups" set,
+      #   the remaining chunks are one for each of those groups, each
+      #   with a list of links for albums from the group that bring
+      #   you to a random track from the chosen album.
+
+      fromGroup:
+        _: "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})"
+        randomAlbum: "Random Album"
+        randomTrack: "Random Track"
+
+      # fromAlbum:
+      #   If the wiki doesn't have "Divide Track Lists By Groups",
+      #   all albums across the wiki are grouped in one list.
+      #  (There aren't "random album" and "random track" links like
+      #   for groups because those are already included at the top,
+      #   under the "miscellaneous" chunk.)
+
+      fromAlbum: "From an album:"
+
+      # album:
+      #   Album entries under each group.
+
+      album: "{ALBUM}"
+
+#
+# newsIndex:
+#   The news index page shows a list of every news entry on the wiki!
+#   (If it's got news entries enabled.) Each entry gets a stylized
+#   heading with its name of and date, sorted newest to oldest, as
+#   well as its body (up to a split) and a link to view the rest of
+#   the entry on its dedicated news entry page.
+#
+newsIndex:
+  title: "News"
+
+  entry:
+    viewRest: "(View rest of entry!)"
+
+#
+# newsEntryPage:
+#   The news entry page displays all the content of a news entry,
+#   as well as its date published, in one big list, and has nav links
+#   to go to the previous and next news entry.
+#
+newsEntryPage:
+  title: "{ENTRY}"
+  published: "(Published {DATE}.)"
+
+#
+# redirectPage:
+#   Static "placeholder" pages when redirecting a visitor from one
+#   page to another - this generally happens automatically, before
+#   you have a chance to read the page, so content is concise.
+#
+redirectPage:
+  title: "Moved to {TITLE}"
+
+  infoLine: >-
+    This page has been moved to {TARGET}.
+
+#
+# tagPage:
+#   The tag gallery page displays all the artworks that a tag has
+#   been featured in, in one neat grid, with each artwork displaying
+#   its illustrators, as well as a short info line that indicates
+#   how many artworks the tag's part of.
+#
+tagPage:
+  title: "{TAG}"
+
+  nav:
+    tag: "Tag: {TAG}"
+
+  infoLine: >-
+    Appears in {COVER_ARTS}.
+
+#
+# trackPage:
+#
+#   The track info page is pretty much the most discrete and common
+#   chunk of information across the whole site, displaying info about
+#   the track like its release date, artists, cover illustrators,
+#   commentary, and more, as well as relational info, like the tracks
+#   it references and tracks which reference it, and flashes which
+#   it's been featured in. Tracks can also have extra related files,
+#   like sheet music and MIDI/project files.
+#
+#   Most of the details about tracks use strings that are defined
+#   under releaseInfo, so this section is a little sparse.
+#
+trackPage:
+  title: "{TRACK}"
+
+  nav:
+    random: "Random"
+
+    track:
+      _: "{TRACK}"
+      withNumber: "{NUMBER}. {TRACK}"
+
+  socialEmbed:
+    heading: "{ALBUM}"
+    title: "{TRACK}"
+
+    body:
+      withArtists.withCoverArtists: "By {ARTISTS}; art by {COVER_ARTISTS}."
+      withArtists: "By {ARTISTS}."
+      withCoverArtists: "Art by {COVER_ARTISTS}."
diff --git a/src/upd8.js b/src/upd8.js
index 27445a8e..24d0b92b 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -39,7 +39,7 @@ import {fileURLToPath} from 'node:url';
 import wrap from 'word-wrap';
 
 import {displayCompositeCacheAnalysis} from '#composite';
-import {processLanguageFile} from '#language';
+import {processLanguageFile, watchLanguageFile} from '#language';
 import {isMain, traverse} from '#node-utils';
 import bootRepl from '#repl';
 import {empty, showAggregate, withEntries} from '#sugar';
@@ -56,14 +56,14 @@ import {
   logError,
   parseOptions,
   progressCallAll,
-  progressPromiseAll,
 } from '#cli';
 
 import genThumbs, {
   CACHE_FILE as thumbsCacheFile,
-  clearThumbs,
   defaultMagickThreads,
+  determineMediaCachePath,
   isThumb,
+  migrateThumbsIntoDedicatedCacheDirectory,
   verifyImagePaths,
 } from '#thumbs';
 
@@ -93,7 +93,7 @@ try {
 
 const BUILD_TIME = new Date();
 
-const DEFAULT_STRINGS_FILE = 'strings-default.json';
+export const DEFAULT_STRINGS_FILE = 'strings-default.yaml';
 
 const STATUS_NOT_STARTED       = `not started`;
 const STATUS_NOT_APPLICABLE    = `not applicable`;
@@ -113,6 +113,12 @@ async function main() {
   Error.stackTraceLimit = Infinity;
 
   stepStatusSummary = {
+    determineMediaCachePath:
+      {...defaultStepStatus, name: `determine media cache path`},
+
+    migrateThumbnails:
+      {...defaultStepStatus, name: `migrate thumbnails`},
+
     loadThumbnailCache:
       {...defaultStepStatus, name: `load thumbnail cache file`},
 
@@ -125,6 +131,9 @@ async function main() {
     linkWikiDataArrays:
       {...defaultStepStatus, name: `link wiki data arrays`},
 
+    precacheCommonData:
+      {...defaultStepStatus, name: `precache common data`},
+
     filterDuplicateDirectories:
       {...defaultStepStatus, name: `filter duplicate directories`},
 
@@ -134,8 +143,8 @@ async function main() {
     sortWikiDataArrays:
       {...defaultStepStatus, name: `sort wiki data arrays`},
 
-    precacheData:
-      {...defaultStepStatus, name: `precache data`},
+    precacheAllData:
+      {...defaultStepStatus, name: `precache nearly all data`},
 
     loadInternalDefaultLanguage:
       {...defaultStepStatus, name: `load internal default language`},
@@ -146,6 +155,9 @@ async function main() {
     initializeDefaultLanguage:
       {...defaultStepStatus, name: `initialize default language`},
 
+    verifyImagePaths:
+      {...defaultStepStatus, name: `verify missing/misplaced image paths`},
+
     preloadFileSizes:
       {...defaultStepStatus, name: `preload file sizes`},
 
@@ -215,6 +227,11 @@ async function main() {
       type: 'value',
     },
 
+    'media-cache-path': {
+      help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred by adding "-cache" to the end of the media directory`,
+      type: 'value',
+    },
+
     // String files! For the most part, this is used for translating the
     // site to different languages, though you can also customize strings
     // for your own 8uild of the site if you'd like. Files here should all
@@ -240,6 +257,11 @@ async function main() {
       type: 'flag',
     },
 
+    'skip-reference-validation': {
+      help: `Skips checking and reporting reference errors, which speeds up the build but may silently allow erroneous data to pass through`,
+      type: 'flag',
+    },
+
     // Thum8nail gener8tion is *usually* something you want, 8ut it can 8e
     // kinda a pain to run every time, since it does necessit8te reading
     // every media file at run time. Pass this to skip it.
@@ -255,8 +277,8 @@ async function main() {
       type: 'flag',
     },
 
-    'clear-thumbs': {
-      help: `Clear the thumbnail cache and remove generated thumbnail files from media directory\n\n(This skips building. Run again without --clear-thumbs to build the site.)`,
+    'migrate-thumbs': {
+      help: `Transfer automatically generated thumbnail files out of an existing media directory and into the easier-to-manage media-cache directory`,
       type: 'flag',
     },
 
@@ -268,6 +290,18 @@ async function main() {
       type: 'flag',
     },
 
+    'no-input': {
+      help: `Don't wait on input from stdin - assume the device is headless`,
+      type: 'flag',
+    },
+
+    'no-language-reloading': {
+      help: `Don't reload language files while the build is running\n\nApplied by default for --static-build`,
+      type: 'flag',
+    },
+
+    'no-language-reload': {alias: 'no-language-reloading'},
+
     // Want sweet, sweet trace8ack info in aggreg8te error messages? This
     // will print all the juicy details (or at least the first relevant
     // line) right to your output, 8ut also pro8a8ly give you a headache
@@ -312,18 +346,18 @@ async function main() {
       type: 'flag',
     },
 
-    // Compute ALL data properties before moving on to building. This ensures
-    // writes are processed at a stable speed (since they don't have to perform
-    // any additional data computation besides what is done for the page
-    // itself), but it'll also take a long while for the initial caching to
-    // complete. This shouldn't have any overall difference on efficiency as
-    // it's the same amount of processing being done regardless; the option is
-    // mostly present for optimization testing (i.e. if you want to focus on
-    // efficiency of data calculation or write generation separately instead of
-    // mixed together).
-    'precache-data': {
-      help: `Compute all runtime-cached values for wiki data objects before proceeding to site build (optimizes rate of content generation/serving, but waits a lot longer before build actually starts, and may compute data which is never required for this build)`,
-      type: 'flag',
+    'precache-mode': {
+      help:
+        `Change the way certain runtime-computed values are preemptively evaluated and cached\n\n` +
+        `common: Preemptively compute certain properties which are needed for basic data loading and site generation\n\n` +
+        `all: Compute every visible data property, optimizing rate of content generation, but causing a long stall before the build actually starts\n\n` +
+        `none: Don't preemptively compute any values - strictly the most efficient, but may result in unpredictably "lopsided" performance for individual steps of loading data and building the site\n\n` +
+        `Defaults to 'common'`,
+      type: 'value',
+      validate(value) {
+        if (['common', 'all', 'none'].includes(value)) return true;
+        return 'common, all, or none';
+      },
     },
   };
 
@@ -429,10 +463,13 @@ async function main() {
   const mediaPath = cliOptions['media-path'] || process.env.HSMUSIC_MEDIA;
   const langPath = cliOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
 
+  const migrateThumbs = cliOptions['migrate-thumbs'] ?? false;
   const skipThumbs = cliOptions['skip-thumbs'] ?? false;
   const thumbsOnly = cliOptions['thumbs-only'] ?? false;
-  const clearThumbsFlag = cliOptions['clear-thumbs'] ?? false;
+  const skipReferenceValidation = cliOptions['skip-reference-validation'] ?? false;
   const noBuild = cliOptions['no-build'] ?? false;
+  const noInput = cliOptions['no-input'] ?? false;
+  let noLanguageReloading = cliOptions['no-language-reloading'] ?? null; // Will get default later.
 
   showStepStatusSummary = cliOptions['show-step-summary'] ?? false;
 
@@ -441,7 +478,7 @@ async function main() {
 
   const showAggregateTraces = cliOptions['show-traces'] ?? false;
 
-  const precacheData = cliOptions['precache-data'] ?? false;
+  const precacheMode = cliOptions['precache-mode'] ?? 'common';
   const showInvalidPropertyAccesses = cliOptions['show-invalid-property-accesses'] ?? false;
 
   // Makes writing nicer on the CPU and file I/O parts of the OS, with a
@@ -473,46 +510,204 @@ async function main() {
     });
   }
 
-  const niceShowAggregate = (error, ...opts) => {
-    showAggregate(error, {
-      showTraces: showAggregateTraces,
-      pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
-      ...opts,
+  // Prepare not-applicable steps before anything else.
+
+  if (skipThumbs) {
+    Object.assign(stepStatusSummary.generateThumbnails, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `provided --skip-thumbs`,
     });
-  };
+  } else {
+    Object.assign(stepStatusSummary.loadThumbnailCache, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `using cache from thumbnail generation`,
+    });
+  }
+
+  if (!migrateThumbs) {
+    Object.assign(stepStatusSummary.migrateThumbnails, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `--migrate-thumbs not provided`,
+    });
+  }
+
+  if (skipReferenceValidation) {
+    logWarn`Skipping reference validation. If any reference errors are present`;
+    logWarn`in data, they will be silently passed along to the build.`;
+
+    Object.assign(stepStatusSummary.filterReferenceErrors, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `--skip-reference-validation provided`,
+    });
+  }
+
+  switch (precacheMode) {
+    case 'common':
+      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`,
+      });
+
+      break;
+
+    case 'none':
+      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`,
+      });
+
+      break;
+  }
+
+  if (!langPath) {
+    Object.assign(stepStatusSummary.loadLanguageFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `neither --lang-path nor HSMUSIC_LANG provided`,
+    });
+  }
+
+  if (noBuild) {
+    logInfo`Won't generate any site or page files this run (--no-build passed).`;
+
+    Object.assign(stepStatusSummary.performBuild, {
+      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}`;
+  }
+
+  noLanguageReloading ??=
+    ({
+      'static-build': true,
+      'live-dev-server': false,
+    })[selectedBuildModeFlag];
 
   if (skipThumbs && thumbsOnly) {
     logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`;
     return false;
   }
 
-  if (clearThumbsFlag) {
-    const result = await clearThumbs(mediaPath, {queueSize});
-    if (result.success) {
-      logInfo`All done! Remove ${'--clear-thumbs'} to run the next build.`;
-      if (skipThumbs) {
-        logInfo`And don't forget to remove ${'--skip-thumbs'} too, eh?`;
-      }
+  Object.assign(stepStatusSummary.determineMediaCachePath, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
+  const {mediaCachePath, annotation: mediaCachePathAnnotation} =
+    await determineMediaCachePath({
+      mediaPath,
+      providedMediaCachePath:
+        cliOptions['media-cache-path'] || process.env.HSMUSIC_MEDIA_CACHE,
+      disallowDoubling:
+        migrateThumbs,
+    });
+
+  if (!mediaCachePath) {
+    logError`Couldn't determine a media cache path. (${mediaCachePathAnnotation})`;
+
+    switch (mediaCachePathAnnotation) {
+      case 'inferred path does not have cache':
+        logError`If you're certain this is the right path, you can provide it via`;
+        logError`${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}, and it should work.`;
+        break;
+
+      case 'inferred path not readable':
+        logError`The folder couldn't be read, which usually indicates`;
+        logError`a permissions error. Try to resolve this, or provide`;
+        logError`a new path with ${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}.`;
+        break;
+
+      case 'media path not provided': /* unreachable */
+        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;
     }
+
+    Object.assign(stepStatusSummary.determineMediaCachePath, {
+      status: STATUS_FATAL_ERROR,
+      annotation: mediaCachePathAnnotation,
+      timeEnd: Date.now(),
+    });
+
+    return false;
+  }
+
+  logInfo`Using media cache at: ${mediaCachePath} (${mediaCachePathAnnotation})`;
+
+  Object.assign(stepStatusSummary.determineMediaCachePath, {
+    status: STATUS_DONE_CLEAN,
+    annotation: mediaCachePathAnnotation,
+    timeEnd: Date.now(),
+  });
+
+  if (migrateThumbs) {
+    Object.assign(stepStatusSummary.migrateThumbnails, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const result = await migrateThumbsIntoDedicatedCacheDirectory({
+      mediaPath,
+      mediaCachePath,
+      queueSize,
+    });
+
+    if (result.succses) {
+      Object.assign(stepStatusSummary.migrateThumbnails, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
+    }
+
+    logInfo`Good to go! Run hsmusic again without ${'--migrate-thumbs'} to start`;
+    logInfo`using the migrated media cache.`;
+
+    Object.assign(stepStatusSummary.migrateThumbnails, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
+
     return true;
   }
 
+  const niceShowAggregate = (error, ...opts) => {
+    showAggregate(error, {
+      showTraces: showAggregateTraces,
+      pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
+      ...opts,
+    });
+  };
+
   let thumbsCache;
 
   if (skipThumbs) {
-    Object.assign(stepStatusSummary.generateThumbnails, {
-      status: STATUS_NOT_APPLICABLE,
-      annotation: `provided --skip-thumbs`,
+    Object.assign(stepStatusSummary.loadThumbnailCache, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
     });
 
-    stepStatusSummary.loadThumbnailCache.status = STATUS_STARTED_NOT_DONE;
-
-    const thumbsCachePath = path.join(mediaPath, thumbsCacheFile);
+    const thumbsCachePath = path.join(mediaCachePath, thumbsCacheFile);
 
     try {
       thumbsCache = JSON.parse(await readFile(thumbsCachePath));
-      logInfo`Thumbnail cache file successfully read.`;
-      stepStatusSummary.loadThumbnailCache.status = STATUS_DONE_CLEAN;
     } catch (error) {
       if (error.code === 'ENOENT') {
         logError`The thumbnail cache doesn't exist, and it's necessary to build`
@@ -523,6 +718,7 @@ async function main() {
         Object.assign(stepStatusSummary.loadThumbnailCache, {
           status: STATUS_FATAL_ERROR,
           annotation: `cache does not exist`,
+          timeEnd: Date.now(),
         });
 
         return false;
@@ -539,24 +735,33 @@ async function main() {
         Object.assign(stepStatusSummary.loadThumbnailCache, {
           status: STATUS_FATAL_ERROR,
           annotation: `cache malformed or unreadable`,
+          timeEnd: Date.now(),
         });
 
         return false;
       }
     }
 
-    logInfo`Skipping thumbnail generation.`;
-  } else {
+    logInfo`Thumbnail cache file successfully read.`;
+
     Object.assign(stepStatusSummary.loadThumbnailCache, {
-      status: STATUS_NOT_APPLICABLE,
-      annotation: `using cache from thumbnail generation`,
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
     });
 
-    stepStatusSummary.generateThumbnails.status = STATUS_STARTED_NOT_DONE;
+    logInfo`Skipping thumbnail generation.`;
+  } else {
+    Object.assign(stepStatusSummary.generateThumbnails, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
     logInfo`Begin thumbnail generation... -----+`;
 
-    const result = await genThumbs(mediaPath, {
+    const result = await genThumbs({
+      mediaPath,
+      mediaCachePath,
+
       queueSize,
       magickThreads,
       quiet: !thumbsOnly,
@@ -568,12 +773,16 @@ async function main() {
       Object.assign(stepStatusSummary.generateThumbnails, {
         status: STATUS_FATAL_ERROR,
         annotation: `view log for details`,
+        timeEnd: Date.now(),
       });
 
       return false;
     }
 
-    stepStatusSummary.generateThumbnails.status = STATUS_DONE_CLEAN;
+    Object.assign(stepStatusSummary.generateThumbnails, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
 
     if (thumbsOnly) {
       return true;
@@ -582,19 +791,14 @@ async function main() {
     thumbsCache = result.cache;
   }
 
-  if (noBuild) {
-    logInfo`Not generating any site or page files this run (--no-build passed).`;
-  } else if (usingDefaultBuildMode) {
-    logInfo`No build mode specified, using default: ${selectedBuildModeFlag}`;
-  } else {
-    logInfo`Using specified build mode: ${selectedBuildModeFlag}`;
-  }
-
   if (showInvalidPropertyAccesses) {
     CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
   }
 
-  stepStatusSummary.loadDataFiles.status = STATUS_STARTED_NOT_DONE;
+  Object.assign(stepStatusSummary.loadDataFiles, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
   let processDataAggregate, wikiDataResult;
 
@@ -610,6 +814,7 @@ async function main() {
     Object.assign(stepStatusSummary.loadDataFiles, {
       status: STATUS_FATAL_ERROR,
       annotation: `javascript error - view log for details`,
+      timeEnd: Date.now(),
     });
 
     return false;
@@ -654,15 +859,7 @@ async function main() {
     } catch (error) {
       niceShowAggregate(error);
       logWarn`The above errors were detected while processing data files.`;
-      logWarn`If the remaining valid data is complete enough, the wiki will`;
-      logWarn`still build - but all errored data will be skipped.`;
-      logWarn`(Resolve errors for more complete output!)`;
       errorless = false;
-
-      Object.assign(stepStatusSummary.loadDataFiles, {
-        status: STATUS_HAS_WARNINGS,
-        annotation: `view log for details`,
-      });
     }
 
     if (!wikiData.wikiInfo) {
@@ -671,6 +868,7 @@ async function main() {
       Object.assign(stepStatusSummary.loadDataFiles, {
         status: STATUS_FATAL_ERROR,
         annotation: `wiki info object not available`,
+        timeEnd: Date.now(),
       });
 
       return false;
@@ -678,7 +876,21 @@ async function main() {
 
     if (errorless) {
       logInfo`All data files processed without any errors - nice!`;
-      stepStatusSummary.loadDataFiles.status = STATUS_DONE_CLEAN;
+
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    } else {
+      logWarn`If the remaining valid data is complete enough, the wiki will`;
+      logWarn`still build - but all errored data will be skipped.`;
+      logWarn`(Resolve errors for more complete output!)`;
+
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
     }
   }
 
@@ -686,16 +898,93 @@ async function main() {
   // complete, so properties (like dates!) are inherited where that's
   // appropriate.
 
-  stepStatusSummary.linkWikiDataArrays.status = STATUS_STARTED_NOT_DONE;
+  Object.assign(stepStatusSummary.linkWikiDataArrays, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
   linkWikiDataArrays(wikiData);
 
-  stepStatusSummary.linkWikiDataArrays.status = STATUS_DONE_CLEAN;
+  Object.assign(stepStatusSummary.linkWikiDataArrays, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+  });
+
+  if (precacheMode === 'common') {
+    Object.assign(stepStatusSummary.precacheCommonData, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const commonDataMap = {
+      albumData: new Set([
+        // Needed for sorting
+        'date', 'tracks',
+        // Needed for computing page paths
+        'commentary',
+      ]),
+
+      artTagData: new Set([
+        // Needed for computing page paths
+        'isContentWarning',
+      ]),
+
+      artistAliasData: new Set([
+        // Needed for computing page paths
+        'aliasedArtist',
+      ]),
+
+      flashData: new Set([
+        // Needed for sorting
+        'act', 'date',
+      ]),
+
+      flashActData: new Set([
+        // Needed for sorting
+        'flashes',
+      ]),
+
+      groupData: new Set([
+        // Needed for computing page paths
+        'albums',
+      ]),
+
+      listingSpec: new Set([
+        // Needed for computing page paths
+        'contentFunction', 'featureFlag',
+      ]),
+
+      trackData: new Set([
+        // Needed for sorting
+        'album', 'date',
+        // Needed for computing page paths
+        'commentary',
+      ]),
+    };
+
+    for (const [wikiDataKey, properties] of Object.entries(commonDataMap)) {
+      const thingData = wikiData[wikiDataKey];
+      const allProperties = new Set(['name', 'directory', ...properties]);
+      for (const thing of thingData) {
+        for (const property of allProperties) {
+          void thing[property];
+        }
+      }
+    }
+
+    Object.assign(stepStatusSummary.precacheCommonData, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
+  }
 
   // Filter out any things with duplicate directories throughout the data,
   // warning about them too.
 
-  stepStatusSummary.filterDuplicateDirectories.status = STATUS_STARTED_NOT_DONE;
+  Object.assign(stepStatusSummary.filterDuplicateDirectories, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
   const filterDuplicateDirectoriesAggregate =
     filterDuplicateDirectories(wikiData);
@@ -703,7 +992,11 @@ async function main() {
   try {
     filterDuplicateDirectoriesAggregate.close();
     logInfo`No duplicate directories found - nice!`;
-    stepStatusSummary.filterDuplicateDirectories.status = STATUS_DONE_CLEAN;
+
+    Object.assign(stepStatusSummary.filterDuplicateDirectories, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
   } catch (aggregate) {
     niceShowAggregate(aggregate);
 
@@ -715,6 +1008,7 @@ async function main() {
     Object.assign(stepStatusSummary.filterDuplicateDirectories, {
       status: STATUS_FATAL_ERROR,
       annotation: `duplicate directories found`,
+      timeEnd: Date.now(),
     });
 
     return false;
@@ -723,38 +1017,58 @@ async function main() {
   // Filter out any reference errors throughout the data, warning about them
   // too.
 
-  stepStatusSummary.filterReferenceErrors.status = STATUS_STARTED_NOT_DONE;
+  if (!skipReferenceValidation) {
+    Object.assign(stepStatusSummary.filterReferenceErrors, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const filterReferenceErrorsAggregate = filterReferenceErrors(wikiData);
 
-  const filterReferenceErrorsAggregate = filterReferenceErrors(wikiData);
+    try {
+      filterReferenceErrorsAggregate.close();
 
-  try {
-    filterReferenceErrorsAggregate.close();
-    logInfo`All references validated without any errors - nice!`;
-    stepStatusSummary.filterReferenceErrors.status = STATUS_DONE_CLEAN;
-  } catch (error) {
-    niceShowAggregate(error);
+      logInfo`All references validated without any errors - nice!`;
 
-    logWarn`The above errors were detected while validating references in data files.`;
-    logWarn`The wiki will still build, but these connections between data objects`;
-    logWarn`will be completely skipped. Resolve the errors for more complete output.`;
+      Object.assign(stepStatusSummary.filterReferenceErrors, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    } catch (error) {
+      niceShowAggregate(error);
 
-    Object.assign(stepStatusSummary.filterReferenceErrors, {
-      status: STATUS_HAS_WARNINGS,
-      annotation: `view log for details`,
-    });
+      logWarn`The above errors were detected while validating references in data files.`;
+      logWarn`The wiki will still build, but these connections between data objects`;
+      logWarn`will be completely skipped. Resolve the errors for more complete output.`;
+
+      Object.assign(stepStatusSummary.filterReferenceErrors, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
+    }
   }
 
   // Sort data arrays so that they're all in order! This may use properties
   // which are only available after the initial linking.
 
-  stepStatusSummary.sortWikiDataArrays.status = STATUS_STARTED_NOT_DONE;
+  Object.assign(stepStatusSummary.sortWikiDataArrays, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
   sortWikiDataArrays(wikiData);
 
-  stepStatusSummary.sortWikiDataArrays.status = STATUS_DONE_CLEAN;
+  Object.assign(stepStatusSummary.sortWikiDataArrays, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+  });
 
-  if (precacheData) {
-    stepStatusSummary.precacheData.status = STATUS_STARTED_NOT_DONE;
+  if (precacheMode === 'all') {
+    Object.assign(stepStatusSummary.precacheAllData, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
     // TODO: Aggregate errors here, instead of just throwing.
     progressCallAll('Caching all data values', Object.entries(wikiData)
@@ -768,148 +1082,373 @@ async function main() {
       .flatMap(([_key, things]) => things)
       .map(thing => () => CacheableObject.cacheAllExposedProperties(thing)));
 
-    stepStatusSummary.precacheData.status = STATUS_DONE_CLEAN;
-  } else {
-    Object.assign(stepStatusSummary.precacheData, {
-      status: STATUS_NOT_APPLICABLE,
-      annotation: `--precache-data not provided`,
+    Object.assign(stepStatusSummary.precacheAllData, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
     });
   }
 
   if (noBuild) {
-    Object.assign(stepStatusSummary.performBuild, {
-      status: STATUS_NOT_APPLICABLE,
-      annotation: `--no-build provided`,
-    });
-
     displayCompositeCacheAnalysis();
 
-    if (precacheData) {
+    if (precacheMode === 'all') {
       return true;
     }
   }
 
+  Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
   let internalDefaultLanguage;
+  let internalDefaultLanguageWatcher;
 
-  try {
-    internalDefaultLanguage =
-      await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE));
+  const internalDefaultStringsFile = path.join(__dirname, DEFAULT_STRINGS_FILE);
 
-    stepStatusSummary.loadInternalDefaultLanguage.status = STATUS_DONE_CLEAN;
-  } catch (error) {
-    console.error(error);
+  let errorLoadingInternalDefaultLanguage = false;
 
+  if (noLanguageReloading) {
+    internalDefaultLanguageWatcher = null;
+
+    try {
+      internalDefaultLanguage = await processLanguageFile(internalDefaultStringsFile);
+    } catch (error) {
+      niceShowAggregate(error);
+      errorLoadingInternalDefaultLanguage = true;
+    }
+  } else {
+    internalDefaultLanguageWatcher = watchLanguageFile(internalDefaultStringsFile);
+
+    try {
+      await new Promise((resolve, reject) => {
+        const watcher = internalDefaultLanguageWatcher;
+
+        const onReady = () => {
+          watcher.removeListener('ready', onReady);
+          watcher.removeListener('error', onError);
+          resolve();
+        };
+
+        const onError = error => {
+          watcher.removeListener('ready', onReady);
+          watcher.removeListener('error', onError);
+          watcher.close();
+          reject(error);
+        };
+
+        watcher.on('ready', onReady);
+        watcher.on('error', onError);
+      });
+
+      internalDefaultLanguage = internalDefaultLanguageWatcher.language;
+    } catch (_error) {
+      // No need to display the error here - it's already printed by
+      // watchLanguageFile.
+      errorLoadingInternalDefaultLanguage = true;
+    }
+  }
+
+  if (errorLoadingInternalDefaultLanguage) {
     logError`There was an error reading the internal language file.`;
     fileIssue();
 
     Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
       status: STATUS_FATAL_ERROR,
       annotation: `see log for details`,
+      timeEnd: Date.now(),
     });
 
     return false;
   }
 
+  if (!noLanguageReloading) {
+    // Bypass node.js special-case handling for uncaught error events
+    internalDefaultLanguageWatcher.on('error', () => {});
+  }
+
+  Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+  });
+
+  let customLanguageWatchers;
   let languages;
 
   if (langPath) {
-    stepStatusSummary.loadLanguageFiles.status = STATUS_STARTED_NOT_DONE;
+    Object.assign(stepStatusSummary.loadLanguageFiles, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
     const languageDataFiles = await traverse(langPath, {
       filterFile: name => path.extname(name) === '.json',
       pathStyle: 'device',
     });
 
-    let results;
+    let errorLoadingCustomLanguages = false;
 
-    // TODO: Aggregate errors (with Promise.allSettled).
-    try {
-      results =
-        await progressPromiseAll(`Reading & processing language files.`,
-          languageDataFiles.map((file) => processLanguageFile(file)));
-    } catch (error) {
-      console.error(error);
+    if (noLanguageReloading) {
+      languages = {};
 
+      const results =
+        await Promise.allSettled(
+          languageDataFiles
+            .map(file => processLanguageFile(file)));
+
+      for (const {status, value: language, reason: error} of results) {
+        if (status === 'rejected') {
+          errorLoadingCustomLanguages = true;
+          niceShowAggregate(error);
+        } else {
+          languages[language.code] = language;
+        }
+      }
+    } else watchCustomLanguages: {
+      customLanguageWatchers =
+        languageDataFiles.map(file => {
+          const watcher = watchLanguageFile(file);
+
+          // Bypass node.js special-case handling for uncaught error events
+          watcher.on('error', () => {});
+
+          return watcher;
+        });
+
+      const waitingOnWatchers = new Set(customLanguageWatchers);
+
+      const initialResults =
+        await Promise.allSettled(
+          customLanguageWatchers
+            .map(watcher => new Promise((resolve, reject) => {
+              const onReady = () => {
+                watcher.removeListener('ready', onReady);
+                watcher.removeListener('error', onError);
+                waitingOnWatchers.delete(watcher);
+                resolve();
+              };
+
+              const onError = error => {
+                watcher.removeListener('ready', onReady);
+                watcher.removeListener('error', onError);
+                reject(error);
+              };
+
+              watcher.on('ready', onReady);
+              watcher.on('error', onError);
+            })));
+
+      if (initialResults.some(({status}) => status === 'rejected')) {
+        logWarn`There were errors loading custom languages from the language path`;
+        logWarn`provided: ${langPath}`;
+
+        if (noInput) {
+          internalDefaultLanguageWatcher.close();
+
+          for (const watcher of Object.values(customLanguageWatchers)) {
+            watcher.close();
+          }
+
+          errorLoadingCustomLanguages = true;
+          break watchCustomLanguages;
+        }
+
+        logWarn`The build should start automatically if you investigate these.`;
+        logWarn`Or, exit by pressing ^C here (control+C) and run again without`;
+        logWarn`providing ${'--lang-path'} (or ${'HSMUSIC_LANG'}) to build without custom`;
+        logWarn`languages.`;
+
+        await new Promise(resolve => {
+          for (const watcher of waitingOnWatchers) {
+            watcher.once('ready', () => {
+              waitingOnWatchers.remove(watcher);
+              if (empty(waitingOnWatchers)) {
+                resolve();
+              }
+            });
+          }
+        });
+      }
+
+      languages =
+        Object.fromEntries(
+          customLanguageWatchers
+            .map(({language}) => [language.code, language]));
+    }
+
+    if (errorLoadingCustomLanguages) {
       logError`Failed to load language files. Please investigate these, or don't provide`;
       logError`--lang-path (or HSMUSIC_LANG) and build again.`;
 
       Object.assign(stepStatusSummary.loadLanguageFiles, {
         status: STATUS_FATAL_ERROR,
         annotation: `see log for details`,
+        timeEnd: Date.now(),
       });
 
       return false;
     }
 
-    languages =
-      Object.fromEntries(
-        results.map((language) => [language.code, language]));
-
-    stepStatusSummary.loadLanguageFiles.status = STATUS_DONE_CLEAN;
-  } else {
-    languages = {};
-
     Object.assign(stepStatusSummary.loadLanguageFiles, {
-      status: STATUS_NOT_APPLICABLE,
-      annotation: `--lang-path and HSMUSIC_LANG not provided`,
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+        annotation:
+        (noLanguageReloading
+          ? (selectedBuildModeFlag === 'static-build'
+              ? `loaded statically, default for --static-build`
+              : `loaded statically, --no-language-reloading provided`)
+          : `watching for changes`),
     });
+  } else {
+    languages = {};
   }
 
-  stepStatusSummary.initializeDefaultLanguage.status = STATUS_STARTED_NOT_DONE;
+  Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
-  const customDefaultLanguage =
-    languages[wikiData.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code];
   let finalDefaultLanguage;
+  let finalDefaultLanguageWatcher;
+  let finalDefaultLanguageAnnotation;
+
+  if (wikiData.wikiInfo.defaultLanguage) {
+    const customDefaultLanguage = languages[wikiData.wikiInfo.defaultLanguage];
+
+    if (!customDefaultLanguage) {
+      logError`Wiki info file specified default language is ${wikiData.wikiInfo.defaultLanguage}, but no such language file exists!`;
+      if (langPath) {
+        logError`Check if an appropriate file exists in ${langPath}?`;
+      } else {
+        logError`Be sure to specify ${'--lang-path'} or ${'HSMUSIC_LANG'} with the path to language files.`;
+      }
+
+      Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `wiki specifies default language whose file is not available`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
+    }
 
-  if (customDefaultLanguage) {
     logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
-    customDefaultLanguage.inheritedStrings = internalDefaultLanguage.strings;
+
     finalDefaultLanguage = customDefaultLanguage;
+    finalDefaultLanguageAnnotation = `using wiki-specified custom default language`;
 
-    Object.assign(stepStatusSummary.initializeDefaultLanguage, {
-      status: STATUS_DONE_CLEAN,
-      annotation: `using wiki-specified custom default language`,
-    });
-  } else if (wikiData.wikiInfo.defaultLanguage) {
-    logError`Wiki info file specified default language is ${wikiData.wikiInfo.defaultLanguage}, but no such language file exists!`;
-    if (langPath) {
-      logError`Check if an appropriate file exists in ${langPath}?`;
-    } else {
-      logError`Be sure to specify ${'--lang-path'} or ${'HSMUSIC_LANG'} with the path to language files.`;
+    if (!noLanguageReloading) {
+      finalDefaultLanguageWatcher =
+        customLanguageWatchers
+          .find(({language}) => language === customDefaultLanguage);
     }
+  } else if (languages[internalDefaultLanguage.code]) {
+    const customDefaultLanguage = languages[internalDefaultLanguage.code];
 
-    Object.assign(stepStatusSummary.initializeDefaultLanguage, {
-      status: STATUS_FATAL_ERROR,
-      annotation: `wiki specifies default language whose file is not available`,
-    });
+    finalDefaultLanguage = customDefaultLanguage;
+    finalDefaultLanguageAnnotation = `using inferred custom default language`;
 
-    return false;
+    if (!noLanguageReloading) {
+      finalDefaultLanguageWatcher =
+        customLanguageWatchers
+          .find(({language}) => language === customDefaultLanguage);
+    }
   } else {
     languages[internalDefaultLanguage.code] = internalDefaultLanguage;
+
     finalDefaultLanguage = internalDefaultLanguage;
-    stepStatusSummary.initializeDefaultLanguage.status = STATUS_DONE_CLEAN;
+    finalDefaultLanguageAnnotation = `no custom default language specified`;
 
-    Object.assign(stepStatusSummary.initializeDefaultLanguage, {
-      status: STATUS_DONE_CLEAN,
-      annotation: `no custom default language specified`,
-    });
+    if (!noLanguageReloading) {
+      finalDefaultLanguageWatcher = internalDefaultLanguageWatcher;
+    }
   }
 
-  for (const language of Object.values(languages)) {
-    if (language === finalDefaultLanguage) {
-      continue;
+  const inheritStringsFromInternalLanguage = () => {
+    // The custom default language, if set, will be the new one providing fallback
+    // strings for other languages. But on its own, it still might not be a complete
+    // list of strings - so it falls back to the internal default language, which
+    // won't otherwise be presented in the build.
+    if (finalDefaultLanguage === internalDefaultLanguage) return;
+    const {strings: inheritedStrings} = internalDefaultLanguage;
+    Object.assign(finalDefaultLanguage, {inheritedStrings});
+  };
+
+  const inheritStringsFromDefaultLanguage = () => {
+    const {strings: inheritedStrings} = finalDefaultLanguage;
+    for (const language of Object.values(languages)) {
+      if (language === finalDefaultLanguage) continue;
+      Object.assign(language, {inheritedStrings});
     }
+  };
 
-    language.inheritedStrings = finalDefaultLanguage.strings;
+  if (finalDefaultLanguage !== internalDefaultLanguage) {
+    inheritStringsFromInternalLanguage();
+  }
+
+  inheritStringsFromDefaultLanguage();
+
+  if (!noLanguageReloading) {
+    if (finalDefaultLanguage !== internalDefaultLanguage) {
+      internalDefaultLanguageWatcher.on('update', () => {
+        inheritStringsFromInternalLanguage();
+        inheritStringsFromDefaultLanguage();
+      });
+    }
+
+    finalDefaultLanguageWatcher.on('update', () => {
+      inheritStringsFromDefaultLanguage();
+    });
   }
 
   logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
 
+  Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+    status: STATUS_DONE_CLEAN,
+    annotation: finalDefaultLanguageAnnotation,
+    timeEnd: Date.now(),
+  });
+
   const urls = generateURLs(urlSpec);
 
-  const {missing: missingImagePaths} =
+  Object.assign(stepStatusSummary.verifyImagePaths, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
+  const {missing: missingImagePaths, misplaced: misplacedImagePaths} =
     await verifyImagePaths(mediaPath, {urls, wikiData});
 
+  if (empty(missingImagePaths) && empty(misplacedImagePaths)) {
+    Object.assign(stepStatusSummary.verifyImagePaths, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
+  } else if (empty(missingImagePaths)) {
+    Object.assign(stepStatusSummary.verifyImagePaths, {
+      status: STATUS_HAS_WARNINGS,
+      annotation: `misplaced images detected`,
+      timeEnd: Date.now(),
+    });
+  } else if (empty(misplacedImagePaths)) {
+    Object.assign(stepStatusSummary.verifyImagePaths, {
+      status: STATUS_HAS_WARNINGS,
+      annotation: `missing images detected`,
+      timeEnd: Date.now(),
+    });
+  } else {
+    Object.assign(stepStatusSummary.verifyImagePaths, {
+      status: STATUS_HAS_WARNINGS,
+      annotation: `missing and misplaced images detected`,
+      timeEnd: Date.now(),
+    });
+  }
+
+  Object.assign(stepStatusSummary.preloadFileSizes, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
   const fileSizePreloader = new FileSizePreloader();
 
   // File sizes of additional files need to be precalculated before we can
@@ -973,8 +1512,6 @@ async function main() {
   const getSizeOfAdditionalFile = getSizeOfMediaFileHelper(additionalFilePaths);
   const getSizeOfImagePath = getSizeOfMediaFileHelper(imageFilePaths);
 
-  stepStatusSummary.preloadFileSizes.status = STATUS_STARTED_NOT_DONE;
-
   logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`;
 
   fileSizePreloader.loadPaths(...additionalFilePaths.map((path) => path.device));
@@ -993,10 +1530,15 @@ async function main() {
     Object.assign(stepStatusSummary.preloadFileSizes, {
       status: STATUS_HAS_WARNINGS,
       annotation: `see log for details`,
+      timeEnd: Date.now(),
     });
   } else {
     logInfo`Done preloading filesizes without any errors - nice!`;
-    stepStatusSummary.preloadFileSizes.status = STATUS_DONE_CLEAN;
+
+    Object.assign(stepStatusSummary.preloadFileSizes, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
   }
 
   if (noBuild) {
@@ -1033,7 +1575,10 @@ async function main() {
       .map(line => `    ` + line)
       .join('\n') + `\n-->`;
 
-  stepStatusSummary.performBuild.status = STATUS_STARTED_NOT_DONE;
+  Object.assign(stepStatusSummary.performBuild, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
   let buildModeResult;
 
@@ -1042,6 +1587,7 @@ async function main() {
       cliOptions,
       dataPath,
       mediaPath,
+      mediaCachePath,
       queueSize,
       srcRootPath: __dirname,
 
@@ -1068,6 +1614,7 @@ async function main() {
     Object.assign(stepStatusSummary.performBuild, {
       status: STATUS_FATAL_ERROR,
       message: `javascript error - view log for details`,
+      timeEnd: Date.now(),
     });
 
     return false;
@@ -1076,13 +1623,17 @@ async function main() {
   if (buildModeResult !== true) {
     Object.assign(stepStatusSummary.performBuild, {
       status: STATUS_HAS_WARNINGS,
-      message: `may not have completed - view log for details`,
+      annotation: `may not have completed - view log for details`,
+      timeEnd: Date.now(),
     });
 
     return false;
   }
 
-  stepStatusSummary.performBuild.status = STATUS_DONE_CLEAN;
+  Object.assign(stepStatusSummary.performBuild, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+  });
 
   return true;
 }
@@ -1093,17 +1644,43 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus
   (async () => {
     let result;
 
+    const totalTimeStart = Date.now();
+
     try {
       result = await main();
     } catch (error) {
       if (error instanceof AggregateError) {
         showAggregate(error);
+      } else if (error.cause) {
+        console.error(error);
+        showAggregate(error);
       } else {
         console.error(error);
       }
     }
 
+    const totalTimeEnd = Date.now();
+
+    const formatDuration = timeDelta => {
+      const seconds = timeDelta / 1000;
+
+      if (seconds > 90) {
+        const modSeconds = Math.floor(seconds % 60);
+        const minutes = Math.floor(seconds - seconds % 60) / 60;
+        return `${minutes}m${modSeconds}s`;
+      }
+
+      if (seconds < 0.1) {
+        return 'instant';
+      }
+
+      const precision = (seconds > 1 ? 3 : 2);
+      return `${seconds.toPrecision(precision)}s`;
+    };
+
     if (showStepStatusSummary) {
+      const totalDuration = formatDuration(totalTimeEnd - totalTimeStart);
+
       console.error(colors.bright(`Step summary:`));
 
       const longestNameLength =
@@ -1111,15 +1688,53 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus
           Object.values(stepStatusSummary)
             .map(({name}) => name.length));
 
-      const anyStepsNotClean =
+      const stepsNotClean =
         Object.values(stepStatusSummary)
-          .some(({status}) =>
+          .map(({status}) =>
             status === STATUS_HAS_WARNINGS ||
             status === STATUS_FATAL_ERROR ||
             status === STATUS_STARTED_NOT_DONE);
 
-      for (const {name, status, annotation} of Object.values(stepStatusSummary)) {
-        let message = `${(name + ': ').padEnd(longestNameLength + 4, '.')} ${status}`;
+      const anyStepsNotClean =
+        stepsNotClean.includes(true);
+
+      const stepDetails = Object.values(stepStatusSummary);
+
+      const stepDurations =
+        stepDetails.map(({status, timeStart, timeEnd}) => {
+          if (
+            status === STATUS_NOT_APPLICABLE ||
+            status === STATUS_NOT_STARTED ||
+            status === STATUS_STARTED_NOT_DONE
+          ) {
+            return '-';
+          }
+
+          if (typeof timeStart !== 'number' || typeof timeEnd !== 'number') {
+            return 'unknown';
+          }
+
+          return formatDuration(timeEnd - timeStart);
+        });
+
+      const longestDurationLength =
+        Math.max(...stepDurations.map(duration => duration.length));
+
+      for (let index = 0; index < stepDetails.length; index++) {
+        const {name, status, annotation} = stepDetails[index];
+        const duration = stepDurations[index];
+
+        let message =
+          (stepsNotClean[index]
+            ? `!! `
+            : ` - `);
+
+        message += `(${duration})`.padStart(longestDurationLength + 2, ' ');
+        message += ` `;
+        message += `${name}: `.padEnd(longestNameLength + 4, '.');
+        message += ` `;
+        message += status;
+
         if (annotation) {
           message += ` (${annotation})`;
         }
@@ -1149,6 +1764,8 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus
         }
       }
 
+      console.error(colors.bright(`Done in ${totalDuration}.`));
+
       if (result === true) {
         if (anyStepsNotClean) {
           console.error(colors.bright(`Final output is true, but some steps aren't clean.`));
@@ -1157,6 +1774,8 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus
         } else {
           console.error(colors.bright(`Final output is true and all steps are clean.`));
         }
+      } else if (result === false) {
+        console.error(colors.bright(`Final output is false.`));
       } else {
         console.error(colors.bright(`Final output is not true (${result}).`));
       }
diff --git a/src/url-spec.js b/src/url-spec.js
index 2ff0fa5b..699f2bef 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -79,12 +79,25 @@ const urlSpec = {
       albumCover: 'album-art/<>/cover.<>',
       albumWallpaper: 'album-art/<>/bg.<>',
       albumBanner: 'album-art/<>/banner.<>',
+
       trackCover: 'album-art/<>/<>.<>',
+
       artistAvatar: 'artist-avatar/<>.<>',
+
       flashArt: 'flash-art/<>.<>',
+
       albumAdditionalFile: 'album-additional/<>/<>',
     },
   },
+
+  thumb: {
+    prefix: 'thumb/',
+
+    paths: {
+      root: '',
+      path: '<>',
+    },
+  },
 };
 
 // This gets automatically switched in place when working from a baseDirectory,
diff --git a/src/util/cli.js b/src/util/cli.js
index 4c08c085..973fef19 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -340,3 +340,34 @@ export function fileIssue({
   console.error(colors.red(`- https://hsmusic.wiki/feedback/`));
   console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`));
 }
+
+export async function logicalCWD() {
+  if (process.env.PWD) {
+    return process.env.PWD;
+  }
+
+  const {exec} = await import('node:child_process');
+  const {stat} = await import('node:fs/promises');
+
+  try {
+    await stat('/bin/sh');
+  } catch (error) {
+    // Not logical, so sad.
+    return process.cwd();
+  }
+
+  const proc = exec('/bin/pwd -L');
+
+  let output = '';
+  proc.stdout.on('data', buf => { output += buf; });
+
+  await new Promise(resolve => proc.on('exit', resolve));
+
+  return output.trim();
+}
+
+export async function logicalPathTo(target) {
+  const {relative} = await import('node:path');
+  const cwd = await logicalCWD();
+  return relative(cwd, target);
+}
diff --git a/src/util/html.js b/src/util/html.js
index 282a52da..5b6743e0 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -181,6 +181,10 @@ export function tags(content, attributes = null) {
   return new Tag(null, attributes, content);
 }
 
+export function normalize(content) {
+  return Tag.normalize(content);
+}
+
 export class Tag {
   #tagName = '';
   #content = null;
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 3e39e98f..9646be37 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -411,6 +411,18 @@ export function aggregateThrows(errorClass) {
   return {[openAggregate.errorClassSymbol]: errorClass};
 }
 
+// Helper function for allowing both (fn, aggregateOpts) and (aggregateOpts, fn)
+// in aggregate utilities.
+function _reorganizeAggregateArguments(arg1, arg2) {
+  if (typeof arg1 === 'function') {
+    return {fn: arg1, opts: arg2 ?? {}};
+  } else if (typeof arg2 === 'function') {
+    return {fn: arg2, opts: arg1 ?? {}};
+  } else {
+    throw new Error(`Expected a function`);
+  }
+}
+
 // Performs an ordinary array map with the given function, collating into a
 // results array (with errored inputs filtered out) and an error aggregate.
 //
@@ -420,15 +432,15 @@ export function aggregateThrows(errorClass) {
 // Note the aggregate property is the result of openAggregate(), still unclosed;
 // use aggregate.close() to throw the error. (This aggregate may be passed to a
 // parent aggregate: `parent.call(aggregate.close)`!)
-export function mapAggregate(array, fn, aggregateOpts) {
-  return _mapAggregate('sync', null, array, fn, aggregateOpts);
+export function mapAggregate(array, arg1, arg2) {
+  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  return _mapAggregate('sync', null, array, fn, opts);
 }
 
-export function mapAggregateAsync(array, fn, {
-  promiseAll = Promise.all.bind(Promise),
-  ...aggregateOpts
-} = {}) {
-  return _mapAggregate('async', promiseAll, array, fn, aggregateOpts);
+export function mapAggregateAsync(array, arg1, arg2) {
+  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
+  return _mapAggregate('async', promiseAll, array, fn, remainingOpts);
 }
 
 // Helper function for mapAggregate which holds code common between sync and
@@ -462,15 +474,15 @@ export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 // inputs to a particular output.
 //
 // As with mapAggregate, the returned aggregate property is not yet closed.
-export function filterAggregate(array, fn, aggregateOpts) {
-  return _filterAggregate('sync', null, array, fn, aggregateOpts);
+export function filterAggregate(array, arg1, arg2) {
+  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  return _filterAggregate('sync', null, array, fn, opts);
 }
 
-export async function filterAggregateAsync(array, fn, {
-  promiseAll = Promise.all.bind(Promise),
-  ...aggregateOpts
-} = {}) {
-  return _filterAggregate('async', promiseAll, array, fn, aggregateOpts);
+export async function filterAggregateAsync(array, arg1, arg2) {
+  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
+  return _filterAggregate('async', promiseAll, array, fn, remainingOpts);
 }
 
 // Helper function for filterAggregate which holds code common between sync and
@@ -530,20 +542,17 @@ function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 // Totally sugar function for opening an aggregate, running the provided
 // function with it, then closing the function and returning the result (if
 // there's no throw).
-export function withAggregate(aggregateOpts, fn) {
-  return _withAggregate('sync', aggregateOpts, fn);
+export function withAggregate(arg1, arg2) {
+  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  return _withAggregate('sync', opts, fn);
 }
 
-export function withAggregateAsync(aggregateOpts, fn) {
-  return _withAggregate('async', aggregateOpts, fn);
+export function withAggregateAsync(arg1, arg2) {
+  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  return _withAggregate('async', opts, fn);
 }
 
 export function _withAggregate(mode, aggregateOpts, fn) {
-  if (typeof aggregateOpts === 'function') {
-    fn = aggregateOpts;
-    aggregateOpts = {};
-  }
-
   const aggregate = openAggregate(aggregateOpts);
 
   if (mode === 'sync') {
@@ -628,27 +637,101 @@ export function showAggregate(topError, {
   }
 }
 
-export function decorateErrorWithIndex(fn) {
-  return (x, index, array) => {
+export function annotateError(error, ...callbacks) {
+  for (const callback of callbacks) {
+    error = callback(error) ?? error;
+  }
+
+  return error;
+}
+
+export function annotateErrorWithIndex(error, index) {
+  return Object.assign(error, {
+    [Symbol.for('hsmusic.annotateError.indexInSourceArray')]:
+      index,
+
+    message:
+      `(${colors.yellow(`#${index + 1}`)}) ` +
+      error.message,
+  });
+}
+
+export function annotateErrorWithFile(error, file) {
+  return Object.assign(error, {
+    [Symbol.for('hsmusic.annotateError.file')]:
+      file,
+
+    message:
+      error.message +
+      (error.message.includes('\n') ? '\n' : ' ') +
+      `(file: ${colors.bright(colors.blue(file))})`,
+  });
+}
+
+export function asyncAdaptiveDecorateError(fn, callback) {
+  if (typeof callback !== 'function') {
+    throw new Error(`Expected callback to be a function, got ${typeAppearance(callback)}`);
+  }
+
+  const syncDecorated = function (...args) {
     try {
-      return fn(x, index, array);
-    } catch (error) {
-      error.message = `(${colors.yellow(`#${index + 1}`)}) ${error.message}`;
-      error[Symbol.for('hsmusic.decorate.indexInSourceArray')] = index;
-      throw error;
+      return fn(...args);
+    } catch (caughtError) {
+      throw callback(caughtError, ...args);
     }
   };
-}
 
-export function decorateErrorWithCause(fn, cause) {
-  return (...args) => {
+  const asyncDecorated = async function(...args) {
     try {
-      return fn(...args);
-    } catch (error) {
-      error.cause = cause;
-      throw error;
+      return await fn(...args);
+    } catch (caughtError) {
+      throw callback(caughtError);
     }
   };
+
+  syncDecorated.async = asyncDecorated;
+
+  return syncDecorated;
+}
+
+export function decorateError(fn, callback) {
+  return asyncAdaptiveDecorateError(fn, callback);
+}
+
+export function asyncDecorateError(fn, callback) {
+  return asyncAdaptiveDecorateError(fn, callback).async;
+}
+
+export function decorateErrorWithAnnotation(fn, ...annotationCallbacks) {
+  return asyncAdaptiveDecorateError(fn,
+    (caughtError, ...args) =>
+      annotateError(caughtError,
+        ...annotationCallbacks
+          .map(callback => error => callback(error, ...args))));
+}
+
+export function decorateErrorWithIndex(fn) {
+  return decorateErrorWithAnnotation(fn,
+    (caughtError, _value, index) =>
+      annotateErrorWithIndex(caughtError, index));
+}
+
+export function decorateErrorWithCause(fn, cause) {
+  return asyncAdaptiveDecorateError(fn,
+    (caughtError) =>
+      Object.assign(caughtError, {cause}));
+}
+
+export function asyncDecorateErrorWithAnnotation(fn, ...annotationCallbacks) {
+  return decorateErrorWithAnnotation(fn, ...annotationCallbacks).async;
+}
+
+export function asyncDecorateErrorWithIndex(fn) {
+  return decorateErrorWithIndex(fn).async;
+}
+
+export function asyncDecorateErrorWithCause(fn, cause) {
+  return decorateErrorWithCause(fn, cause).async;
 }
 
 export function conditionallySuppressError(conditionFn, callbackFn) {
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index 1339c322..ab6ceecb 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -44,6 +44,11 @@ export function getCLIOptions() {
       help: `Enables outputting [200] and [404] responses in the server log, which are suppressed by default`,
       type: 'flag',
     },
+
+    'skip-serving': {
+      help: `Causes the build to exit when it would start serving over HTTP instead\n\nMainly useful for testing performance`,
+      type: 'flag',
+    },
   };
 }
 
@@ -51,6 +56,7 @@ export async function go({
   cliOptions,
   _dataPath,
   mediaPath,
+  mediaCachePath,
 
   defaultLanguage,
   languages,
@@ -77,6 +83,7 @@ export async function go({
   const host = cliOptions['host'] ?? defaultHost;
   const port = parseInt(cliOptions['port'] ?? defaultPort);
   const loudResponses = cliOptions['loud-responses'] ?? false;
+  const skipServing = cliOptions['skip-serving'] ?? false;
 
   const contentDependenciesWatcher = await watchContentDependencies();
   const {contentDependencies} = contentDependenciesWatcher;
@@ -171,7 +178,7 @@ export async function go({
     const {
       area: localFileArea,
       path: localFilePath
-    } = pathname.match(/^\/(?<area>static|util|media)\/(?<path>.*)/)?.groups ?? {};
+    } = pathname.match(/^\/(?<area>static|util|media|thumb)\/(?<path>.*)/)?.groups ?? {};
 
     if (localFileArea) {
       // Not security tested, man, this is a dev server!!
@@ -182,6 +189,8 @@ export async function go({
         localDirectory = path.join(srcRootPath, localFileArea);
       } else if (localFileArea === 'media') {
         localDirectory = mediaPath;
+      } else if (localFileArea === 'thumb') {
+        localDirectory = mediaCachePath;
       }
 
       let filePath;
@@ -393,10 +402,14 @@ export async function go({
     }
   });
 
-  server.listen(port, host);
+  if (skipServing) {
+    logInfo`Ready to serve! But --skip-serving was passed, so all done.`;
+  } else {
+    server.listen(port, host);
 
-  // Just keep going... forever!!!
-  await new Promise(() => {});
+    // Just keep going... forever!!!
+    await new Promise(() => {});
+  }
 
   return true;
 }
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index 09316999..b6dc9643 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -84,6 +84,7 @@ export async function go({
   cliOptions,
   _dataPath,
   mediaPath,
+  mediaCachePath,
   queueSize,
 
   defaultLanguage,
@@ -133,6 +134,7 @@ export async function go({
   await writeSymlinks({
     srcRootPath,
     mediaPath,
+    mediaCachePath,
     outputPath,
     urls,
   });
@@ -372,6 +374,8 @@ export async function go({
     logWarn`available - albeit possibly outdated! Please scroll up and send`;
     logWarn`the HSMusic developers a copy of the errors:`;
     fileIssue({topMessage: null});
+
+    return false;
   }
 
   return true;
@@ -414,6 +418,7 @@ async function writePage({
 function writeSymlinks({
   srcRootPath,
   mediaPath,
+  mediaCachePath,
   outputPath,
   urls,
 }) {
@@ -421,6 +426,7 @@ function writeSymlinks({
     link(path.join(srcRootPath, 'util'), 'shared.utilityRoot'),
     link(path.join(srcRootPath, 'static'), 'shared.staticRoot'),
     link(mediaPath, 'media.root'),
+    link(mediaCachePath, 'thumb.root'),
   ]);
 
   async function link(directory, urlKey) {