« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/upd8.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/upd8.js')
-rwxr-xr-xsrc/upd8.js2380
1 files changed, 231 insertions, 2149 deletions
diff --git a/src/upd8.js b/src/upd8.js
index 79b5747..3937283 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -31,38 +31,19 @@
 // Oh yeah, like. Just run this through some relatively recent version of
 // node.js and you'll 8e fine. ...Within the project root. O8viously.
 
+import {execSync} from 'child_process';
 import * as path from 'path';
 import {fileURLToPath} from 'url';
-
-import chroma from 'chroma-js';
-
-import {
-  copyFile,
-  mkdir,
-  stat,
-  symlink,
-  writeFile,
-  unlink,
-} from 'fs/promises';
-
-import { execSync } from 'child_process';
+import wrap from 'word-wrap';
 
 import genThumbs from './gen-thumbs.js';
 import {listingSpec, listingTargetSpec} from './listing-spec.js';
 import urlSpec from './url-spec.js';
-import * as pageSpecs from './page/index.js';
 
-import find, {bindFind} from './util/find.js';
-import * as html from './util/html.js';
-import {getColors} from './util/colors.js';
-import {findFiles} from './util/io.js';
-import {isMain} from './util/node-utils.js';
+import {processLanguageFile} from './data/language.js';
 
 import CacheableObject from './data/things/cacheable-object.js';
 
