« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--README.md184
-rw-r--r--src/data/things.js9
-rw-r--r--src/data/yaml.js13
-rw-r--r--src/gen-thumbs.js65
-rw-r--r--src/listing-spec.js56
-rw-r--r--src/misc-templates.js22
-rw-r--r--src/page/album-commentary.js1
-rw-r--r--src/page/album.js60
-rw-r--r--src/page/artist.js38
-rw-r--r--src/page/flash.js8
-rw-r--r--src/page/group.js10
-rw-r--r--src/page/homepage.js33
-rw-r--r--src/page/listing.js1
-rw-r--r--src/page/news.js1
-rw-r--r--src/page/tag.js1
-rw-r--r--src/page/track.js42
-rw-r--r--src/static/site.css102
-rw-r--r--src/strings-default.json9
-rwxr-xr-xsrc/upd8.js53
-rw-r--r--src/url-spec.js2
-rw-r--r--src/util/node-utils.js9
-rw-r--r--src/util/wiki-data.js243
22 files changed, 751 insertions, 211 deletions
diff --git a/README.md b/README.md
index 2a67c00..ee787c0 100644
--- a/README.md
+++ b/README.md
@@ -1,47 +1,181 @@
 # HSMusic
 
-HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagining of [earlier][fandom] [projects][nsnd] archiving and celebrating the expansive history of Homestuck official and fan music. Roughly periodic releases of the website are released at [hsmusic.wiki][hsmusic]; all development occurs in this public Git repository, which can be accessed at [notabug.org][notabug].
+HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagining of [earlier][fandom] [projects][nsnd] archiving and celebrating the expansive history of Homestuck official and fan music. Roughly periodic releases of the website are released at [hsmusic.wiki][hsmusic]; all development occurs in this public Git repository, which can be accessed at [github.com][github].
 
-## Project Structure
+## Quick Start
+
+Install dependencies:
+
+- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 16.x LTS should also work
+- [ImageMagick](https://imagemagick.org/) - check your package manager if it's available (e.g. apt or homebrew) or follow [installation info right from the official website](https://imagemagick.org/script/download.php)
+
+Make a new empty folder for storing all your HSMusic repositories, then clone 'em with git:
+
+```
+$ cd /path/to/my/projects/
+$ mkdir hsmusic
+$ cd hsmusic
+$ git clone https://github.com/hsmusic/hsmusic-wiki code
+Cloning into 'code'...
+$ git clone https://github.com/hsmusic/hsmusic-data data
+Cloning into 'data'...
+$ git clone https://nebula.ed1.club/git/hsmusic-media media
+Cloning into 'media'...
+```
+
+Install NPM dependencies (packages) used by HSMusic:
+
+```
+$ cd code
+$ npm install
+added 413 packages, and audited 612 packages in 10s
+```
+
+Optionally, use `npm link` to make `hsmusic` available from the command line anywhere on your device:
+
+```
+$ npm link
+# This doesn't work reliably on every device. If it shows
+# an error about permissions (and you aren't interested in
+# working out the details yourself), you can just move on.
+```
+
+Go back to the main directory (containing all the repos) and make an empty folder for the first and subsequent builds:
+
+```
+$ cd ..
+
+$ pwd
+/path/to/my/projects/hsmusic
+$ ls
+code/  data/  media/
+# If you don't see the above info, you've moved to the wrong directory.
+# Just do cd /path/to/my/projects/hsmusic (with whatever path you created
+# the main directory in) to get back.
 
-**Disclaimer:** most of the code here *sucks*. It's been shambled together over the course of over a year, and while we're fairly confident it's all at minimum functional, we can't guarantee the same about its understandability! Still, for the official release of [hsmusic.wiki][hsmusic], we've done our best to put together a codebase which is *somewhat* navigable. The description below summarizes it:
+$ mkdir out
+```
 
-* `src/upd8.js`: "Build" code for the site. Everything specific to generating the structure and HTML content of the website is conatined in this file. As expected, it's pretty massive, and is currently undergoing some much-belated restructuring.
-* `src/static`: Static code and supporting files. Everything here is wholly client-side and referenced by the generated HTML files.
-* `src/common`: Code which is depended upon by both client- and server-side code. For the most part, this is constants such as directory paths, though there are a few handy algorithms here too.
-* In the not quite so far past, we used to have `data` and `media` folders too. Today, for portability and convenience in project structure, those are saved in separate repositories, and you can pass hsmusic paths to them through the `--data-path` and `--media-path` options, or the `HSMUSIC_DATA` and `HSMUSIC_MEDIA` environment variables.
-  * Data directory: The majority of data files belonging to the wiki are here. If you were to, say, create a fork of hsmusic for some other music archival project, you'd want to change the files here. Data files are all a custom text format designed to be easy to edit, process, and maintain; they should be self-descriptive.
-  * Media directory: Images and other static files referenced by generated and static content across the site. Many of the files here are cover art, and their names match the automatically generated "kebab case" identifiers for tracks and albums (or a manually overridden one).
-* Same for the output root: previously it was in a `site` folder; today, use `--out-path` or `HSMUSIC_OUT`!
+Then build the site:
 
-The upd8 code process was politely introduced by 2019!us back when we were beginning the site, and it's essentially the same structure followed today. In summary:
+```
+# If you used npm link:
+$ hsmusic --data-path data --media-path media --out-path out
 
-1. Locate and read data files, processing them into relatively usable JS object-style formats. (The formats themselves are hard-coded and somewhat arbitrary, and are often extended when more or different data is useful.)
-2. Validate the data and show any errors that might've been caught during processing. (These aren't exhaustive test cases; they're designed to catch a majority of common errors and typos.)
-3. Create symlinks for static files and generate the basic directory structure for the site.
-4. Generate and write HTML files containing all content. (Rather than use external templates and a complex build system, we just use template strings in combination with [a whitespace utility][fixws] and some handy tricks for manipulating strings and JS.)
+# If you didn't:
+$ node code/src/upd8.js --data-path data --media-path media --out-path out
+```
 
-The majority of the code volume is generated HTML content and supporting utility functions; while we've attempted to keep the update file more or less organized, the most reliable way to navigate is to just ctrl-F for the function definitions of whatever you intend to work on. Code order isn't super strict since everything is handled by separate function calls (which all branch off of the "main" function at the end of the file).
+You should get a bunch of info eventually showing the site building! It may take a while (especially since HSMusic has a lot of data nowadays).
 
-In the past, data, HTML, and media files were all interspersed with each other. Yea, even the generated HTML files were included as part of the repository; their diffs, part of every commit. Those were dark times indeed.
+If all goes according to plan and there aren't any errors, all the site HTML should have been written to the `out` directory. Use a simple HTTP server to view it in your browser:
+
+```
+$ cd site
+
+# choose your favorite HTTP server
+$ npx http-server -p 8002
+$ python3 -m http.server 8002
+$ python2 -m SimpleHTTPServer 8002
+```
+
+If you don't have access to an HTTP server or lack device permissions to run one, you can also just view the generated HTML files in your browser and *most* features should still work. (Try `--append-index-html` in the `hsmusic`/`upd8.js` command to make generated links more direct.) This isn't an officially supported way to develop, so there might be bugs, but most of the site should still work.
+
+**If you encounter any errors along the way, or would like help getting the wiki working,** please feel welcomed to reach out through the [HSMusic Community Discord Server][discord]. We're a fairly active group there and are always happy to help! **This also applies if you don't have much experience with Git, GitHub, Node, or any of the necessary tooling, and want help getting used to them.**
+
+## Project Structure
+
+### General build process
+
+When you run HSMusic to build the wiki, several processes happen in succession. Any errors along the way will be reported - we hope with human-readable feedback, but [pop by the Discord][discord] if you have any questions or need help understanding errors or parts of the code.
+
+1. Update thumbnails in the media repo so that any new images automatically get thumbnails.
+2. Locate and read data files, processing them into relatively usable JS object-style formats.
+3. Validate the data and show any errors caught during processing.
+4. Create symlinks for static files and generate the basic directory structure for the site.
+5. Generate and write HTML files (and any supporting files) containing all content.
+
+### Multiple repositories
+
+HSMusic works using a number of repositories in tandem:
+
+- [`hsmusic-wiki`][github-code] (colloquially "code"): The code repository, including all behavior required to process data and content from the other repositories and turn it into an actual website. This is probably the repo you're viewing right now.
+  - Code is written entirely in modern JavaScript, with the actual website a static combination of HTML and CSS (with inexhaustive JavaScript for certain features).
+  - More details about the code repository below.
+- [`hsmusic-data`][github-data]: The data repository, comprising all the data which makes a given wiki what it *is*. The repository linked here is for the [Homestuck Music Wiki][hsmusic] itself, but it may be swapped out for other data repos to build other completely different wikis.
+  - This repo covers albums, tracks, artists, groups, and a variety of other things which make up the content of a music wiki.
+  - The data repo also contains all the metadata which makes one wiki unique from another: layout info, static pages (like "About & Credits"), whether or not certain site features are enabled (like "Flashes & Games" or UI for browsing groups), and so on.
+  - All data is written and accessed in the YAML format, and every file follows a specific structure described within this (code) repository. See below and the `src/data` directory for details.
+- [`hsmusic-media`][ed1-media]: The media repository, holding all album, track, and layout media used across the site in one place. Media and organization directly corresponds to entries in the data repository; generally the data and media repositories go together and are swapped out for another together.
+- *Language repo:* The language repository, holding up-to-date strings and other localization info for HSMusic. NB: This repo isn't currently online as its structure and tooling haven't been polished or properly put together yet, but it's not required for building the site.
+  - Strings and language info are stored in top-level JSON files within this repository. They're based off the `src/strings-default.json` file within the code repo (and don't need to provide translations for all strings to be used for site building).
+
+The code repository as well as the data and media repositories are require for site building, with the language repo optionally provided to add localization support to the wiki build.
+
+The path to each repo may be specified respectively by the `--data-path`, `--media-path`, and `--lang-path` arguments (when building the site or using e.g. data-related CLI tools). If you find it inconvenient to type or keep track of these values, you may alternatively set environment variables `HSMUSIC_DATA`, `HSMUSIC_MEDIA`, and `HSMUSIC_LANG` to provide the same values. One convenient layout for locally organizing the HSMusic repositories is shown below:
+
+    path/to/my/projects/
+      hsmusic/
+        code/   <clone of hsmusic-wiki>
+        data/   <clone of hsmusic-data>
+        media/  <clone of hsmusic-media>
+        out/    <empty directory> (will be overwritten)
+        env.sh
+
+The `env.sh` script shown above is a straightforward utility for loading those variables into the envronment, so you don't need to type path arguments every time:
+
+    #!/bin/bash
+    base="$(realpath "$(dirname ${BASH_SOURCE[0]})")"
+    export HSMUSIC_DATA="$base/data/"
+    export HSMUSIC_MEDIA="$base/media/"
+    # export HSMUSIC_LANG="$base/lang/" # uncomment if present
+    export HSMUSIC_OUT="$base/out/"
+
+Then use `source env.sh` when starting work from the CLI to get access to all the convenient environment variables. (This setup is written for Bash of course, but you can use the same idea to export env variables with your own shell's syntax.)
+
+### Code repository source structure
+
+The source code for HSMusic is divided across a number of source files, loosely grouped together in a number of directories:
+
+- `src/`
+  - `data/`
+    - `cacheable-object.js`: Backbone of how data objects (colloquially "things") store, share, and compute their properties
+    - `things.js`: Descriptors for all "thing" classes used across the wiki: albums, tracks, artists, groups, etc
+    - `validators.js`: Convenient error-throwing utilities which help ensure properties set on things follow the right format
+    - `yaml.js`: Mappings from YAML documents (the format used in `hsmusic-data`) to things (actual data objects), and a full set of utilities used to actually load that data from scratch
+  - `page/`
+    - All page templates (HTML content and layout metadata) are kept in source files under this directory
+  - `static/`
+    - Purely client-side files are kept here, e.g. site CSS, icon SVGs, and client-side JS
+  - `util/`
+    - Common utilities which generally may be accessed from both Node.js or the client (web browser)
+  - `upd8.js`: Main entry point which controls and directs site generation from start to finish
+  - `gen-thumbs.js`: Standalone utility also called every time HSMusic is run (unless `--skip-thumbs` is provided) which keeps a persistent cache of media MD5s and (re)generates thumbnails for new or updated image files
+  - `repl.js`: Standalone utility for loading all wiki data and providing a convenient REPL to run filters and transformations on data objects right from the Node.js command line
+  - `listing-spec.js`: Descriptors for computations and HTML templates used for the Listings part of the site
+  - `misc-templates.js`: General collection of HTML patterns used across page generation
+  - `url-spec.js`: Index of output paths where generated HTML ends up; also controls where `<a>`, `<img>`, etc tags link
+  - `file-size-preloader.js`: Simple utility for calculating size of files in media directory
+  - `strings-default.json`: Template for localization strings and index of default (English) strings used all across the site layout
 
 ## Forking
 
 hsmusic is a relatively generic music wiki software, so you're more than encouraged to create a fork for your own archival or cataloguing purposes! You're encouraged to [drop us a link][feedback] if you do - we'd love to hear from you.
 
-Still, at present moment, a fair bit of the wiki design is baked into the update code itself - any configuration (such as getting rid of the "flashes & games") section will have you digging into the code yourself. In the future, we'd love to make the wiki software more customizable from a forking perspective, but we haven't gotten to it yet. Let us know if this is something you're interested in - we'd love to chat about what additions or changes would be useful in making a more versatile generic music wiki software!
-
 ## Pull Requests
 
-As mentioned, part of the focus of the hsmusic.wiki release was to create a more modular and develop-able repository. So, on the curious chance anyone would like to contribute code to the repo, such is certainly capable now!
+As mentioned, part of the focus of the hsmusic.wiki release, as well as most development since, has been to create a more modular and developer-friendly repository. So, on the curious chance anyone would like to contribute code to the repo, that's more possible now than it used to be!
 
-Still, for larger additions, I'd encourage you to throw an email or contact ([links here][feedback]) before writing all the implementation code: besides code tips which might make your life a bit easier (questions are welcome), I'd also love to discuss feature designs and values while they're still being brainstormed! That way, I don't need to tell you there are fundamental ideas or code details I'd want rebuilt - the last thing I want is anyone putting hours into code which could have been avoided being poured down the drain!
+Still, for larger additions, we encourage you to [drop the main dev an email][feedback] or, better yet, [pop by the Discord][discord] before writing all the implementation code: besides code tips which might make your life a bit easier (questions are welcome), we also love to discuss feature designs and values while they're still being brainstormed! That way, nobody has to tell you there are fundamental ideas or implementation details that should be rebuilt from the ground up - the last thing we want is anyone putting hours into code that has to be replaced by another implementation before it ever ends up part of the wiki!
 
 As ever, feedback is always welcome, and may be shared via the usual links. Thank you for checking the repository out!
 
+  [ed1-media]: https://nebula.ed1.club/git/hsmusic-media/
+  [discord]: https://hsmusic.wiki/discord/
   [fandom]: https://homestuck-and-mspa-music.fandom.com/wiki/Homestuck_and_MSPA_Music_Wiki
-  [nsnd]: https://homestuck.net/music/references.html
-  [hsmusic]: https://hsmusic.wiki
-  [notabug]: https://notabug.org/hsmusic/hsmusic
-  [fixws]: https://www.npmjs.com/package/fix-whitespace
   [feedback]: https://hsmusic.wiki/feedback/
+  [github]: https://github.com/hsmusic/hsmusic-wiki
+  [github-code]: https://github.com/hsmusic/hsmusic-wiki
+  [github-data]: https://github.com/hsmusic/hsmusic-data
+  [hsmusic]: https://hsmusic.wiki
+  [nsnd]: https://homestuck.net/music/references.html
diff --git a/src/data/things.js b/src/data/things.js
index daec610..6a5cdb5 100644
--- a/src/data/things.js
+++ b/src/data/things.js
@@ -33,7 +33,7 @@ import * as S from './serialize.js';
 
 import {
     getKebabCase,
-    sortByArtDate,
+    sortAlbumsTracksChronologically,
 } from '../util/wiki-data.js';
 
 import find from '../util/find.js';
@@ -1128,8 +1128,10 @@ ArtTag.propertyDescriptors = {
         expose: {
             dependencies: ['albumData', 'trackData'],
             compute: ({ albumData, trackData, [ArtTag.instance]: artTag }) => (
-                sortByArtDate([...albumData, ...trackData]
-                    .filter(thing => thing.artTags?.includes(artTag))))
+                sortAlbumsTracksChronologically(
+                    ([...albumData, ...trackData]
+                        .filter(thing => thing.artTags?.includes(artTag))),
+                    {getDate: o => o.coverArtDate}))
         }
     }
 };