-import {processLanguageFile} from './data/language.js';
-import {serializeThings} from './data/serialize.js';
-
 import {
   filterDuplicateDirectories,
   filterReferenceErrors,
@@ -72,33 +53,18 @@ import {
   WIKI_INFO_FILE,
 } from './data/yaml.js';
 
-import {
-  fancifyFlashURL,
-  fancifyURL,
-  generateAdditionalFilesShortcut,
-  generateAdditionalFilesList,
-  generateChronologyLinks,
-  generateCoverLink,
-  generateInfoGalleryLinks,
-  generateNavigationLinks,
-  generateStickyHeadingContainer,
-  generateTrackListDividedByGroups,
-  getAlbumGridHTML,
-  getAlbumStylesheet,
-  getArtistString,
-  getFlashGridHTML,
-  getFooterLocalizationLinks,
-  getGridHTML,
-  getCarouselHTML,
-  getRevealStringFromTags,
-  getRevealStringFromWarnings,
-  getThemeString as unbound_getThemeString,
-  iconifyURL,
-} from './misc-templates.js';
-
-import unbound_link, {
-  getLinkThemeString as unbound_getLinkThemeString,
-} from './util/link.js';
+import find from './util/find.js';
+import {findFiles} from './util/io.js';
+import link from './util/link.js';
+import {isMain} from './util/node-utils.js';
+import {validateReplacerSpec} from './util/replacer.js';
+import {empty, showAggregate, withEntries} from './util/sugar.js';
+import {replacerSpec} from './util/transform-content.js';
+import {generateURLs} from './util/urls.js';
+import {sortByName} from './util/wiki-data.js';
+
+import {generateDevelopersCommentHTML} from './write/page-template.js';
+import * as buildModes from './write/build-modes/index.js';
 
 import {
   color,
@@ -111,43 +77,11 @@ import {
   progressPromiseAll,
 } from './util/cli.js';
 
-import {validateReplacerSpec, transformInline} from './util/replacer.js';
-
-import {
-  getAlbumCover,
-  getArtistAvatar,
-  getFlashCover,
-  getTrackCover,
-} from './util/wiki-data.js';
-
-/*
-import {
-  serializeContribs,
-  serializeCover,
-  serializeGroupsForAlbum,
-  serializeGroupsForTrack,
-  serializeImagePaths,
-  serializeLink,
-} from './util/serialize.js';
-*/
-
-import {
-  bindOpts,
-  queue,
-  showAggregate,
-  withEntries,
-} from './util/sugar.js';
-
-import {generateURLs, thumb} from './util/urls.js';
-
-// Pensive emoji!
-import { OFFICIAL_GROUP_DIRECTORY } from './util/magic-constants.js';
-
 import FileSizePreloader from './file-size-preloader.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-const CACHEBUST = 17;
+const CACHEBUST = 18;
 
 let COMMIT;
 try {
@@ -160,1508 +94,66 @@ const BUILD_TIME = new Date();
 
 const DEFAULT_STRINGS_FILE = 'strings-default.json';
 
-// Code that's common 8etween the 8uild code (i.e. upd8.js) and gener8ted
-// site code should 8e put here. Which, uh, ~~only really means this one
-// file~~ is now a variety of useful utilities!
-//
-// Rather than hard code it, anything in this directory can 8e shared across
-// 8oth ends of the code8ase.
-// (This gets symlinked into the --data-path directory.)
-const UTILITY_DIRECTORY = 'util';
-
-// Code that's used only in the static site! CSS, cilent JS, etc.
-// (This gets symlinked into the --data-path directory.)
-const STATIC_DIRECTORY = 'static';
-
-// This exists adjacent to index.html for any page with oEmbed metadata.
-const OEMBED_JSON_FILE = 'oembed.json';
-
-// Automatically copied (if present) from media directory to site root.
-const FAVICON_FILE = 'favicon.ico';
-
-// Shared varia8les! These are more efficient to access than a shared varia8le
-// (or at least I h8pe so), and are easier to pass across functions than a
-// 8unch of specific arguments.
-//
-// Upd8: Okay yeah these aren't actually any different. Still cleaner than
-// passing around a data object containing all this, though.
-let dataPath;
-let mediaPath;
-let langPath;
-let outputPath;
-
-// Glo8al data o8ject shared 8etween 8uild functions and all that. This keeps
-// everything encapsul8ted in one place, so it's easy to pass and share across
-// modules!
-let wikiData = {};
-
-let queueSize;
-
-const urls = generateURLs(urlSpec);
-
-function splitLines(text) {
-  return text.split(/\r\n|\r|\n/);
-}
-
-const replacerSpec = {
-  album: {
-    find: 'album',
-    link: 'album',
-  },
-  'album-commentary': {
-    find: 'album',
-    link: 'albumCommentary',
-  },
-  'album-gallery': {
-    find: 'album',
-    link: 'albumGallery',
-  },
-  artist: {
-    find: 'artist',
-    link: 'artist',
-  },
-  'artist-gallery': {
-    find: 'artist',
-    link: 'artistGallery',
-  },
-  'commentary-index': {
-    find: null,
-    link: 'commentaryIndex',
-  },
-  date: {
-    find: null,
-    value: (ref) => new Date(ref),
-    html: (date, {language}) =>
-      html.tag('time',
-        {datetime: date.toString()},
-        language.formatDate(date)),
-  },
-  'flash-index': {
-    find: null,
-    link: 'flashIndex',
-  },
-  flash: {
-    find: 'flash',
-    link: 'flash',
-    transformName(name, node, input) {
-      const nextCharacter = input[node.iEnd];
-      const lastCharacter = name[name.length - 1];
-      if (![' ', '\n', '<'].includes(nextCharacter) && lastCharacter === '.') {
-        return name.slice(0, -1);
-      } else {
-        return name;
-      }
-    },
-  },
-  group: {
-    find: 'group',
-    link: 'groupInfo',
-  },
-  'group-gallery': {
-    find: 'group',
-    link: 'groupGallery',
-  },
-  home: {
-    find: null,
-    link: 'home',
-  },
-  'listing-index': {
-    find: null,
-    link: 'listingIndex',
-  },
-  listing: {
-    find: 'listing',
-    link: 'listing',
-  },
-  media: {
-    find: null,
-    link: 'media',
-  },
-  'news-index': {
-    find: null,
-    link: 'newsIndex',
-  },
-  'news-entry': {
-    find: 'newsEntry',
-    link: 'newsEntry',
-  },
-  root: {
-    find: null,
-    link: 'root',
-  },
-  site: {
-    find: null,
-    link: 'site',
-  },
-  static: {
-    find: 'staticPage',
-    link: 'staticPage',
-  },
-  string: {
-    find: null,
-    value: (ref) => ref,
-    html: (ref, {language, args}) => language.$(ref, args),
-  },
-  tag: {
-    find: 'artTag',
-    link: 'tag',
-  },
-  track: {
-    find: 'track',
-    link: 'track',
-  },
-};
-
-if (!validateReplacerSpec(replacerSpec, {find, link: unbound_link})) {
+if (!validateReplacerSpec(replacerSpec, {find, link})) {
   process.exit();
 }
 
-function parseAttributes(string, {to}) {
-  const attributes = Object.create(null);
-  const skipWhitespace = (i) => {
-    const ws = /\s/;
-    if (ws.test(string[i])) {
-      const match = string.slice(i).match(/[^\s]/);
-      if (match) {
-        return i + match.index;
-      } else {
-        return string.length;
-      }
-    } else {
-      return i;
-    }
-  };
-
-  for (let i = 0; i < string.length; ) {
-    i = skipWhitespace(i);
-    const aStart = i;
-    const aEnd = i + string.slice(i).match(/[\s=]|$/).index;
-    const attribute = string.slice(aStart, aEnd);
-    i = skipWhitespace(aEnd);
-    if (string[i] === '=') {
-      i = skipWhitespace(i + 1);
-      let end, endOffset;
-      if (string[i] === '"' || string[i] === "'") {
-        end = string[i];
-        endOffset = 1;
-        i++;
-      } else {
-        end = '\\s';
-        endOffset = 0;
-      }
-      const vStart = i;
-      const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index;
-      const value = string.slice(vStart, vEnd);
-      i = vEnd + endOffset;
-      if (attribute === 'src' && value.startsWith('media/')) {
-        attributes[attribute] = to('media.path', value.slice('media/'.length));
-      } else {
-        attributes[attribute] = value;
-      }
-    } else {
-      attributes[attribute] = attribute;
-    }
-  }
-  return Object.fromEntries(
-    Object.entries(attributes).map(([key, val]) => [
-      key,
-      val === 'true'
-        ? true
-        : val === 'false'
-        ? false
-        : val === key
-        ? true
-        : val,
-    ])
-  );
-}
-
-function joinLineBreaks(sourceLines) {
-  const outLines = [];
-
-  let lineSoFar = '';
-  for (let i = 0; i < sourceLines.length; i++) {
-    const line = sourceLines[i];
-    lineSoFar += line;
-    if (!line.endsWith('<br>')) {
-      outLines.push(lineSoFar);
-      lineSoFar = '';
-    }
-  }
-
-  if (lineSoFar) {
-    outLines.push(lineSoFar);
-  }
-
-  return outLines;
-}
-
-function transformMultiline(text, {
-  parseAttributes,
-  to,
-  transformInline,
-  thumb = null,
-}) {
-  // Heck yes, HTML magics.
-
-  text = transformInline(text.trim());
-
-  const outLines = [];
-
-  const indentString = ' '.repeat(4);
-
-  let levelIndents = [];
-  const openLevel = (indent) => {
-    // opening a sublist is a pain: to be semantically *and* visually
-    // correct, we have to append the <ul> at the end of the existing
-    // previous <li>
-    const previousLine = outLines[outLines.length - 1];
-    if (previousLine?.endsWith('</li>')) {
-      // we will re-close the <li> later
-      outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>';
-    } else {
-      // if the previous line isn't a list item, this is the opening of
-      // the first list level, so no need for indent
-      outLines.push('<ul>');
-    }
-    levelIndents.push(indent);
-  };
-  const closeLevel = () => {
-    levelIndents.pop();
-    if (levelIndents.length) {
-      // closing a sublist, so close the list item containing it too
-      outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>');
-    } else {
-      // closing the final list level! no need for indent here
-      outLines.push('</ul>');
-    }
-  };
-
-  // okay yes we should support nested formatting, more than one blockquote
-  // layer, etc, but hear me out here: making all that work would basically
-  // be the same as implementing an entire markdown converter, which im not
-  // interested in doing lol. sorry!!!
-  let inBlockquote = false;
-
-  let lines = splitLines(text);
-  lines = joinLineBreaks(lines);
-  for (let line of lines) {
-    const imageLine = line.startsWith('<img');
-    line = line.replace(/<img (.*?)>/g, (match, attributes) =>
-      img({
-        lazy: true,
-        link: true,
-        thumb,
-        ...parseAttributes(attributes),
-      })
-    );
-
-    let indentThisLine = 0;
-    let lineContent = line;
-    let lineTag = 'p';
-
-    const listMatch = line.match(/^( *)- *(.*)$/);
-    if (listMatch) {
-      // is a list item!
-      if (!levelIndents.length) {
-        // first level is always indent = 0, regardless of actual line
-        // content (this is to avoid going to a lesser indent than the
-        // initial level)
-        openLevel(0);
-      } else {
-        // find level corresponding to indent
-        const indent = listMatch[1].length;
-        let i;
-        for (i = levelIndents.length - 1; i >= 0; i--) {
-          if (levelIndents[i] <= indent) break;
-        }
-        // note: i cannot equal -1 because the first indentation level
-        // is always 0, and the minimum indentation is also 0
-        if (levelIndents[i] === indent) {
-          // same indent! return to that level
-          while (levelIndents.length - 1 > i) closeLevel();
-          // (if this is already the current level, the above loop
-          // will do nothing)
-        } else if (levelIndents[i] < indent) {
-          // lesser indent! branch based on index
-          if (i === levelIndents.length - 1) {
-            // top level is lesser: add a new level
-            openLevel(indent);
-          } else {
-            // lower level is lesser: return to that level
-            while (levelIndents.length - 1 > i) closeLevel();
-          }
-        }
-      }
-      // finally, set variables for appending content line
-      indentThisLine = levelIndents.length;
-      lineContent = listMatch[2];
-      lineTag = 'li';
-    } else {
-      // not a list item! close any existing list levels
-      while (levelIndents.length) closeLevel();
-
-      // like i said, no nested shenanigans - quotes only appear outside
-      // of lists. sorry!
-      const quoteMatch = line.match(/^> *(.*)$/);
-      if (quoteMatch) {
-        // is a quote! open a blockquote tag if it doesnt already exist
-        if (!inBlockquote) {
-          inBlockquote = true;
-          outLines.push('<blockquote>');
-        }
-        indentThisLine = 1;
-        lineContent = quoteMatch[1];
-      } else if (inBlockquote) {
-        // not a quote! close a blockquote tag if it exists
-        inBlockquote = false;
-        outLines.push('</blockquote>');
-      }
-
-      // let some escaped symbols display as the normal symbol, since the
-      // point of escaping them is just to avoid having them be treated as
-      // syntax markers!
-      if (lineContent.match(/( *)\\-/)) {
-        lineContent = lineContent.replace('\\-', '-');
-      } else if (lineContent.match(/( *)\\>/)) {
-        lineContent = lineContent.replace('\\>', '>');
-      }
-    }
-
-    if (lineTag === 'p') {
-      // certain inline element tags should still be postioned within a
-      // paragraph; other elements (e.g. headings) should be added as-is
-      const elementMatch = line.match(/^<(.*?)[ >]/);
-      if (
-        elementMatch &&
-        !imageLine &&
-        ![
-          'a',
-          'abbr',
-          'b',
-          'bdo',
-          'br',
-          'cite',
-          'code',
-          'data',
-          'datalist',
-          'del',
-          'dfn',
-          'em',
-          'i',
-          'img',
-          'ins',
-          'kbd',
-          'mark',
-          'output',
-          'picture',
-          'q',
-          'ruby',
-          'samp',
-          'small',
-          'span',
-          'strong',
-          'sub',
-          'sup',
-          'svg',
-          'time',
-          'var',
-          'wbr',
-        ].includes(elementMatch[1])
-      ) {
-        lineTag = '';
-      }
-
-      // for sticky headings!
-      if (elementMatch && elementMatch[1] === 'h2') {
-        lineContent = lineContent.replace(/<h2(.*?)>/g, (match, attributes) => {
-          const parsedAttributes = parseAttributes(attributes, {to});
-          return `<h2 ${html.attributes({
-            ...parsedAttributes,
-            class: [...parsedAttributes.class?.split(' ') ?? [], 'content-heading'],
-          })}>`;
-        });
-      }
-    }
-
-    let pushString = indentString.repeat(indentThisLine);
-    if (lineTag) {
-      pushString += `<${lineTag}>${lineContent}</${lineTag}>`;
-    } else {
-      pushString += lineContent;
-    }
-    outLines.push(pushString);
-  }
-
-  // after processing all lines...
-
-  // if still in a list, close all levels
-  while (levelIndents.length) closeLevel();
-
-  // if still in a blockquote, close its tag
-  if (inBlockquote) {
-    inBlockquote = false;
-    outLines.push('</blockquote>');
-  }
-
-  return outLines.join('\n');
-}
-
-function transformLyrics(text, {transformInline, transformMultiline}) {
-  // Different from transformMultiline 'cuz it joins multiple lines together
-  // with line 8reaks (<br>); transformMultiline treats each line as its own
-  // complete paragraph (or list, etc).
-
-  // If it looks like old data, then like, oh god.
-  // Use the normal transformMultiline tool.
-  if (text.includes('<br')) {
-    return transformMultiline(text);
-  }
-
-  text = transformInline(text.trim());
-
-  let buildLine = '';
-  const addLine = () => outLines.push(`<p>${buildLine}</p>`);
-  const outLines = [];
-  for (const line of text.split('\n')) {
-    if (line.length) {
-      if (buildLine.length) {
-        buildLine += '<br>';
-      }
-      buildLine += line;
-    } else if (buildLine.length) {
-      addLine();
-      buildLine = '';
-    }
-  }
-  if (buildLine.length) {
-    addLine();
-  }
-  return outLines.join('\n');
-}
-
-function stringifyThings(thingData) {
-  return JSON.stringify(serializeThings(thingData));
-}
-
-function img({
-  src,
-  alt,
-  noSrcText = '',
-  thumb: thumbKey,
-  reveal,
-  id,
-  class: className,
-  width,
-  height,
-  link = false,
-  lazy = false,
-  square = false,
-}) {
-  const willSquare = square;
-  const willLink = typeof link === 'string' || link;
-
-  const originalSrc = src;
-  const thumbSrc = src && (thumbKey ? thumb[thumbKey](src) : src);
-
-  const imgAttributes = {
-    id: link ? '' : id,
-    class: className,
-    alt,
-    width,
-    height,
-  };
+async function main() {
+  Error.stackTraceLimit = Infinity;
 
-  const noSrcHTML =
-    !src &&
-      wrap(
-        html.tag('div',
-          {class: 'image-text-area'},
-          noSrcText));
-
-  const nonlazyHTML =
-    src &&
-      wrap(
-        html.tag('img', {
-          ...imgAttributes,
-          src: thumbSrc,
-        }));
-
-  const lazyHTML =
-    src &&
-    lazy &&
-      wrap(
-        html.tag('img',
-          {
-            ...imgAttributes,
-            class: [className, 'lazy'],
-            'data-original': thumbSrc,
-          }),
-        true);
-
-  if (!src) {
-    return noSrcHTML;
-  } else if (lazy) {
-    return html.tag('noscript', nonlazyHTML) + '\n' + lazyHTML;
+  const defaultQueueSize = 500;
+
+  const buildModeFlagOptions = (
+    withEntries(buildModes, entries =>
+      entries.map(([key, mode]) => [key, {
+        help: mode.description,
+        type: 'flag',
+      }])));
+
+  const selectedBuildModeFlags = Object.keys(
+    await parseOptions(process.argv.slice(2), {
+      [parseOptions.handleUnknown]: () => {},
+      ...buildModeFlagOptions,
+    }));
+
+  let selectedBuildModeFlag;
+  let usingDefaultBuildMode;
+
+  if (empty(selectedBuildModeFlags)) {
+    selectedBuildModeFlag = 'static-build';
+    usingDefaultBuildMode = true;
+    logInfo`No build mode specified, using default: ${selectedBuildModeFlag}`;
+  } else if (selectedBuildModeFlags.length > 1) {
+    logError`Building multiple modes (${selectedBuildModeFlags.join(', ')}) at once not supported.`;
+    logError`Please specify a maximum of one build mode.`;
+    return;
   } else {
-    return nonlazyHTML;
+    selectedBuildModeFlag = selectedBuildModeFlags[0];
+    usingDefaultBuildMode = false;
+    logInfo`Using specified build mode: ${selectedBuildModeFlag}`;
   }
 
-  function wrap(input, hide = false) {
-    let wrapped = input;
-
-    wrapped = html.tag('div', {class: 'image-container'}, wrapped);
-
-    if (reveal) {
-      wrapped = html.tag('div', {class: 'reveal'}, [
-        wrapped,
-        html.tag('span', {class: 'reveal-text'}, reveal),
-      ]);
-    }
-
-    if (willSquare) {
-      wrapped = html.tag('div', {class: 'square-content'}, wrapped);
-      wrapped = html.tag('div',
-        {class: ['square', hide && !willLink && 'js-hide']},
-        wrapped);
-    }
-
-    if (willLink) {
-      wrapped = html.tag('a',
-        {
-          id,
-          class: ['box', hide && 'js-hide'],
-          href: typeof link === 'string' ? link : originalSrc,
-        },
-        wrapped);
-    }
-
-    return wrapped;
-  }
-}
-
-function validateWritePath(path, urlGroup) {
-  if (!Array.isArray(path)) {
-    return {error: `Expected array, got ${path}`};
-  }
-
-  const {paths} = urlGroup;
-
-  const definedKeys = Object.keys(paths);
-  const specifiedKey = path[0];
-
-  if (!definedKeys.includes(specifiedKey)) {
-    return {error: `Specified key ${specifiedKey} isn't defined`};
-  }
-
-  const expectedArgs = paths[specifiedKey].match(/<>/g)?.length ?? 0;
-  const specifiedArgs = path.length - 1;
-
-  if (specifiedArgs !== expectedArgs) {
-    return {
-      error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`,
-    };
-  }
-
-  return {success: true};
-}
-
-function validateWriteObject(obj) {
-  if (typeof obj !== 'object') {
-    return {error: `Expected object, got ${typeof obj}`};
-  }
-
-  if (typeof obj.type !== 'string') {
-    return {error: `Expected type to be string, got ${obj.type}`};
-  }
+  const selectedBuildMode = buildModes[selectedBuildModeFlag];
 
-  switch (obj.type) {
-    case 'legacy': {
-      if (typeof obj.write !== 'function') {
-        return {error: `Expected write to be string, got ${obj.write}`};
-      }
-
-      break;
-    }
-
-    case 'page': {
-      const path = validateWritePath(obj.path, urlSpec.localized);
-      if (path.error) {
-        return {error: `Path validation failed: ${path.error}`};
-      }
-
-      if (typeof obj.page !== 'function') {
-        return {error: `Expected page to be function, got ${obj.content}`};
-      }
-
-      break;
-    }
-
-    case 'data': {
-      const path = validateWritePath(obj.path, urlSpec.data);
-      if (path.error) {
-        return {error: `Path validation failed: ${path.error}`};
-      }
-
-      if (typeof obj.data !== 'function') {
-        return {error: `Expected data to be function, got ${obj.data}`};
-      }
-
-      break;
-    }
-
-    case 'redirect': {
-      const fromPath = validateWritePath(obj.fromPath, urlSpec.localized);
-      if (fromPath.error) {
-        return {
-          error: `Path (fromPath) validation failed: ${fromPath.error}`,
-        };
-      }
-
-      const toPath = validateWritePath(obj.toPath, urlSpec.localized);
-      if (toPath.error) {
-        return {error: `Path (toPath) validation failed: ${toPath.error}`};
-      }
-
-      if (typeof obj.title !== 'function') {
-        return {error: `Expected title to be function, got ${obj.title}`};
-      }
-
-      break;
-    }
-
-    default: {
-      return {error: `Unknown type: ${obj.type}`};
-    }
-  }
-
-  return {success: true};
-}
-
-export function getURLsFrom({
-  baseDirectory,
-  pageSubKey,
-  paths,
-}) {
-  return (targetFullKey, ...args) => {
-    const [groupKey, subKey] = targetFullKey.split('.');
-    let path = paths.subdirectoryPrefix;
-
-    let from;
-    let to;
-
-    // When linking to *outside* the localized area of the site, we need to
-    // make sure the result is correctly relative to the 8ase directory.
-    if (
-      groupKey !== 'localized' &&
-      groupKey !== 'localizedDefaultLanguage' &&
-      baseDirectory
-    ) {
-      from = 'localizedWithBaseDirectory.' + pageSubKey;
-      to = targetFullKey;
-    } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) {
-      // Special case for specifically linking *from* a page with base
-      // directory *to* a page without! Used for the language switcher and
-      // hopefully nothing else oh god.
-      from = 'localizedWithBaseDirectory.' + pageSubKey;
-      to = 'localized.' + subKey;
-    } else if (groupKey === 'localizedDefaultLanguage') {
-      // Linking to the default, except surprise, we're already IN the default
-      // (no baseDirectory set).
-      from = 'localized.' + pageSubKey;
-      to = 'localized.' + subKey;
-    } else {
-      // If we're linking inside the localized area (or there just is no
-      // 8ase directory), the 8ase directory doesn't matter.
-      from = 'localized.' + pageSubKey;
-      to = targetFullKey;
-    }
-
-    path += urls.from(from).to(to, ...args);
-
-    return path;
+  // This is about to get a whole lot more stuff put in it.
+  const wikiData = {
+    listingSpec,
+    listingTargetSpec,
   };
-}
-
-export function generateDocumentHTML(pageInfo, {
-  defaultLanguage,
-  getThemeString,
-  language,
-  languages,
-  localizedPaths,
-  paths,
-  oEmbedJSONHref,
-  to,
-  transformMultiline,
-  wikiData,
-}) {
-  const {wikiInfo} = wikiData;
-
-  let {
-    title = '',
-    meta = {},
-    theme = '',
-    stylesheet = '',
-
-    showWikiNameInTitle = true,
-    themeColor = '',
-
-    // missing properties are auto-filled, see below!
-    body = {},
-    banner = {},
-    main = {},
-    sidebarLeft = {},
-    sidebarRight = {},
-    nav = {},
-    secondaryNav = {},
-    footer = {},
-    socialEmbed = {},
-  } = pageInfo;
-
-  body.style ??= '';
-
-  theme = theme || getThemeString(wikiInfo.color);
-
-  banner ||= {};
-  banner.classes ??= [];
-  banner.src ??= '';
-  banner.position ??= '';
-  banner.dimensions ??= [0, 0];
-
-  main.classes ??= [];
-  main.content ??= '';
-
-  sidebarLeft ??= {};
-  sidebarRight ??= {};
-
-  for (const sidebar of [sidebarLeft, sidebarRight]) {
-    sidebar.classes ??= [];
-    sidebar.content ??= '';
-    sidebar.collapse ??= true;
-  }
 
-  nav.classes ??= [];
-  nav.content ??= '';
-  nav.bottomRowContent ??= '';
-  nav.links ??= [];
-  nav.linkContainerClasses ??= [];
-
-  secondaryNav ??= {};
-  secondaryNav.content ??= '';
-  secondaryNav.content ??= '';
-
-  footer.classes ??= [];
-  footer.content ??= wikiInfo.footerContent
-    ? transformMultiline(wikiInfo.footerContent)
-    : '';
-
-  const colors = themeColor
-    ? getColors(themeColor, {chroma})
-    : null;
-
-  const canonical = wikiInfo.canonicalBase
-    ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathname)
-    : '';
-
-  const localizedCanonical = wikiInfo.canonicalBase
-    ? Object.entries(localizedPaths).map(([code, {pathname}]) => ({
-        lang: code,
-        href: wikiInfo.canonicalBase + (pathname === '/' ? '' : pathname),
-      }))
-    : [];
-
-  const collapseSidebars =
-    sidebarLeft.collapse !== false && sidebarRight.collapse !== false;
-
-  const mainHTML =
-    main.content &&
-      html.tag('main',
-        {
-          id: 'content',
-          class: main.classes,
-        },
-        main.content);
-
-  const footerHTML =
-    html.tag('footer',
-      {
-        [html.onlyIfContent]: true,
-        id: 'footer',
-        class: footer.classes,
-      },
-      [
-        html.tag('div',
-          {
-            [html.onlyIfContent]: true,
-            class: 'footer-content',
-          },
-          footer.content),
-
-        getFooterLocalizationLinks(paths.pathname, {
-          defaultLanguage,
-          html,
-          language,
-          languages,
-          paths,
-          to,
-        }),
-      ]);
-
-  const generateSidebarHTML = (id, {
-    content,
-    multiple,
-    classes,
-    collapse = true,
-    wide = false,
-
-    // 'last' - last or only sidebar box is sticky
-    // 'column' - entire column, incl. multiple boxes from top, is sticky
-    // 'none' - sidebar not sticky at all, stays at top of page
-    stickyMode = 'last',
-  }) =>
-    content
-      ? html.tag('div',
-          {
-            id,
-            class: [
-              'sidebar-column',
-              'sidebar',
-              wide && 'wide',
-              !collapse && 'no-hide',
-              stickyMode !== 'none' && 'sticky-' + stickyMode,
-              ...classes,
-            ],
-          },
-          content)
-      : multiple
-      ? html.tag('div',
-          {
-            id,
-            class: [
-              'sidebar-column',
-              'sidebar-multiple',
-              wide && 'wide',
-              !collapse && 'no-hide',
-              stickyMode !== 'none' && 'sticky-' + stickyMode,
-            ],
-          },
-          multiple
-            .map((infoOrContent) =>
-              (typeof infoOrContent === 'object' && !Array.isArray(infoOrContent))
-                ? infoOrContent
-                : {content: infoOrContent})
-            .filter(({content}) => content)
-            .map(({
-              content,
-              classes: classes2 = [],
-            }) =>
-              html.tag('div',
-                {
-                  class: ['sidebar', ...classes, ...classes2],
-                },
-                html.fragment(content))))
-      : '';
-
-  const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft);
-  const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight);
-
-  if (nav.simple) {
-    nav.linkContainerClasses = ['nav-links-hierarchy'];
-    nav.links = [{toHome: true}, {toCurrentPage: true}];
-  }
-
-  const links = (nav.links || []).filter(Boolean);
-
-  const navLinkParts = [];
-  for (let i = 0; i < links.length; i++) {
-    let cur = links[i];
-
-    let {title: linkTitle} = cur;
-
-    if (cur.toHome) {
-      linkTitle ??= wikiInfo.nameShort;
-    } else if (cur.toCurrentPage) {
-      linkTitle ??= title;
-    }
+  const buildOptions = selectedBuildMode.getCLIOptions();
 
-    let partContent;
-
-    if (typeof cur.html === 'string') {
-      partContent = cur.html;
-    } else {
-      const attributes = {
-        class: (cur.toCurrentPage || i === links.length - 1) && 'current',
-        href: cur.toCurrentPage
-          ? ''
-          : cur.toHome
-          ? to('localized.home')
-          : cur.path
-          ? to(...cur.path)
-          : cur.href
-          ? (() => {
-              logWarn`Using legacy href format nav link in ${paths.pathname}`;
-              return cur.href;
-            })()
-          : null,
-      };
-      if (attributes.href === null) {
-        throw new Error(
-          `Expected some href specifier for link to ${linkTitle} (${JSON.stringify(
-            cur
-          )})`
-        );
-      }
-      partContent = html.tag('a', attributes, linkTitle);
-    }
-
-    if (!partContent) continue;
-
-    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,
-        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.bottomRowContent &&
-        html.tag('div', {class: 'nav-bottom-row'}, nav.bottomRowContent),
-      nav.content && html.tag('div', {class: 'nav-content'}, nav.content),
-    ]);
-
-  const secondaryNavHTML = html.tag('nav',
-    {
-      [html.onlyIfContent]: true,
-      id: 'secondary-nav',
-      class: secondaryNav.classes,
-    },
-    secondaryNav.content);
-
-  const bannerSrc = banner.src
-    ? banner.src
-    : banner.path
-    ? to(...banner.path)
-    : null;
-
-  const bannerHTML =
-    banner.position &&
-    bannerSrc &&
-    html.tag('div',
-      {
-        id: 'banner',
-        class: banner.classes,
-      },
-      html.tag('img', {
-        src: bannerSrc,
-        alt: banner.alt,
-        width: banner.dimensions[0] || 1100,
-        height: banner.dimensions[1] || 200,
-      }));
-
-  const layoutHTML = [
-    navHTML,
-    banner.position === 'top' && bannerHTML,
-    secondaryNavHTML,
-    html.tag('div',
-      {
-        class: [
-          'layout-columns',
-          !collapseSidebars && 'vertical-when-thin',
-          (sidebarLeftHTML || sidebarRightHTML) && 'has-one-sidebar',
-          (sidebarLeftHTML && sidebarRightHTML) && 'has-two-sidebars',
-          !(sidebarLeftHTML || sidebarRightHTML) && 'has-zero-sidebars',
-          sidebarLeftHTML && 'has-sidebar-left',
-          sidebarRightHTML && 'has-sidebar-right',
-        ],
-      },
-      [
-        sidebarLeftHTML,
-        mainHTML,
-        sidebarRightHTML,
-      ]),
-    banner.position === 'bottom' && bannerHTML,
-    footerHTML,
-  ].filter(Boolean).join('\n');
-
-  const infoCardHTML = html.tag('div', {id: 'info-card-container'},
-    html.tag('div', {id: 'info-card-decor'},
-      html.tag('div', {id: 'info-card'}, [
-        html.tag('div', {class: ['info-card-art-container', 'no-reveal']},
-          img({
-            class: 'info-card-art',
-            src: '',
-            link: true,
-            square: true,
-          })),
-        html.tag('div', {class: ['info-card-art-container', 'reveal']},
-          img({
-            class: 'info-card-art',
-            src: '',
-            link: true,
-            square: true,
-            reveal: getRevealStringFromWarnings(
-              html.tag('span', {class: 'info-card-art-warnings'}),
-              {html, language}),
-          })),
-        html.tag('h1', {class: 'info-card-name'},
-          html.tag('a')),
-        html.tag('p', {class: 'info-card-album'},
-          language.$('releaseInfo.from', {
-            album: html.tag('a'),
-          })),
-        html.tag('p', {class: 'info-card-artists'},
-          language.$('releaseInfo.by', {
-            artists: html.tag('span'),
-          })),
-        html.tag('p', {class: 'info-card-cover-artists'},
-          language.$('releaseInfo.coverArtBy', {
-            artists: html.tag('span'),
-          })),
-      ])));
-
-  const socialEmbedHTML = [
-    socialEmbed.title &&
-      html.tag('meta', {property: 'og:title', content: socialEmbed.title}),
-
-    socialEmbed.description &&
-      html.tag('meta', {
-        property: 'og:description',
-        content: socialEmbed.description,
-      }),
-
-    socialEmbed.image &&
-      html.tag('meta', {property: 'og:image', content: socialEmbed.image}),
-
-    ...html.fragment(
-      colors && [
-        html.tag('meta', {
-          name: 'theme-color',
-          content: colors.dark,
-          media: '(prefers-color-scheme: dark)',
-        }),
-
-        html.tag('meta', {
-          name: 'theme-color',
-          content: colors.light,
-          media: '(prefers-color-scheme: light)',
-        }),
-
-        html.tag('meta', {
-          name: 'theme-color',
-          content: colors.primary,
-        }),
-      ]),
-
-    oEmbedJSONHref &&
-      html.tag('link', {
-        type: 'application/json+oembed',
-        href: oEmbedJSONHref,
-      }),
-  ].filter(Boolean).join('\n');
-
-  return `<!DOCTYPE html>\n` + html.tag('html',
-    {
-      lang: language.intlCode,
-      'data-language-code': language.code,
-      'data-url-key': paths.urlPath[0],
-      ...Object.fromEntries(
-        paths.urlPath.slice(1).map((v, i) => [['data-url-value' + i], v])
-      ),
-      'data-rebase-localized': to('localized.root'),
-      'data-rebase-shared': to('shared.root'),
-      'data-rebase-media': to('media.root'),
-      'data-rebase-data': to('data.root'),
+  const commonOptions = {
+    'help': {
+      help: `Display usage info and basic information for the \`hsmusic\` command`,
+      type: 'flag',
     },
-    [
-      `<!--\n` + [
-        wikiInfo.canonicalBase
-          ? `hsmusic.wiki - ${wikiInfo.name}, ${wikiInfo.canonicalBase}`
-          : `hsmusic.wiki - ${wikiInfo.name}`,
-        'Code copyright 2019-2022 Quasar Nebula et al (MIT License)',
-        ...wikiInfo.canonicalBase === 'https://hsmusic.wiki/' ? [
-          'Data avidly compiled and localization brought to you',
-          'by our awesome team and community of wiki contributors',
-          '***',
-          'Want to contribute? Join our Discord or leave feedback!',
-          '- https://hsmusic.wiki/discord/',
-          '- https://hsmusic.wiki/feedback/',
-          '- https://github.com/hsmusic/',
-        ] : [
-          'https://github.com/hsmusic/',
-        ],
-        '***',
-        `Site built: ${BUILD_TIME.toLocaleString('en-US', {
-          dateStyle: 'long',
-          timeStyle: 'long',
-        })}`,
-        `Latest code commit: ${COMMIT}`,
-      ]
-        .filter(Boolean)
-        .map(line => `    ` + line)
-        .join('\n') + `\n-->`,
-
-      html.tag('head', [
-        html.tag('title',
-          showWikiNameInTitle
-            ? language.formatString('misc.pageTitle.withWikiName', {
-                title,
-                wikiName: wikiInfo.nameShort,
-              })
-            : language.formatString('misc.pageTitle', {title})),
-
-        html.tag('meta', {charset: 'utf-8'}),
-        html.tag('meta', {
-          name: 'viewport',
-          content: 'width=device-width, initial-scale=1',
-        }),
-
-        ...(
-          Object.entries(meta)
-            .filter(([key, value]) => value)
-            .map(([key, value]) => html.tag('meta', {[key]: value}))),
-
-        canonical &&
-          html.tag('link', {
-            rel: 'canonical',
-            href: canonical,
-          }),
-
-        ...(
-          localizedCanonical
-            .map(({lang, href}) => html.tag('link', {
-              rel: 'alternate',
-              hreflang: lang,
-              href,
-            }))),
-
-        socialEmbedHTML,
-
-        html.tag('link', {
-          rel: 'stylesheet',
-          href: to('shared.staticFile', `site2.css?${CACHEBUST}`),
-        }),
-
-        html.tag('style',
-          {[html.onlyIfContent]: true},
-          [
-            theme,
-            stylesheet,
-          ]),
-
-        html.tag('script', {
-          src: to('shared.staticFile', `lazy-loading.js?${CACHEBUST}`),
-        }),
-      ]),
-
-      html.tag('body',
-        {style: body.style || ''},
-        [
-          html.tag('div', {id: 'page-container'}, [
-            mainHTML &&
-              html.tag('div', {id: 'skippers'},
-                [
-                  ['#content', language.$('misc.skippers.skipToContent')],
-                  sidebarLeftHTML &&
-                    [
-                      '#sidebar-left',
-                      sidebarRightHTML
-                        ? language.$('misc.skippers.skipToSidebar.left')
-                        : language.$('misc.skippers.skipToSidebar'),
-                    ],
-                  sidebarRightHTML &&
-                    [
-                      '#sidebar-right',
-                      sidebarLeftHTML
-                        ? language.$('misc.skippers.skipToSidebar.right')
-                        : language.$('misc.skippers.skipToSidebar'),
-                    ],
-                  footerHTML &&
-                    ['#footer', language.$('misc.skippers.skipToFooter')],
-                ]
-                  .filter(Boolean)
-                  .map(([href, title]) =>
-                    html.tag('span', {class: 'skipper'},
-                      html.tag('a', {href}, title)))),
-            layoutHTML,
-          ]),
-
-          infoCardHTML,
-
-          html.tag('script', {
-            type: 'module',
-            src: to('shared.staticFile', `client.js?${CACHEBUST}`),
-          }),
-        ]),
-    ]);
-}
-
-function generateOEmbedJSON(pageInfo, {language, wikiData}) {
-  const {socialEmbed} = pageInfo;
-  const {wikiInfo} = wikiData;
-  const {canonicalBase, nameShort} = wikiInfo;
-
-  if (!socialEmbed) return '';
-
-  const entries = [
-    socialEmbed.heading && [
-      'author_name',
-      language.$('misc.socialEmbed.heading', {
-        wikiName: nameShort,
-        heading: socialEmbed.heading,
-      }),
-    ],
-    socialEmbed.headingLink &&
-      canonicalBase && [
-        'author_url',
-        canonicalBase.replace(/\/$/, '') +
-          '/' +
-          socialEmbed.headingLink.replace(/^\//, ''),
-      ],
-  ].filter(Boolean);
-
-  if (!entries.length) return '';
-
-  return JSON.stringify(Object.fromEntries(entries));
-}
-
-async function writePage({
-  html,
-  oEmbedJSON = '',
-  paths,
-}) {
-  await mkdir(paths.output.directory, {recursive: true});
-
-  await Promise.all(
-    [
-      writeFile(paths.output.documentHTML, html),
-
-      oEmbedJSON &&
-        writeFile(paths.output.oEmbedJSON, oEmbedJSON),
-    ].filter(Boolean)
-  );
-}
-
-function getPagePaths({
-  baseDirectory,
-  fullKey,
-  urlArgs,
-
-  file = 'index.html',
-}) {
-  const [groupKey, subKey] = fullKey.split('.');
-
-  const pathname =
-    groupKey === 'localized' && baseDirectory
-      ? urls
-          .from('shared.root')
-          .toDevice(
-            'localizedWithBaseDirectory.' + subKey,
-            baseDirectory,
-            ...urlArgs)
-      : urls
-          .from('shared.root')
-          .toDevice(fullKey, ...urlArgs);
-
-  // Needed for the rare path arguments which themselves contains one or more
-  // slashes, e.g. for listings, with arguments like 'albums/by-name'.
-  const subdirectoryPrefix =
-    '../'.repeat(urlArgs.join('/').split('/').length - 1);
-
-  const outputDirectory = path.join(outputPath, pathname);
-
-  const output = {
-    directory: outputDirectory,
-    documentHTML: path.join(outputDirectory, file),
-    oEmbedJSON: path.join(outputDirectory, OEMBED_JSON_FILE)
-  };
 
-  return {
-    urlPath: [fullKey, ...urlArgs],
-
-    output,
-    pathname,
-    subdirectoryPrefix,
-  };
-}
-
-async function writeFavicon() {
-  try {
-    await stat(path.join(mediaPath, FAVICON_FILE));
-  } catch (error) {
-    return;
-  }
-
-  try {
-    await copyFile(
-      path.join(mediaPath, FAVICON_FILE),
-      path.join(outputPath, FAVICON_FILE)
-    );
-  } catch (error) {
-    logWarn`Failed to copy favicon! ${error.message}`;
-    return;
-  }
-
-  logInfo`Copied favicon to site root.`;
-}
-
-function writeSymlinks() {
-  return progressPromiseAll('Writing site symlinks.', [
-    link(path.join(__dirname, UTILITY_DIRECTORY), 'shared.utilityRoot'),
-    link(path.join(__dirname, STATIC_DIRECTORY), 'shared.staticRoot'),
-    link(mediaPath, 'media.root'),
-  ]);
-
-  async function link(directory, urlKey) {
-    const pathname = urls.from('shared.root').toDevice(urlKey);
-    const file = path.join(outputPath, pathname);
-    try {
-      await unlink(file);
-    } catch (error) {
-      if (error.code !== 'ENOENT') {
-        throw error;
-      }
-    }
-    try {
-      await symlink(path.resolve(directory), file);
-    } catch (error) {
-      if (error.code === 'EPERM') {
-        await symlink(path.resolve(directory), file, 'junction');
-      }
-    }
-  }
-}
-
-function writeSharedFilesAndPages({language, wikiData}) {
-  const {groupData, wikiInfo} = wikiData;
-
-  const redirect = async (title, from, urlKey, directory) => {
-    const target = path.relative(
-      from,
-      urls.from('shared.root').to(urlKey, directory)
-    );
-    const content = generateRedirectHTML(title, target, {language});
-    await mkdir(path.join(outputPath, from), {recursive: true});
-    await writeFile(path.join(outputPath, from, 'index.html'), content);
-  };
-
-  return progressPromiseAll(`Writing files & pages shared across languages.`, [
-    groupData?.some((group) => group.directory === 'fandom') &&
-      redirect(
-        'Fandom - Gallery',
-        'albums/fandom',
-        'localized.groupGallery',
-        'fandom'
-      ),
-
-    groupData?.some((group) => group.directory === 'official') &&
-      redirect(
-        'Official - Gallery',
-        'albums/official',
-        'localized.groupGallery',
-        'official'
-      ),
-
-    wikiInfo.enableListings &&
-      redirect(
-        'Album Commentary',
-        'list/all-commentary',
-        'localized.commentaryIndex',
-        ''
-      ),
-
-    writeFile(
-      path.join(outputPath, 'data.json'),
-      (
-        '{\n' +
-        [
-          `"albumData": ${stringifyThings(wikiData.albumData)},`,
-          wikiInfo.enableFlashesAndGames &&
-            `"flashData": ${stringifyThings(wikiData.flashData)},`,
-          `"artistData": ${stringifyThings(wikiData.artistData)}`,
-        ]
-          .filter(Boolean)
-          .map(line => '  ' + line)
-          .join('\n') +
-        '\n}')),
-  ].filter(Boolean));
-}
-
-function generateRedirectHTML(title, target, {language}) {
-  return `<!DOCTYPE html>\n` + html.tag('html', [
-    html.tag('head', [
-      html.tag('title', language.$('redirectPage.title', {title})),
-      html.tag('meta', {charset: 'utf-8'}),
-
-      html.tag('meta', {
-        'http-equiv': 'refresh',
-        content: `0;url=${target}`,
-      }),
-
-      // TODO: Is this OK for localized pages?
-      html.tag('link', {
-        rel: 'canonical',
-        href: target,
-      }),
-    ]),
-
-    html.tag('body',
-      html.tag('main', [
-        html.tag('h1',
-          language.$('redirectPage.title', {title})),
-        html.tag('p',
-          language.$('redirectPage.infoLine', {
-            target: html.tag('a', {href: target}, target),
-          })),
-      ])),
-  ]);
-}
-
-// Wrapper function for running a function once for all languages.
-async function wrapLanguages(fn, {languages, writeOneLanguage = null}) {
-  const k = writeOneLanguage;
-  const languagesToRun = k ? {[k]: languages[k]} : languages;
-
-  const entries = Object.entries(languagesToRun).filter(
-    ([key]) => key !== 'default'
-  );
-
-  for (let i = 0; i < entries.length; i++) {
-    const [_key, language] = entries[i];
-
-    await fn(language, i, entries);
-  }
-}
-
-async function main() {
-  Error.stackTraceLimit = Infinity;
-
-  const WD = wikiData;
-
-  WD.listingSpec = listingSpec;
-  WD.listingTargetSpec = listingTargetSpec;
-
-  const miscOptions = await parseOptions(process.argv.slice(2), {
     // Data files for the site, including flash, artist, and al8um data,
     // and like a jillion other things too. Pretty much everything which
     // makes an individual wiki what it is goes here!
     'data-path': {
+      help: `Specify path to data directory, including YAML files that cover all info about wiki content, layout, and structure\n\nAlways required for wiki building, but may be provided via the HSMUSIC_DATA environment variable instead`,
       type: 'value',
     },
 
@@ -1669,6 +161,7 @@ async function main() {
     // categorized; check out MEDIA_ALBUM_ART_DIRECTORY and other constants
     // near the top of this file (upd8.js).
     'media-path': {
+      help: `Specify path to media directory, including album artwork and additional files, as well as custom site layout media and other media files for reference or linking in wiki content\n\nAlways required for wiki building, but may be provided via the HSMUSIC_MEDIA environment variable instead`,
       type: 'value',
     },
 
@@ -1683,16 +176,7 @@ async function main() {
     // 8uild with the default (English) strings if this path is left
     // unspecified.
     'lang-path': {
-      type: 'value',
-    },
-
-    // This is the output directory. It's the one you'll upload online with
-    // rsync or whatever when you're pushing an upd8, and also the one
-    // you'd archive if you wanted to make a 8ackup of the whole dang
-    // site. Just keep in mind that the gener8ted result will contain a
-    // couple symlinked directories, so if you're uploading, you're pro8a8ly
-    // gonna want to resolve those yourself.
-    'out-path': {
+      help: `Specify path to language directory, including JSON files that mapping internal string keys to localized language content, and various language metadata`,
       type: 'value',
     },
 
@@ -1700,12 +184,14 @@ async function main() {
     // kinda a pain to run every time, since it does necessit8te reading
     // every media file at run time. Pass this to skip it.
     'skip-thumbs': {
+      help: `Skip processing and generating thumbnails in media directory (speeds up subsequent builds, but remove this option [or use --thumbs-only] and re-run once when you add or modify media files to ensure thumbnails stay up-to-date!)`,
       type: 'flag',
     },
 
     // Or, if you *only* want to gener8te newly upd8ted thum8nails, you can
     // pass this flag! It exits 8efore 8uilding the rest of the site.
     'thumbs-only': {
+      help: `Skip everything besides processing media directory and generating up-to-date thumbnails (useful when using --skip-thumbs for most runs)`,
       type: 'flag',
     },
 
@@ -1713,20 +199,7 @@ async function main() {
     // generating site HTML yet? This flag will cut execution off right
     // 8efore any site 8uilding actually happens.
     'no-build': {
-      type: 'flag',
-    },
-
-    // Only want to 8uild one language during testing? This can chop down
-    // 8uild times a pretty 8ig chunk! Just pass a single language code.
-    lang: {
-      type: 'value',
-    },
-
-    // Working without a dev server and just using file:// URLs in your we8
-    // 8rowser? This will automatically append index.html to links across
-    // the site. Not recommended for production, since it isn't guaranteed
-    // 100% error-free (and index.html-style links are less pretty anyway).
-    'append-index-html': {
+      help: `Don't run a build of the site at all; only process data/media and report any errors detected`,
       type: 'flag',
     },
 
@@ -1735,10 +208,12 @@ async function main() {
     // line) right to your output, 8ut also pro8a8ly give you a headache
     // 8ecause wow that is a lot of visual noise.
     'show-traces': {
+      help: `Show JavaScript source code paths for reported errors in "aggregate" error displays\n\n(Debugging use only, but please enable this if you're reporting bugs for our issue tracker!)`,
       type: 'flag',
     },
 
     'queue-size': {
+      help: `Process more or fewer disk files at once to optimize performance or avoid I/O errors, unlimited if set to 0 (between 500 and 700 is usually a safe range for building HSMusic on Windows machines)\nDefaults to ${defaultQueueSize}`,
       type: 'value',
       validate(size) {
         if (parseInt(size) !== parseFloat(size)) return 'an integer';
@@ -1752,6 +227,7 @@ async function main() {
     // CacheableObject in a mode where every instance is a Proxy which will
     // keep track of invalid property accesses.
     'show-invalid-property-accesses': {
+      help: `Report accesses at runtime to nonexistant properties on wiki data objects, at a dramatic performance cost\n(Internal/development use only)`,
       type: 'flag',
     },
 
@@ -1765,18 +241,126 @@ async function main() {
     // 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',
     },
+  };
+
+  const cliOptions = await parseOptions(process.argv.slice(2), {
+    // We don't want to error when we receive these options, so specify them
+    // here, even though we won't be doing anything with them later.
+    // (This is a bit of a hack.)
+    ...buildModeFlagOptions,
 
-    [parseOptions.handleUnknown]: () => {},
+    ...commonOptions,
+    ...buildOptions,
   });
 
-  dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA;
-  mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA;
-  langPath = miscOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
-  outputPath = miscOptions['out-path'] || process.env.HSMUSIC_OUT;
+  if (cliOptions['help']) {
+    const indentWrap = (spaces, str) => wrap(str, {width: 60 - spaces, indent: ' '.repeat(spaces)});
+
+    const showOptions = (msg, options) => {
+      console.log(color.bright(msg));
+
+      const entries = Object.entries(options);
+      const sortedOptions = sortByName(entries
+        .map(([name, descriptor]) => ({name, descriptor})));
+
+      if (!sortedOptions.length) {
+        console.log(`(No options available)`)
+      }
+
+      let justInsertedPaddingLine = false;
+
+      for (const {name, descriptor} of sortedOptions) {
+        if (descriptor.alias) {
+          continue;
+        }
+
+        const aliases = entries
+          .filter(([_name, {alias}]) => alias === name)
+          .map(([name]) => name);
+
+        let wrappedHelp, wrappedHelpLines = 0;
+        if (descriptor.help) {
+          wrappedHelp = indentWrap(4, descriptor.help);
+          wrappedHelpLines = wrappedHelp.split('\n').length;
+        }
+
+        if (wrappedHelpLines > 0 && !justInsertedPaddingLine) {
+          console.log('');
+        }
+
+        console.log(color.bright(` --` + name) +
+          (aliases.length
+            ? ` (or: ${aliases.map(alias => color.bright(`--` + alias)).join(', ')})`
+            : '') +
+          (descriptor.help
+            ? ''
+            : color.dim('  (no help provided)')));
+
+        if (wrappedHelp) {
+          console.log(wrappedHelp);
+        }
+
+        if (wrappedHelpLines > 1) {
+          console.log('');
+          justInsertedPaddingLine = true;
+        } else {
+          justInsertedPaddingLine = false;
+        }
+      }
+
+      if (!justInsertedPaddingLine) {
+        console.log(``);
+      }
+    };
+
+    console.log(
+      color.bright(`hsmusic (aka. Homestuck Music Wiki)\n`) +
+      `static wiki software cataloguing collaborative creation\n`);
+
+    console.log(indentWrap(0,
+      `The \`hsmusic\` command provides basic control over all parts of generating user-visible HTML pages and website content/structure from provided data, media, and language directories.\n` +
+      `\n` +
+      `CLI options are divided into three groups:\n`));
+    console.log(` 1) ` + indentWrap(4,
+      `Common options: These are shared by all build modes and always have the same essential behavior`).trim());
+    console.log(` 2) ` + indentWrap(4,
+      `Build mode selection: One build mode may be selected (or else the default, --static-build, is used), and it decides which entire set of behavior to use for providing site content to the user`).trim());
+    console.log(` 3) ` + indentWrap(4,
+      `Build options: Each build mode has a set of unique options which customize behavior for that build mode`).trim());
+    console.log(``);
 
-  const writeOneLanguage = miscOptions['lang'];
+    showOptions(`Common options`, commonOptions);
+    showOptions(`Build mode selection`, buildModeFlagOptions);
+
+    if (buildOptions) {
+      showOptions(`Build options for --${selectedBuildModeFlag} (${
+        usingDefaultBuildMode ? 'default' : 'selected'
+      })`, buildOptions);
+    }
+
+    return;
+  }
+
+  const dataPath = cliOptions['data-path'] || process.env.HSMUSIC_DATA;
+  const mediaPath = cliOptions['media-path'] || process.env.HSMUSIC_MEDIA;
+  const langPath = cliOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
+
+  const skipThumbs = cliOptions['skip-thumbs'] ?? false;
+  const thumbsOnly = cliOptions['thumbs-only'] ?? false;
+  const noBuild = cliOptions['no-build'] ?? false;
+
+  const showAggregateTraces = cliOptions['show-traces'] ?? false;
+
+  const precacheData = cliOptions['precache-data'] ?? false;
+  const showInvalidPropertyAccesses = cliOptions['show-invalid-property-accesses'] ?? false;
+
+  // Makes writing nicer on the CPU and file I/O parts of the OS, with a
+  // marginal performance deficit while waiting for file writes to finish
+  // before proceeding to more page processing.
+  const queueSize = +(cliOptions['queue-size'] ?? defaultQueueSize);
 
   {
     let errored = false;
@@ -1788,46 +372,11 @@ async function main() {
     };
     error(!dataPath, `Expected --data-path option or HSMUSIC_DATA to be set`);
     error(!mediaPath, `Expected --media-path option or HSMUSIC_MEDIA to be set`);
-    error(!outputPath, `Expected --out-path option or HSMUSIC_OUT to be set`);
     if (errored) {
       return;
     }
   }
 
-  const appendIndexHTML = miscOptions['append-index-html'] ?? false;
-  if (appendIndexHTML) {
-    logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`;
-    unbound_link.globalOptions.appendIndexHTML = true;
-  }
-
-  const skipThumbs = miscOptions['skip-thumbs'] ?? false;
-  const thumbsOnly = miscOptions['thumbs-only'] ?? false;
-  const noBuild = miscOptions['no-build'] ?? false;
-  const showAggregateTraces = miscOptions['show-traces'] ?? false;
-  const precacheData = miscOptions['precache-data'] ?? false;
-
-  // NOT for ena8ling or disa8ling specific features of the site!
-  // This is only in charge of what general groups of files to 8uild.
-  // They're here to make development quicker when you're only working
-  // on some particular area(s) of the site rather than making changes
-  // across all of them.
-  const writeFlags = await parseOptions(process.argv.slice(2), {
-    all: {type: 'flag'}, // Defaults to true if none 8elow specified.
-
-    // Kinda a hack t8h!
-    ...Object.fromEntries(
-      Object.keys(pageSpecs).map((key) => [key, {type: 'flag'}])
-    ),
-
-    [parseOptions.handleUnknown]: () => {},
-  });
-
-  const writeAll = !Object.keys(writeFlags).length || writeFlags.all;
-
-  logInfo`Writing site pages: ${
-    writeAll ? 'all' : Object.keys(writeFlags).join(', ')
-  }`;
-
   const niceShowAggregate = (error, ...opts) => {
     showAggregate(error, {
       showTraces: showAggregateTraces,
@@ -1851,9 +400,6 @@ async function main() {
     if (thumbsOnly) return;
   }
 
-  const showInvalidPropertyAccesses =
-    miscOptions['show-invalid-property-accesses'] ?? false;
-
   if (showInvalidPropertyAccesses) {
     CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
   }
@@ -1912,7 +458,7 @@ async function main() {
     }
   }
 
-  if (!WD.wikiInfo) {
+  if (!wikiData.wikiInfo) {
     logError`Can't proceed without wiki info file (${WIKI_INFO_FILE}) successfully loading`;
     return;
   }
@@ -1985,9 +531,7 @@ async function main() {
     progressCallAll('Caching all data values', Object.entries(wikiData)
       .filter(([key]) =>
         key !== 'listingSpec' &&
-        key !== 'listingTargetSpec' &&
-        key !== 'officialAlbumData' &&
-        key !== 'fandomAlbumData')
+        key !== 'listingTargetSpec')
       .map(([key, value]) =>
         key === 'wikiInfo' ? [key, [value]] :
         key === 'homepageLayout' ? [key, [value]] :
@@ -1997,8 +541,7 @@ async function main() {
   }
 
   const internalDefaultLanguage = await processLanguageFile(
-    path.join(__dirname, DEFAULT_STRINGS_FILE)
-  );
+    path.join(__dirname, DEFAULT_STRINGS_FILE));
 
   let languages;
   if (langPath) {
@@ -2006,28 +549,25 @@ async function main() {
       filter: (f) => path.extname(f) === '.json',
     });
 
-    const results = await progressPromiseAll(
-      `Reading & processing language files.`,
-      languageDataFiles.map((file) => processLanguageFile(file))
-    );
+    const results = await progressPromiseAll(`Reading & processing language files.`,
+      languageDataFiles.map((file) => processLanguageFile(file)));
 
     languages = Object.fromEntries(
-      results.map((language) => [language.code, language])
-    );
+      results.map((language) => [language.code, language]));
   } else {
     languages = {};
   }
 
   const customDefaultLanguage =
-    languages[WD.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code];
+    languages[wikiData.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code];
   let finalDefaultLanguage;
 
   if (customDefaultLanguage) {
     logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
     customDefaultLanguage.inheritedStrings = internalDefaultLanguage.strings;
     finalDefaultLanguage = customDefaultLanguage;
-  } else if (WD.wikiInfo.defaultLanguage) {
-    logError`Wiki info file specified default language is ${WD.wikiInfo.defaultLanguage}, but no such language file exists!`;
+  } 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 {
@@ -2051,22 +591,15 @@ async function main() {
 
   if (noBuild) {
     logInfo`Not generating any site or page files this run (--no-build passed).`;
-  } else if (writeOneLanguage && !(writeOneLanguage in languages)) {
-    logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`;
-    return;
-  } else if (writeOneLanguage) {
-    logInfo`Writing only language ${writeOneLanguage} this run.`;
-  } else {
-    logInfo`Writing all languages.`;
   }
 
   {
     const tagRefs = new Set(
-      [...WD.trackData, ...WD.albumData]
+      [...wikiData.trackData, ...wikiData.albumData]
         .flatMap((thing) => thing.artTagsByRef ?? []));
 
     for (const ref of tagRefs) {
-      if (find.artTag(ref, WD.artTagData)) {
+      if (find.artTag(ref, wikiData.artTagData)) {
         tagRefs.delete(ref);
       }
     }
@@ -2079,10 +612,7 @@ async function main() {
     }
   }
 
-  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));
+  const urls = generateURLs(urlSpec);
 
   const fileSizePreloader = new FileSizePreloader();
 
@@ -2094,7 +624,7 @@ async function main() {
   // function between them so that when site code requests a site path,
   // it'll get the size of the file at the corresponding device path.
   const additionalFilePaths = [
-    ...WD.albumData.flatMap((album) =>
+    ...wikiData.albumData.flatMap((album) =>
       [
         ...(album.additionalFiles ?? []),
         ...album.tracks.flatMap((track) => track.additionalFiles ?? []),
@@ -2130,503 +660,55 @@ async function main() {
 
   if (noBuild) return;
 
-  // Makes writing nicer on the CPU and file I/O parts of the OS, with a
-  // marginal performance deficit while waiting for file writes to finish
-  // before proceeding to more page processing.
-  queueSize = +(miscOptions['queue-size'] ?? 500);
-
-  const buildDictionary = pageSpecs;
-
-  await writeFavicon();
-  await writeSymlinks();
-  await writeSharedFilesAndPages({language: finalDefaultLanguage, wikiData});
-
-  const buildSteps = writeAll
-    ? Object.entries(buildDictionary)
-    : Object.entries(buildDictionary).filter(([flag]) => writeFlags[flag]);
-
-  let writes;
-  {
-    let error = false;
-
-    const buildStepsWithTargets = buildSteps
-      .map(([flag, pageSpec]) => {
-        // Condition not met: skip this build step altogether.
-        if (pageSpec.condition && !pageSpec.condition({wikiData})) {
-          return null;
-        }
-
-        // May still call writeTargetless if present.
-        if (!pageSpec.targets) {
-          return {flag, pageSpec, targets: []};
-        }
-
-        if (!pageSpec.write) {
-          logError`${flag + '.targets'} is specified, but ${flag + '.write'} is missing!`;
-          error = true;
-          return null;
-        }
-
-        const targets = pageSpec.targets({wikiData});
-        if (!Array.isArray(targets)) {
-          logError`${flag + '.targets'} was called, but it didn't return an array! (${typeof targets})`;
-          error = true;
-          return null;
-        }
-
-        return {flag, pageSpec, targets};
-      })
-      .filter(Boolean);
-
-    if (error) {
-      return;
-    }
-
-    const validateWrites = (writes, fnName) => {
-      // Do a quick valid8tion! If one of the writeThingPages functions go
-      // wrong, this will stall out early and tell us which did.
-
-      if (!Array.isArray(writes)) {
-        logError`${fnName} didn't return an array!`;
-        error = true;
-        return false;
-      }
-
-      if (
-        !(
-          writes.every((obj) => typeof obj === 'object') &&
-          writes.every((obj) => {
-            const result = validateWriteObject(obj);
-            if (result.error) {
-              logError`Validating write object failed: ${result.error}`;
-              return false;
-            } else {
-              return true;
-            }
-          })
-        )
-      ) {
-        logError`${fnName} returned invalid entries!`;
-        error = true;
-        return false;
-      }
-
-      return true;
-    };
-
-    // return;
-
-    writes = progressCallAll('Computing page & data writes.', buildStepsWithTargets.flatMap(({flag, pageSpec, targets}) => {
-      const writesFns = targets.map(target => () => {
-        const writes = pageSpec.write(target, {wikiData})?.slice() || [];
-        return validateWrites(writes, flag + '.write') ? writes : [];
-      });
-
-      if (pageSpec.writeTargetless) {
-        writesFns.push(() => {
-          const writes = pageSpec.writeTargetless({wikiData});
-          return validateWrites(writes, flag + '.writeTargetless') ? writes : [];
-        });
-      }
-
-      return writesFns;
-    })).flat();
-
-    if (error) {
-      return;
-    }
-  }
-
-  const pageWrites = writes.filter(({type}) => type === 'page');
-  const dataWrites = writes.filter(({type}) => type === 'data');
-  const redirectWrites = writes.filter(({type}) => type === 'redirect');
-
-  if (writes.length) {
-    logInfo`Total of ${writes.length} writes returned. (${pageWrites.length} page, ${dataWrites.length} data [currently skipped], ${redirectWrites.length} redirect)`;
-  } else {
-    logWarn`No writes returned at all, so exiting early. This is probably a bug!`;
-    return;
-  }
+  const developersComment = generateDevelopersCommentHTML({
+    buildTime: BUILD_TIME,
+    commit: COMMIT,
+    wikiData,
+  });
 
-  /*
-  await progressPromiseAll(`Writing data files shared across languages.`, queue(
-    dataWrites.map(({path, data}) => () => {
-      const bound = {};
-
-      bound.serializeLink = bindOpts(serializeLink, {});
-
-      bound.serializeContribs = bindOpts(serializeContribs, {});
-
-      bound.serializeImagePaths = bindOpts(serializeImagePaths, {
-        thumb
-      });
-
-      bound.serializeCover = bindOpts(serializeCover, {
-        [bindOpts.bindIndex]: 2,
-        serializeImagePaths: bound.serializeImagePaths,
-        urls
-      });
-
-      bound.serializeGroupsForAlbum = bindOpts(serializeGroupsForAlbum, {
-        serializeLink
-      });
-
-      bound.serializeGroupsForTrack = bindOpts(serializeGroupsForTrack, {
-        serializeLink
-      });
-
-      // TODO: This only supports one <>-style argument.
-      return writeData(path[0], path[1], data({...bound}));
-    }),
-    queueSize
-  ));
-  */
-
-  const perLanguageFn = async (language, i, entries) => {
-    const baseDirectory =
-      language === finalDefaultLanguage ? '' : language.code;
-
-    console.log(`\x1b[34;1m${`[${i + 1}/${entries.length}] ${language.code} (-> /${baseDirectory}) `.padEnd(60, '-')}\x1b[0m`);
-
-    await progressPromiseAll(`Writing ${language.code}`, queue([
-      ...pageWrites.map((props) => () => {
-        const {path, page} = props;
-
-        const pageSubKey = path[0];
-        const urlArgs = path.slice(1);
-
-        const localizedPaths = Object.fromEntries(
-          Object.entries(languages)
-            .filter(([key, language]) =>
-              key !== 'default' &&
-              !language.hidden)
-            .map(([_key, language]) => [
-              language.code,
-              getPagePaths({
-                baseDirectory:
-                  (language === finalDefaultLanguage
-                    ? ''
-                    : language.code),
-                fullKey: 'localized.' + pageSubKey,
-                urlArgs,
-              }),
-            ]));
-
-        const paths = getPagePaths({
-          baseDirectory,
-          fullKey: 'localized.' + pageSubKey,
-          urlArgs,
-        });
-
-        const to = getURLsFrom({
-          baseDirectory,
-          pageSubKey,
-          paths,
-        });
-
-        const absoluteTo = (targetFullKey, ...args) => {
-          const [groupKey, subKey] = targetFullKey.split('.');
-          const from = urls.from('shared.root');
-          return (
-            '/' +
-            (groupKey === 'localized' && baseDirectory
-              ? from.to(
-                  'localizedWithBaseDirectory.' + subKey,
-                  baseDirectory,
-                  ...args
-                )
-              : from.to(targetFullKey, ...args))
-          );
-        };
-
-        // TODO: Is there some nicer way to define these,
-        // may8e without totally re-8inding everything for
-        // each page?
-        const bound = {};
-
-        bound.html = html;
-
-        bound.getColors = bindOpts(getColors, {
-          chroma,
-        });
-
-        bound.getLinkThemeString = bindOpts(unbound_getLinkThemeString, {
-          getColors: bound.getColors,
-        });
-
-        bound.getThemeString = bindOpts(unbound_getThemeString, {
-          getColors: bound.getColors,
-        });
-
-        bound.link = withEntries(unbound_link, (entries) =>
-          entries
-            .map(([key, fn]) => [key, bindOpts(fn, {
-              getLinkThemeString: bound.getLinkThemeString,
-              to,
-            })]));
-
-        bound.parseAttributes = bindOpts(parseAttributes, {
-          to,
-        });
-
-        bound.find = bindFind(wikiData, {mode: 'warn'});
-
-        bound.transformInline = bindOpts(transformInline, {
-          find: bound.find,
-          link: bound.link,
-          replacerSpec,
-          language,
-          to,
-          wikiData,
-        });
-
-        bound.transformMultiline = bindOpts(transformMultiline, {
-          transformInline: bound.transformInline,
-          parseAttributes: bound.parseAttributes,
-          to,
-        });
-
-        bound.transformLyrics = bindOpts(transformLyrics, {
-          transformInline: bound.transformInline,
-          transformMultiline: bound.transformMultiline,
-        });
-
-        bound.iconifyURL = bindOpts(iconifyURL, {
-          html,
-          language,
-          to,
-        });
-
-        bound.fancifyURL = bindOpts(fancifyURL, {
-          html,
-          language,
-        });
-
-        bound.fancifyFlashURL = bindOpts(fancifyFlashURL, {
-          [bindOpts.bindIndex]: 2,
-          html,
-          language,
-
-          fancifyURL: bound.fancifyURL,
-        });
-
-        bound.getRevealStringFromWarnings = bindOpts(getRevealStringFromWarnings, {
-          html,
-          language,
-        });
-
-        bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, {
-          language,
-
-          getRevealStringFromWarnings: bound.getRevealStringFromWarnings,
-        });
-
-        bound.getArtistString = bindOpts(getArtistString, {
-          html,
-          link: bound.link,
-          language,
-
-          iconifyURL: bound.iconifyURL,
-        });
-
-        bound.getAlbumCover = bindOpts(getAlbumCover, {
-          to,
-        });
-
-        bound.getTrackCover = bindOpts(getTrackCover, {
-          to,
-        });
-
-        bound.getFlashCover = bindOpts(getFlashCover, {
-          to,
-        });
-
-        bound.getArtistAvatar = bindOpts(getArtistAvatar, {
-          to,
-        });
-
-        bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, {
-          html,
-          language,
-        });
-
-        bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, {
-          html,
-          language,
-        });
-
-        bound.generateNavigationLinks = bindOpts(generateNavigationLinks, {
-          link: bound.link,
-          language,
-        });
-
-        bound.generateStickyHeadingContainer = bindOpts(generateStickyHeadingContainer, {
-          [bindOpts.bindIndex]: 0,
-          getRevealStringFromTags: bound.getRevealStringFromTags,
-          html,
-          img,
-        });
-
-        bound.generateChronologyLinks = bindOpts(generateChronologyLinks, {
-          html,
-          language,
-          link: bound.link,
-          wikiData,
-
-          generateNavigationLinks: bound.generateNavigationLinks,
-        });
-
-        bound.generateCoverLink = bindOpts(generateCoverLink, {
-          [bindOpts.bindIndex]: 0,
-          html,
-          img,
-          link: bound.link,
-          language,
-          to,
-          wikiData,
-
-          getRevealStringFromTags: bound.getRevealStringFromTags,
-        });
-
-        bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, {
-          [bindOpts.bindIndex]: 2,
-          link: bound.link,
-          language,
-        });
-
-        bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, {
-          html,
-          language,
-          wikiData,
-        });
-
-        bound.getGridHTML = bindOpts(getGridHTML, {
-          [bindOpts.bindIndex]: 0,
-          img,
-          html,
-          language,
-
-          getRevealStringFromTags: bound.getRevealStringFromTags,
-        });
-
-        bound.getAlbumGridHTML = bindOpts(getAlbumGridHTML, {
-          [bindOpts.bindIndex]: 0,
-          link: bound.link,
-          language,
-
-          getAlbumCover: bound.getAlbumCover,
-          getGridHTML: bound.getGridHTML,
-        });
-
-        bound.getFlashGridHTML = bindOpts(getFlashGridHTML, {
-          [bindOpts.bindIndex]: 0,
-          link: bound.link,
-
-          getFlashCover: bound.getFlashCover,
-          getGridHTML: bound.getGridHTML,
-        });
-
-        bound.getCarouselHTML = bindOpts(getCarouselHTML, {
-          [bindOpts.bindIndex]: 0,
-          img,
-          html,
-        })
-
-        bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, {
-          to,
-        });
-
-        const pageInfo = page({
-          ...bound,
-
-          language,
-
-          absoluteTo,
-          relativeTo: to,
-          to,
-          urls,
-
-          getSizeOfAdditionalFile,
-        });
-
-        const oEmbedJSON = generateOEmbedJSON(pageInfo, {
-          language,
-          wikiData,
-        });
-
-        const oEmbedJSONHref =
-          oEmbedJSON &&
-          wikiData.wikiInfo.canonicalBase &&
-          wikiData.wikiInfo.canonicalBase +
-            urls
-              .from('shared.root')
-              .to('shared.path', paths.pathname + OEMBED_JSON_FILE);
-
-        const pageHTML = generateDocumentHTML(pageInfo, {
-          defaultLanguage: finalDefaultLanguage,
-          getThemeString: bound.getThemeString,
-          language,
-          languages,
-          localizedPaths,
-          oEmbedJSONHref,
-          paths,
-          to,
-          transformMultiline: bound.transformMultiline,
-          wikiData,
-        });
-
-        return writePage({
-          html: pageHTML,
-          oEmbedJSON,
-          paths,
-        });
-      }),
-      ...redirectWrites.map(({fromPath, toPath, title: titleFn}) => () => {
-        const title = titleFn({
-          language,
-        });
-
-        const from = getPagePaths({
-          baseDirectory,
-          fullKey: 'localized.' + fromPath[0],
-          urlArgs: fromPath.slice(1),
-        });
-
-        const to = getURLsFrom({
-          baseDirectory,
-          pageSubKey: fromPath[0],
-          paths: from,
-        });
-
-        const target = to('localized.' + toPath[0], ...toPath.slice(1));
-        const html = generateRedirectHTML(title, target, {language});
-        return writePage({html, paths: from});
-      }),
-    ], queueSize));
-  };
+  return selectedBuildMode.go({
+    cliOptions,
+    dataPath,
+    mediaPath,
+    queueSize,
+    srcRootPath: __dirname,
 
-  await wrapLanguages(perLanguageFn, {
+    defaultLanguage: finalDefaultLanguage,
     languages,
-    writeOneLanguage,
-  });
+    wikiData,
+    urls,
+    urlSpec,
 
-  // The single most important step.
-  logInfo`Written!`;
+    cachebust: '?' + CACHEBUST,
+    developersComment,
+    getSizeOfAdditionalFile,
+  });
 }
 
 // TODO: isMain detection isn't consistent across platforms here
 /* eslint-disable-next-line no-constant-condition */
 if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmusic') {
-  main()
-    .catch((error) => {
+  (async () => {
+    let result;
+
+    try {
+      result = await main();
+    } catch (error) {
       if (error instanceof AggregateError) {
         showAggregate(error);
       } else {
         console.error(error);
       }
-    })
-    .then(() => {
-      decorateTime.displayTime();
-      CacheableObject.showInvalidAccesses();
-    });
+    }
+
+    if (result !== true) {
+      process.exit(1);
+      return;
+    }
+
+    decorateTime.displayTime();
+    CacheableObject.showInvalidAccesses();
+
+    process.exit(0);
+  })();
 }