@@ -1710,6 +1712,7 @@ Object.assign(Language.prototype, {
     },
 
     // TODO: These are hard-coded. Is there a better way?
+    countAdditionalFiles: countHelper('additionalFiles', 'files'),
     countAlbums: countHelper('albums'),
     countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
     countContributions: countHelper('contributions'),
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 32cf729..763dfd2 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -42,8 +42,9 @@ import {
 } from '../util/sugar.js';
 
 import {
-    sortByDate,
-    sortByName,
+    sortAlbumsTracksChronologically,
+    sortAlphabetically,
+    sortChronologically,
 } from '../util/wiki-data.js';
 
 import find, { bindFind } from '../util/find.js';
@@ -861,7 +862,7 @@ export const dataSteps = [
         processDocument: processNewsEntryDocument,
 
         save(newsData) {
-            sortByDate(newsData);
+            sortChronologically(newsData);
             newsData.reverse();
 
             return {newsData};
@@ -876,7 +877,7 @@ export const dataSteps = [
         processDocument: processArtTagDocument,
 
         save(artTagData) {
-            artTagData.sort(sortByName);
+            sortAlphabetically(artTagData);
 
             return {artTagData};
         }
@@ -1108,8 +1109,8 @@ export function linkWikiDataArrays(wikiData) {
 
 export function sortWikiDataArrays(wikiData) {
     Object.assign(wikiData, {
-        albumData: sortByDate(wikiData.albumData.slice()),
-        trackData: sortByDate(wikiData.trackData.slice())
+        albumData: sortChronologically(wikiData.albumData.slice()),
+        trackData: sortAlbumsTracksChronologically(wikiData.trackData.slice()),
     });
 
     // Re-link data arrays, so that every object has the new, sorted versions.
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index e6dfeae..839c1d4 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -100,6 +100,7 @@ import {
 } from './util/cli.js';
 
 import {
+    commandExists,
     isMain,
     promisifyProcess,
 } from './util/node-utils.js';
@@ -133,18 +134,62 @@ function readFileMD5(filePath) {
     });
 }
 
-function generateImageThumbnails(filePath) {
+async function getImageMagickVersion(spawnConvert) {
+    const proc = spawnConvert(['--version'], false);
+
+    let allData = '';
+    proc.stdout.on('data', data => {
+        allData += data.toString();
+    });
+
+    await promisifyProcess(proc, false);
+
+    if (!allData.match(/ImageMagick/i)) {
+        return null;
+    }
+
+    const match = allData.match(/Version: (.*)/i);
+    if (!match) {
+        return 'unknown version';
+    }
+
+    return match[1];
+}
+
+async function getSpawnConvert() {
+    let fn, description, version;
+    if (await commandExists('convert')) {
+        fn = args => spawn('convert', args);
+        description = 'convert';
+    } else if (await commandExists('magick')) {
+        fn = (args, prefix = true) => spawn('magick',
+            (prefix ? ['convert', ...args] : args));
+        description = 'magick convert';
+    } else {
+        return [`no convert or magick binary`, null];
+    }
+
+    version = await getImageMagickVersion(fn);
+
+    if (version === null) {
+        return [`binary --version output didn't indicate it's ImageMagick`];
+    }
+
+    return [`${description} (${version})`, fn];
+}
+
+function generateImageThumbnails(filePath, {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}) => spawn('convert', [
+    const convert = (name, {size, quality}) => spawnConvert([
+        filePath,
         '-strip',
         '-resize', `${size}x${size}>`,
         '-interlace', 'Plane',
         '-quality', `${quality}%`,
-        filePath,
         output(name)
     ]);
 
@@ -192,6 +237,20 @@ export default async function genThumbs(mediaPath, {
         return true;
     };
 
+    const [convertInfo, spawnConvert] = await getSpawnConvert() ?? [];
+    if (!spawnConvert) {
+        logError`${`It looks like you don't have ImageMagick installed.`}`;
+        logError`ImageMagick is required to generate thumbnails for display on the wiki.`;
+        logError`(Error message: ${convertInfo})`;
+        logInfo`You can find info to help install ImageMagick on Linux, Windows, or macOS`;
+        logInfo`from its official website: ${`https://imagemagick.org/script/download.php`}`;
+        logInfo`If you have trouble working ImageMagick and would like some help, feel free`;
+        logInfo`to drop a message in the HSMusic Discord server! ${'https://hsmusic.wiki/discord/'}`;
+        return false;
+    } else {
+        logInfo`Found ImageMagick binary: ${convertInfo}`;
+    }
+
     let cache, firstRun = false, failedReadingCache = false;
     try {
         cache = JSON.parse(await readFile(path.join(mediaPath, CACHE_FILE)));
diff --git a/src/listing-spec.js b/src/listing-spec.js
index bb0c0a5..df2b038 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -4,8 +4,8 @@ import {
     chunkByProperties,
     getArtistNumContributions,
     getTotalDuration,
-    sortByDate,
-    sortByName
+    sortAlphabetically,
+    sortChronologically,
 } from './util/wiki-data.js';
 
 const listingSpec = [
@@ -14,8 +14,7 @@ const listingSpec = [
         stringsKey: 'listAlbums.byName',
 
         data({wikiData}) {
-            return wikiData.albumData.slice()
-                .sort(sortByName);
+            return sortAlphabetically(wikiData.albumData.slice());
         },
 
         row(album, {link, language}) {
@@ -66,7 +65,7 @@ const listingSpec = [
         stringsKey: 'listAlbums.byDate',
 
         data({wikiData}) {
-            return sortByDate(wikiData.albumData.filter(album => album.date));
+            return sortChronologically(wikiData.albumData.filter(album => album.date));
         },
 
         row(album, {link, language}) {
@@ -82,7 +81,7 @@ const listingSpec = [
         stringsKey: 'listAlbums.byDateAdded',
 
         data({wikiData}) {
-            return chunkByProperties(wikiData.albumData.slice().sort((a, b) => {
+            return chunkByProperties(wikiData.albumData.filter(a => a.dateAddedToWiki).sort((a, b) => {
                 if (a.dateAddedToWiki < b.dateAddedToWiki) return -1;
                 if (a.dateAddedToWiki > b.dateAddedToWiki) return 1;
             }), ['dateAddedToWiki']);
@@ -114,8 +113,7 @@ const listingSpec = [
         stringsKey: 'listArtists.byName',
 
         data({wikiData}) {
-            return wikiData.artistData.slice()
-                .sort(sortByName)
+            return sortAlphabetically(wikiData.artistData.slice())
                 .map(artist => ({artist, contributions: getArtistNumContributions(artist)}));
         },
 
@@ -254,22 +252,23 @@ const listingSpec = [
         stringsKey: 'listArtists.byLatest',
 
         data({wikiData}) {
-            const reversedTracks = wikiData.trackData.filter(t => t.date).reverse();
-            const reversedArtThings = wikiData.justEverythingSortedByArtDateMan.filter(t => t.date).reverse();
+            const reversedTracks = sortChronologically(wikiData.trackData.filter(t => t.date)).reverse();
+            const reversedArtThings = sortChronologically([...wikiData.trackData, ...wikiData.albumData].filter(t => t.coverArtDate)).reverse();
 
             return {
-                toTracks: sortByDate(wikiData.artistData
+                toTracks: sortChronologically(wikiData.artistData
                     .map(artist => ({
                         artist,
+                        directory: artist.directory,
+                        name: artist.name,
                         date: reversedTracks.find(track => ([
                             ...track.artistContribs ?? [],
                             ...track.contributorContribs ?? []
                         ].some(({ who }) => who === artist)))?.date
                     }))
-                    .filter(({ date }) => date)
-                    .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0)).reverse(),
+                    .filter(({ date }) => date)).reverse(),
 
-                toArtAndFlashes: sortByDate(wikiData.artistData
+                toArtAndFlashes: sortChronologically(wikiData.artistData
                     .map(artist => {
                         const thing = reversedArtThings.find(thing => ([
                             ...thing.coverArtistContribs ?? [],
@@ -277,6 +276,8 @@ const listingSpec = [
                         ].some(({ who }) => who === artist)));
                         return thing && {
                             artist,
+                            directory: artist.directory,
+                            name: artist.name,
                             date: (thing.coverArtistContribs?.some(({ who }) => who === artist)
                                 ? thing.coverArtDate
                                 : thing.date)
@@ -332,7 +333,7 @@ const listingSpec = [
         directory: 'groups/by-name',
         stringsKey: 'listGroups.byName',
         condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-        data: ({wikiData}) => wikiData.groupData.slice().sort(sortByName),
+        data: ({wikiData}) => sortAlphabetically(wikiData.groupData.slice()),
 
         row(group, {link, language}) {
             return language.$('listingPage.listGroups.byCategory.group', {
@@ -437,11 +438,13 @@ const listingSpec = [
         condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
 
         data({wikiData}) {
-            return sortByDate(wikiData.groupData
+            return sortChronologically(wikiData.groupData
                 .map(group => {
                     const albums = group.albums.filter(a => a.date);
                     return albums.length && {
                         group,
+                        directory: group.directory,
+                        name: group.name,
                         date: albums[albums.length - 1].date
                     };
                 })
@@ -456,8 +459,8 @@ const listingSpec = [
                 // l8ter, that flips them, and UMSPAF ends up displaying 8efore
                 // Fandom. So we do an extra reverse here, which will fix that
                 // and only affect groups that share the same d8te (8ecause
-                // groups that don't will 8e moved 8y the sortByDate call
-                // surrounding this).
+                // groups that don't will 8e moved 8y the sortChronologically
+                // call surrounding this).
                 .reverse()).reverse()
         },
 
@@ -474,7 +477,7 @@ const listingSpec = [
         stringsKey: 'listTracks.byName',
 
         data({wikiData}) {
-            return wikiData.trackData.slice().sort(sortByName);
+            return sortAlphabetically(wikiData.trackData.slice());
         },
 
         row(track, {link, language}) {
@@ -516,7 +519,7 @@ const listingSpec = [
 
         data({wikiData}) {
             return chunkByProperties(
-                sortByDate(wikiData.trackData.filter(t => t.date)),
+                sortChronologically(wikiData.trackData.filter(t => t.date)),
                 ['album', 'date']
             );
         },
@@ -659,7 +662,7 @@ const listingSpec = [
         html(flashData, {link, language}) {
             return fixWS`
                 <dl>
-                    ${sortByDate(flashData.slice()).map(flash => fixWS`
+                    ${sortChronologically(flashData.slice()).map(flash => fixWS`
                         <dt>${language.$('listingPage.listTracks.inFlashes.byFlash.flash', {
                             flash: link.flash(flash),
                             date: language.formatDate(flash.date)
@@ -684,13 +687,16 @@ const listingSpec = [
         stringsKey: 'listTracks.withLyrics',
 
         data({wikiData}) {
-            return chunkByProperties(wikiData.trackData.filter(t => t.lyrics), ['album']);
+            return wikiData.albumData.map(album => ({
+                album,
+                tracks: album.tracks.filter(t => t.lyrics)
+            })).filter(({ tracks }) => tracks.length > 0);
         },
 
         html(chunks, {link, language}) {
             return fixWS`
                 <dl>
-                    ${chunks.map(({album, chunk: tracks}) => fixWS`
+                    ${chunks.map(({album, tracks}) => fixWS`
                         <dt>${language.$('listingPage.listTracks.withLyrics.album', {
                             album: link.album(album),
                             date: language.formatDate(album.date)
@@ -715,9 +721,7 @@ const listingSpec = [
         condition: ({wikiData}) => wikiData.wikiInfo.enableArtTagUI,
 
         data({wikiData}) {
-            return wikiData.artTagData
-                .filter(tag => !tag.isContentWarning)
-                .sort(sortByName)
+            return sortAlphabetically(wikiData.artTagData.filter(tag => !tag.isContentWarning))
                 .map(tag => ({tag, timesUsed: tag.taggedInThings?.length}));
         },
 
diff --git a/src/misc-templates.js b/src/misc-templates.js
index f40229d..61afa71 100644
--- a/src/misc-templates.js
+++ b/src/misc-templates.js
@@ -7,6 +7,11 @@ import fixWS from 'fix-whitespace';
 import * as html from './util/html.js';
 
 import {
+    Track,
+    Album,
+} from './data/things.js';
+
+import {
     getColors
 } from './util/colors.js';
 
@@ -16,7 +21,8 @@ import {
 
 import {
     getTotalDuration,
-    sortByDate
+    sortAlbumsTracksChronologically,
+    sortChronologically,
 } from './util/wiki-data.js';
 
 const BANDCAMP_DOMAINS = [
@@ -45,7 +51,9 @@ export function generateAdditionalFilesList(additionalFiles, {language, getFileS
     const fileCount = additionalFiles.flatMap(g => g.files).length;
 
     return fixWS`
-        <p id="additional-files">${language.$('releaseInfo.additionalFiles.heading', {fileCount})}</p>
+        <p id="additional-files">${language.$('releaseInfo.additionalFiles.heading', {
+            additionalFiles: language.countAdditionalFiles(fileCount, {unit: true})
+        })}</p>
         <dl>
             ${additionalFiles.map(({ title, description, files }) => fixWS`
                 <dt>${(description
@@ -112,7 +120,15 @@ export function generateChronologyLinks(currentThing, {
     }
 
     return contributions.map(({ who: artist }) => {
-        const things = sortByDate(unique(getThings(artist)).filter(t => t[dateKey]), dateKey);
+        const thingsUnsorted = unique(getThings(artist)).filter(t => t[dateKey]);
+
+        // Kinda a hack, but we automatically detect which is (probably) the
+        // right function to use here.
+        const args = [thingsUnsorted, {getDate: t => t[dateKey]}];
+        const things = (thingsUnsorted.every(t => t instanceof Album || t instanceof Track)
+            ? sortAlbumsTracksChronologically(...args)
+            : sortChronologically(...args));
+
         const index = things.indexOf(currentThing);
 
         if (index === -1) return '';
diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js
index e587b16..57135a4 100644
--- a/src/page/album-commentary.js
+++ b/src/page/album-commentary.js
@@ -69,6 +69,7 @@ export function write(album, {wikiData}) {
             },
 
             nav: {
+                linkContainerClasses: ['nav-links-hierarchy'],
                 links: [
                     {toHome: true},
                     {
diff --git a/src/page/album.js b/src/page/album.js
index b68189f..c265fdc 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -255,6 +255,7 @@ export function write(album, {wikiData}) {
                 }),
 
                 nav: {
+                    linkContainerClasses: ['nav-links-hierarchy'],
                     links: [
                         {toHome: true},
                         {
@@ -262,14 +263,16 @@ export function write(album, {wikiData}) {
                                 album: link.album(album, {class: 'current'})
                             })
                         },
-                        album.tracks.length > 1 &&
-                        {
-                            divider: false,
-                            html: generateAlbumNavLinks(album, null, {language})
-                        }
                     ],
-                    content: html.tag('div', generateAlbumChronologyLinks(album, null, {generateChronologyLinks}))
-                }
+                    bottomRowContent: generateAlbumNavLinks(album, null, {language}),
+                    content: generateAlbumChronologyLinks(album, null, {generateChronologyLinks}),
+                },
+
+                secondaryNav: generateAlbumSecondaryNav(album, null, {
+                    language,
+                    link,
+                    getLinkThemeString,
+                }),
             };
         }
     };
@@ -398,6 +401,42 @@ export function generateAlbumSidebar(album, currentTrack, {
     }
 }
 
+export function generateAlbumSecondaryNav(album, currentTrack, {
+    link,
+    language,
+    getLinkThemeString,
+}) {
+    const { groups } = album;
+
+    if (!groups.length) {
+        return null;
+    }
+
+    const groupParts = groups.map(group => {
+        const albums = group.albums.filter(album => album.date);
+        const index = albums.indexOf(album);
+        const next = index >= 0 && albums[index + 1];
+        const previous = index > 0 && albums[index - 1];
+        return {group, next, previous};
+    }).map(({group, next, previous}) => {
+        const previousNext = !currentTrack && [
+            previous && link.album(previous, {color: false, text: language.$('misc.nav.previous')}),
+            next && link.album(next, {color: false, text: language.$('misc.nav.next')})
+        ].filter(Boolean);
+        return html.tag('span', {style: getLinkThemeString(group.color)}, [
+            language.$('albumSidebar.groupBox.title', {
+                group: link.groupInfo(group)
+            }),
+            previousNext?.length && `(${previousNext.join(',\n')})`
+        ]);
+    });
+
+    return {
+        classes: ['dot-between-spans'],
+        content: groupParts.join('\n'),
+    };
+}
+
 export function generateAlbumNavLinks(album, currentTrack, {
     generatePreviousNextLinks,
     language
@@ -422,7 +461,10 @@ export function generateAlbumNavLinks(album, currentTrack, {
 }
 
 export function generateAlbumChronologyLinks(album, currentTrack, {generateChronologyLinks}) {
-    return [
+    return html.tag('div', {
+        [html.onlyIfContent]: true,
+        class: 'nav-chronology-links',
+    }, [
         currentTrack && generateChronologyLinks(currentTrack, {
             contribKey: 'artistContribs',
             getThings: artist => [...artist.tracksAsArtist, ...artist.tracksAsContributor],
@@ -439,5 +481,5 @@ export function generateAlbumChronologyLinks(album, currentTrack, {generateChron
             getThings: artist => [...artist.albumsAsCoverArtist, ...artist.tracksAsCoverArtist],
             headingString: 'misc.chronology.heading.coverArt'
         })
-    ].filter(Boolean).join('\n');
+    ].filter(Boolean).join('\n'));
 }
diff --git a/src/page/artist.js b/src/page/artist.js
index c15e034..6c31a01 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -16,7 +16,10 @@ import {
 import {
     chunkByProperties,
     getTotalDuration,
-    sortByDate
+    sortAlbumsTracksChronologically,
+    sortByDate,
+    sortByDirectory,
+    sortChronologically,
 } from '../util/wiki-data.js';
 
 // Page exports
@@ -30,19 +33,19 @@ export function write(artist, {wikiData}) {
 
     const { name, urls, contextNotes } = artist;
 
-    const artThingsAll = sortByDate(unique([
+    const artThingsAll = sortAlbumsTracksChronologically(unique([
         ...artist.albumsAsCoverArtist ?? [],
         ...artist.albumsAsWallpaperArtist ?? [],
         ...artist.albumsAsBannerArtist ?? [],
         ...artist.tracksAsCoverArtist ?? []
-    ]));
+    ]), {getDate: o => o.coverArtDate});
 
-    const artThingsGallery = sortByDate([
+    const artThingsGallery = sortAlbumsTracksChronologically([
         ...artist.albumsAsCoverArtist ?? [],
         ...artist.tracksAsCoverArtist ?? []
-    ]);
+    ], {getDate: o => o.coverArtDate});
 
-    const commentaryThings = sortByDate([
+    const commentaryThings = sortAlbumsTracksChronologically([
         ...artist.albumsAsCommentator ?? [],
         ...artist.tracksAsCommentator ?? []
     ]);
@@ -56,24 +59,24 @@ export function write(artist, {wikiData}) {
         key
     });
 
-    const artListChunks = chunkByProperties(sortByDate(artThingsAll.flatMap(thing =>
+    const artListChunks = chunkByProperties(artThingsAll.flatMap(thing =>
         (['coverArtistContribs', 'wallpaperArtistContribs', 'bannerArtistContribs']
             .map(key => getArtistsAndContrib(thing, key))
             .filter(({ contrib }) => contrib)
             .map(props => ({
                 album: thing.album || thing,
                 track: thing.album ? thing : null,
-                date: +(thing.coverArtDate || thing.date),
+                date: thing.date,
                 ...props
             })))
-    )), ['date', 'album']);
+    ), ['date', 'album']);
 
     const commentaryListChunks = chunkByProperties(commentaryThings.map(thing => ({
         album: thing.album || thing,
         track: thing.album ? thing : null
     })), ['album']);
 
-    const allTracks = sortByDate(unique([
+    const allTracks = sortAlbumsTracksChronologically(unique([
         ...artist.tracksAsArtist ?? [],
         ...artist.tracksAsContributor ?? []
     ]));
@@ -119,7 +122,7 @@ export function write(artist, {wikiData}) {
 
     let flashes, flashListChunks;
     if (wikiInfo.enableFlashesAndGames) {
-        flashes = sortByDate(artist.flashesAsContributor?.slice() ?? []);
+        flashes = sortChronologically(artist.flashesAsContributor?.slice() ?? []);
         flashListChunks = (
             chunkByProperties(flashes.map(flash => ({
                 act: flash.act,
@@ -140,9 +143,9 @@ export function write(artist, {wikiData}) {
 
     const generateEntryAccents = ({
         getArtistString, language,
-        aka, entry, artists, contrib
+        original, entry, artists, contrib
     }) =>
-        (aka
+        (original
             ? language.$('artistPage.creditList.entry.rerelease', {entry})
             : (artists.length
                 ? ((contrib.what || contrib.whatArray?.length)
@@ -184,16 +187,16 @@ export function write(artist, {wikiData}) {
                 <dd><ul>
                     ${(chunk
                         .map(({track, ...props}) => ({
-                            aka: track.aka,
+                            original: track.originalReleaseTrack,
                             entry: language.$('artistPage.creditList.entry.track.withDuration', {
                                 track: link.track(track),
                                 duration: language.formatDuration(track.duration ?? 0)
                             }),
                             ...props
                         }))
-                        .map(({aka, ...opts}) => html.tag('li',
-                            {class: aka && 'rerelease'},
-                            generateEntryAccents({getArtistString, language, aka, ...opts})))
+                        .map(({original, ...opts}) => html.tag('li',
+                            {class: original && 'rerelease'},
+                            generateEntryAccents({getArtistString, language, original, ...opts})))
                         .join('\n'))}
                 </ul></dd>
             `).join('\n')}
@@ -492,6 +495,7 @@ function generateNavForArtist(artist, isGallery, hasGallery, {
         }))
 
     return {
+        linkContainerClasses: ['nav-links-hierarchy'],
         links: [
             {toHome: true},
             wikiInfo.enableListings &&
diff --git a/src/page/flash.js b/src/page/flash.js
index 58969b1..21a22b9 100644
--- a/src/page/flash.js
+++ b/src/page/flash.js
@@ -161,6 +161,7 @@ function generateNavForFlash(flash, {
     });
 
     return {
+        linkContainerClasses: ['nav-links-hierarchy'],
         links: [
             {toHome: true},
             {
@@ -172,13 +173,10 @@ function generateNavForFlash(flash, {
                     flash: link.flash(flash, {class: 'current'})
                 })
             },
-            previousNextLinks &&
-            {
-                divider: false,
-                html: `(${previousNextLinks})`
-            }
         ],
 
+        bottomRowContent: previousNextLinks && `(${previousNextLinks})`,
+
         content: fixWS`
             <div>
                 ${generateChronologyLinks(flash, {
diff --git a/src/page/group.js b/src/page/group.js
index eb401dd..b83244a 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -8,7 +8,7 @@ import * as html from '../util/html.js';
 
 import {
     getTotalDuration,
-    sortByDate
+    sortChronologically,
 } from '../util/wiki-data.js';
 
 // Page exports
@@ -142,7 +142,12 @@ export function write(group, {wikiData}) {
                     )}
                     <div class="grid-listing">
                         ${getAlbumGridHTML({
-                            entries: sortByDate(group.albums.map(item => ({item}))).reverse(),
+                            entries: sortChronologically(group.albums.map(album => ({
+                                item: album,
+                                directory: album.directory,
+                                name: album.name,
+                                date: album.date,
+                            }))).reverse(),
                             details: true
                         })}
                     </div>
@@ -240,6 +245,7 @@ function generateGroupNav(currentGroup, isGallery, {
     });
 
     return {
+        linkContainerClasses: ['nav-links-hierarchy'],
         links: [
             {toHome: true},
             wikiInfo.enableListings &&
diff --git a/src/page/homepage.js b/src/page/homepage.js
index 534ce78..a19df6c 100644
--- a/src/page/homepage.js
+++ b/src/page/homepage.js
@@ -99,22 +99,23 @@ export function writeTargetless({wikiData}) {
             },
 
             nav: {
-                content: fixWS`
-                    <h2 class="dot-between-spans">
-                        ${[
-                            link.home('', {text: wikiInfo.nameShort, class: 'current', to}),
-                            wikiInfo.enableListings &&
-                            link.listingIndex('', {text: language.$('listingIndex.title'), to}),
-                            wikiInfo.enableNews &&
-                            link.newsIndex('', {text: language.$('newsIndex.title'), to}),
-                            wikiInfo.enableFlashesAndGames &&
-                            link.flashIndex('', {text: language.$('flashIndex.title'), to}),
-                            ...(staticPageData
-                                .filter(page => page.showInNavigationBar)
-                                .map(page => link.staticPage(page, {text: page.nameShort})))
-                        ].filter(Boolean).map(link => `<span>${link}</span>`).join('\n')}
-                    </h2>
-                `
+                linkContainerClasses: ['nav-links-index'],
+                links: [
+                    link.home('', {text: wikiInfo.nameShort, class: 'current', to}),
+
+                    wikiInfo.enableListings &&
+                    link.listingIndex('', {text: language.$('listingIndex.title'), to}),
+
+                    wikiInfo.enableNews &&
+                    link.newsIndex('', {text: language.$('newsIndex.title'), to}),
+
+                    wikiInfo.enableFlashesAndGames &&
+                    link.flashIndex('', {text: language.$('flashIndex.title'), to}),
+
+                    ...(staticPageData
+                        .filter(page => page.showInNavigationBar)
+                        .map(page => link.staticPage(page, {text: page.nameShort}))),
+                ].filter(Boolean).map(html => ({html})),
             }
         })
     };
diff --git a/src/page/listing.js b/src/page/listing.js
index 261b1e9..447a0c8 100644
--- a/src/page/listing.js
+++ b/src/page/listing.js
@@ -76,6 +76,7 @@ export function write(listing, {wikiData}) {
                 },
 
                 nav: {
+                    linkContainerClasses: ['nav-links-hierarchy'],
                     links: [
                         {toHome: true},
                         {
diff --git a/src/page/news.js b/src/page/news.js
index 4f5c505..9336506 100644
--- a/src/page/news.js
+++ b/src/page/news.js
@@ -104,6 +104,7 @@ function generateNewsEntryNav(entry, {
     });
 
     return {
+        linkContainerClasses: ['nav-links-hierarchy'],
         links: [
             {toHome: true},
             {
diff --git a/src/page/tag.js b/src/page/tag.js
index 8e5e699..471439d 100644
--- a/src/page/tag.js
+++ b/src/page/tag.js
@@ -87,6 +87,7 @@ function generateTagNav(tag, {
     });
 
     return {
+        linkContainerClasses: ['nav-links-hierarchy'],
         links: [
             {toHome: true},
             wikiData.wikiInfo.enableListings &&
diff --git a/src/page/track.js b/src/page/track.js
index d51cee2..c4ec6c5 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -7,6 +7,7 @@ import fixWS from 'fix-whitespace';
 import {
     generateAlbumChronologyLinks,
     generateAlbumNavLinks,
+    generateAlbumSecondaryNav,
     generateAlbumSidebar
 } from './album.js';
 
@@ -19,7 +20,7 @@ import {
 import {
     getTrackCover,
     getAlbumListTag,
-    sortByDate
+    sortChronologically,
 } from '../util/wiki-data.js';
 
 // Page exports
@@ -36,8 +37,15 @@ export function write(track, {wikiData}) {
 
     let flashesThatFeature;
     if (wikiInfo.enableFlashesAndGames) {
-        flashesThatFeature = sortByDate([track, ...otherReleases]
-            .flatMap(track => track.featuredInFlashes.map(flash => ({flash, as: track}))));
+        flashesThatFeature = sortChronologically([track, ...otherReleases]
+            .flatMap(track => track.featuredInFlashes
+                .map(flash => ({
+                    flash,
+                    as: track,
+                    directory: flash.directory,
+                    name: flash.name,
+                    date: flash.date
+                }))));
     }
 
     const unbound_getTrackItem = (track, {getArtistString, link, language}) => (
@@ -298,6 +306,7 @@ export function write(track, {wikiData}) {
                 }),
 
                 nav: {
+                    linkContainerClasses: ['nav-links-hierarchy'],
                     links: [
                         {toHome: true},
                         {
@@ -314,21 +323,20 @@ export function write(track, {wikiData}) {
                                 track: link.track(track, {class: 'current', to})
                             })
                         },
-                        album.tracks.length > 1 &&
-                        {
-                            divider: false,
-                            html: generateAlbumNavLinks(album, track, {
-                                generatePreviousNextLinks,
-                                language
-                            })
-                        }
                     ].filter(Boolean),
-                    content: fixWS`
-                        <div>
-                            ${generateAlbumChronologyLinks(album, track, {generateChronologyLinks})}
-                        </div>
-                    `
-                }
+                    content: generateAlbumChronologyLinks(album, track, {generateChronologyLinks}),
+                    bottomRowContent: (album.tracks.length > 1 &&
+                        generateAlbumNavLinks(album, track, {
+                            generatePreviousNextLinks,
+                            language,
+                        })),
+                },
+
+                secondaryNav: generateAlbumSecondaryNav(album, track, {
+                    language,
+                    link,
+                    getLinkThemeString,
+                }),
             };
         }
     };
diff --git a/src/static/site.css b/src/static/site.css
index 53a2ad9..e003135 100644
--- a/src/static/site.css
+++ b/src/static/site.css
@@ -106,12 +106,12 @@ a:hover {
     display: flex;
 }
 
-#header, #skippers, #footer {
+#header, #secondary-nav, #skippers, #footer {
     padding: 5px;
     font-size: 0.85em;
 }
 
-#header, #skippers {
+#header, #secondary-nav, #skippers {
     margin-bottom: 10px;
 }
 
@@ -120,39 +120,57 @@ a:hover {
 }
 
 #header {
-    display: flex;
+    display: grid;
 }
 
-#header > h2 {
-    font-size: 1em;
-    margin: 0 20px 0 0;
-    font-weight: normal;
+#header.nav-has-main-links.nav-has-content {
+    grid-template-columns: 2.5fr 3fr;
+    grid-template-rows: min-content 1fr;
+    grid-template-areas:
+        "main-links content"
+        "bottom-row content";
 }
 
-#header > h2 a.current {
-    font-weight: 800;
+#header.nav-has-main-links:not(.nav-has-content) {
+    grid-template-columns: 1fr;
+    grid-template-areas:
+        "main-links"
+        "bottom-row";
 }
 
-#header > h2.dot-between-spans > span:not(:last-child)::after {
-    content: " \00b7 ";
-    font-weight: 800;
+.nav-main-links {
+    grid-area: main-links;
+    margin-right: 20px;
 }
 
-#header > h2 > span {
+.nav-content {
+    grid-area: content;
+}
+
+.nav-bottom-row {
+    grid-area: bottom-row;
+    align-self: start;
+}
+
+.nav-main-links > span {
     white-space: nowrap;
 }
 
-#header > div {
-    flex-grow: 1;
+.nav-main-links > span > a.current {
+    font-weight: 800;
 }
 
-#header > div > *:not(:last-child)::after {
-    content: " \00b7 ";
+.nav-links-index > span:not(:first-child):not(.no-divider)::before {
+    content: "\0020\00b7\0020";
     font-weight: 800;
 }
 
+.nav-links-hierarchy > span:not(:first-child):not(.no-divider)::before {
+    content: "\0020/\0020";
+}
+
 #header .chronology {
-    display: inline-block;
+    display: block;
 }
 
 #header .chronology .heading,
@@ -160,6 +178,14 @@ a:hover {
     display: inline-block;
 }
 
+#secondary-nav {
+    text-align: center;
+}
+
+#secondary-nav:not(.no-hide) {
+    display: none;
+}
+
 footer {
     text-align: center;
     font-style: oblique;
@@ -225,7 +251,7 @@ footer > :last-child {
     font-size: 1em;
 }
 
-.sidebar, #content, #header, #skippers, #footer {
+.sidebar, #content, #header, #secondary-nav, #skippers, #footer {
     background-color: rgba(0, 0, 0, 0.6);
     border: 1px dotted var(--primary-color);
     border-radius: 3px;
@@ -564,6 +590,7 @@ h1 {
     display: flex;
     flex-direction: column;
     margin: 15px;
+    align-self: center;
 }
 
 .grid-actions > .grid-item {
@@ -923,6 +950,10 @@ li > ul {
         display: none;
     }
 
+    #secondary-nav:not(.no-hide) {
+        display: block;
+    }
+
     .layout-columns.vertical-when-thin {
         flex-direction: column;
     }
@@ -960,27 +991,34 @@ li > ul {
         display: block;
     }
 
-    #header > div {
+    #header > div:not(:first-child) {
         margin-top: 0.5em;
     }
 }
 
 /* important easter egg mode */
 
-html[data-language-code=preview-en][data-url-key="localized.home"] body {
-    background-color: #ff00a2;
-    animation: preview-rainbow 30s infinite;
-    background: linear-gradient(rgba(255,0,0,1) 0%, rgba(255,154,0,1) 10%, rgba(208,222,33,1) 20%, rgba(79,220,74,1) 30%, rgba(63,218,216,1) 40%, rgba(47,201,226,1) 50%, rgba(28,127,238,1) 60%, rgba(95,21,242,1) 70%, rgba(186,12,248,1) 80%, rgba(251,7,217,1) 90%, rgba(255,0,0,1) 100%) 0 0/100% 200%;
-}
-
-@keyframes preview-rainbow {
-    to {
-        background-position: 0 -200%;
-    }
-}
-
 html[data-language-code=preview-en][data-url-key="localized.home"] #content h1::after {
     font-family: cursive;
     display: block;
     content: "(Preview Build)";
 }
+
+html[data-language-code=preview-en] #header h2 > :first-child::before {
+    content: "(Preview Build! ✨) ";
+    animation: preview-notice 4s infinite;
+}
+
+@keyframes preview-notice {
+    0% {
+        color: #cc5500;
+    }
+
+    50% {
+        color: #ffaa00;
+    }
+
+    100% {
+        color: #cc5500;
+    }
+}
diff --git a/src/strings-default.json b/src/strings-default.json
index b607b06..fb2e333 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -8,6 +8,13 @@
     "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} additional file",
+    "count.additionalFiles.withUnit.two": "",
+    "count.additionalFiles.withUnit.few": "",
+    "count.additionalFiles.withUnit.many": "",
+    "count.additionalFiles.withUnit.other": "{FILES} additional files",
     "count.albums": "{ALBUMS}",
     "count.albums.withUnit.zero": "",
     "count.albums.withUnit.one": "{ALBUMS} album",
@@ -105,7 +112,7 @@
     "releaseInfo.artTags": "Tags:",
     "releaseInfo.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}",
     "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:",
-    "releaseInfo.additionalFiles.heading": "Has {FILE_COUNT} additional files:",
+    "releaseInfo.additionalFiles.heading": "Has {ADDITIONAL_FILES}:",
     "releaseInfo.additionalFiles.entry": "{TITLE}",
     "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}",
     "releaseInfo.additionalFiles.file": "{FILE}",
diff --git a/src/upd8.js b/src/upd8.js
index ba59068..d9bca28 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -128,15 +128,11 @@ import {
     getAlbumListTag,
     getAllTracks,
     getArtistAvatar,
-    getArtistCommentary,
     getArtistNumContributions,
     getFlashCover,
     getKebabCase,
     getTotalDuration,
     getTrackCover,
-    sortByArtDate,
-    sortByDate,
-    sortByName
 } from './util/wiki-data.js';
 
 import {
@@ -179,7 +175,7 @@ import FileSizePreloader from './file-size-preloader.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-const CACHEBUST = 8;
+const CACHEBUST = 10;
 
 const DEFAULT_STRINGS_FILE = 'strings-default.json';
 
@@ -858,6 +854,7 @@ writePage.html = (pageInfo, {
         sidebarLeft = {},
         sidebarRight = {},
         nav = {},
+        secondaryNav = {},
         footer = {},
         socialEmbed = {},
     } = pageInfo;
@@ -886,7 +883,13 @@ writePage.html = (pageInfo, {
 
     nav.classes ??= [];
     nav.content ??= '';
+    nav.bottomRowContent ??= '';
     nav.links ??= [];
+    nav.linkContainerClasses ??= [];
+
+    secondaryNav ??= {};
+    secondaryNav.content ??= '';
+    secondaryNav.content ??= '';
 
     footer.classes ??= [];
     footer.content ??= (wikiInfo.footerContent ? transformMultiline(wikiInfo.footerContent) : '');
@@ -950,6 +953,7 @@ writePage.html = (pageInfo, {
     const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight);
 
     if (nav.simple) {
+        nav.linkContainerClasses = ['nav-links-hierarchy'];
         nav.links = [
             {toHome: true},
             {toCurrentPage: true}
@@ -972,13 +976,14 @@ writePage.html = (pageInfo, {
             linkTitle ??= title;
         }
 
-        let part = prev && (cur.divider ?? true) ? '/ ' : '';
+        let partContent;
 
         if (typeof cur.html === 'string') {
             if (!cur.html) {
                 logWarn`Empty HTML in nav link ${JSON.stringify(cur)}`;
+                console.trace();
             }
-            part += `<span>${cur.html}</span>`;
+            partContent = cur.html;
         } else {
             const attributes = {
                 class: (cur.toCurrentPage || i === links.length - 1) && 'current',
@@ -995,18 +1000,39 @@ writePage.html = (pageInfo, {
             if (attributes.href === null) {
                 throw new Error(`Expected some href specifier for link to ${linkTitle} (${JSON.stringify(cur)})`);
             }
-            part += html.tag('a', attributes, linkTitle);
+            partContent = html.tag('a', attributes, linkTitle);
         }
+
+        const part = html.tag('span',
+            {class: cur.divider === false && 'no-divider'},
+            partContent);
+
         navLinkParts.push(part);
     }
 
     const navHTML = html.tag('nav', {
         [html.onlyIfContent]: true,
         id: 'header',
-        class: nav.classes
+        class: [
+            ...nav.classes,
+            links.length && 'nav-has-main-links',
+            nav.content && 'nav-has-content',
+            nav.bottomRowContent && 'nav-has-bottom-row',
+        ],
+    }, [
+        links.length && html.tag('div',
+            {class: ['nav-main-links', ...nav.linkContainerClasses]},
+            navLinkParts),
+        nav.content && html.tag('div', {class: 'nav-content'}, nav.content),
+        nav.bottomRowContent && html.tag('div', {class: 'nav-bottom-row'}, nav.bottomRowContent),
+    ]);
+
+    const secondaryNavHTML = html.tag('nav', {
+        [html.onlyIfContent]: true,
+        id: 'secondary-nav',
+        class: secondaryNav.classes
     }, [
-        links.length && html.tag('h2', {class: 'highlight-last-link'}, navLinkParts),
-        nav.content
+        secondaryNav.content
     ]);
 
     const bannerSrc = (
@@ -1030,6 +1056,7 @@ writePage.html = (pageInfo, {
     const layoutHTML = [
         navHTML,
         banner.position === 'top' && bannerHTML,
+        secondaryNavHTML,
         html.tag('div',
             {class: ['layout-columns', !collapseSidebars && 'vertical-when-thin']},
             [
@@ -1738,10 +1765,6 @@ async function main() {
         }
     }
 
-    WD.justEverythingMan = sortByDate([...WD.albumData, ...WD.trackData, ...(WD.flashData || [])]);
-    WD.justEverythingSortedByArtDateMan = sortByArtDate(WD.justEverythingMan.slice());
-    // console.log(JSON.stringify(justEverythingSortedByArtDateMan.map(toAnythingMan), null, 2));
-
     WD.officialAlbumData = WD.albumData.filter(album => album.groups.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY));
     WD.fandomAlbumData = WD.albumData.filter(album => album.groups.every(group => group.directory !== OFFICIAL_GROUP_DIRECTORY));
 
diff --git a/src/url-spec.js b/src/url-spec.js
index c1ed1eb..5c59941 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -22,7 +22,7 @@ const urlSpec = {
             root: '',
             path: '<>',
 
-            home: '/',
+            home: '',
 
             album: 'album/<>/',
             albumCommentary: 'commentary/album/<>/',
diff --git a/src/util/node-utils.js b/src/util/node-utils.js
index a46d614..ad87cae 100644
--- a/src/util/node-utils.js
+++ b/src/util/node-utils.js
@@ -2,6 +2,15 @@
 
 import { fileURLToPath } from 'url';
 
+import _commandExists from 'command-exists';
+
+// This package throws an error instead of returning false when the command
+// doesn't exist, for some reason. Yay for making logic more difficult!
+// Here's a straightforward workaround.
+export function commandExists(command) {
+    return _commandExists(command).then(() => true, () => false);
+}
+
 // Very cool function origin8ting in... http-music pro8a8ly!
 // Sorry if we happen to 8e violating past-us's copyright, lmao.
 export function promisifyProcess(proc, showLogging = true) {
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
index b4f7f21..5aef812 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -62,32 +62,115 @@ export function chunkByProperties(array, properties) {
         }));
 }
 
-// Sorting functions
-
-export function sortByName(a, b) {
-    let an = a.name.toLowerCase();
-    let bn = b.name.toLowerCase();
-    if (an.startsWith('the ')) an = an.slice(4);
-    if (bn.startsWith('the ')) bn = bn.slice(4);
-    return an < bn ? -1 : an > bn ? 1 : 0;
+// Sorting functions - all utils here are mutating, so make sure to initially
+// slice/filter/somehow generate a new array from input data if retaining the
+// initial sort matters! (Spoilers: If what you're doing involves any kind of
+// parallelization, it definitely matters.)
+
+// General sorting utilities! These don't do any sorting on their own but are
+// handy in the sorting functions below (or if you're making your own sort).
+
+export function compareCaseLessSensitive(a, b) {
+    // Compare two strings without considering capitalization... unless they
+    // happen to be the same that way.
+
+    const al = a.toLowerCase();
+    const bl = b.toLowerCase();
+
+    return (al === bl
+        ? a.localeCompare(b, undefined, {numeric: true})
+        : al.localeCompare(bl, undefined, {numeric: true}));
+}
+
+// Subtract common prefixes and other characters which some people don't like
+// to have considered while sorting. The words part of this is English-only for
+// now, which is totally evil.
+export function normalizeName(s) {
+    // Turn (some) ligatures into expanded variant for cleaner sorting, e.g.
+    // "ff" into "ff", in decompose mode, so that "ü" is represented as two
+    // bytes ("u" + \u0308 combining diaeresis).
+    s = s.normalize('NFKD');
+
+    // Replace one or more whitespace of any kind in a row, as well as certain
+    // punctuation, with a single typical space, then trim the ends.
+    s = s.replace(/[\p{Separator}\p{Dash_Punctuation}\p{Connector_Punctuation}]+/gu, ' ').trim();
+
+    // Discard anything that isn't a letter, number, or space.
+    s = s.replace(/[^\p{Letter}\p{Number} ]/gu, '');
+
+    // Remove common English (only, for now) prefixes.
+    s = s.replace(/^(?:an?|the) /i, '');
+
+    return s;
+}
+
+// Component sort functions - these sort by one particular property, applying
+// unique particulars where appropriate. Usually you don't want to use these
+// directly, but if you're making a custom sort they can come in handy.
+
+// Universal method for sorting things into a predictable order, as directory
+// is taken to be unique. There are two exceptions where this function (and
+// thus any of the composite functions that start with it) *can't* be taken as
+// deterministic:
+//
+//  1) Mixed data of two different Things, as directories are only taken as
+//     unique within one given class of Things. For example, this function
+//     won't be deterministic if its array contains both <album:ithaca> and
+//     <track:ithaca>.
+//
+//  2) Duplicate directories, or multiple instances of the "same" Thing.
+//     This function doesn't differentiate between two objects of the same
+//     directory, regardless of any other properties or the overall "identity"
+//     of the object.
+//
+// These exceptions are unavoidable except for not providing that kind of data
+// in the first place, but you can still ensure the overall program output is
+// deterministic by ensuring the input is arbitrarily sorted according to some
+// other criteria - ex, although sortByDirectory itself isn't determinstic when
+// given mixed track and album data, the final output (what goes on the site)
+// will always be the same if you're doing sortByDirectory([...albumData,
+// ...trackData]), because the initial sort places albums before tracks - and
+// sortByDirectory will handle the rest, given all directories are unique
+// except when album and track directories overlap with each other.
+export function sortByDirectory(data, {
+    getDirectory = o => o.directory
+} = {}) {
+    return data.sort((a, b) => {
+        const ad = getDirectory(a);
+        const bd = getDirectory(b);
+        return compareCaseLessSensitive(ad, bd)
+    });
 }
 
-// This function was originally made to sort just al8um data, 8ut its exact
-// code works fine for sorting tracks too, so I made the varia8les and names
-// more general.
-export function sortByDate(data, dateKey = 'date') {
-    // Just to 8e clear: sort is a mutating function! I only return the array
-    // 8ecause then you don't have to define it as a separate varia8le 8efore
-    // passing it into this function.
-    return data.sort(({ [dateKey]: a }, { [dateKey]: b }) => {
+export function sortByName(data, {
+    getName = o => o.name
+} = {}) {
+    return data.sort((a, b) => {
+        const an = getName(a);
+        const bn = getName(b);
+        const ann = normalizeName(an);
+        const bnn = normalizeName(bn);
+        return (
+            compareCaseLessSensitive(ann, bnn) ||
+            compareCaseLessSensitive(an, bn));
+    });
+}
+
+export function sortByDate(data, {
+    getDate = o => o.date
+} = {}) {
+    return data.sort((a, b) => {
+        const ad = getDate(a);
+        const bd = getDate(b);
+
         // It's possible for objects with and without dates to be mixed
         // together in the same array. If that's the case, we put all items
         // without dates at the end.
-        if (a && b) {
-            return a - b;
-        } else if (a) {
+        if (ad && bd) {
+            return ad - bd;
+        } else if (ad) {
             return -1;
-        } else if (b) {
+        } else if (bd) {
             return 1;
         } else {
             // If neither of the items being compared have a date, don't move
@@ -99,9 +182,116 @@ export function sortByDate(data, dateKey = 'date') {
     });
 }
 
-// Same details as the sortByDate, 8ut for covers~
-export function sortByArtDate(data) {
-    return data.sort((a, b) => (a.coverArtDate || a.date) - (b.coverArtDate || b.date));
+export function sortByPositionInAlbum(data) {
+    return data.sort((a, b) => {
+        const aa = a.album;
+        const ba = b.album;
+
+        // Don't change the sort when the two tracks are from separate albums.
+        // This function doesn't change the order of albums or try to "merge"
+        // two separated chunks of tracks from the same album together.
+        if (aa !== ba) {
+            return 0;
+        }
+
+        // Don't change the sort when only one (or neither) item is actually
+        // a track (i.e. has an album).
+        if (!aa || !ba) {
+            return 0;
+        }
+
+        const ai = aa.tracks.indexOf(a);
+        const bi = ba.tracks.indexOf(b);
+
+        // There's no reason this two-way reference (a track's album and the
+        // album's track list) should be broken, but if for any reason it is,
+        // don't change the sort.
+        if (ai === -1 || bi === -1) {
+            return 0;
+        }
+
+        return ai - bi;
+    });
+}
+
+// Sorts data so that items are grouped together according to whichever of a
+// set of arbitrary given conditions is true first. If no conditions are met
+// for a given item, it's moved over to the end!
+export function sortByConditions(data, conditions) {
+    data.sort((a, b) => {
+        const ai = conditions.findIndex(f => f(a));
+        const bi = conditions.findIndex(f => f(b));
+
+        if (ai >= 0 && bi >= 0) {
+            return ai - bi;
+        } else if (ai >= 0) {
+            return -1;
+        } else if (bi >= 0) {
+            return 1;
+        } else {
+            return 0;
+        }
+    });
+}
+
+// Composite sorting functions - these consider multiple properties, generally
+// always returning the same output regardless of how the input was originally
+// sorted (or left unsorted). If you're working with arbitrarily sorted inputs
+// (typically wiki data, either in full or unsorted filter), these make sure
+// what gets put on the actual website (or wherever) is deterministic. Also
+// they're just handy sorting utilities.
+//
+// Note that because these are each comprised of multiple component sorting
+// functions, they expect more than just one property to be present for full
+// sorting (listed above each function). If you're mapping thing objects to
+// another representation, try to include all of these listed properties.
+
+// Expects thing properties:
+//  * directory (or override getDirectory)
+//  * name (or override getName)
+export function sortAlphabetically(data, {getDirectory, getName} = {}) {
+    sortByDirectory(data, {getDirectory});
+    sortByName(data, {getName});
+    return data;
+}
+
+// Expects thing properties:
+//  * directory (or override getDirectory)
+//  * name (or override getName)
+//  * date (or override getDate)
+export function sortChronologically(data, {getDirectory, getName, getDate} = {}) {
+    sortAlphabetically(data, {getDirectory, getName});
+    sortByDate(data, {getDate});
+    return data;
+}
+
+// Highly contextual sort functions - these are only for very specific types
+// of Things, and have appropriately hard-coded behavior.
+
+// Sorts so that tracks from the same album are generally grouped together in
+// their original (album track list) order, while prioritizing date (by default
+// release date but can be overridden) above all else.
+//
+// This function also works for data lists which contain only tracks.
+export function sortAlbumsTracksChronologically(data, {getDate} = {}) {
+    // Sort albums before tracks...
+    sortByConditions(data, [t => t.album === undefined]);
+
+    // Group tracks by album...
+    sortByDirectory(data, {
+        getDirectory: t => (t.album ? t.album.directory : t.directory)
+    });
+
+    // Sort tracks by position in album...
+    sortByPositionInAlbum(data);
+
+    // ...and finally sort by date. If tracks from more than one album were
+    // released on the same date, they'll still be grouped together by album,
+    // and tracks within an album will retain their relative positioning (i.e.
+    // stay in the same order as part of the album's track listing).
+    sortByDate(data, {getDate});
+
+    return data;
 }
 
 // Specific data utilities
@@ -152,13 +342,6 @@ export function getArtistNumContributions(artist) {
     );
 }
 
-export function getArtistCommentary(artist, {justEverythingMan}) {
-    return justEverythingMan.filter(thing =>
-        (thing?.commentary
-            .replace(/<\/?b>/g, '')
-            .includes('<i>' + artist.name + ':</i>')));
-}
-
 export function getFlashCover(flash, {to}) {
     return to('media.flashArt', flash.directory, flash.coverArtFileExtension);
 }